+
+ Analytics
+
+ {% if overview.total_views == 0 %}
+ No view data yet. Analytics appear after visitors spend at least 7 seconds on your content.
+ {% else %}
+
+ {# --- Section 1: Overview Strip (last 7 days) --- #}
+
+
+ {{ overview.total_views }}
+ Views (7d)
+
+
+ {{ overview.unique_products }}
+ Products Viewed
+
+
+ {{ overview.avg_session }}
+ Avg Session
+
+
+ {{ overview.avg_ring_depth }}
+ Avg Ring Depth
+
+
+ {{ overview.top_source }}
+ Top Source
+
+
+ {{ overview.device_split }}
+ Devices (7d)
+
+
+
+ {# --- Section 2: Top Products by Views --- #}
+ {% if top_products %}
+ Top Products by Views
+
+
+
+
+ | # |
+ Title |
+ 7d |
+ 14d |
+ 21d |
+ Trend |
+
+
+
+ {% for p in top_products %}
+
+ | {{ p.rank }} |
+ {{ p.title }} |
+ {{ p.views_7d }} |
+ {{ p.views_14d }} |
+ {{ p.views_21d }} |
+ {% if p.trend == "rising" %}▲{% else %}▼{% endif %} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# --- Section 3: Ring Entry Points --- #}
+ {% if ring_entries %}
+ Ring Entry Points (top 7 front doors, last 21 days)
+ People find your shop through these products — make sure they're polished.
+
+
+
+
+ | # |
+ Title |
+ Entries |
+ % of All |
+
+
+
+ {% for r in ring_entries %}
+
+ | {{ r.rank }} |
+ {{ r.title }} |
+ {{ r.count }} |
+ {{ r.pct }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# --- Sections 4 & 5: Engagement & Attention (side by side) --- #}
+ {% if engagement or attention %}
+
+ {% if engagement %}
+
+
Engagement Leaders
+
Highest lean-in: active interaction vs wall clock.
+
+
+
+ | # |
+ Title |
+ Avg |
+ Sessions |
+
+
+
+ {% for e in engagement %}
+
+ | {{ e.rank }} |
+ {{ e.title }} |
+ {{ e.score }} |
+ {{ e.sessions }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if attention %}
+
+
Attention Holders
+
Highest focused presence: visible time vs wall clock.
+
+
+
+ | # |
+ Title |
+ Avg |
+ Sessions |
+
+
+
+ {% for a in attention %}
+
+ | {{ a.rank }} |
+ {{ a.title }} |
+ {{ a.score }} |
+ {{ a.sessions }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+ {# --- Sections 6 & 7: Study Material & Background Favorites (side by side) --- #}
+ {% if learning or passive %}
+
+ {% if learning %}
+
+
Study Material
+
People rewind, slow down, re-read. Score out of 5.
+
+
+
+ | # |
+ Title |
+ Score |
+ Sessions |
+
+
+
+ {% for l in learning %}
+
+ | {{ l.rank }} |
+ {{ l.title }} |
+ {{ l.score }} |
+ {{ l.sessions }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if passive %}
+
+
Background Favorites
+
Lean-back plays: visible but hands-off. Score out of 5.
+
+
+
+ | # |
+ Title |
+ Score |
+ Sessions |
+
+
+
+ {% for b in passive %}
+
+ | {{ b.rank }} |
+ {{ b.title }} |
+ {{ b.score }} |
+ {{ b.sessions }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+ {# --- Sections 8 & 9: Traffic Sources & Device Split (side by side) --- #}
+ {% if traffic or devices %}
+
+ {% if traffic %}
+
+
Traffic Sources (last 21 days)
+
+
+
+ | Source |
+ Sessions |
+ % |
+
+
+
+ {% for t in traffic %}
+
+ | {{ t.source }} |
+ {{ t.count }} |
+ {{ t.pct }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if devices %}
+
+
Device Split (last 21 days)
+
+
+
+ | Device |
+ Sessions |
+ % |
+
+
+
+ {% for d in devices %}
+
+ | {{ d.device }} |
+ {{ d.count }} |
+ {{ d.pct }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+ {% endif %}{# end overview.total_views check #}
+
+ All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.
+
+
+
+{%- endblock -%}
diff --git a/make_post_sell/tests/conftest.py b/make_post_sell/tests/conftest.py
index 568f059..12be284 100644
--- a/make_post_sell/tests/conftest.py
+++ b/make_post_sell/tests/conftest.py
@@ -16,7 +16,7 @@ def pytest_configure(config):
SQLite locking conflicts. WAL mode is enabled for better concurrency.
"""
# Get worker ID (e.g., "gw0", "gw1", etc.) for pytest-xdist
- worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
+ worker_id = os.environ.get("PYTEST_XDIST_WORKER", f"pid{os.getpid()}")
# Set unique database path for this worker
test_db_path = f"test_make_post_sell_{worker_id}.sqlite"
@@ -30,11 +30,13 @@ def pytest_unconfigure(config):
"""Clean up test database after all tests complete."""
if hasattr(config, "test_db_path"):
db_path = config.test_db_path
- if os.path.exists(db_path):
- try:
- os.remove(db_path)
- except Exception as e:
- print(f"Warning: Could not remove test database {db_path}: {e}")
+ for suffix in ("", "-wal", "-shm"):
+ path = db_path + suffix
+ if os.path.exists(path):
+ try:
+ os.remove(path)
+ except Exception as e:
+ print(f"Warning: Could not remove {path}: {e}")
@pytest.fixture(scope="session")
@@ -48,7 +50,7 @@ def db_engine(request):
from sqlalchemy import create_engine, event
# Use worker-specific database
- worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
+ worker_id = os.environ.get("PYTEST_XDIST_WORKER", f"pid{os.getpid()}")
db_path = f"test_make_post_sell_{worker_id}.sqlite"
db_url = f"sqlite:///{db_path}"
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index ecf93b1..3e62bae 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -3413,3 +3413,83 @@ class TestBeacon(AuthenticatedFunctionalTests):
.count()
)
self.assertEqual(count2, 1)
+
+
+class TestAnalytics(AuthenticatedFunctionalTests):
+ """Functional tests for the /s/{shop_id}/analytics page."""
+
+ def test_analytics_requires_editor(self):
+ """Unauthenticated users get redirected from analytics."""
+ shop = self._create_shop_helper()
+ shop_id = str(shop.id)
+ self.testapp.get("/log-out")
+ res = self.testapp.get(f"/s/{shop_id}/analytics", status=302)
+ self.assertEqual(res.status_int, 302)
+
+ def test_analytics_accessible_by_owner(self):
+ """Shop owner can access the analytics page."""
+ shop = self._create_shop_helper()
+ shop_id = str(shop.id)
+ res = self.testapp.get(f"/s/{shop_id}/analytics", status=200)
+ self.assertIn("Analytics", res.body.decode())
+
+ def test_analytics_empty_state(self):
+ """Analytics page shows empty state message when no data exists."""
+ shop = self._create_shop_helper()
+ shop_id = str(shop.id)
+ res = self.testapp.get(f"/s/{shop_id}/analytics", status=200)
+ body = res.body.decode()
+ self.assertIn("No view data yet", body)
+
+ def test_analytics_shows_overview_with_data(self):
+ """Analytics page shows overview strip when session data exists."""
+ shop, product = self._setup_shop_and_product()
+ shop_id = str(shop.id)
+
+ # Insert a session via beacon with visible_ms >= 7000
+ self.testapp.post_json(
+ "/signals/beacon",
+ {
+ "product_id": str(product.id),
+ "shop_id": shop_id,
+ "session_token": "analytics_test_1",
+ "wall_clock_ms": 15000,
+ "visible_ms": 12000,
+ "active_ms": 8000,
+ "viewport_width": 1920,
+ },
+ status=204,
+ )
+
+ res = self.testapp.get(f"/s/{shop_id}/analytics", status=200)
+ body = res.body.decode()
+ # Should show overview, not empty state
+ self.assertNotIn("No view data yet", body)
+ self.assertIn("Views (7d)", body)
+
+ def test_analytics_privacy_note(self):
+ """Analytics page always shows the privacy note."""
+ shop = self._create_shop_helper()
+ shop_id = str(shop.id)
+ res = self.testapp.get(f"/s/{shop_id}/analytics", status=200)
+ body = res.body.decode()
+ self.assertIn("anonymous", body)
+
+ def _setup_shop_and_product(self):
+ """Create a shop and a content product, return (shop, product)."""
+ shop = self._create_shop_helper()
+
+ res = self.testapp.post(
+ "/c/new",
+ {
+ "title": "Analytics Test Content",
+ "description": "For testing analytics",
+ "submit": True,
+ },
+ )
+ if res.status_int == 302:
+ res.follow()
+
+ products = get_all_products(self.dbsession).all()
+ product = products[0]
+ return shop, product
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 3cabe35..60cc6ff 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -3185,3 +3185,74 @@ class TestSignalClassifiers(unittest.TestCase):
def test_device_none(self):
self.assertIsNone(classify_device(None))
+
+
+class TestAnalyticsHelpers(unittest.TestCase):
+ """Unit tests for analytics view helper functions."""
+
+ def test_fmt_ms_none(self):
+ from ..views.analytics import _fmt_ms
+ self.assertEqual(_fmt_ms(None), "\u2014")
+
+ def test_fmt_ms_seconds(self):
+ from ..views.analytics import _fmt_ms
+ self.assertEqual(_fmt_ms(42000), "42s")
+
+ def test_fmt_ms_minutes(self):
+ from ..views.analytics import _fmt_ms
+ self.assertEqual(_fmt_ms(125000), "2m 5s")
+
+ def test_fmt_ms_hours(self):
+ from ..views.analytics import _fmt_ms
+ self.assertEqual(_fmt_ms(3661000), "1h 1m")
+
+ def test_fmt_ms_zero(self):
+ from ..views.analytics import _fmt_ms
+ self.assertEqual(_fmt_ms(0), "0s")
+
+ def test_fmt_pct_none(self):
+ from ..views.analytics import _fmt_pct
+ self.assertEqual(_fmt_pct(None), "\u2014")
+
+ def test_fmt_pct_half(self):
+ from ..views.analytics import _fmt_pct
+ self.assertEqual(_fmt_pct(0.5), "50%")
+
+ def test_fmt_pct_full(self):
+ from ..views.analytics import _fmt_pct
+ self.assertEqual(_fmt_pct(1.0), "100%")
+
+ def test_fmt_pct_zero(self):
+ from ..views.analytics import _fmt_pct
+ self.assertEqual(_fmt_pct(0.0), "0%")
+
+ def test_fmt_score_none(self):
+ from ..views.analytics import _fmt_score
+ self.assertEqual(_fmt_score(None), "\u2014")
+
+ def test_fmt_score_value(self):
+ from ..views.analytics import _fmt_score
+ self.assertEqual(_fmt_score(3.14159), "3.1")
+
+ def test_fmt_score_zero(self):
+ from ..views.analytics import _fmt_score
+ self.assertEqual(_fmt_score(0.0), "0.0")
+
+ def test_cutoffs_are_7_14_21_days_apart(self):
+ from ..views.analytics import _cutoffs
+ c7, c14, c21 = _cutoffs()
+ day_ms = 24 * 60 * 60 * 1000
+ self.assertAlmostEqual(c7 - c14, 7 * day_ms, delta=1000)
+ self.assertAlmostEqual(c14 - c21, 7 * day_ms, delta=1000)
+
+ def test_referrer_labels_complete(self):
+ from ..views.analytics import REFERRER_LABELS
+ self.assertEqual(len(REFERRER_LABELS), 5)
+ self.assertIn(0, REFERRER_LABELS)
+ self.assertIn(4, REFERRER_LABELS)
+
+ def test_device_labels_complete(self):
+ from ..views.analytics import DEVICE_LABELS
+ self.assertEqual(len(DEVICE_LABELS), 3)
+ self.assertEqual(DEVICE_LABELS[0], "Mobile")
+ self.assertEqual(DEVICE_LABELS[2], "Desktop")
diff --git a/make_post_sell/views/analytics.py b/make_post_sell/views/analytics.py
new file mode 100644
index 0000000..f36bcd4
--- /dev/null
+++ b/make_post_sell/views/analytics.py
@@ -0,0 +1,353 @@
+from pyramid.view import view_config
+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
+
+
+REFERRER_LABELS = {0: "Direct", 1: "Search", 2: "Social", 3: "Internal", 4: "Unknown"}
+DEVICE_LABELS = {0: "Mobile", 1: "Tablet", 2: "Desktop"}
+MIN_SESSIONS = 7
+
+
+def _cutoffs():
+ """Return 7d, 14d, 21d cutoff timestamps in milliseconds."""
+ now = now_timestamp()
+ day_ms = 24 * 60 * 60 * 1000
+ return now - 7 * day_ms, now - 14 * day_ms, now - 21 * day_ms
+
+
+def _product_titles(dbsession, product_ids):
+ """Batch-fetch product titles and URLs for a set of product IDs."""
+ if not product_ids:
+ return {}
+ products = (
+ dbsession.query(Product)
+ .filter(Product.id.in_(product_ids))
+ .all()
+ )
+ return {
+ p.id: {
+ "title": p.title,
+ "url": f"/{'p' if p.is_sellable else 'c'}/{p.id}/{p.slug}",
+ }
+ for p in products
+ }
+
+
+def _fmt_ms(ms):
+ """Format milliseconds as human-readable duration."""
+ if ms is None:
+ return "\u2014"
+ s = int(ms / 1000)
+ if s < 60:
+ return f"{s}s"
+ m, s = divmod(s, 60)
+ if m < 60:
+ return f"{m}m {s}s"
+ h, m = divmod(m, 60)
+ return f"{h}h {m}m"
+
+
+def _fmt_pct(val):
+ """Format a 0-1 float as a percentage string."""
+ if val is None:
+ return "\u2014"
+ return f"{val * 100:.0f}%"
+
+
+def _fmt_score(val):
+ """Format a 0-5 score with one decimal place."""
+ if val is None:
+ return "\u2014"
+ return f"{val:.1f}"
+
+
+@view_config(route_name="shop_analytics", renderer="analytics.j2")
+@shop_editor_required()
+def shop_analytics(request):
+ shop = request.shop
+ db = request.dbsession
+ cutoff_7d, cutoff_14d, cutoff_21d = _cutoffs()
+
+ def _view_filter(cutoff):
+ """Sessions that count as views (7s visible) after cutoff."""
+ return and_(
+ PageSession.shop_id == shop.id,
+ PageSession.visible_ms >= 7000,
+ PageSession.created_timestamp > cutoff,
+ )
+
+ def _session_filter(cutoff):
+ """Sessions with real engagement (7s wall clock) after cutoff."""
+ return and_(
+ PageSession.shop_id == shop.id,
+ PageSession.wall_clock_ms >= 7000,
+ PageSession.created_timestamp > cutoff,
+ )
+
+ # --- Section 1: Overview Strip (last 7 days) ---
+
+ ov = db.query(
+ func.count().label("total_views"),
+ func.count(func.distinct(PageSession.product_id)).label("unique_products"),
+ func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
+ ).filter(_view_filter(cutoff_7d)).one()
+
+ avg_ring_depth = db.query(func.avg(PageSession.ring_position)).filter(
+ PageSession.shop_id == shop.id,
+ PageSession.ring_position.isnot(None),
+ PageSession.created_timestamp > cutoff_7d,
+ ).scalar()
+
+ top_src = db.query(
+ PageSession.referrer_class, func.count().label("cnt"),
+ ).filter(
+ _view_filter(cutoff_7d),
+ ).group_by(
+ PageSession.referrer_class,
+ ).order_by(func.count().desc()).first()
+
+ dev_rows_7d = db.query(
+ PageSession.device_class, func.count().label("cnt"),
+ ).filter(_view_filter(cutoff_7d)).group_by(PageSession.device_class).all()
+ dev_total_7d = sum(r.cnt for r in dev_rows_7d) or 1
+ dev_split_str = " \u00b7 ".join(
+ f"{_fmt_pct(r.cnt / dev_total_7d)} {DEVICE_LABELS.get(r.device_class, '?')}"
+ for r in sorted(dev_rows_7d, key=lambda r: -(r.cnt or 0))
+ ) or "\u2014"
+
+ overview = {
+ "total_views": ov.total_views or 0,
+ "unique_products": ov.unique_products or 0,
+ "avg_session": _fmt_ms(ov.avg_session_ms),
+ "avg_ring_depth": f"{avg_ring_depth:.1f}" if avg_ring_depth else "\u2014",
+ "top_source": REFERRER_LABELS.get(top_src[0], "\u2014") if top_src else "\u2014",
+ "device_split": dev_split_str,
+ }
+
+ # --- Section 2: Top Products by Views (7d / 14d / 21d) ---
+
+ views_7d_expr = func.sum(case(
+ (PageSession.created_timestamp > cutoff_7d, 1), else_=0,
+ ))
+ views_14d_expr = func.sum(case(
+ (PageSession.created_timestamp > cutoff_14d, 1), else_=0,
+ ))
+
+ top_prods_raw = db.query(
+ PageSession.product_id,
+ views_7d_expr.label("views_7d"),
+ views_14d_expr.label("views_14d"),
+ func.count().label("views_21d"),
+ ).filter(
+ _view_filter(cutoff_21d),
+ ).group_by(
+ PageSession.product_id,
+ ).order_by(views_7d_expr.desc()).limit(21).all()
+
+ # --- Section 3: Ring Entry Points (top 7 front doors, 21d) ---
+
+ ring_raw = db.query(
+ PageSession.product_id, func.count().label("cnt"),
+ ).filter(
+ PageSession.shop_id == shop.id,
+ PageSession.is_ring_entry == True, # noqa: E712
+ PageSession.created_timestamp > cutoff_21d,
+ ).group_by(PageSession.product_id).order_by(func.count().desc()).limit(7).all()
+
+ ring_total = db.query(func.count()).filter(
+ PageSession.shop_id == shop.id,
+ PageSession.is_ring_entry == True, # noqa: E712
+ PageSession.created_timestamp > cutoff_21d,
+ ).scalar() or 1
+
+ # --- Section 4: Engagement Leaders (active_ms / wall_clock_ms) ---
+
+ eng_expr = func.avg(
+ cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
+ )
+ eng_raw = db.query(
+ PageSession.product_id, eng_expr.label("score"), func.count().label("sessions"),
+ ).filter(
+ _session_filter(cutoff_21d),
+ ).group_by(
+ PageSession.product_id,
+ ).having(func.count() >= MIN_SESSIONS).order_by(eng_expr.desc()).limit(7).all()
+
+ # --- Section 5: Attention Holders (visible_ms / wall_clock_ms) ---
+
+ att_expr = func.avg(
+ cast(PageSession.visible_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
+ )
+ att_raw = db.query(
+ PageSession.product_id, att_expr.label("score"), func.count().label("sessions"),
+ ).filter(
+ _session_filter(cutoff_21d),
+ ).group_by(
+ PageSession.product_id,
+ ).having(func.count() >= MIN_SESSIONS).order_by(att_expr.desc()).limit(7).all()
+
+ # --- Section 6: Study Material (learning score: rewind, slow, re-read) ---
+
+ learn_expr = (
+ case((PageSession.media_seek_back_count > 0, 1), else_=0)
+ + case(
+ (and_(PageSession.media_speed.isnot(None), PageSession.media_speed < 1.0), 1),
+ else_=0,
+ )
+ + case((PageSession.scroll_direction_changes > 3, 1), else_=0)
+ + case((PageSession.media_pause_count > 2, 1), else_=0)
+ + case(
+ (
+ and_(
+ PageSession.wall_clock_ms > 0,
+ cast(PageSession.active_ms, Float) / PageSession.wall_clock_ms > 0.7,
+ ),
+ 1,
+ ),
+ else_=0,
+ )
+ )
+ learn_avg = func.avg(learn_expr)
+ learn_raw = db.query(
+ PageSession.product_id, learn_avg.label("score"), func.count().label("sessions"),
+ ).filter(
+ _session_filter(cutoff_21d),
+ ).group_by(
+ PageSession.product_id,
+ ).having(func.count() >= MIN_SESSIONS).order_by(learn_avg.desc()).limit(7).all()
+
+ # --- Section 7: Background Favorites (passive score: lean-back plays) ---
+
+ pass_expr = (
+ case(
+ (
+ and_(
+ PageSession.wall_clock_ms > 0,
+ cast(PageSession.visible_ms, Float) / PageSession.wall_clock_ms > 0.7,
+ ),
+ 1,
+ ),
+ else_=0,
+ )
+ + case(
+ (
+ and_(
+ PageSession.wall_clock_ms > 0,
+ cast(PageSession.active_ms, Float) / PageSession.wall_clock_ms < 0.3,
+ ),
+ 1,
+ ),
+ else_=0,
+ )
+ + case((PageSession.media_percent_played > 0.69, 1), else_=0)
+ + case((PageSession.media_play_count == 1, 1), else_=0)
+ + case((PageSession.media_pause_count == 0, 1), else_=0)
+ )
+ pass_avg = func.avg(pass_expr)
+ pass_raw = db.query(
+ PageSession.product_id, pass_avg.label("score"), func.count().label("sessions"),
+ ).filter(
+ _session_filter(cutoff_21d),
+ ).group_by(
+ PageSession.product_id,
+ ).having(func.count() >= MIN_SESSIONS).order_by(pass_avg.desc()).limit(7).all()
+
+ # --- Section 8: Traffic Sources (21d) ---
+
+ traffic_raw = db.query(
+ PageSession.referrer_class, func.count().label("cnt"),
+ ).filter(
+ _view_filter(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
+
+ # --- Section 9: Device Split (21d) ---
+
+ device_raw = db.query(
+ PageSession.device_class, func.count().label("cnt"),
+ ).filter(
+ _view_filter(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
+
+ # --- 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 r in rows:
+ all_pids.add(r.product_id)
+ titles = _product_titles(db, list(all_pids))
+
+ def _t(pid):
+ return titles.get(pid, {}).get("title", "Unknown")
+
+ def _u(pid):
+ return titles.get(pid, {}).get("url", "#")
+
+ # --- Build template-ready data ---
+
+ top_products = []
+ for i, r in enumerate(top_prods_raw):
+ v7, v14 = r.views_7d or 0, r.views_14d or 0
+ top_products.append({
+ "rank": i + 1,
+ "title": _t(r.product_id),
+ "url": _u(r.product_id),
+ "views_7d": v7,
+ "views_14d": v14,
+ "views_21d": r.views_21d or 0,
+ "trend": "rising" if v14 > 0 and v7 > v14 / 2 else "falling",
+ })
+
+ ring_entries = [
+ {
+ "rank": i + 1,
+ "title": _t(r.product_id),
+ "url": _u(r.product_id),
+ "count": r.cnt,
+ "pct": _fmt_pct(r.cnt / ring_total),
+ }
+ for i, r in enumerate(ring_raw)
+ ]
+
+ def _ranked(raw, score_fmt):
+ return [
+ {
+ "rank": i + 1,
+ "title": _t(r.product_id),
+ "url": _u(r.product_id),
+ "score": score_fmt(r.score),
+ "sessions": r.sessions,
+ }
+ for i, r in enumerate(raw)
+ ]
+
+ return {
+ "overview": overview,
+ "top_products": top_products,
+ "ring_entries": ring_entries,
+ "engagement": _ranked(eng_raw, _fmt_pct),
+ "attention": _ranked(att_raw, _fmt_pct),
+ "learning": _ranked(learn_raw, _fmt_score),
+ "passive": _ranked(pass_raw, _fmt_score),
+ "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": [
+ {
+ "device": DEVICE_LABELS.get(r.device_class, "Unknown"),
+ "count": r.cnt,
+ "pct": _fmt_pct(r.cnt / device_total),
+ }
+ for r in device_raw
+ ],
+ }
diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py
index 9652798..8f94d69 100644
--- a/make_post_sell/views/product.py
+++ b/make_post_sell/views/product.py
@@ -359,6 +359,7 @@ def product_edit(request):
reforge_discovery_ring_async(
product.shop.id, request.registry["dbsession_factory"]
)
+ return HTTPFound(product.absolute_edit_url(request))
signed_posts = {}