Precompute discovery ring per shop for deterministic content traversal
Replace per-request Jaccard similarity with a precomputed circular ordering of all public products. Greedy nearest-neighbor walk starts from the newest product, accumulating stems to cluster similar content. Ring reforges on product create/edit/upload when watch mode is enabled. Frontend stores ring position, direction, and watch history in localStorage. Queue items splice into playback without moving ring position. Watched items dim in sidebar with progress indicator.
This commit is contained in:
parent
4c1cd11050
commit
bc41c3481b
9 changed files with 516 additions and 74 deletions
|
|
@ -697,6 +697,40 @@ def tokenize_and_stem(text):
|
|||
return {stem_word(w) for w in words}
|
||||
|
||||
|
||||
def get_ring_related_products(product, ring, limit=8):
|
||||
"""Get related products by walking forward from current position in the ring.
|
||||
|
||||
Returns a list of Product objects in ring order starting after the current product.
|
||||
Falls back to get_related_products if product not in ring.
|
||||
"""
|
||||
product_id_str = str(product.id)
|
||||
if product_id_str not in ring:
|
||||
return get_related_products(product, limit=limit)
|
||||
|
||||
idx = ring.index(product_id_str)
|
||||
ring_len = len(ring)
|
||||
|
||||
# Walk forward from current position, wrapping around
|
||||
related_ids = []
|
||||
for i in range(1, ring_len):
|
||||
next_idx = (idx + i) % ring_len
|
||||
related_ids.append(ring[next_idx])
|
||||
if len(related_ids) >= limit:
|
||||
break
|
||||
|
||||
if not related_ids:
|
||||
return []
|
||||
|
||||
# Fetch products and preserve ring order
|
||||
products = get_products_by_ids(product.dbsession, related_ids)
|
||||
if not products:
|
||||
return []
|
||||
|
||||
# Build lookup by ID string for ordering
|
||||
product_map = {str(p.id): p for p in products}
|
||||
return [product_map[pid] for pid in related_ids if pid in product_map]
|
||||
|
||||
|
||||
def get_related_products(product, limit=8):
|
||||
"""Find related products in same shop using tiered fallback:
|
||||
1. Stem overlap (highest score first)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, func
|
||||
|
|
@ -133,6 +134,9 @@ class Shop(RBase, Base):
|
|||
# Email digest subscriptions
|
||||
subscriptions_enabled = Column(Boolean, default=True)
|
||||
|
||||
# Precomputed discovery ring: circular ordering of all public products
|
||||
json_discovery_ring = Column(UnicodeText, nullable=True)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
|
||||
|
||||
|
|
@ -581,6 +585,108 @@ class Shop(RBase, Base):
|
|||
def stamp_updated_timestamp(self):
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
@property
|
||||
def discovery_ring(self):
|
||||
"""Return the discovery ring as a list of product ID strings, or []."""
|
||||
if not hasattr(self, "_discovery_ring"):
|
||||
if self.json_discovery_ring:
|
||||
self._discovery_ring = json.loads(self.json_discovery_ring)
|
||||
else:
|
||||
self._discovery_ring = []
|
||||
return self._discovery_ring
|
||||
|
||||
@discovery_ring.setter
|
||||
def discovery_ring(self, ring_list):
|
||||
"""Set the discovery ring from a list of product ID strings."""
|
||||
self._discovery_ring = ring_list
|
||||
self.json_discovery_ring = json.dumps(ring_list)
|
||||
|
||||
|
||||
def compute_discovery_ring(shop):
|
||||
"""Compute a circular ordering of all public products using greedy nearest-neighbor.
|
||||
|
||||
Algorithm:
|
||||
1. Start from the newest public product
|
||||
2. Accumulate its stems into a running set
|
||||
3. Find unvisited product with highest Jaccard similarity to accumulated stems
|
||||
4. Add to ring, merge stems, repeat until all public products are ordered
|
||||
|
||||
Returns a list of product ID strings.
|
||||
"""
|
||||
from .product import tokenize_and_stem
|
||||
|
||||
# Gather all public products, sorted newest-first for deterministic seed
|
||||
candidates = []
|
||||
for p in shop.products:
|
||||
if p.visibility == 1:
|
||||
candidates.append(p)
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Sort newest-first; tiebreak by ID for determinism
|
||||
candidates.sort(key=lambda p: (-p.created_timestamp, str(p.id)))
|
||||
|
||||
if len(candidates) == 1:
|
||||
return [str(candidates[0].id)]
|
||||
|
||||
# Pre-compute stems for all candidates
|
||||
stems_map = {}
|
||||
for p in candidates:
|
||||
stems_map[p.id] = tokenize_and_stem(
|
||||
(p.title or '') + ' ' + (p.description or '')
|
||||
)
|
||||
|
||||
# Greedy walk
|
||||
ring = []
|
||||
visited = set()
|
||||
accumulated_stems = set()
|
||||
|
||||
# Start with newest product
|
||||
current = candidates[0]
|
||||
ring.append(str(current.id))
|
||||
visited.add(current.id)
|
||||
accumulated_stems |= stems_map[current.id]
|
||||
|
||||
while len(ring) < len(candidates):
|
||||
best_score = -1
|
||||
best_candidate = None
|
||||
|
||||
for p in candidates:
|
||||
if p.id in visited:
|
||||
continue
|
||||
p_stems = stems_map[p.id]
|
||||
union = accumulated_stems | p_stems
|
||||
if union:
|
||||
score = len(accumulated_stems & p_stems) / len(union)
|
||||
else:
|
||||
score = 0
|
||||
|
||||
# Tiebreak: highest score, then newest, then ID
|
||||
if (score > best_score or
|
||||
(score == best_score and best_candidate is not None and
|
||||
(p.created_timestamp > best_candidate.created_timestamp or
|
||||
(p.created_timestamp == best_candidate.created_timestamp and
|
||||
str(p.id) < str(best_candidate.id))))):
|
||||
best_score = score
|
||||
best_candidate = p
|
||||
|
||||
if best_candidate is None:
|
||||
break
|
||||
|
||||
ring.append(str(best_candidate.id))
|
||||
visited.add(best_candidate.id)
|
||||
accumulated_stems |= stems_map[best_candidate.id]
|
||||
|
||||
return ring
|
||||
|
||||
|
||||
def reforge_discovery_ring(shop):
|
||||
"""Compute and store the discovery ring on the shop."""
|
||||
ring = compute_discovery_ring(shop)
|
||||
shop.discovery_ring = ring
|
||||
return ring
|
||||
|
||||
|
||||
def is_shop_name_available(dbsession, name):
|
||||
return not _shop_by_name_query(dbsession, unicode(name)).count()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
"""add discovery ring to shop
|
||||
|
||||
Revision ID: 05f7eb8cecf1
|
||||
Revises: c3f9d1cdffb4
|
||||
Create Date: 2026-02-08 15:27:04.955833
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '05f7eb8cecf1'
|
||||
down_revision = 'c3f9d1cdffb4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _column_exists("mps_shop", "json_discovery_ring"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("json_discovery_ring", sa.UnicodeText(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_shop", "json_discovery_ring")
|
||||
|
|
@ -2739,6 +2739,30 @@ textarea {
|
|||
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.related-content-row-watched {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.ring-progress {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted, #999);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.watch-direction-label {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.direction-label-text {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted, #999);
|
||||
}
|
||||
|
||||
.related-content-index {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted, #999);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,75 @@
|
|||
var BUFFER_AHEAD_SECONDS = 30;
|
||||
var bufferingStarted = false;
|
||||
|
||||
// --- Ring state (persisted in localStorage) ---
|
||||
var ringProductIds = [];
|
||||
var ringPosition = 0;
|
||||
var ringDirection = 1; // 1 = forward, -1 = reverse
|
||||
var ringHistory = {}; // { productId: true } — set of watched IDs
|
||||
|
||||
// Load ring state from localStorage
|
||||
try {
|
||||
var savedRing = localStorage.getItem('watchRing');
|
||||
if (savedRing) ringProductIds = JSON.parse(savedRing);
|
||||
} catch (e) {}
|
||||
try {
|
||||
var savedPos = localStorage.getItem('watchRingPosition');
|
||||
if (savedPos !== null) ringPosition = parseInt(savedPos, 10) || 0;
|
||||
} catch (e) {}
|
||||
try {
|
||||
var savedDir = localStorage.getItem('watchRingDirection');
|
||||
if (savedDir !== null) ringDirection = parseInt(savedDir, 10) || 1;
|
||||
} catch (e) {}
|
||||
try {
|
||||
var savedHistory = localStorage.getItem('watchRingHistory');
|
||||
if (savedHistory) ringHistory = JSON.parse(savedHistory);
|
||||
} catch (e) {}
|
||||
|
||||
function saveRingState() {
|
||||
try {
|
||||
localStorage.setItem('watchRing', JSON.stringify(ringProductIds));
|
||||
localStorage.setItem('watchRingPosition', String(ringPosition));
|
||||
localStorage.setItem('watchRingDirection', String(ringDirection));
|
||||
localStorage.setItem('watchRingHistory', JSON.stringify(ringHistory));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function markWatched(productId) {
|
||||
if (!productId) return;
|
||||
ringHistory[productId] = true;
|
||||
saveRingState();
|
||||
updateProgressDisplay();
|
||||
}
|
||||
|
||||
function isWatched(productId) {
|
||||
return !!ringHistory[productId];
|
||||
}
|
||||
|
||||
// Sync ring position to the current product
|
||||
function syncRingPosition(productId) {
|
||||
if (!ringProductIds.length || !productId) return;
|
||||
var idx = ringProductIds.indexOf(productId);
|
||||
if (idx !== -1) {
|
||||
ringPosition = idx;
|
||||
saveRingState();
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress display ("23 / 87")
|
||||
function updateProgressDisplay() {
|
||||
var el = document.getElementById('ring-progress');
|
||||
if (!el) return;
|
||||
if (!ringProductIds.length) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
var watched = 0;
|
||||
for (var id in ringHistory) {
|
||||
if (ringProductIds.indexOf(id) !== -1) watched++;
|
||||
}
|
||||
el.textContent = watched + ' / ' + ringProductIds.length;
|
||||
}
|
||||
|
||||
// Autoplay preference (persisted in localStorage)
|
||||
var autoplayEnabled = true;
|
||||
try {
|
||||
|
|
@ -46,48 +115,12 @@
|
|||
if (savedQueue) queue = JSON.parse(savedQueue);
|
||||
} catch (e) {}
|
||||
|
||||
// --- Recently played tracking (localStorage) ---
|
||||
var RECENTLY_PLAYED_COOLDOWN = 4 * 60 * 60 * 1000; // 4 hours
|
||||
var recentlyPlayed = {};
|
||||
try {
|
||||
var savedRecent = localStorage.getItem('watchRecentlyPlayed');
|
||||
if (savedRecent) recentlyPlayed = JSON.parse(savedRecent);
|
||||
} catch (e) {}
|
||||
|
||||
function cleanRecentlyPlayed() {
|
||||
var now = Date.now();
|
||||
var changed = false;
|
||||
for (var id in recentlyPlayed) {
|
||||
if (now - recentlyPlayed[id] > RECENTLY_PLAYED_COOLDOWN) {
|
||||
delete recentlyPlayed[id];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) saveRecentlyPlayed();
|
||||
}
|
||||
|
||||
function saveRecentlyPlayed() {
|
||||
try { localStorage.setItem('watchRecentlyPlayed', JSON.stringify(recentlyPlayed)); } catch (e) {}
|
||||
}
|
||||
|
||||
function markPlayed(productId) {
|
||||
if (!productId) return;
|
||||
recentlyPlayed[productId] = Date.now();
|
||||
saveRecentlyPlayed();
|
||||
}
|
||||
|
||||
function isRecentlyPlayed(productId) {
|
||||
if (!recentlyPlayed[productId]) return false;
|
||||
return (Date.now() - recentlyPlayed[productId]) < RECENTLY_PLAYED_COOLDOWN;
|
||||
}
|
||||
|
||||
// Clean stale entries on load
|
||||
cleanRecentlyPlayed();
|
||||
|
||||
// Mark current item as played
|
||||
// Mark current item as watched and sync ring position
|
||||
var initialProductEl = document.querySelector('[data-watch-product-id]');
|
||||
if (initialProductEl) {
|
||||
markPlayed(initialProductEl.getAttribute('data-watch-product-id'));
|
||||
var initialId = initialProductEl.getAttribute('data-watch-product-id');
|
||||
markWatched(initialId);
|
||||
syncRingPosition(initialId);
|
||||
}
|
||||
|
||||
// --- Autoplay with sound (for video/audio) ---
|
||||
|
|
@ -382,7 +415,13 @@
|
|||
|
||||
// --- Update page content (title, description, related, URL) ---
|
||||
function updatePageContent(data) {
|
||||
markPlayed(data.product_id);
|
||||
// Update ring from server response
|
||||
if (data.ring && data.ring.length) {
|
||||
ringProductIds = data.ring;
|
||||
}
|
||||
markWatched(data.product_id);
|
||||
syncRingPosition(data.product_id);
|
||||
|
||||
document.title = data.title;
|
||||
|
||||
var h1 = document.querySelector('.product-images h1');
|
||||
|
|
@ -419,27 +458,34 @@
|
|||
}
|
||||
|
||||
var autoplayChecked = autoplayEnabled ? ' checked' : '';
|
||||
var directionChecked = ringDirection === -1 ? ' checked' : '';
|
||||
var headerHtml = '<div class="related-content-header">'
|
||||
+ '<h3>Up Next</h3>'
|
||||
+ '<span id="ring-progress" class="ring-progress"></span>'
|
||||
+ '<label class="watch-autoplay-label js-only">'
|
||||
+ '<span class="autoplay-label-text">Autoplay</span>'
|
||||
+ '<input type="checkbox" id="watch-autoplay-toggle"' + autoplayChecked + ' />'
|
||||
+ '<span class="autoplay-slider"></span>'
|
||||
+ '</label>'
|
||||
+ '<label class="watch-direction-label js-only">'
|
||||
+ '<span class="direction-label-text">Reverse</span>'
|
||||
+ '<input type="checkbox" id="watch-direction-toggle"' + directionChecked + ' />'
|
||||
+ '<span class="autoplay-slider"></span>'
|
||||
+ '</label></div>';
|
||||
|
||||
if (!related || related.length === 0) {
|
||||
container.innerHTML = headerHtml + '<p>No more items</p>';
|
||||
rebindAutoplayToggle();
|
||||
rebindToggles();
|
||||
return;
|
||||
}
|
||||
|
||||
var html = headerHtml;
|
||||
var visibleIndex = 0;
|
||||
related.forEach(function(item) {
|
||||
var played = isRecentlyPlayed(item.id);
|
||||
if (played) return; // skip recently played
|
||||
visibleIndex++;
|
||||
html += '<div class="related-content-row">';
|
||||
var watched = isWatched(item.id);
|
||||
var dimClass = watched ? ' related-content-row-watched' : '';
|
||||
html += '<div class="related-content-row' + dimClass + '">';
|
||||
html += '<span class="related-content-index">' + visibleIndex + '</span>';
|
||||
html += '<a href="' + item.url + '" class="related-content-item" data-watch-id="' + item.id + '">';
|
||||
if (item.thumbnail_url) {
|
||||
|
|
@ -454,10 +500,10 @@
|
|||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
rebindAutoplayToggle();
|
||||
rebindToggles();
|
||||
}
|
||||
|
||||
function rebindAutoplayToggle() {
|
||||
function rebindToggles() {
|
||||
autoplayToggle = document.getElementById('watch-autoplay-toggle');
|
||||
if (autoplayToggle) {
|
||||
autoplayToggle.checked = autoplayEnabled;
|
||||
|
|
@ -472,6 +518,17 @@
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
var directionToggle = document.getElementById('watch-direction-toggle');
|
||||
if (directionToggle) {
|
||||
directionToggle.checked = (ringDirection === -1);
|
||||
directionToggle.addEventListener('change', function() {
|
||||
ringDirection = this.checked ? -1 : 1;
|
||||
saveRingState();
|
||||
});
|
||||
}
|
||||
|
||||
updateProgressDisplay();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
|
|
@ -480,25 +537,38 @@
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Countdown timer ---
|
||||
// --- Ring-based getNextItem ---
|
||||
function getNextItem() {
|
||||
// Queue items always play (user explicitly added them)
|
||||
// Queue items splice into current position — play queue first
|
||||
if (queue.length > 0) return queue[0];
|
||||
// Find first related item that hasn't been recently played
|
||||
var allRelated = document.querySelectorAll('.related-content-item[data-watch-id]');
|
||||
for (var i = 0; i < allRelated.length; i++) {
|
||||
var el = allRelated[i];
|
||||
var id = el.getAttribute('data-watch-id');
|
||||
if (!isRecentlyPlayed(id)) {
|
||||
|
||||
// Advance ring position by direction, return that item
|
||||
if (!ringProductIds.length) {
|
||||
// No ring — fall back to first related item in DOM
|
||||
var allRelated = document.querySelectorAll('.related-content-item[data-watch-id]');
|
||||
if (allRelated.length > 0) {
|
||||
var el = allRelated[0];
|
||||
return {
|
||||
id: id,
|
||||
id: el.getAttribute('data-watch-id'),
|
||||
title: el.querySelector('span') ? el.querySelector('span').textContent : '',
|
||||
thumbnail_url: el.querySelector('img') ? el.querySelector('img').src : null,
|
||||
url: el.getAttribute('href')
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
var nextPos = (ringPosition + ringDirection + ringProductIds.length) % ringProductIds.length;
|
||||
var nextId = ringProductIds[nextPos];
|
||||
|
||||
// Find the related item in the DOM for title/thumbnail
|
||||
var el = document.querySelector('[data-watch-id="' + nextId + '"]');
|
||||
return {
|
||||
id: nextId,
|
||||
title: el ? (el.querySelector('span') ? el.querySelector('span').textContent : '') : '',
|
||||
thumbnail_url: el ? (el.querySelector('img') ? el.querySelector('img').src : null) : null,
|
||||
url: el ? el.getAttribute('href') : '/c/' + nextId
|
||||
};
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
|
|
@ -551,10 +621,17 @@
|
|||
if (!next || !next.id) return;
|
||||
var productId = next.id;
|
||||
|
||||
// If this came from the queue, pop it (ring position unchanged)
|
||||
if (queue.length > 0 && queue[0].id === productId) {
|
||||
queue.shift();
|
||||
saveQueue();
|
||||
renderQueue();
|
||||
} else {
|
||||
// Advance ring position
|
||||
if (ringProductIds.length) {
|
||||
ringPosition = (ringPosition + ringDirection + ringProductIds.length) % ringProductIds.length;
|
||||
saveRingState();
|
||||
}
|
||||
}
|
||||
|
||||
if (preloadedData && preloadedData.product_id === productId) {
|
||||
|
|
@ -692,22 +769,18 @@
|
|||
);
|
||||
}
|
||||
|
||||
// Filter recently played from server-rendered related content
|
||||
function filterRecentlyPlayedFromDOM() {
|
||||
// Dim already-watched items in server-rendered related content
|
||||
function dimWatchedInDOM() {
|
||||
var rows = document.querySelectorAll('.related-content-row');
|
||||
var visibleIndex = 0;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var link = rows[i].querySelector('.related-content-item[data-watch-id]');
|
||||
if (link && isRecentlyPlayed(link.getAttribute('data-watch-id'))) {
|
||||
rows[i].style.display = 'none';
|
||||
} else {
|
||||
visibleIndex++;
|
||||
var indexEl = rows[i].querySelector('.related-content-index');
|
||||
if (indexEl) indexEl.textContent = visibleIndex;
|
||||
if (link && isWatched(link.getAttribute('data-watch-id'))) {
|
||||
rows[i].classList.add('related-content-row-watched');
|
||||
}
|
||||
}
|
||||
}
|
||||
filterRecentlyPlayedFromDOM();
|
||||
dimWatchedInDOM();
|
||||
updateProgressDisplay();
|
||||
|
||||
renderQueue();
|
||||
preloadNext();
|
||||
|
|
|
|||
|
|
@ -2367,6 +2367,141 @@ class TestStemmer(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class TestDiscoveryRing(unittest.TestCase):
|
||||
"""Test the discovery ring precomputation algorithm."""
|
||||
|
||||
def _make_shop(self):
|
||||
return Shop(
|
||||
"test-shop",
|
||||
"555-555-5555",
|
||||
"123 Test St",
|
||||
"Test shop description",
|
||||
)
|
||||
|
||||
def _make_product(self, title, desc, timestamp, visibility=1):
|
||||
from ..models.product import Product
|
||||
p = Product(title, desc)
|
||||
p.visibility = visibility
|
||||
p.created_timestamp = timestamp
|
||||
return p
|
||||
|
||||
def test_empty_shop(self):
|
||||
"""Empty shop returns empty ring."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = []
|
||||
self.assertEqual(compute_discovery_ring(shop), [])
|
||||
|
||||
def test_single_product(self):
|
||||
"""Single public product returns ring of one."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
p = self._make_product("Solo", "Only item", 100)
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = [p]
|
||||
ring = compute_discovery_ring(shop)
|
||||
self.assertEqual(ring, [str(p.id)])
|
||||
|
||||
def test_all_public_products_included(self):
|
||||
"""Every public product appears exactly once, no dupes."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
products = [self._make_product(f"Item {i}", f"Description {i}", 100 + i) for i in range(10)]
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = products
|
||||
ring = compute_discovery_ring(shop)
|
||||
ids = [str(p.id) for p in products]
|
||||
self.assertEqual(len(ring), 10)
|
||||
self.assertEqual(set(ring), set(ids))
|
||||
# No duplicates
|
||||
self.assertEqual(len(ring), len(set(ring)))
|
||||
|
||||
def test_private_and_unlisted_excluded(self):
|
||||
"""Only visibility==1 products appear in the ring."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
public = self._make_product("Public Song", "Visible", 300, visibility=1)
|
||||
private = self._make_product("Private Song", "Hidden", 200, visibility=0)
|
||||
unlisted = self._make_product("Unlisted Song", "Unlisted", 100, visibility=2)
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = [public, private, unlisted]
|
||||
ring = compute_discovery_ring(shop)
|
||||
self.assertEqual(ring, [str(public.id)])
|
||||
self.assertNotIn(str(private.id), ring)
|
||||
self.assertNotIn(str(unlisted.id), ring)
|
||||
|
||||
def test_starts_with_newest(self):
|
||||
"""Newest product (highest created_timestamp) is at position 0."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
old = self._make_product("Old Song", "First release", 100)
|
||||
mid = self._make_product("Mid Song", "Second release", 200)
|
||||
new = self._make_product("New Song", "Latest release", 300)
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = [old, mid, new]
|
||||
ring = compute_discovery_ring(shop)
|
||||
self.assertEqual(ring[0], str(new.id))
|
||||
|
||||
def test_similar_products_adjacent(self):
|
||||
"""Guitar cluster should appear before unrelated piano cluster."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
g1 = self._make_product("Blues Guitar Jam", "Electric guitar blues riffs", 400)
|
||||
g2 = self._make_product("Guitar Solo Practice", "Learn guitar solo techniques", 300)
|
||||
g3 = self._make_product("Acoustic Guitar Chords", "Guitar chord progressions", 200)
|
||||
piano = self._make_product("Piano Sonata", "Classical piano performance", 100)
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = [g1, g2, g3, piano]
|
||||
ring = compute_discovery_ring(shop)
|
||||
# All guitar items should cluster together before piano
|
||||
guitar_ids = {str(g1.id), str(g2.id), str(g3.id)}
|
||||
guitar_positions = [i for i, rid in enumerate(ring) if rid in guitar_ids]
|
||||
piano_pos = ring.index(str(piano.id))
|
||||
# Guitar items should be contiguous (adjacent)
|
||||
self.assertEqual(max(guitar_positions) - min(guitar_positions), 2)
|
||||
# Piano should be after all guitar items
|
||||
self.assertGreater(piano_pos, max(guitar_positions))
|
||||
|
||||
def test_deterministic(self):
|
||||
"""Same input produces same output every time."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
products = [self._make_product(f"Song {i}", f"Description {i}", 100 + i) for i in range(8)]
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = products
|
||||
ring1 = compute_discovery_ring(shop)
|
||||
ring2 = compute_discovery_ring(shop)
|
||||
self.assertEqual(ring1, ring2)
|
||||
|
||||
def test_ring_is_list_of_strings(self):
|
||||
"""Ring elements are UUID strings, not UUID objects."""
|
||||
from ..models.shop import compute_discovery_ring
|
||||
shop = self._make_shop()
|
||||
p = self._make_product("Test Song", "Some description", 100)
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = [p]
|
||||
ring = compute_discovery_ring(shop)
|
||||
self.assertIsInstance(ring, list)
|
||||
for item in ring:
|
||||
self.assertIsInstance(item, str)
|
||||
|
||||
def test_reforge_sets_shop_attribute(self):
|
||||
"""reforge_discovery_ring stores the ring on the shop object."""
|
||||
from ..models.shop import reforge_discovery_ring
|
||||
shop = self._make_shop()
|
||||
p1 = self._make_product("Song A", "Alpha", 200)
|
||||
p2 = self._make_product("Song B", "Beta", 100)
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
||||
mp.return_value = [p1, p2]
|
||||
ring = reforge_discovery_ring(shop)
|
||||
self.assertEqual(shop.discovery_ring, ring)
|
||||
self.assertEqual(len(ring), 2)
|
||||
# Verify JSON column was set
|
||||
import json
|
||||
self.assertEqual(json.loads(shop.json_discovery_ring), ring)
|
||||
|
||||
|
||||
class TestShopSubscription(unittest.TestCase):
|
||||
"""Test ShopSubscription model."""
|
||||
|
||||
|
|
|
|||
|
|
@ -74,8 +74,13 @@ def content(request):
|
|||
|
||||
related_products = []
|
||||
if product.shop.watch_mode_enabled:
|
||||
from ..models.product import get_related_products
|
||||
related_products = get_related_products(product)
|
||||
ring = product.shop.discovery_ring
|
||||
if ring:
|
||||
from ..models.product import get_ring_related_products
|
||||
related_products = get_ring_related_products(product, ring)
|
||||
else:
|
||||
from ..models.product import get_related_products
|
||||
related_products = get_related_products(product)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from . import (
|
|||
)
|
||||
|
||||
from ..models.product import Product
|
||||
from ..models.shop import reforge_discovery_ring
|
||||
from ..models.shop_location import ShopLocation
|
||||
from ..models.inventory import Inventory
|
||||
|
||||
|
|
@ -93,8 +94,13 @@ def product(request):
|
|||
|
||||
related_products = []
|
||||
if product.shop.watch_mode_enabled:
|
||||
from ..models.product import get_related_products
|
||||
related_products = get_related_products(product)
|
||||
ring = product.shop.discovery_ring
|
||||
if ring:
|
||||
from ..models.product import get_ring_related_products
|
||||
related_products = get_ring_related_products(product, ring)
|
||||
else:
|
||||
from ..models.product import get_related_products
|
||||
related_products = get_related_products(product)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
|
|
@ -163,6 +169,9 @@ def product_new(request):
|
|||
|
||||
request.dbsession.add(product)
|
||||
request.dbsession.flush()
|
||||
if request.shop.watch_mode_enabled:
|
||||
reforge_discovery_ring(request.shop)
|
||||
request.dbsession.flush()
|
||||
msg = ("Great, next you may upload files.", "success")
|
||||
request.session.flash(msg)
|
||||
return HTTPFound(f"/p/{product.id}/edit")
|
||||
|
|
@ -198,6 +207,9 @@ def product_edit_description(request):
|
|||
request.session.flash(msg)
|
||||
request.dbsession.add(product)
|
||||
request.dbsession.flush()
|
||||
if product.shop.watch_mode_enabled:
|
||||
reforge_discovery_ring(product.shop)
|
||||
request.dbsession.flush()
|
||||
|
||||
return {
|
||||
"markup_subject": f"Product Description for {product.title}",
|
||||
|
|
@ -330,6 +342,9 @@ def product_edit(request):
|
|||
product.stamp_updated_timestamp()
|
||||
request.dbsession.add(product)
|
||||
request.dbsession.flush()
|
||||
if product.shop.watch_mode_enabled:
|
||||
reforge_discovery_ring(product.shop)
|
||||
request.dbsession.flush()
|
||||
|
||||
# redirect back to this page to clear
|
||||
# the params posted by the s3 webhooks.
|
||||
|
|
@ -338,6 +353,9 @@ def product_edit(request):
|
|||
if product_modified and product.error_message is None:
|
||||
request.dbsession.add(product)
|
||||
request.dbsession.flush()
|
||||
if product.shop.watch_mode_enabled:
|
||||
reforge_discovery_ring(product.shop)
|
||||
request.dbsession.flush()
|
||||
|
||||
signed_posts = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from pyramid.view import view_config
|
||||
|
||||
from ..models.product import get_media_type, get_related_products
|
||||
from ..models.product import get_media_type, get_related_products, get_ring_related_products
|
||||
from ..models.shop import reforge_discovery_ring
|
||||
|
||||
|
||||
@view_config(route_name="watch_json", renderer="json")
|
||||
|
|
@ -75,8 +76,17 @@ def watch_json(request):
|
|||
p_or_c = "p" if product.is_sellable else "c"
|
||||
canonical_url = f"/{p_or_c}/{product.id}/{product.slug}"
|
||||
|
||||
# Related products
|
||||
related = get_related_products(product)
|
||||
# Related products via discovery ring (fallback to stem-based)
|
||||
ring = request.shop.discovery_ring
|
||||
if ring:
|
||||
if str(product.id) not in ring:
|
||||
# Product missing from ring — reforge inline
|
||||
ring = reforge_discovery_ring(request.shop)
|
||||
request.dbsession.flush()
|
||||
related = get_ring_related_products(product, ring)
|
||||
else:
|
||||
related = get_related_products(product)
|
||||
|
||||
related_data = []
|
||||
for r in related:
|
||||
r_ext = r.extensions.get("product", "")
|
||||
|
|
@ -107,4 +117,5 @@ def watch_json(request):
|
|||
"canonical_url": canonical_url,
|
||||
"is_sellable": product.is_sellable,
|
||||
"related": related_data,
|
||||
"ring": ring,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue