Video watch metrics, per-product analytics page, SPA thumbnail sync
Analytics:
- Add video watch metrics section to shop dashboard (avg % played,
completion rate, seeks, rewinds, pauses, speed) with per-product
video breakdown table
- Add per-product analytics detail page at /s/{shop_id}/analytics/{product_id}
with views over time, video watch, traffic, devices, ring entries,
engagement, comment sentiment, and price history
- Product title links on shop analytics now go to per-product analytics
- Add get_sentiment_summary_for_product() helper
SPA fix:
- Update thumbnail image src and wrapping link href during DJ fade
- Clear stale extra thumbnails from previous product
- Add file_url to watch JSON for thumbnail-to-file links
This commit is contained in:
parent
2f5498a1f4
commit
faefd94abf
21 changed files with 1221 additions and 27 deletions
|
|
@ -1,12 +1,16 @@
|
|||
# Add sentiment analysis to comments
|
||||
|
||||
Integrate sentiment analysis for comments to support the Love pillar:
|
||||
Lightweight, self-contained feature. Comments exist, sentiment scoring doesn't.
|
||||
|
||||
## Scope
|
||||
|
||||
- Classify comments as positive/neutral/negative
|
||||
- Products with >7:1 positive:negative ratio get "Love Verified" badge
|
||||
- Weight thoughtful 42+ word comments 14x more than emoji-only or single-word reactions
|
||||
- Feed sentiment scores into the engagement weighting system (positive comments = 7x, neutral = 1x, negative = 0x)
|
||||
- Use a lightweight local model or rule-based approach to avoid external API dependency
|
||||
- Use a lightweight local rule-based approach (no external API dependency)
|
||||
- Store sentiment score per comment in DB
|
||||
- Feed sentiment data into existing analytics dashboard (MPS-3)
|
||||
|
||||
Blocked by: engagement signal weighting system.
|
||||
## Notes
|
||||
|
||||
- No longer blocked by engagement signal weighting — analytics is live (MPS-3)
|
||||
- Products with high positive:negative ratio could surface in analytics
|
||||
- Keep it simple: rule-based first, upgrade to ML later if needed
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# Implement Love Gifts (voluntary tipping system)
|
||||
|
||||
Add voluntary tip/gift system beyond purchases:
|
||||
**Decision**: Descoped. xmrchat.com already solves tipping better and accepts
|
||||
more coins. If revisited, scope to internal shop owners and power shoppers or
|
||||
paying members only — not a public feature.
|
||||
|
||||
## Original scope (archived)
|
||||
|
||||
- Love Gifts: Tips from 1-420 units of currency
|
||||
- No platform cut on the first $0.69 (pure creator love)
|
||||
- UI: Gift button on product pages and in watch mode
|
||||
- Integrate with existing payment infrastructure (Stripe, PayPal, crypto)
|
||||
- Track gifts in DB for engagement scoring (counts as strong positive signal)
|
||||
- Update watch.js SPA to include gift button in updatePageContent()
|
||||
- Server-rendered gift button for non-JS users
|
||||
|
||||
## Status: ON HOLD — revisit when membership tiers exist
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
# Build transparent pricing history display
|
||||
|
||||
Show price history for products to enforce Truth:
|
||||
Price model already exists (`price_history` relationship on Product). Needs display.
|
||||
|
||||
- Display 14-day, 21-day, and 42-day price history on product pages
|
||||
- Track all price changes in a price_history table (product_id, price, timestamp)
|
||||
## Scope
|
||||
|
||||
- Visible to **shop owners** in settings/analytics — not public by default
|
||||
- Optionally visible to paying members (future membership tier gate)
|
||||
- Display price timeline on product pages (expandable section)
|
||||
- No dark patterns — "was $420, now $7!" must be verifiable from history
|
||||
- Simple visual: small text or expandable section showing price timeline
|
||||
- If product has digital scarcity limits, enforce and display actual remaining count
|
||||
|
||||
## Implementation
|
||||
|
||||
- `price_history` relationship already on Product model — just wire up display
|
||||
- Small, honest, useful: show price change dates and amounts
|
||||
- Add to analytics dashboard for shop owner view
|
||||
- Keep public display behind a toggle (off by default)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
e1a8b2f
|
||||
2f5498a
|
||||
98
make_post_sell/lib/sentiment.py
Normal file
98
make_post_sell/lib/sentiment.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""
|
||||
Rule-based comment sentiment classifier.
|
||||
|
||||
No external dependencies — uses only the ``re`` module. Returns −1
|
||||
(negative), 0 (neutral), or 1 (positive) for a given comment string.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
POSITIVE_WORDS = frozenset({
|
||||
"love", "great", "awesome", "amazing", "excellent", "fantastic",
|
||||
"wonderful", "beautiful", "perfect", "brilliant", "outstanding",
|
||||
"superb", "incredible", "magnificent", "thank", "thanks", "good",
|
||||
"best", "cool", "nice", "happy", "enjoy", "enjoyed", "helpful",
|
||||
"fire", "impressive", "solid", "recommend", "recommended",
|
||||
"favorite", "favourite", "gem", "inspiring", "inspirational",
|
||||
"masterpiece", "phenomenal", "stellar", "bravo", "wow",
|
||||
"delightful", "exceptional", "glorious", "dope",
|
||||
})
|
||||
|
||||
NEGATIVE_WORDS = frozenset({
|
||||
"bad", "terrible", "awful", "horrible", "worst", "hate",
|
||||
"disappointing", "disappointed", "poor", "boring", "waste",
|
||||
"ugly", "broken", "useless", "annoying", "frustrating",
|
||||
"mediocre", "overpriced", "scam", "trash", "garbage",
|
||||
"rubbish", "dreadful", "pathetic", "lame", "sucks",
|
||||
"meh", "bland", "uninspired", "weak", "confusing",
|
||||
"disgusting", "offensive", "painful", "regret", "refund",
|
||||
"spam", "fake", "ripoff", "avoid", "lackluster",
|
||||
})
|
||||
|
||||
NEGATION_WORDS = frozenset({
|
||||
"not", "no", "never", "neither", "nor", "nothing",
|
||||
"nowhere", "hardly", "barely", "scarcely", "dont",
|
||||
"doesnt", "didnt", "wasnt", "werent", "wont",
|
||||
"wouldnt", "couldnt", "shouldnt", "isnt", "arent",
|
||||
"cant", "cannot",
|
||||
})
|
||||
|
||||
INTENSIFIERS = frozenset({
|
||||
"very", "really", "extremely", "absolutely", "incredibly",
|
||||
"totally", "completely", "utterly", "super", "so",
|
||||
})
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-zA-Z]{2,}")
|
||||
_NEGATION_WINDOW = 3
|
||||
|
||||
|
||||
def classify_sentiment(text):
|
||||
"""Classify *text* and return ``-1``, ``0``, or ``1``."""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
tokens = [t.lower() for t in _TOKEN_RE.findall(text)]
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
score = 0.0
|
||||
negation_countdown = 0
|
||||
intensifier_active = False
|
||||
|
||||
for token in tokens:
|
||||
# Strip trailing apostrophe contractions already handled by regex
|
||||
clean = token.replace("'", "")
|
||||
|
||||
if clean in NEGATION_WORDS:
|
||||
negation_countdown = _NEGATION_WINDOW
|
||||
continue
|
||||
|
||||
if clean in INTENSIFIERS:
|
||||
intensifier_active = True
|
||||
continue
|
||||
|
||||
weight = 0.0
|
||||
if clean in POSITIVE_WORDS:
|
||||
weight = 1.0
|
||||
elif clean in NEGATIVE_WORDS:
|
||||
weight = -1.0
|
||||
|
||||
if weight != 0.0:
|
||||
if intensifier_active:
|
||||
weight *= 1.5
|
||||
if negation_countdown > 0:
|
||||
weight = -weight
|
||||
score += weight
|
||||
|
||||
intensifier_active = False
|
||||
if negation_countdown > 0:
|
||||
negation_countdown -= 1
|
||||
|
||||
# Normalize by token count to avoid bias toward long comments
|
||||
normalized = score / len(tokens)
|
||||
|
||||
if normalized > 0.04:
|
||||
return 1
|
||||
elif normalized < -0.04:
|
||||
return -1
|
||||
return 0
|
||||
|
|
@ -26,6 +26,7 @@ from make_post_sell.lib.time_funcs import (
|
|||
)
|
||||
|
||||
from make_post_sell.lib.render import markdown_to_html
|
||||
from make_post_sell.lib.sentiment import classify_sentiment
|
||||
|
||||
from .meta import Base, RBase
|
||||
from .meta import UUIDType
|
||||
|
|
@ -82,6 +83,9 @@ class Comment(RBase, Base):
|
|||
# by default comments are approved. unless shop requires approval.
|
||||
approved = Column(Boolean, default=True)
|
||||
|
||||
# sentiment: -1 = negative, 0 = neutral, 1 = positive, None = unscored
|
||||
sentiment = Column(Integer, default=None)
|
||||
|
||||
ip_address = Column(Unicode(45), default=None)
|
||||
|
||||
# lazy='joined' performs a left join to reduce queries, it's magic.
|
||||
|
|
@ -190,6 +194,22 @@ class Comment(RBase, Base):
|
|||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def human_sentiment(self):
|
||||
if self.sentiment == 1:
|
||||
return "positive"
|
||||
elif self.sentiment == -1:
|
||||
return "negative"
|
||||
return "neutral"
|
||||
|
||||
@property
|
||||
def sentiment_icon(self):
|
||||
if self.sentiment == 1:
|
||||
return "+"
|
||||
elif self.sentiment == -1:
|
||||
return "-"
|
||||
return "~"
|
||||
|
||||
def set_data(self, data):
|
||||
"""Set comment data and generate HTML."""
|
||||
self.data = data
|
||||
|
|
@ -197,8 +217,10 @@ class Comment(RBase, Base):
|
|||
# Pass shop context if available through product relationship
|
||||
shop = self.product.shop if self.product else None
|
||||
self.data_html = markdown_to_html(data, shop)
|
||||
self.sentiment = classify_sentiment(data)
|
||||
else:
|
||||
self.data_html = None
|
||||
self.sentiment = 0
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
def stamp_updated_timestamp(self):
|
||||
|
|
@ -330,3 +352,66 @@ def get_total_comment_count_for_product(dbsession, product_id, shop=None, user=N
|
|||
)
|
||||
|
||||
return query.count()
|
||||
|
||||
|
||||
def get_sentiment_summary_for_shop(dbsession, shop_id, cutoff_ms):
|
||||
"""Return counts of positive/neutral/negative comments since *cutoff_ms*.
|
||||
|
||||
Returns a dict ``{"positive": int, "neutral": int, "negative": int, "total": int}``.
|
||||
"""
|
||||
from .product import Product
|
||||
|
||||
rows = (
|
||||
dbsession.query(Comment.sentiment, func.count().label("cnt"))
|
||||
.join(Product)
|
||||
.filter(
|
||||
Product.shop_id == shop_id,
|
||||
Comment.disabled == False,
|
||||
Comment.approved == True,
|
||||
Comment.sentiment.isnot(None),
|
||||
Comment.created_timestamp > cutoff_ms,
|
||||
)
|
||||
.group_by(Comment.sentiment)
|
||||
.all()
|
||||
)
|
||||
|
||||
summary = {"positive": 0, "neutral": 0, "negative": 0, "total": 0}
|
||||
for sentiment_val, cnt in rows:
|
||||
if sentiment_val == 1:
|
||||
summary["positive"] = cnt
|
||||
elif sentiment_val == -1:
|
||||
summary["negative"] = cnt
|
||||
else:
|
||||
summary["neutral"] = cnt
|
||||
summary["total"] += cnt
|
||||
return summary
|
||||
|
||||
|
||||
def get_sentiment_summary_for_product(dbsession, product_id, cutoff_ms):
|
||||
"""Return counts of positive/neutral/negative comments for a product since *cutoff_ms*.
|
||||
|
||||
Returns a dict ``{"positive": int, "neutral": int, "negative": int, "total": int}``.
|
||||
"""
|
||||
rows = (
|
||||
dbsession.query(Comment.sentiment, func.count().label("cnt"))
|
||||
.filter(
|
||||
Comment.product_id == product_id,
|
||||
Comment.disabled == False,
|
||||
Comment.approved == True,
|
||||
Comment.sentiment.isnot(None),
|
||||
Comment.created_timestamp > cutoff_ms,
|
||||
)
|
||||
.group_by(Comment.sentiment)
|
||||
.all()
|
||||
)
|
||||
|
||||
summary = {"positive": 0, "neutral": 0, "negative": 0, "total": 0}
|
||||
for sentiment_val, cnt in rows:
|
||||
if sentiment_val == 1:
|
||||
summary["positive"] = cnt
|
||||
elif sentiment_val == -1:
|
||||
summary["negative"] = cnt
|
||||
else:
|
||||
summary["neutral"] = cnt
|
||||
summary["total"] += cnt
|
||||
return summary
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ def includeme(config):
|
|||
config.add_route("shop_sales", "/s/{shop_id}/sales")
|
||||
config.add_route("shop_comments", "/s/{shop_id}/comments")
|
||||
config.add_route("shop_analytics", "/s/{shop_id}/analytics")
|
||||
config.add_route("product_analytics", "/s/{shop_id}/analytics/{product_id}")
|
||||
|
||||
config.add_route("shop_settings", "/s/{shop_id}/settings")
|
||||
config.add_route(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"""add sentiment column to comment
|
||||
|
||||
Revision ID: 3c0996ae20f3
|
||||
Revises: e620bf529ed9
|
||||
Create Date: 2026-02-11 06:43:24.008752
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3c0996ae20f3'
|
||||
down_revision = 'e620bf529ed9'
|
||||
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_comment", "sentiment"):
|
||||
op.add_column(
|
||||
"mps_comment",
|
||||
sa.Column("sentiment", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
# Backfill existing comments
|
||||
from make_post_sell.lib.sentiment import classify_sentiment
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, data FROM mps_comment WHERE sentiment IS NULL AND data IS NOT NULL")
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
comment_id, data = row
|
||||
sentiment = classify_sentiment(data)
|
||||
conn.execute(
|
||||
sa.text("UPDATE mps_comment SET sentiment = :s WHERE id = :id"),
|
||||
{"s": sentiment, "id": comment_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_comment", "sentiment")
|
||||
|
|
@ -2721,12 +2721,18 @@ textarea {
|
|||
|
||||
.ring-header-controls {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-columns: auto auto auto;
|
||||
gap: 16px;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skip-next-btn {
|
||||
font-size: 0.75em;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.related-content-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
|
|
@ -2811,6 +2817,9 @@ textarea {
|
|||
.related-content-row-current .related-content-index {
|
||||
color: white;
|
||||
}
|
||||
.related-content-row-current .queue-add-btn {
|
||||
color: white;
|
||||
}
|
||||
.related-content-now-playing {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -3291,3 +3300,7 @@ textarea {
|
|||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.price-history-current td {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
var djCrossfadeData = null;
|
||||
var preloadingId = null;
|
||||
var currentProductId = null;
|
||||
var currentCanonicalUrl = null;
|
||||
var currentProductMediaType = null;
|
||||
var currentThumbnailUrl = null;
|
||||
var urlRefreshTimer = null;
|
||||
var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes
|
||||
var PROGRESS_KEY = 'mps_watch_progress';
|
||||
|
|
@ -537,6 +540,9 @@
|
|||
}
|
||||
if (data.is_mod) isMod = true;
|
||||
currentProductId = data.product_id;
|
||||
currentCanonicalUrl = data.canonical_url || null;
|
||||
currentProductMediaType = data.media_type || null;
|
||||
currentThumbnailUrl = data.thumbnail_url || null;
|
||||
markWatched(data.product_id);
|
||||
syncRingPosition(data.product_id);
|
||||
scheduleUrlRefresh();
|
||||
|
|
@ -581,6 +587,26 @@
|
|||
if (dlWell) dlWell.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update non-media product-main images (audio cover art, thumbnail-to-file links)
|
||||
var mainImgs = document.querySelectorAll('img.product-main');
|
||||
for (var mi = 0; mi < mainImgs.length; mi++) {
|
||||
if (data.thumbnail_url) {
|
||||
mainImgs[mi].src = data.thumbnail_url;
|
||||
}
|
||||
var parentLink = mainImgs[mi].closest('a');
|
||||
if (parentLink && data.file_url) {
|
||||
parentLink.href = data.file_url;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear stale extra thumbnails from previous product
|
||||
var staleThumbs = document.querySelectorAll('.product-thumbnail');
|
||||
for (var st = 0; st < staleThumbs.length; st++) {
|
||||
var thumbLink = staleThumbs[st].closest('a');
|
||||
if (thumbLink) thumbLink.remove();
|
||||
else staleThumbs[st].remove();
|
||||
}
|
||||
|
||||
// Update file type/size info
|
||||
var descDiv = document.querySelector('.product-description');
|
||||
if (descDiv) {
|
||||
|
|
@ -672,6 +698,7 @@
|
|||
+ '<span id="ring-progress" class="ring-progress"></span>'
|
||||
+ '</div>'
|
||||
+ '<div class="ring-header-controls">'
|
||||
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next ▶</button>'
|
||||
+ '<label class="watch-direction-label js-only">'
|
||||
+ '<span class="direction-label-text">Reverse</span>'
|
||||
+ '<input type="checkbox" id="watch-direction-toggle"' + directionChecked + ' />'
|
||||
|
|
@ -715,6 +742,7 @@
|
|||
}
|
||||
html += '<span class="related-content-title">' + escapeHtml(currentTitle) + '</span>';
|
||||
html += '</span>';
|
||||
html += '<button class="queue-add-btn js-only" data-product-id="' + currentProductId + '" data-title="' + escapeHtml(currentTitle) + '" data-url="' + (currentCanonicalUrl || '') + '" data-media-type="' + (currentProductMediaType || '') + '" data-thumbnail="' + (currentThumb || '') + '" title="Add to queue">+</button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -805,6 +833,47 @@
|
|||
};
|
||||
}
|
||||
|
||||
// --- Skip to next unwatched ring item ---
|
||||
function getNextUnwatchedItem() {
|
||||
if (!ringProductIds.length) return getNextItem();
|
||||
|
||||
// Walk forward through the ring, skipping watched items
|
||||
for (var i = 1; i < ringProductIds.length; i++) {
|
||||
var pos = (ringPosition + i * ringDirection + ringProductIds.length * ringProductIds.length) % ringProductIds.length;
|
||||
var id = ringProductIds[pos];
|
||||
if (!isWatched(id)) {
|
||||
var el = document.querySelector('[data-watch-id="' + id + '"]');
|
||||
return {
|
||||
id: id,
|
||||
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/' + id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All items watched — fall back to regular next
|
||||
return getNextItem();
|
||||
}
|
||||
|
||||
function skipToNextUnwatched() {
|
||||
if (djCrossfadeActive) cancelDjCrossfade();
|
||||
cancelCountdown();
|
||||
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
|
||||
|
||||
var item = getNextUnwatchedItem();
|
||||
if (!item || !item.id) return;
|
||||
|
||||
// Advance ring position to the target item
|
||||
var idx = ringProductIds.indexOf(item.id);
|
||||
if (idx !== -1) {
|
||||
ringPosition = idx;
|
||||
saveRingState();
|
||||
}
|
||||
|
||||
fetchAndNavigate(item.id);
|
||||
}
|
||||
|
||||
function showCountdownOverlay(next) {
|
||||
var overlay = document.getElementById('watch-countdown');
|
||||
if (!overlay) return;
|
||||
|
|
@ -1370,6 +1439,12 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (e.target.id === 'skip-next-btn' || e.target.closest('#skip-next-btn')) {
|
||||
e.preventDefault();
|
||||
skipToNextUnwatched();
|
||||
return;
|
||||
}
|
||||
|
||||
var link = e.target.closest('.related-content-item[data-watch-id]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
{% for p in top_products %}
|
||||
<tr>
|
||||
<td>{{ p.rank }}</td>
|
||||
<td><a href="{{ p.url }}">{{ p.title }}</a></td>
|
||||
<td><a href="{{ p.analytics_url }}">{{ p.title }}</a></td>
|
||||
<td class="analytics-num">{{ p.views_7d }}</td>
|
||||
<td class="analytics-num">{{ p.views_14d }}</td>
|
||||
<td class="analytics-num">{{ p.views_21d }}</td>
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
{% for r in ring_entries %}
|
||||
<tr>
|
||||
<td>{{ r.rank }}</td>
|
||||
<td><a href="{{ r.url }}">{{ r.title }}</a></td>
|
||||
<td><a href="{{ r.analytics_url }}">{{ r.title }}</a></td>
|
||||
<td class="analytics-num">{{ r.count }}</td>
|
||||
<td class="analytics-num">{{ r.pct }}</td>
|
||||
</tr>
|
||||
|
|
@ -143,7 +143,7 @@
|
|||
{% for e in engagement %}
|
||||
<tr>
|
||||
<td>{{ e.rank }}</td>
|
||||
<td><a href="{{ e.url }}">{{ e.title }}</a></td>
|
||||
<td><a href="{{ e.analytics_url }}">{{ e.title }}</a></td>
|
||||
<td class="analytics-num">{{ e.score }}</td>
|
||||
<td class="analytics-num">{{ e.sessions }}</td>
|
||||
</tr>
|
||||
|
|
@ -170,7 +170,7 @@
|
|||
{% for a in attention %}
|
||||
<tr>
|
||||
<td>{{ a.rank }}</td>
|
||||
<td><a href="{{ a.url }}">{{ a.title }}</a></td>
|
||||
<td><a href="{{ a.analytics_url }}">{{ a.title }}</a></td>
|
||||
<td class="analytics-num">{{ a.score }}</td>
|
||||
<td class="analytics-num">{{ a.sessions }}</td>
|
||||
</tr>
|
||||
|
|
@ -202,7 +202,7 @@
|
|||
{% for l in learning %}
|
||||
<tr>
|
||||
<td>{{ l.rank }}</td>
|
||||
<td><a href="{{ l.url }}">{{ l.title }}</a></td>
|
||||
<td><a href="{{ l.analytics_url }}">{{ l.title }}</a></td>
|
||||
<td class="analytics-num">{{ l.score }}</td>
|
||||
<td class="analytics-num">{{ l.sessions }}</td>
|
||||
</tr>
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
{% for b in passive %}
|
||||
<tr>
|
||||
<td>{{ b.rank }}</td>
|
||||
<td><a href="{{ b.url }}">{{ b.title }}</a></td>
|
||||
<td><a href="{{ b.analytics_url }}">{{ b.title }}</a></td>
|
||||
<td class="analytics-num">{{ b.score }}</td>
|
||||
<td class="analytics-num">{{ b.sessions }}</td>
|
||||
</tr>
|
||||
|
|
@ -241,6 +241,71 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Video Watch Metrics (21d) --- #}
|
||||
{% if video_overview %}
|
||||
<h4>Video Watch Metrics (last 21 days)</h4>
|
||||
<p class="analytics-hint">Aggregate viewing behavior across {{ video_overview.total_sessions }} media sessions.</p>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video_overview.avg_pct_played }}</span>
|
||||
<span class="analytics-overview-label">Avg % Played</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video_overview.completion_rate }}</span>
|
||||
<span class="analytics-overview-label">Completion Rate</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video_overview.avg_seeks }}</span>
|
||||
<span class="analytics-overview-label">Avg Seeks</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video_overview.avg_rewinds }}</span>
|
||||
<span class="analytics-overview-label">Avg Rewinds</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video_overview.avg_pauses }}</span>
|
||||
<span class="analytics-overview-label">Avg Pauses</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video_overview.avg_speed }}</span>
|
||||
<span class="analytics-overview-label">Avg Speed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if video_products %}
|
||||
<h4>Per-Product Video Breakdown</h4>
|
||||
<p class="analytics-hint">Top 14 by average % played (min {{ video_products[0].sessions if video_products else 7 }} sessions).</p>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Title</th>
|
||||
<th class="analytics-num">Avg %</th>
|
||||
<th class="analytics-num">Completed</th>
|
||||
<th class="analytics-num">Seeks</th>
|
||||
<th class="analytics-num">Pauses</th>
|
||||
<th class="analytics-num">Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for v in video_products %}
|
||||
<tr>
|
||||
<td>{{ v.rank }}</td>
|
||||
<td><a href="{{ v.analytics_url }}">{{ v.title }}</a></td>
|
||||
<td class="analytics-num">{{ v.avg_pct }}</td>
|
||||
<td class="analytics-num">{{ v.completion_rate }}</td>
|
||||
<td class="analytics-num">{{ v.avg_seeks }}</td>
|
||||
<td class="analytics-num">{{ v.avg_pauses }}</td>
|
||||
<td class="analytics-num">{{ v.sessions }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# --- Sections 8 & 9: Traffic Sources & Device Split (side by side) --- #}
|
||||
{% if traffic or devices %}
|
||||
<div class="analytics-pair">
|
||||
|
|
@ -296,6 +361,54 @@
|
|||
|
||||
{% endif %}{# end overview.total_views check #}
|
||||
|
||||
{# --- Comment Sentiment (21d) --- #}
|
||||
{% if sentiment and sentiment.total > 0 %}
|
||||
<h4>Comment Sentiment (last 21 days)</h4>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
|
||||
<span class="analytics-overview-label">Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.neutral }}</span>
|
||||
<span class="analytics-overview-label">Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.negative }}</span>
|
||||
<span class="analytics-overview-label">Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.total }}</span>
|
||||
<span class="analytics-overview-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Recent Price Changes (21d) --- #}
|
||||
{% if price_changes %}
|
||||
<h4>Recent Price Changes (last 21 days)</h4>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th class="analytics-num">Price</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pc in price_changes %}
|
||||
<tr>
|
||||
<td><a href="{{ pc.analytics_url }}">{{ pc.title }}</a></td>
|
||||
<td class="analytics-num">{{ pc.price }}</td>
|
||||
<td>{{ pc.ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="analytics-privacy">All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.</p>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
238
make_post_sell/templates/analytics_product.j2
Normal file
238
make_post_sell/templates/analytics_product.j2
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>Analytics - {{ product.title }} - {{ request.shop.name }}</title>
|
||||
{%- endblock append_to_head_tag_section -%}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
<section class="analytics-page">
|
||||
|
||||
<p><a href="/s/{{ request.shop.uuid_str }}/analytics">← Shop Analytics</a> · <a href="{{ product_url }}">View Product</a></p>
|
||||
|
||||
<h3>{{ product.title }}</h3>
|
||||
|
||||
{# --- Overview Strip --- #}
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ overview.views_7d }}</span>
|
||||
<span class="analytics-overview-label">Views (7d)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ overview.avg_session }}</span>
|
||||
<span class="analytics-overview-label">Avg Session</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ overview.bounce_rate }}</span>
|
||||
<span class="analytics-overview-label">Bounce Rate</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ overview.lifetime_views }}</span>
|
||||
<span class="analytics-overview-label">Lifetime Views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- Views Over Time --- #}
|
||||
<h4>Views Over Time</h4>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ views_over_time['7d'] }}</span>
|
||||
<span class="analytics-overview-label">7 days</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ views_over_time['14d'] }}</span>
|
||||
<span class="analytics-overview-label">14 days</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ views_over_time['21d'] }}</span>
|
||||
<span class="analytics-overview-label">21 days</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ views_over_time['28d'] }}</span>
|
||||
<span class="analytics-overview-label">28 days</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ views_over_time['365d'] }}</span>
|
||||
<span class="analytics-overview-label">Year</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ views_over_time['lifetime'] }}</span>
|
||||
<span class="analytics-overview-label">Lifetime</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- Video Watch Metrics --- #}
|
||||
{% if video %}
|
||||
<h4>Video Watch Metrics (last 21 days)</h4>
|
||||
<p class="analytics-hint">Based on {{ video.sessions }} media sessions.</p>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.avg_pct_played }}</span>
|
||||
<span class="analytics-overview-label">Avg % Played</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.completion_rate }}</span>
|
||||
<span class="analytics-overview-label">Completion Rate</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.avg_seeks }}</span>
|
||||
<span class="analytics-overview-label">Avg Seeks</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.avg_rewinds }}</span>
|
||||
<span class="analytics-overview-label">Avg Rewinds</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.avg_pauses }}</span>
|
||||
<span class="analytics-overview-label">Avg Pauses</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.avg_speed }}</span>
|
||||
<span class="analytics-overview-label">Avg Speed</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ video.play_duration_ratio }}</span>
|
||||
<span class="analytics-overview-label">Play/Duration</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Traffic Sources & Devices (side by side) --- #}
|
||||
{% if traffic or devices %}
|
||||
<div class="analytics-pair">
|
||||
{% if traffic %}
|
||||
<div>
|
||||
<h4>Traffic Sources (last 21 days)</h4>
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th class="analytics-num">Sessions</th>
|
||||
<th class="analytics-num">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in traffic %}
|
||||
<tr>
|
||||
<td>{{ t.source }}</td>
|
||||
<td class="analytics-num">{{ t.count }}</td>
|
||||
<td class="analytics-num">{{ t.pct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if devices %}
|
||||
<div>
|
||||
<h4>Device Split (last 21 days)</h4>
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th class="analytics-num">Sessions</th>
|
||||
<th class="analytics-num">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td>{{ d.device }}</td>
|
||||
<td class="analytics-num">{{ d.count }}</td>
|
||||
<td class="analytics-num">{{ d.pct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Ring Entries --- #}
|
||||
{% if ring_entry_count > 0 %}
|
||||
<h4>Ring Entry Sessions (last 21 days)</h4>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ ring_entry_count }}</span>
|
||||
<span class="analytics-overview-label">Ring Entries</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Engagement --- #}
|
||||
{% if engagement %}
|
||||
<h4>Engagement (last 21 days)</h4>
|
||||
<p class="analytics-hint">Based on {{ engagement.sessions }} sessions with 7s+ wall clock.</p>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ engagement.active_ratio }}</span>
|
||||
<span class="analytics-overview-label">Active / Wall</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ engagement.visible_ratio }}</span>
|
||||
<span class="analytics-overview-label">Visible / Wall</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ engagement.avg_scroll_depth }}</span>
|
||||
<span class="analytics-overview-label">Avg Scroll Depth</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ engagement.avg_direction_changes }}</span>
|
||||
<span class="analytics-overview-label">Direction Changes</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Comment Sentiment --- #}
|
||||
{% if sentiment and sentiment.total > 0 %}
|
||||
<h4>Comment Sentiment (last 21 days)</h4>
|
||||
<div class="analytics-overview well">
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
|
||||
<span class="analytics-overview-label">Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.neutral }}</span>
|
||||
<span class="analytics-overview-label">Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.negative }}</span>
|
||||
<span class="analytics-overview-label">Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%)</span>
|
||||
</div>
|
||||
<div class="analytics-overview-item">
|
||||
<span class="analytics-overview-value">{{ sentiment.total }}</span>
|
||||
<span class="analytics-overview-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Price History --- #}
|
||||
{% if price_changes %}
|
||||
<h4>Price History (last 21 changes)</h4>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="analytics-num">Price</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pc in price_changes %}
|
||||
<tr>
|
||||
<td class="analytics-num">{{ pc.price }}</td>
|
||||
<td>{{ pc.ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="analytics-privacy">All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.</p>
|
||||
|
||||
</section>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
@ -250,6 +250,32 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
|
|||
</form>
|
||||
</section>
|
||||
|
||||
{% if price_history | length > 1 %}
|
||||
<section class="price-history well2">
|
||||
<h3>Price History</h3>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Price</th>
|
||||
<th>When</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ph in price_history %}
|
||||
<tr{% if ph.is_current %} class="price-history-current"{% endif %}>
|
||||
<td>{{ ph.price_formatted }}</td>
|
||||
<td>{{ ph.ago }}</td>
|
||||
<td>{{ ph.datetime }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
<th>Product</th>
|
||||
<th>Author</th>
|
||||
<th>Comment</th>
|
||||
<th>Tone</th>
|
||||
<th class="hide-on-mobile">Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
<td><a href="{{ comment.product.absolute_url(request) }}">{{ comment.product.title | truncate(30) }}</a></td>
|
||||
<td>{{ comment.user.name if comment.user else "Anonymous" }}</td>
|
||||
<td>{{ comment.data | truncate(80) }}</td>
|
||||
<td>{{ comment.sentiment_icon }}</td>
|
||||
<td class="hide-on-mobile">{{ comment.ago_string }}</td>
|
||||
<td>
|
||||
{% if comment.disabled %}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
</div>
|
||||
{% if request.shop.watch_mode_enabled %}
|
||||
<div class="ring-header-controls">
|
||||
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next ▶</button>
|
||||
<label class="watch-direction-label js-only">
|
||||
<span class="direction-label-text">Reverse</span>
|
||||
<input type="checkbox" id="watch-direction-toggle" />
|
||||
|
|
@ -41,6 +42,12 @@
|
|||
{% endif %}
|
||||
<span class="related-content-title">{{ product.title }}</span>
|
||||
</span>
|
||||
<button class="queue-add-btn js-only"
|
||||
data-product-id="{{ product.id }}"
|
||||
data-title="{{ product.title }}"
|
||||
data-url="{{ product.absolute_url(request) }}"
|
||||
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
|
||||
title="Add to queue">+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="related-content-row{% if offset < 0 %} related-content-row-prev{% endif %}">
|
||||
|
|
|
|||
|
|
@ -2295,6 +2295,10 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
self.assertIsNone(data["parent_id"])
|
||||
self.assertIn("AJAX comment", data["data_html"])
|
||||
|
||||
# Sentiment field is present and is an integer
|
||||
self.assertIn("sentiment", data)
|
||||
self.assertIsInstance(data["sentiment"], int)
|
||||
|
||||
@patch("smtplib.SMTP")
|
||||
def test_non_ajax_comment_returns_redirect(self, mock_smtp):
|
||||
"""Regular POST to /comments/new still returns 302 redirect."""
|
||||
|
|
@ -3493,3 +3497,75 @@ class TestAnalytics(AuthenticatedFunctionalTests):
|
|||
products = get_all_products(self.dbsession).all()
|
||||
product = products[0]
|
||||
return shop, product
|
||||
|
||||
@patch("smtplib.SMTP")
|
||||
def test_ajax_comment_sentiment_positive(self, mock_smtp):
|
||||
"""AJAX comment with positive text returns sentiment=1."""
|
||||
shop, product = self._create_shop_and_product_for_comments()
|
||||
|
||||
res = self.testapp.post(
|
||||
"/comments/new",
|
||||
{
|
||||
"product_id": str(product.id),
|
||||
"parent_id": "",
|
||||
"data": "This is amazing and wonderful!",
|
||||
},
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
data = res.json
|
||||
self.assertEqual(data["sentiment"], 1)
|
||||
|
||||
@patch("smtplib.SMTP")
|
||||
def test_ajax_comment_sentiment_negative(self, mock_smtp):
|
||||
"""AJAX comment with negative text returns sentiment=-1."""
|
||||
shop, product = self._create_shop_and_product_for_comments()
|
||||
|
||||
res = self.testapp.post(
|
||||
"/comments/new",
|
||||
{
|
||||
"product_id": str(product.id),
|
||||
"parent_id": "",
|
||||
"data": "Terrible product, total waste",
|
||||
},
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
data = res.json
|
||||
self.assertEqual(data["sentiment"], -1)
|
||||
|
||||
@patch("smtplib.SMTP")
|
||||
def test_price_history_on_edit_page(self, mock_smtp):
|
||||
"""Price history table shows on product edit page after a price change."""
|
||||
shop = self._create_shop_helper()
|
||||
|
||||
# Create a product with initial price
|
||||
res = self.testapp.post(
|
||||
f"/p/new?shop_id={shop.id}",
|
||||
self.product1_params,
|
||||
)
|
||||
res = res.follow()
|
||||
|
||||
products = get_all_products(self.dbsession).all()
|
||||
product = products[0]
|
||||
|
||||
# Change the price (use 20.00 to avoid float precision issues)
|
||||
self.testapp.post(
|
||||
f"/p/{product.id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": product.description,
|
||||
"price": "20.00",
|
||||
"visibility": str(product.visibility),
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
|
||||
# Visit the edit page and check for price history
|
||||
res = self.testapp.get(f"/p/{product.id}/edit", status=200)
|
||||
body = res.body.decode()
|
||||
self.assertIn("Price History", body)
|
||||
self.assertIn("$20.00", body)
|
||||
self.assertIn("$3.50", body)
|
||||
|
|
|
|||
|
|
@ -3258,3 +3258,51 @@ class TestAnalyticsHelpers(unittest.TestCase):
|
|||
self.assertEqual(len(DEVICE_LABELS), 3)
|
||||
self.assertEqual(DEVICE_LABELS[0], "Mobile")
|
||||
self.assertEqual(DEVICE_LABELS[2], "Desktop")
|
||||
|
||||
|
||||
class TestSentiment(unittest.TestCase):
|
||||
"""Unit tests for the rule-based comment sentiment classifier."""
|
||||
|
||||
def _classify(self, text):
|
||||
from ..lib.sentiment import classify_sentiment
|
||||
return classify_sentiment(text)
|
||||
|
||||
def test_positive_comment(self):
|
||||
self.assertEqual(self._classify("This is amazing and wonderful!"), 1)
|
||||
|
||||
def test_negative_comment(self):
|
||||
self.assertEqual(self._classify("Terrible product, total waste"), -1)
|
||||
|
||||
def test_neutral_comment(self):
|
||||
self.assertEqual(self._classify("I downloaded the file yesterday"), 0)
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertEqual(self._classify(""), 0)
|
||||
|
||||
def test_none_input(self):
|
||||
self.assertEqual(self._classify(None), 0)
|
||||
|
||||
def test_negation_flips_positive(self):
|
||||
self.assertEqual(self._classify("not good"), -1)
|
||||
|
||||
def test_negation_flips_negative(self):
|
||||
self.assertEqual(self._classify("not bad"), 1)
|
||||
|
||||
def test_intensifier_boosts_score(self):
|
||||
# "really great" should still be positive
|
||||
self.assertEqual(self._classify("really great"), 1)
|
||||
# "very terrible" should still be negative
|
||||
self.assertEqual(self._classify("very terrible"), -1)
|
||||
|
||||
def test_mixed_sentiment_leans_neutral(self):
|
||||
# Roughly equal positive and negative words
|
||||
self.assertEqual(self._classify("good but also bad"), 0)
|
||||
|
||||
def test_only_punctuation_and_numbers(self):
|
||||
self.assertEqual(self._classify("123 !!! ???"), 0)
|
||||
|
||||
def test_short_positive(self):
|
||||
self.assertEqual(self._classify("love it"), 1)
|
||||
|
||||
def test_short_negative(self):
|
||||
self.assertEqual(self._classify("hate it"), -1)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ from sqlalchemy import func, case, and_, cast, Float
|
|||
from . import shop_editor_required
|
||||
from ..models.page_session import PageSession
|
||||
from ..models.product import Product
|
||||
from ..models.meta import now_timestamp
|
||||
from ..models.price import Price
|
||||
from ..models.comment import get_sentiment_summary_for_shop, get_sentiment_summary_for_product
|
||||
from ..models.meta import now_timestamp, get_object_by_id
|
||||
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
|
||||
from ..lib.currency import cents_to_dollars
|
||||
|
||||
|
||||
REFERRER_LABELS = {0: "Direct", 1: "Search", 2: "Social", 3: "Internal", 4: "Unknown"}
|
||||
|
|
@ -27,7 +31,7 @@ def _cutoffs():
|
|||
|
||||
|
||||
def _product_titles(dbsession, product_ids):
|
||||
"""Batch-fetch product titles and URLs for a set of product IDs."""
|
||||
"""Batch-fetch product titles, URLs, and IDs for a set of product IDs."""
|
||||
if not product_ids:
|
||||
return {}
|
||||
products = (
|
||||
|
|
@ -39,6 +43,7 @@ def _product_titles(dbsession, product_ids):
|
|||
p.id: {
|
||||
"title": p.title,
|
||||
"url": f"/{'p' if p.is_sellable else 'c'}/{p.id}/{p.slug}",
|
||||
"uuid_str": p.uuid_str,
|
||||
}
|
||||
for p in products
|
||||
}
|
||||
|
|
@ -300,12 +305,81 @@ def shop_analytics(request):
|
|||
).group_by(PageSession.device_class).order_by(func.count().desc()).all()
|
||||
device_total = sum(r.cnt for r in device_raw) or 1
|
||||
|
||||
# --- Video Watch Metrics (21d, sessions with media only) ---
|
||||
|
||||
_media_filter = and_(
|
||||
PageSession.shop_id == shop.id,
|
||||
PageSession.media_duration_ms.isnot(None),
|
||||
PageSession.media_duration_ms > 0,
|
||||
PageSession.created_timestamp > cutoff_21d,
|
||||
)
|
||||
|
||||
video_agg = db.query(
|
||||
func.avg(PageSession.media_percent_played).label("avg_pct"),
|
||||
func.avg(
|
||||
case((PageSession.media_completed == True, 1.0), else_=0.0) # noqa: E712
|
||||
).label("completion_rate"),
|
||||
func.avg(PageSession.media_seek_count).label("avg_seeks"),
|
||||
func.avg(PageSession.media_seek_back_count).label("avg_rewinds"),
|
||||
func.avg(PageSession.media_pause_count).label("avg_pauses"),
|
||||
func.avg(PageSession.media_speed).label("avg_speed"),
|
||||
func.count().label("total_sessions"),
|
||||
).filter(_media_filter).one()
|
||||
|
||||
video_overview = None
|
||||
if video_agg.total_sessions and video_agg.total_sessions >= 1:
|
||||
video_overview = {
|
||||
"avg_pct_played": _fmt_pct(video_agg.avg_pct),
|
||||
"completion_rate": _fmt_pct(video_agg.completion_rate),
|
||||
"avg_seeks": f"{video_agg.avg_seeks:.1f}" if video_agg.avg_seeks else "\u2014",
|
||||
"avg_rewinds": f"{video_agg.avg_rewinds:.1f}" if video_agg.avg_rewinds else "\u2014",
|
||||
"avg_pauses": f"{video_agg.avg_pauses:.1f}" if video_agg.avg_pauses else "\u2014",
|
||||
"avg_speed": f"{video_agg.avg_speed:.2f}x" if video_agg.avg_speed else "\u2014",
|
||||
"total_sessions": video_agg.total_sessions,
|
||||
}
|
||||
|
||||
# Per-product video breakdown (top 14 by avg % played, min 7 sessions)
|
||||
video_prod_raw = db.query(
|
||||
PageSession.product_id,
|
||||
func.avg(PageSession.media_percent_played).label("avg_pct"),
|
||||
func.avg(
|
||||
case((PageSession.media_completed == True, 1.0), else_=0.0) # noqa: E712
|
||||
).label("completion_rate"),
|
||||
func.avg(PageSession.media_seek_count).label("avg_seeks"),
|
||||
func.avg(PageSession.media_pause_count).label("avg_pauses"),
|
||||
func.count().label("sessions"),
|
||||
).filter(_media_filter).group_by(
|
||||
PageSession.product_id,
|
||||
).having(func.count() >= MIN_SESSIONS).order_by(
|
||||
func.avg(PageSession.media_percent_played).desc()
|
||||
).limit(14).all()
|
||||
|
||||
# --- Comment Sentiment (21d) ---
|
||||
|
||||
sentiment = get_sentiment_summary_for_shop(db, shop.id, cutoff_21d)
|
||||
|
||||
# --- Recent Price Changes (21d) ---
|
||||
|
||||
price_changes_raw = (
|
||||
db.query(Price)
|
||||
.join(Product)
|
||||
.filter(
|
||||
Product.shop_id == shop.id,
|
||||
Price.created_timestamp > cutoff_21d,
|
||||
)
|
||||
.order_by(Price.created_timestamp.desc())
|
||||
.limit(21)
|
||||
.all()
|
||||
)
|
||||
|
||||
# --- Resolve all product titles in one batch ---
|
||||
|
||||
all_pids = set()
|
||||
for rows in (top_prods_raw, ring_raw, eng_raw, att_raw, learn_raw, pass_raw):
|
||||
for rows in (top_prods_raw, ring_raw, eng_raw, att_raw, learn_raw, pass_raw, video_prod_raw):
|
||||
for r in rows:
|
||||
all_pids.add(r.product_id)
|
||||
for pc in price_changes_raw:
|
||||
all_pids.add(pc.product_id)
|
||||
titles = _product_titles(db, list(all_pids))
|
||||
|
||||
def _t(pid):
|
||||
|
|
@ -314,6 +388,11 @@ def shop_analytics(request):
|
|||
def _u(pid):
|
||||
return titles.get(pid, {}).get("url", "#")
|
||||
|
||||
def _a(pid):
|
||||
"""Analytics URL for a product."""
|
||||
uid = titles.get(pid, {}).get("uuid_str", "")
|
||||
return f"/s/{shop.uuid_str}/analytics/{uid}" if uid else "#"
|
||||
|
||||
# --- Build template-ready data ---
|
||||
|
||||
top_products = []
|
||||
|
|
@ -323,6 +402,7 @@ def shop_analytics(request):
|
|||
"rank": i + 1,
|
||||
"title": _t(r.product_id),
|
||||
"url": _u(r.product_id),
|
||||
"analytics_url": _a(r.product_id),
|
||||
"views_7d": v7,
|
||||
"views_14d": v14,
|
||||
"views_21d": r.views_21d or 0,
|
||||
|
|
@ -334,6 +414,7 @@ def shop_analytics(request):
|
|||
"rank": i + 1,
|
||||
"title": _t(r.product_id),
|
||||
"url": _u(r.product_id),
|
||||
"analytics_url": _a(r.product_id),
|
||||
"count": r.cnt,
|
||||
"pct": _fmt_pct(r.cnt / ring_total),
|
||||
}
|
||||
|
|
@ -346,12 +427,40 @@ def shop_analytics(request):
|
|||
"rank": i + 1,
|
||||
"title": _t(r.product_id),
|
||||
"url": _u(r.product_id),
|
||||
"analytics_url": _a(r.product_id),
|
||||
"score": score_fmt(r.score),
|
||||
"sessions": r.sessions,
|
||||
}
|
||||
for i, r in enumerate(raw)
|
||||
]
|
||||
|
||||
video_products = [
|
||||
{
|
||||
"rank": i + 1,
|
||||
"title": _t(r.product_id),
|
||||
"url": _u(r.product_id),
|
||||
"analytics_url": _a(r.product_id),
|
||||
"avg_pct": _fmt_pct(r.avg_pct),
|
||||
"completion_rate": _fmt_pct(r.completion_rate),
|
||||
"avg_seeks": f"{r.avg_seeks:.1f}" if r.avg_seeks else "\u2014",
|
||||
"avg_pauses": f"{r.avg_pauses:.1f}" if r.avg_pauses else "\u2014",
|
||||
"sessions": r.sessions,
|
||||
}
|
||||
for i, r in enumerate(video_prod_raw)
|
||||
]
|
||||
|
||||
price_changes = [
|
||||
{
|
||||
"title": _t(pc.product_id),
|
||||
"url": _u(pc.product_id),
|
||||
"analytics_url": _a(pc.product_id),
|
||||
"price": f"${cents_to_dollars(pc.price_in_cents):.2f}",
|
||||
"ago": timestamp_to_ago_string(pc.created_timestamp),
|
||||
"datetime": str(timestamp_to_datetime(pc.created_timestamp)),
|
||||
}
|
||||
for pc in price_changes_raw
|
||||
]
|
||||
|
||||
return {
|
||||
"overview": overview,
|
||||
"ring_size": ring_size,
|
||||
|
|
@ -361,6 +470,10 @@ def shop_analytics(request):
|
|||
"attention": _ranked(att_raw, _fmt_pct),
|
||||
"learning": _ranked(learn_raw, _fmt_score),
|
||||
"passive": _ranked(pass_raw, _fmt_score),
|
||||
"video_overview": video_overview,
|
||||
"video_products": video_products,
|
||||
"sentiment": sentiment,
|
||||
"price_changes": price_changes,
|
||||
"traffic": [
|
||||
{
|
||||
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
|
||||
|
|
@ -378,3 +491,211 @@ def shop_analytics(request):
|
|||
for r in device_raw
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="product_analytics", renderer="analytics_product.j2")
|
||||
@shop_editor_required()
|
||||
def product_analytics(request):
|
||||
from pyramid.httpexceptions import HTTPNotFound
|
||||
|
||||
shop = request.shop
|
||||
db = request.dbsession
|
||||
product = get_object_by_id(db, request.matchdict["product_id"], Product)
|
||||
if not product or product.shop_id != shop.id:
|
||||
raise HTTPNotFound()
|
||||
|
||||
cuts = _cutoffs()
|
||||
cutoff_7d = cuts["7d"]
|
||||
cutoff_21d = cuts["21d"]
|
||||
|
||||
def _pf(cutoff):
|
||||
"""Product view filter: 7s visible on this product after cutoff."""
|
||||
return and_(
|
||||
PageSession.product_id == product.id,
|
||||
PageSession.visible_ms >= 7000,
|
||||
PageSession.created_timestamp > cutoff,
|
||||
)
|
||||
|
||||
def _sf(cutoff):
|
||||
"""Product session filter: 7s wall clock after cutoff."""
|
||||
return and_(
|
||||
PageSession.product_id == product.id,
|
||||
PageSession.wall_clock_ms >= 7000,
|
||||
PageSession.created_timestamp > cutoff,
|
||||
)
|
||||
|
||||
# --- Overview ---
|
||||
ov = db.query(
|
||||
func.count().label("views"),
|
||||
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
|
||||
).filter(_pf(cutoff_7d)).one()
|
||||
|
||||
bounce_q = db.query(
|
||||
func.count().label("total"),
|
||||
func.sum(case(
|
||||
(PageSession.wall_clock_ms < 7000, 1), else_=0,
|
||||
)).label("bounced"),
|
||||
).filter(
|
||||
PageSession.product_id == product.id,
|
||||
PageSession.created_timestamp > cutoff_7d,
|
||||
).one()
|
||||
bounce_rate = None
|
||||
if bounce_q.total and bounce_q.total > 0:
|
||||
bounce_rate = (bounce_q.bounced or 0) / bounce_q.total
|
||||
|
||||
lifetime_views = db.query(func.count()).filter(
|
||||
PageSession.product_id == product.id,
|
||||
PageSession.visible_ms >= 7000,
|
||||
).scalar() or 0
|
||||
|
||||
overview = {
|
||||
"views_7d": ov.views or 0,
|
||||
"avg_session": _fmt_ms(ov.avg_session_ms),
|
||||
"bounce_rate": _fmt_pct(bounce_rate),
|
||||
"lifetime_views": lifetime_views,
|
||||
}
|
||||
|
||||
# --- Views over time ---
|
||||
views_over_time = {}
|
||||
for label, key in [("7d", "7d"), ("14d", "14d"), ("21d", "21d"), ("28d", "28d"), ("365d", "365d")]:
|
||||
views_over_time[label] = db.query(func.count()).filter(
|
||||
_pf(cuts[key])
|
||||
).scalar() or 0
|
||||
views_over_time["lifetime"] = lifetime_views
|
||||
|
||||
# --- Video Watch Metrics ---
|
||||
video = None
|
||||
_mf = and_(
|
||||
PageSession.product_id == product.id,
|
||||
PageSession.media_duration_ms.isnot(None),
|
||||
PageSession.media_duration_ms > 0,
|
||||
PageSession.created_timestamp > cutoff_21d,
|
||||
)
|
||||
vagg = db.query(
|
||||
func.avg(PageSession.media_percent_played).label("avg_pct"),
|
||||
func.avg(
|
||||
case((PageSession.media_completed == True, 1.0), else_=0.0) # noqa: E712
|
||||
).label("completion_rate"),
|
||||
func.avg(PageSession.media_seek_count).label("avg_seeks"),
|
||||
func.avg(PageSession.media_seek_back_count).label("avg_rewinds"),
|
||||
func.avg(PageSession.media_pause_count).label("avg_pauses"),
|
||||
func.avg(PageSession.media_speed).label("avg_speed"),
|
||||
func.avg(PageSession.media_play_ms).label("avg_play_ms"),
|
||||
func.avg(PageSession.media_duration_ms).label("avg_duration_ms"),
|
||||
func.count().label("sessions"),
|
||||
).filter(_mf).one()
|
||||
|
||||
if vagg.sessions and vagg.sessions >= 1:
|
||||
play_ratio = None
|
||||
if vagg.avg_duration_ms and vagg.avg_duration_ms > 0:
|
||||
play_ratio = (vagg.avg_play_ms or 0) / vagg.avg_duration_ms
|
||||
video = {
|
||||
"avg_pct_played": _fmt_pct(vagg.avg_pct),
|
||||
"completion_rate": _fmt_pct(vagg.completion_rate),
|
||||
"avg_seeks": f"{vagg.avg_seeks:.1f}" if vagg.avg_seeks else "\u2014",
|
||||
"avg_rewinds": f"{vagg.avg_rewinds:.1f}" if vagg.avg_rewinds else "\u2014",
|
||||
"avg_pauses": f"{vagg.avg_pauses:.1f}" if vagg.avg_pauses else "\u2014",
|
||||
"avg_speed": f"{vagg.avg_speed:.2f}x" if vagg.avg_speed else "\u2014",
|
||||
"play_duration_ratio": _fmt_pct(play_ratio),
|
||||
"sessions": vagg.sessions,
|
||||
}
|
||||
|
||||
# --- Traffic Sources (21d) ---
|
||||
traffic_raw = db.query(
|
||||
PageSession.referrer_class, func.count().label("cnt"),
|
||||
).filter(_pf(cutoff_21d)).group_by(
|
||||
PageSession.referrer_class,
|
||||
).order_by(func.count().desc()).all()
|
||||
traffic_total = sum(r.cnt for r in traffic_raw) or 1
|
||||
|
||||
traffic = [
|
||||
{
|
||||
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
|
||||
"count": r.cnt,
|
||||
"pct": _fmt_pct(r.cnt / traffic_total),
|
||||
}
|
||||
for r in traffic_raw
|
||||
]
|
||||
|
||||
# --- Devices (21d) ---
|
||||
device_raw = db.query(
|
||||
PageSession.device_class, func.count().label("cnt"),
|
||||
).filter(_pf(cutoff_21d)).group_by(
|
||||
PageSession.device_class,
|
||||
).order_by(func.count().desc()).all()
|
||||
device_total = sum(r.cnt for r in device_raw) or 1
|
||||
|
||||
devices = [
|
||||
{
|
||||
"device": DEVICE_LABELS.get(r.device_class, "Unknown"),
|
||||
"count": r.cnt,
|
||||
"pct": _fmt_pct(r.cnt / device_total),
|
||||
}
|
||||
for r in device_raw
|
||||
]
|
||||
|
||||
# --- Ring Entries (21d) ---
|
||||
ring_entry_count = db.query(func.count()).filter(
|
||||
PageSession.product_id == product.id,
|
||||
PageSession.is_ring_entry == True, # noqa: E712
|
||||
PageSession.created_timestamp > cutoff_21d,
|
||||
).scalar() or 0
|
||||
|
||||
# --- Engagement (21d) ---
|
||||
eng_q = db.query(
|
||||
func.avg(
|
||||
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
|
||||
).label("active_ratio"),
|
||||
func.avg(
|
||||
cast(PageSession.visible_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
|
||||
).label("visible_ratio"),
|
||||
func.avg(PageSession.scroll_depth_max).label("avg_scroll"),
|
||||
func.avg(PageSession.scroll_direction_changes).label("avg_dir_changes"),
|
||||
func.count().label("sessions"),
|
||||
).filter(_sf(cutoff_21d)).one()
|
||||
|
||||
engagement = None
|
||||
if eng_q.sessions and eng_q.sessions >= 1:
|
||||
engagement = {
|
||||
"active_ratio": _fmt_pct(eng_q.active_ratio),
|
||||
"visible_ratio": _fmt_pct(eng_q.visible_ratio),
|
||||
"avg_scroll_depth": _fmt_pct(eng_q.avg_scroll),
|
||||
"avg_direction_changes": f"{eng_q.avg_dir_changes:.1f}" if eng_q.avg_dir_changes else "\u2014",
|
||||
"sessions": eng_q.sessions,
|
||||
}
|
||||
|
||||
# --- Comment Sentiment (21d) ---
|
||||
sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_21d)
|
||||
|
||||
# --- Price History (last 21 changes) ---
|
||||
price_changes_raw = (
|
||||
db.query(Price)
|
||||
.filter(Price.product_id == product.id)
|
||||
.order_by(Price.created_timestamp.desc())
|
||||
.limit(21)
|
||||
.all()
|
||||
)
|
||||
price_changes = [
|
||||
{
|
||||
"price": f"${cents_to_dollars(pc.price_in_cents):.2f}",
|
||||
"ago": timestamp_to_ago_string(pc.created_timestamp),
|
||||
"datetime": str(timestamp_to_datetime(pc.created_timestamp)),
|
||||
}
|
||||
for pc in price_changes_raw
|
||||
]
|
||||
|
||||
product_url = f"/{'p' if product.is_sellable else 'c'}/{product.uuid_str}/{product.slug}"
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"product_url": product_url,
|
||||
"overview": overview,
|
||||
"views_over_time": views_over_time,
|
||||
"video": video,
|
||||
"traffic": traffic,
|
||||
"devices": devices,
|
||||
"ring_entry_count": ring_entry_count,
|
||||
"engagement": engagement,
|
||||
"sentiment": sentiment,
|
||||
"price_changes": price_changes,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ def comment_new(request):
|
|||
"author_name": request.user.name or "Anonymous",
|
||||
"ago_string": comment.ago_string,
|
||||
"approved": comment.approved,
|
||||
"sentiment": comment.sentiment,
|
||||
"depth": comment.graph_depth,
|
||||
"parent_id": str(comment.parent_id) if comment.parent_id else None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ from ..models.shop import reforge_discovery_ring_async
|
|||
from ..models.shop_location import ShopLocation
|
||||
from ..models.inventory import Inventory
|
||||
|
||||
from ..lib.currency import validate_float
|
||||
from ..lib.currency import validate_float, cents_to_dollars
|
||||
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
|
||||
|
||||
|
||||
def checkbox_to_bool(checkbox):
|
||||
|
|
@ -402,6 +403,17 @@ def product_edit(request):
|
|||
inv.shop_location.id: inv.quantity for inv in product.inventories
|
||||
}
|
||||
|
||||
price_history = []
|
||||
if product.is_sellable:
|
||||
history_rows = product.price_history.limit(20).all()
|
||||
for i, ph in enumerate(history_rows):
|
||||
price_history.append({
|
||||
"price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
|
||||
"ago": timestamp_to_ago_string(ph.created_timestamp),
|
||||
"datetime": str(timestamp_to_datetime(ph.created_timestamp)),
|
||||
"is_current": i == 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"title": title,
|
||||
|
|
@ -411,6 +423,7 @@ def product_edit(request):
|
|||
"signed_posts": signed_posts,
|
||||
"locations": locations,
|
||||
"inventories": inventories if product.is_physical else {},
|
||||
"price_history": price_history,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,15 @@ def watch_json(request):
|
|||
ExpiresIn=900,
|
||||
)
|
||||
|
||||
# Public CDN URL for the product file (thumbnail-to-file links)
|
||||
file_url = None
|
||||
if has_product_file:
|
||||
file_url = (
|
||||
f"{request.app['bucket.secure_uploads.get_endpoint']}"
|
||||
f"/{product.s3_path}/{file_key}"
|
||||
f"?ts={product.updated_timestamp}"
|
||||
)
|
||||
|
||||
# Check if current user is a shop editor (mod)
|
||||
is_mod = False
|
||||
if request.user and request.user.authenticated:
|
||||
|
|
@ -151,6 +160,7 @@ def watch_json(request):
|
|||
"is_sellable": product.is_sellable,
|
||||
"has_product_file": has_product_file,
|
||||
"download_url": download_url,
|
||||
"file_url": file_url,
|
||||
"file_type": file_type_str,
|
||||
"file_size": file_size_str,
|
||||
"view_count": product.view_count or 0,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue