diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py
index f9f05b6..10d290d 100644
--- a/make_post_sell/models/product.py
+++ b/make_post_sell/models/product.py
@@ -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)
diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index e459c76..7437114 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -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()
diff --git a/make_post_sell/scripts/alembic/versions/05f7eb8cecf1_add_discovery_ring_to_shop.py b/make_post_sell/scripts/alembic/versions/05f7eb8cecf1_add_discovery_ring_to_shop.py
new file mode 100644
index 0000000..5246cd6
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/05f7eb8cecf1_add_discovery_ring_to_shop.py
@@ -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")
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index 2232cfe..94fe185 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -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);
diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js
index 6ccd0e0..9901c2e 100644
--- a/make_post_sell/static/js/watch.js
+++ b/make_post_sell/static/js/watch.js
@@ -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 = '
';
if (!related || related.length === 0) {
container.innerHTML = headerHtml + 'No more items
';
- 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 += '';
+ var watched = isWatched(item.id);
+ var dimClass = watched ? ' related-content-row-watched' : '';
+ html += '
';
html += '
' + visibleIndex + '';
html += '
';
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();
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 918d355..2d9481d 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -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."""
diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py
index 90a5c5b..dea0693 100644
--- a/make_post_sell/views/content.py
+++ b/make_post_sell/views/content.py
@@ -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,
diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py
index 3ce0dda..bdd7d72 100644
--- a/make_post_sell/views/product.py
+++ b/make_post_sell/views/product.py
@@ -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 = {}
diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py
index ac86295..981bdaf 100644
--- a/make_post_sell/views/watch.py
+++ b/make_post_sell/views/watch.py
@@ -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,
}