From 8adf2fadafdf3bb1142385d06d12eca1a63fa1d1 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 18 Feb 2026 07:26:03 -0500 Subject: [PATCH] analytics: exclude shop owner traffic from sales funnel metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add is_owner flag to PageSession — set via data attribute when the viewer can_edit_shop. beacon stores the flag, view_count skips increment for owners, and all analytics queries filter owner sessions out of funnel metrics. owner sessions are still recorded for separate analysis. existing NULL data treated as non-owner. --- make_post_sell/models/page_session.py | 1 + ...f7a2c8d1e5_add_is_owner_to_page_session.py | 26 +++++++++++++++++++ make_post_sell/static/js/signals.js | 4 ++- make_post_sell/templates/content.j2 | 2 +- make_post_sell/templates/product.j2 | 2 +- make_post_sell/views/analytics.py | 19 +++++++++++++- make_post_sell/views/signals.py | 4 ++- 7 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 make_post_sell/scripts/alembic/versions/b3f7a2c8d1e5_add_is_owner_to_page_session.py diff --git a/make_post_sell/models/page_session.py b/make_post_sell/models/page_session.py index 283279b..3dab7f3 100644 --- a/make_post_sell/models/page_session.py +++ b/make_post_sell/models/page_session.py @@ -42,6 +42,7 @@ class PageSession(RBase, Base): ring_position = Column(SmallInteger, nullable=True) referrer_class = Column(SmallInteger, nullable=True) # 0-4 device_class = Column(SmallInteger, nullable=True) # 0-2 + is_owner = Column(Boolean, nullable=True) # shop owner/editor viewing own product def __init__(self): self.id = uuid.uuid1() diff --git a/make_post_sell/scripts/alembic/versions/b3f7a2c8d1e5_add_is_owner_to_page_session.py b/make_post_sell/scripts/alembic/versions/b3f7a2c8d1e5_add_is_owner_to_page_session.py new file mode 100644 index 0000000..cfc8e74 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/b3f7a2c8d1e5_add_is_owner_to_page_session.py @@ -0,0 +1,26 @@ +"""add is_owner to page_session + +Revision ID: b3f7a2c8d1e5 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-18 06:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "b3f7a2c8d1e5" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "PageSession", + sa.Column("is_owner", sa.Boolean(), nullable=True), + ) + + +def downgrade(): + op.drop_column("PageSession", "is_owner") diff --git a/make_post_sell/static/js/signals.js b/make_post_sell/static/js/signals.js index 0ec8202..17fff21 100644 --- a/make_post_sell/static/js/signals.js +++ b/make_post_sell/static/js/signals.js @@ -6,6 +6,7 @@ var productId = root.getAttribute('data-signal-product-id'); var shopId = root.getAttribute('data-signal-shop-id'); if (!productId || !shopId) return; + var isOwner = root.hasAttribute('data-signal-is-owner'); // Per-session random token (never persisted) var sessionToken = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); @@ -211,7 +212,8 @@ scroll_direction_changes: scrollDirectionChanges, viewport_width: window.innerWidth || 0, is_ring_entry: isRingEntry, - ring_position: ringPosition + ring_position: ringPosition, + is_owner: isOwner }; // Only include media fields if there was media diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index fc2c111..cab5390 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -30,7 +30,7 @@ {% block content -%} -
+
{% if request.shop.watch_mode_enabled %}
{% endif %} diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index a08e62e..fb97bf7 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -28,7 +28,7 @@ {% block content -%} -
+
{% if request.shop.watch_mode_enabled %}
{% endif %} diff --git a/make_post_sell/views/analytics.py b/make_post_sell/views/analytics.py index 0c19bdf..d5b70b0 100644 --- a/make_post_sell/views/analytics.py +++ b/make_post_sell/views/analytics.py @@ -1,5 +1,5 @@ from pyramid.view import view_config -from sqlalchemy import func, case, and_, cast, Float +from sqlalchemy import func, case, and_, or_, cast, Float from . import shop_editor_required from ..models.page_session import PageSession @@ -85,12 +85,16 @@ def shop_analytics(request): cuts = _cutoffs() cutoff_7d, cutoff_14d, cutoff_21d = cuts["7d"], cuts["14d"], cuts["21d"] + # Exclude owner/editor traffic from all funnel metrics + _not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712 + 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, + _not_owner, ) def _session_filter(cutoff): @@ -99,6 +103,7 @@ def shop_analytics(request): PageSession.shop_id == shop.id, PageSession.wall_clock_ms >= 7000, PageSession.created_timestamp > cutoff, + _not_owner, ) # --- Section 1: Overview Strip (last 7 days) --- @@ -113,6 +118,7 @@ def shop_analytics(request): _ring_base = and_( PageSession.shop_id == shop.id, PageSession.ring_position.isnot(None), + _not_owner, ) def _ring_seen(cutoff=None): @@ -187,12 +193,14 @@ def shop_analytics(request): PageSession.shop_id == shop.id, PageSession.is_ring_entry == True, # noqa: E712 PageSession.created_timestamp > cutoff_21d, + _not_owner, ).group_by(PageSession.product_id).order_by(func.count().desc()).limit(42).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, + _not_owner, ).scalar() or 1 # --- Section 4: Engagement Leaders (active_ms / wall_clock_ms) --- @@ -312,6 +320,7 @@ def shop_analytics(request): PageSession.media_duration_ms.isnot(None), PageSession.media_duration_ms > 0, PageSession.created_timestamp > cutoff_21d, + _not_owner, ) video_agg = db.query( @@ -508,12 +517,15 @@ def product_analytics(request): cutoff_7d = cuts["7d"] cutoff_21d = cuts["21d"] + _not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712 + 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, + _not_owner, ) def _sf(cutoff): @@ -522,6 +534,7 @@ def product_analytics(request): PageSession.product_id == product.id, PageSession.wall_clock_ms >= 7000, PageSession.created_timestamp > cutoff, + _not_owner, ) # --- Overview --- @@ -538,6 +551,7 @@ def product_analytics(request): ).filter( PageSession.product_id == product.id, PageSession.created_timestamp > cutoff_7d, + _not_owner, ).one() bounce_rate = None if bounce_q.total and bounce_q.total > 0: @@ -546,6 +560,7 @@ def product_analytics(request): lifetime_views = db.query(func.count()).filter( PageSession.product_id == product.id, PageSession.visible_ms >= 7000, + _not_owner, ).scalar() or 0 overview = { @@ -570,6 +585,7 @@ def product_analytics(request): PageSession.media_duration_ms.isnot(None), PageSession.media_duration_ms > 0, PageSession.created_timestamp > cutoff_21d, + _not_owner, ) vagg = db.query( func.avg(PageSession.media_percent_played).label("avg_pct"), @@ -639,6 +655,7 @@ def product_analytics(request): PageSession.product_id == product.id, PageSession.is_ring_entry == True, # noqa: E712 PageSession.created_timestamp > cutoff_21d, + _not_owner, ).scalar() or 0 # --- Engagement (21d) --- diff --git a/make_post_sell/views/signals.py b/make_post_sell/views/signals.py index b1dd8e8..05cad95 100644 --- a/make_post_sell/views/signals.py +++ b/make_post_sell/views/signals.py @@ -219,12 +219,14 @@ def beacon_view(request): ps.device_class = classify_device(body.get("viewport_width")) ps.is_ring_entry = bool(body.get("is_ring_entry")) if body.get("is_ring_entry") is not None else None ps.ring_position = _clamp_int(body.get("ring_position"), -32768, 32767) + ps.is_owner = bool(body.get("is_owner")) if body.get("is_owner") is not None else None request.dbsession.add(ps) # Increment view_count if visible >= 7 seconds (atomic SQL UPDATE) + # Skip increment for shop owner/editor traffic visible_ms = ps.visible_ms or 0 - if visible_ms >= 7000: + if visible_ms >= 7000 and not ps.is_owner: from ..models.product import Product request.dbsession.execute( sa.update(Product)