Add YouTube-style watch mode with sticky video, autoplay, and related content
Shop owners can enable watch mode in settings to get: direct video autoplay with muted fallback, sticky video player while scrolling, and a stemming-powered "Up Next" related content sidebar. Degrades gracefully per capability.
This commit is contained in:
parent
68089dff5b
commit
bc4858c58a
15 changed files with 526 additions and 4 deletions
37
docs/tickets/mps-1.md
Normal file
37
docs/tickets/mps-1.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# MPS-1: YouTube-Style Watch Experience
|
||||
|
||||
## Summary
|
||||
|
||||
Add a YouTube-like watch experience to product/content pages: sticky video player, autoplay with sound, stemming-powered "Up Next" related content sidebar, and a shop settings toggle to opt in.
|
||||
|
||||
## Features
|
||||
|
||||
- **Sticky Video**: `position: sticky; top: 0` on `.product-images` keeps video visible while scrolling
|
||||
- **Autoplay**: Direct `<video autoplay controls>` render (no thumbnail gate) with play() promise fallback to muted + unmute overlay
|
||||
- **Related Content**: Custom suffix-stripping stemmer scores title+description overlap to find related shop content (max 8 items)
|
||||
- **Shop Setting**: Radio button toggle in shop settings, off by default
|
||||
|
||||
## Degradation
|
||||
|
||||
| Capability | Experience |
|
||||
|-----------|-----------|
|
||||
| Full JS + autoplay | Video autoplays with sound, sticky, AJAX comments, related sidebar |
|
||||
| JS + autoplay blocked | Muted autoplay + unmute button |
|
||||
| No JS | `<noscript>` link, form POST comments, server-rendered sidebar |
|
||||
| No CSS sticky | Video scrolls normally |
|
||||
| Watch mode off | Existing click-to-play thumbnail behavior |
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `models/shop.py` - `watch_mode_enabled` column
|
||||
- `models/product.py` - `stem_word()`, `tokenize_and_stem()`, `get_related_products()`
|
||||
- `views/product.py`, `views/content.py` - pass `related_products` to template
|
||||
- `views/shop.py` - handle `watch_mode` setting
|
||||
- `templates/shop_settings.j2` - watch mode radio button
|
||||
- `templates/product.j2`, `templates/content.j2` - sticky container, direct video render
|
||||
- `templates/snippets/related_content.j2` - "Up Next" sidebar snippet
|
||||
- `static/js/watch.js` - autoplay promise handler
|
||||
- `static/css/common.css` - sticky video, unmute overlay, related content styles
|
||||
- `scripts/alembic/versions/` - migration for `watch_mode_enabled`
|
||||
- `tests/test_models.py` - stemmer + related products tests
|
||||
- `tests/test_functional.py` - watch mode settings tests
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import re
|
||||
import uuid
|
||||
|
||||
import json
|
||||
|
|
@ -673,3 +674,49 @@ def get_products_by_keywords(dbsession, keywords, shop=None):
|
|||
|
||||
# return a list of objects in sorted order by score.
|
||||
return [hits[_id] for _id in score_sorted_ids]
|
||||
|
||||
|
||||
_STEM_SUFFIXES = [
|
||||
'tion', 'ment', 'ness', 'able', 'ible', 'less',
|
||||
'ful', 'ous', 'ive', 'ing', 'ed', 'ly', 'er', 'est', 'es', 's',
|
||||
]
|
||||
|
||||
|
||||
def stem_word(word):
|
||||
"""Strip common English suffixes for fuzzy matching."""
|
||||
word = word.lower()
|
||||
for suffix in _STEM_SUFFIXES:
|
||||
if word.endswith(suffix) and len(word) - len(suffix) >= 3:
|
||||
return word[:-len(suffix)]
|
||||
return word
|
||||
|
||||
|
||||
def tokenize_and_stem(text):
|
||||
"""Split text into a set of stemmed words."""
|
||||
words = re.findall(r'[a-zA-Z]{3,}', text.lower())
|
||||
return {stem_word(w) for w in words}
|
||||
|
||||
|
||||
def get_related_products(product, limit=8):
|
||||
"""Find related products in same shop by stemmed title+description overlap."""
|
||||
current_stems = tokenize_and_stem(
|
||||
(product.title or '') + ' ' + (product.description or '')
|
||||
)
|
||||
if not current_stems:
|
||||
return []
|
||||
|
||||
candidates = []
|
||||
for other in product.shop.products:
|
||||
if other.id == product.id:
|
||||
continue
|
||||
if other.visibility != 1:
|
||||
continue
|
||||
other_stems = tokenize_and_stem(
|
||||
(other.title or '') + ' ' + (other.description or '')
|
||||
)
|
||||
score = len(current_stems & other_stems)
|
||||
if score > 0:
|
||||
candidates.append((score, other))
|
||||
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
return [p for _, p in candidates[:limit]]
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ class Shop(RBase, Base):
|
|||
# Show created/updated dates on product and content pages
|
||||
show_dates = Column(Boolean, default=True)
|
||||
|
||||
# Watch mode: sticky video, autoplay, related content sidebar
|
||||
watch_mode_enabled = Column(Boolean, default=False)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"""add watch_mode_enabled to shop
|
||||
|
||||
Revision ID: abe5eb8acb97
|
||||
Revises: ecb51cffd213
|
||||
Create Date: 2026-02-07 13:22:12.663420
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'abe5eb8acb97'
|
||||
down_revision = 'ecb51cffd213'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("watch_mode_enabled", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_shop", "watch_mode_enabled")
|
||||
|
|
@ -2526,3 +2526,65 @@ textarea {
|
|||
.theme-toggle-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Sticky video — watch mode only */
|
||||
.watch-mode .product-images {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
/* Watch video container for unmute overlay positioning */
|
||||
.watch-video-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.watch-video-container video.product-main {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Unmute overlay — shown by JS when autoplay-with-sound is blocked */
|
||||
.unmute-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Related content sidebar */
|
||||
.related-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.related-content h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.related-content-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.related-content-item img {
|
||||
width: 120px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.related-content-item span {
|
||||
align-self: center;
|
||||
}
|
||||
|
|
|
|||
29
make_post_sell/static/js/watch.js
Normal file
29
make_post_sell/static/js/watch.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
(function() {
|
||||
var video = document.getElementById('watch-video');
|
||||
if (!video) return;
|
||||
|
||||
// Try autoplay with sound (works when user navigated here via click)
|
||||
var playPromise = video.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(function() {
|
||||
// Browser blocked autoplay with sound — try muted
|
||||
video.muted = true;
|
||||
video.play().then(function() {
|
||||
// Playing muted — show unmute button
|
||||
showUnmuteOverlay(video);
|
||||
}).catch(function() {
|
||||
// Even muted autoplay blocked — user needs to click play
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showUnmuteOverlay(video) {
|
||||
var btn = document.querySelector('.unmute-overlay');
|
||||
if (!btn) return;
|
||||
btn.style.display = 'block';
|
||||
btn.addEventListener('click', function() {
|
||||
video.muted = false;
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -30,12 +30,22 @@
|
|||
|
||||
{% block content -%}
|
||||
|
||||
<section class="two-column">
|
||||
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
|
||||
|
||||
<div class="product-images">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
{% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %}
|
||||
{% if product.extensions.get("product") in video_extensions %}
|
||||
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
|
||||
{# Watch mode: direct video render with autoplay #}
|
||||
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
<div class="watch-video-container">
|
||||
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
|
||||
<button class="unmute-overlay">Tap to unmute</button>
|
||||
</div>
|
||||
<noscript>
|
||||
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
|
||||
</noscript>
|
||||
{% elif product.extensions.get("product") in video_extensions %}
|
||||
{# Video: play button overlay, click to play inline #}
|
||||
{% set video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ video_url }}')">
|
||||
|
|
@ -66,6 +76,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'snippets/related_content.j2' %}
|
||||
</section>
|
||||
|
||||
<div class="product-description">
|
||||
|
|
@ -118,5 +129,8 @@ function playInline(container, videoUrl) {
|
|||
container.appendChild(video);
|
||||
}
|
||||
</script>
|
||||
{% if request.shop.watch_mode_enabled %}
|
||||
<script src="/static/js/watch.js"></script>
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -28,14 +28,28 @@
|
|||
|
||||
{% block content -%}
|
||||
|
||||
<section class="two-column">
|
||||
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
|
||||
|
||||
<div class="product-images">
|
||||
<h1>{{ product.title }} <span class="subtitle-text">sold by <a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ product.shop.name }}</a></span></h1>
|
||||
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
{% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %}
|
||||
{% if signed_get_object_url and product.extensions.get("product") in video_extensions %}
|
||||
{% if request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in video_extensions %}
|
||||
{# Watch mode: direct video render with autoplay #}
|
||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
||||
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
{% else %}
|
||||
{% set watch_video_url = signed_get_object_url %}
|
||||
{% endif %}
|
||||
<div class="watch-video-container">
|
||||
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
|
||||
<button class="unmute-overlay">Tap to unmute</button>
|
||||
</div>
|
||||
<noscript>
|
||||
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
|
||||
</noscript>
|
||||
{% elif signed_get_object_url and product.extensions.get("product") in video_extensions %}
|
||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
||||
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ preview_url }}')">
|
||||
|
|
@ -128,6 +142,8 @@
|
|||
{% endif %}
|
||||
<div>
|
||||
|
||||
{% include 'snippets/related_content.j2' %}
|
||||
|
||||
</section>
|
||||
|
||||
<div class="product-description">
|
||||
|
|
@ -196,5 +212,8 @@ function playInline(container, videoUrl) {
|
|||
container.appendChild(video);
|
||||
}
|
||||
</script>
|
||||
{% if request.shop.watch_mode_enabled %}
|
||||
<script src="/static/js/watch.js"></script>
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -740,6 +740,21 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
<label>Watch Mode</label>
|
||||
<br />
|
||||
<input type="radio" name="watch_mode" id="watch_mode_on" value="1"
|
||||
{% if request.shop.watch_mode_enabled %}checked{% endif %} />
|
||||
<label for="watch_mode_on" class="inline-label">Enable watch mode (sticky video, autoplay, related content sidebar)</label>
|
||||
<br />
|
||||
<input type="radio" name="watch_mode" id="watch_mode_off" value="0"
|
||||
{% if not request.shop.watch_mode_enabled %}checked{% endif %} />
|
||||
<label for="watch_mode_off" class="inline-label">Disable watch mode</label>
|
||||
<br />
|
||||
<small class="note-text">When enabled, video content pages autoplay, the video stays visible while scrolling, and a related content sidebar appears.</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
<br />
|
||||
|
|
|
|||
13
make_post_sell/templates/snippets/related_content.j2
Normal file
13
make_post_sell/templates/snippets/related_content.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% if related_products %}
|
||||
<div class="related-content">
|
||||
<h3>Up Next</h3>
|
||||
{% for related in related_products %}
|
||||
<a href="{{ related.absolute_url(request) }}" class="related-content-item">
|
||||
{% if "thumbnail1" in related.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
|
||||
{% endif %}
|
||||
<span>{{ related.title }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -2364,3 +2364,53 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
|
||||
data = res.json
|
||||
self.assertFalse(data["approved"])
|
||||
|
||||
def test_watch_mode_setting_default_off(self):
|
||||
"""Test that new shops have watch mode disabled by default."""
|
||||
shop = self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
|
||||
# New shops should have watch mode disabled
|
||||
self.dbsession.refresh(shop)
|
||||
self.assertFalse(shop.watch_mode_enabled)
|
||||
|
||||
def test_watch_mode_setting_toggle(self):
|
||||
"""Test enabling and disabling watch mode via settings form."""
|
||||
shop = self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
|
||||
# Enable watch mode (watch_mode is in the ribbon-settings form section)
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "ribbon-settings",
|
||||
"watch_mode": "1",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn("Watch mode is now enabled", res.text)
|
||||
|
||||
# Verify it was saved
|
||||
self.dbsession.expire(shop)
|
||||
self.assertTrue(shop.watch_mode_enabled)
|
||||
|
||||
# Disable watch mode
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "ribbon-settings",
|
||||
"watch_mode": "0",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn("Watch mode is now disabled", res.text)
|
||||
|
||||
# Verify it was saved
|
||||
self.dbsession.expire(shop)
|
||||
self.assertFalse(shop.watch_mode_enabled)
|
||||
|
|
|
|||
|
|
@ -1852,3 +1852,181 @@ class TestAdyen(unittest.TestCase):
|
|||
invoice.shop = self.shop
|
||||
invoice.adyen_psp_reference = "882619391893263J"
|
||||
self.assertEqual(invoice.adyen_psp_reference, "882619391893263J")
|
||||
|
||||
|
||||
class TestStemmer(unittest.TestCase):
|
||||
"""Test the stemmer and related products functions."""
|
||||
|
||||
def test_stem_word_basic(self):
|
||||
"""Test common suffixes stripped correctly."""
|
||||
from ..models.product import stem_word
|
||||
|
||||
self.assertEqual(stem_word("running"), "runn")
|
||||
self.assertEqual(stem_word("played"), "play")
|
||||
self.assertEqual(stem_word("quickly"), "quick")
|
||||
self.assertEqual(stem_word("cats"), "cat")
|
||||
self.assertEqual(stem_word("boxes"), "box")
|
||||
self.assertEqual(stem_word("creation"), "crea")
|
||||
self.assertEqual(stem_word("movement"), "move")
|
||||
self.assertEqual(stem_word("darkness"), "dark")
|
||||
self.assertEqual(stem_word("readable"), "read")
|
||||
self.assertEqual(stem_word("hopeful"), "hope")
|
||||
self.assertEqual(stem_word("careless"), "care")
|
||||
self.assertEqual(stem_word("dangerous"), "danger")
|
||||
self.assertEqual(stem_word("creative"), "creat")
|
||||
self.assertEqual(stem_word("bigger"), "bigg")
|
||||
self.assertEqual(stem_word("biggest"), "bigg")
|
||||
|
||||
def test_stem_word_short_preserved(self):
|
||||
"""Test words too short to stem are unchanged."""
|
||||
from ..models.product import stem_word
|
||||
|
||||
# Words where remaining stem would be < 3 chars should not be stripped
|
||||
self.assertEqual(stem_word("it"), "it")
|
||||
self.assertEqual(stem_word("is"), "is")
|
||||
self.assertEqual(stem_word("an"), "an")
|
||||
self.assertEqual(stem_word("the"), "the")
|
||||
self.assertEqual(stem_word("bed"), "bed")
|
||||
|
||||
def test_stem_word_case_insensitive(self):
|
||||
"""Test stemming is case insensitive."""
|
||||
from ..models.product import stem_word
|
||||
|
||||
self.assertEqual(stem_word("Running"), stem_word("running"))
|
||||
self.assertEqual(stem_word("PLAYED"), stem_word("played"))
|
||||
|
||||
def test_tokenize_and_stem(self):
|
||||
"""Test sentence -> set of stems."""
|
||||
from ..models.product import tokenize_and_stem
|
||||
|
||||
result = tokenize_and_stem("The cats are running quickly")
|
||||
# "the" -> "the" (3 chars, no suffix stripped)
|
||||
# "cats" -> "cat", "are" -> "are", "running" -> "runn", "quickly" -> "quick"
|
||||
self.assertIn("cat", result)
|
||||
self.assertIn("runn", result)
|
||||
self.assertIn("quick", result)
|
||||
self.assertIn("are", result)
|
||||
self.assertIn("the", result)
|
||||
# Short words (<3 chars) are excluded by the regex
|
||||
self.assertNotIn("is", result)
|
||||
self.assertNotIn("a", result)
|
||||
|
||||
def test_tokenize_and_stem_empty(self):
|
||||
"""Test empty string returns empty set."""
|
||||
from ..models.product import tokenize_and_stem
|
||||
|
||||
self.assertEqual(tokenize_and_stem(""), set())
|
||||
self.assertEqual(tokenize_and_stem("12 34"), set())
|
||||
|
||||
def test_get_related_products(self):
|
||||
"""Test returns ranked matches, excludes self and non-public."""
|
||||
from ..models.product import Product, get_related_products
|
||||
|
||||
shop = Shop(
|
||||
"test-shop",
|
||||
"555-555-5555",
|
||||
"123 Test St",
|
||||
"Test shop description",
|
||||
)
|
||||
|
||||
# Create products with related titles
|
||||
product1 = Product("Blues Guitar Lessons", "Learn acoustic guitar blues riffs")
|
||||
product1.shop = shop
|
||||
product1.visibility = 1
|
||||
|
||||
# shares: guitar, learn = 2 stems
|
||||
product2 = Product("Jazz Guitar Tutorial", "Learn jazz techniques")
|
||||
product2.shop = shop
|
||||
product2.visibility = 1
|
||||
|
||||
# shares: learn = 1 stem (no guitar/blues overlap)
|
||||
product3 = Product("Piano for Beginners", "Learn piano basics")
|
||||
product3.shop = shop
|
||||
product3.visibility = 1
|
||||
|
||||
# shares: guitar, blues, learn, riff = 4 stems (most overlap)
|
||||
product4 = Product("Guitar Blues Collection", "Learn blues guitar riffs and licks")
|
||||
product4.shop = shop
|
||||
product4.visibility = 1
|
||||
|
||||
product5_private = Product("Secret Guitar Video", "Private guitar content")
|
||||
product5_private.shop = shop
|
||||
product5_private.visibility = 0 # private
|
||||
|
||||
# Mock shop.products to return our test products
|
||||
all_products = [product1, product2, product3, product4, product5_private]
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
||||
mock_products.return_value = all_products
|
||||
|
||||
related = get_related_products(product1)
|
||||
|
||||
# Should not include product1 itself
|
||||
self.assertNotIn(product1, related)
|
||||
|
||||
# Should not include private product
|
||||
self.assertNotIn(product5_private, related)
|
||||
|
||||
# Should include related products
|
||||
related_titles = [p.title for p in related]
|
||||
self.assertIn("Guitar Blues Collection", related_titles)
|
||||
self.assertIn("Jazz Guitar Tutorial", related_titles)
|
||||
self.assertIn("Piano for Beginners", related_titles)
|
||||
|
||||
# product4 shares most stems with product1
|
||||
# so it should be ranked higher (earlier index) than product3
|
||||
self.assertLess(related.index(product4), related.index(product3))
|
||||
|
||||
def test_get_related_products_empty_description(self):
|
||||
"""Test with empty title/description."""
|
||||
from ..models.product import Product, get_related_products
|
||||
|
||||
shop = Shop(
|
||||
"test-shop",
|
||||
"555-555-5555",
|
||||
"123 Test St",
|
||||
"Test shop description",
|
||||
)
|
||||
|
||||
product = Product("", "")
|
||||
product.shop = shop
|
||||
product.visibility = 1
|
||||
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
||||
mock_products.return_value = []
|
||||
related = get_related_products(product)
|
||||
self.assertEqual(related, [])
|
||||
|
||||
def test_get_related_products_limit(self):
|
||||
"""Test that limit parameter is respected."""
|
||||
from ..models.product import Product, get_related_products
|
||||
|
||||
shop = Shop(
|
||||
"test-shop",
|
||||
"555-555-5555",
|
||||
"123 Test St",
|
||||
"Test shop description",
|
||||
)
|
||||
|
||||
product1 = Product("Guitar Music", "Guitar tutorial")
|
||||
product1.shop = shop
|
||||
product1.visibility = 1
|
||||
|
||||
# Create many related products
|
||||
other_products = []
|
||||
for i in range(15):
|
||||
p = Product(f"Guitar Video {i}", f"Guitar lesson {i}")
|
||||
p.shop = shop
|
||||
p.visibility = 1
|
||||
other_products.append(p)
|
||||
|
||||
all_products = [product1] + other_products
|
||||
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
||||
mock_products.return_value = all_products
|
||||
|
||||
# Default limit is 8
|
||||
related = get_related_products(product1)
|
||||
self.assertLessEqual(len(related), 8)
|
||||
|
||||
# Custom limit
|
||||
related = get_related_products(product1, limit=3)
|
||||
self.assertLessEqual(len(related), 3)
|
||||
|
|
|
|||
|
|
@ -72,10 +72,16 @@ def content(request):
|
|||
request.dbsession, product.id, shop=product.shop, user=request.user
|
||||
)
|
||||
|
||||
related_products = []
|
||||
if product.shop.watch_mode_enabled:
|
||||
from ..models.product import get_related_products
|
||||
related_products = get_related_products(product)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"product_size": product_size,
|
||||
"signed_get_object_url": signed_get_object_url,
|
||||
"comments": comments,
|
||||
"shop": product.shop,
|
||||
"related_products": related_products,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,12 +91,18 @@ def product(request):
|
|||
request.dbsession, product.id, shop=product.shop, user=request.user
|
||||
)
|
||||
|
||||
related_products = []
|
||||
if product.shop.watch_mode_enabled:
|
||||
from ..models.product import get_related_products
|
||||
related_products = get_related_products(product)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"product_size": product_size,
|
||||
"signed_get_object_url": signed_get_object_url,
|
||||
"comments": comments,
|
||||
"shop": product.shop,
|
||||
"related_products": related_products,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -606,6 +606,20 @@ def shop_settings(request):
|
|||
)
|
||||
)
|
||||
|
||||
# Handle watch mode setting
|
||||
watch_mode_value = request.params.get("watch_mode")
|
||||
if watch_mode_value is not None:
|
||||
watch_mode = int(watch_mode_value) == 1
|
||||
if shop.watch_mode_enabled != watch_mode:
|
||||
shop.watch_mode_enabled = watch_mode
|
||||
status = "enabled" if watch_mode else "disabled"
|
||||
request.session.flash(
|
||||
(
|
||||
f"Watch mode is now {status}",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
# Handle stripe settings form
|
||||
if form_section == "stripe-settings":
|
||||
# Handle disable action
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue