feat: referrer trend line chart, sandbox + S3 storage functional tests

- Add _daily_referrer_counts() for 28-day external referrer line chart
  bucketing in shop and product analytics views
- Add SVG line chart sections for referrer trends in both analytics
  templates (analytics.j2 and analytics_product.j2)
- Add functional tests for sandbox mode enable/disable, toolbar
  conditional rendering, and form isolation
- Add functional tests for user S3 storage settings (save, clear,
  validation) and presigned upload endpoint
- Add test for data-has-bucket attribute on sandbox toolbar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
russell@unturf.com 2026-03-04 22:51:55 -05:00
parent 38bb64b97b
commit e662f29e86
4 changed files with 341 additions and 0 deletions

View file

@ -425,6 +425,13 @@
</div>
{% endif %}
{# --- Daily External Referrer Trend (28 days) --- #}
{% if daily_referrers_max > 0 %}
<h4>External Referrer Trend (last 28 days)</h4>
<p class="analytics-hint">Daily count of visits from external referrers (excludes direct and internal traffic).</p>
{{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)") }}
{% endif %}
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
<h4>Top Referrer Domains (last 21 days)</h4>

View file

@ -218,6 +218,13 @@
</div>
{% endif %}
{# --- Daily External Referrer Trend (28 days) --- #}
{% if daily_referrers_max > 0 %}
<h4>External Referrer Trend (last 28 days)</h4>
<p class="analytics-hint">Daily count of visits from external referrers (excludes direct and internal traffic).</p>
{{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)") }}
{% endif %}
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
<h4>Top Referrer Domains (last 21 days)</h4>

View file

@ -4153,3 +4153,279 @@ class TestAnalytics(_AuthenticatedBase):
)
res = res.follow()
self.assertIn("Endpoint, bucket, access key, and secret key are all required", res.text)
# ================================================================
# Sandbox Mode Tests
# ================================================================
def test_sandbox_mode_enable_disable(self):
"""Test toggling sandbox_mode on a shop via ribbon-settings form."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Default should be off
self.dbsession.expire(shop)
self.assertFalse(shop.sandbox_mode)
# Enable sandbox mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"ribbon_text": "",
"ribbon_text_color": "",
"ribbon_color_1": "",
"ribbon_color_2": "",
"default_theme": "1",
"show_dates": "1",
"color_filter": "0",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
self.dbsession.expire(shop)
self.assertTrue(shop.sandbox_mode)
# Disable sandbox mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"ribbon_text": "",
"ribbon_text_color": "",
"ribbon_color_1": "",
"ribbon_color_2": "",
"default_theme": "1",
"show_dates": "1",
"color_filter": "0",
"sandbox_mode": "0",
"submit": "Save Settings",
},
status=302,
)
self.dbsession.expire(shop)
self.assertFalse(shop.sandbox_mode)
def test_sandbox_toolbar_appears_when_enabled(self):
"""Test that the sandbox toolbar HTML is present when sandbox_mode is on."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Enable sandbox mode
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"ribbon_text": "",
"ribbon_text_color": "",
"ribbon_color_1": "",
"ribbon_color_2": "",
"default_theme": "1",
"show_dates": "1",
"color_filter": "0",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
# Visit the shop page — toolbar should be present
res = self.testapp.get(f"/s/{shop.id}", status=200)
self.assertIn("sandbox-toolbar", res.text)
self.assertIn("sandbox.js", res.text)
def test_sandbox_toolbar_absent_when_disabled(self):
"""Test that the sandbox toolbar is NOT present when sandbox_mode is off."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# sandbox_mode is off by default
res = self.testapp.get(f"/s/{shop.id}", status=200)
self.assertNotIn("sandbox-toolbar", res.text)
def test_sandbox_mode_doesnt_affect_other_settings(self):
"""Test that toggling sandbox mode doesn't alter comment or crypto settings."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Store initial state
self.dbsession.expire(shop)
initial_comments = shop.comments_enabled
initial_watch = shop.watch_mode_enabled
# Enable sandbox mode
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"ribbon_text": "",
"ribbon_text_color": "",
"ribbon_color_1": "",
"ribbon_color_2": "",
"default_theme": "1",
"show_dates": "1",
"color_filter": "0",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
self.dbsession.expire(shop)
self.assertTrue(shop.sandbox_mode)
self.assertEqual(shop.comments_enabled, initial_comments)
self.assertEqual(shop.watch_mode_enabled, initial_watch)
# ================================================================
# User S3 Storage Settings Tests
# ================================================================
def test_user_storage_settings_save(self):
"""Test saving S3 bucket credentials via user storage settings."""
self.log_in_user(self.user1_creds)
res = self.testapp.post(
"/u/settings/storage",
{
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"s3_region": "nyc3",
"s3_bucket": "test-sandbox-artifacts",
"s3_access_key": "TESTKEY123",
"s3_secret_key": "TESTSECRET456",
},
status=302,
)
res = res.follow()
self.assertIn("Artifact storage settings saved", res.text)
def test_user_storage_settings_clear(self):
"""Test clearing S3 bucket credentials."""
self.log_in_user(self.user1_creds)
# First save credentials
self.testapp.post(
"/u/settings/storage",
{
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"s3_region": "nyc3",
"s3_bucket": "test-bucket",
"s3_access_key": "TESTKEY",
"s3_secret_key": "TESTSECRET",
},
status=302,
)
# Clear by submitting all empty
res = self.testapp.post(
"/u/settings/storage",
{
"s3_endpoint": "",
"s3_region": "",
"s3_bucket": "",
"s3_access_key": "",
"s3_secret_key": "",
},
status=302,
)
res = res.follow()
self.assertIn("Artifact storage credentials cleared", res.text)
def test_user_storage_settings_validation(self):
"""Test that S3 settings require all credential fields."""
self.log_in_user(self.user1_creds)
# Submit with endpoint but missing other required fields
res = self.testapp.post(
"/u/settings/storage",
{
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"s3_region": "",
"s3_bucket": "",
"s3_access_key": "",
"s3_secret_key": "",
},
status=302,
)
res = res.follow()
self.assertIn("Endpoint, bucket name, access key, and secret key are all required", res.text)
def test_user_storage_settings_endpoint_validation(self):
"""Test that S3 endpoint must start with http."""
self.log_in_user(self.user1_creds)
res = self.testapp.post(
"/u/settings/storage",
{
"s3_endpoint": "ftp://bad-endpoint.com",
"s3_region": "us-east-1",
"s3_bucket": "test-bucket",
"s3_access_key": "KEY",
"s3_secret_key": "SECRET",
},
status=302,
)
res = res.follow()
self.assertIn("S3 endpoint must start with http", res.text)
def test_sandbox_presign_requires_s3_bucket(self):
"""Test that sandbox upload endpoint returns error when no S3 bucket configured."""
self.log_in_user(self.user1_creds)
res = self.testapp.post(
"/u/sandbox/upload",
{"filename": "test.png", "content_type": "image/png"},
status=400,
)
self.assertIn("No S3 bucket configured", res.json["error"])
def test_data_has_bucket_attribute(self):
"""Test that data-has-bucket attribute is set when user has S3 credentials."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Enable sandbox mode to render the toolbar
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"ribbon_text": "",
"ribbon_text_color": "",
"ribbon_color_1": "",
"ribbon_color_2": "",
"default_theme": "1",
"show_dates": "1",
"color_filter": "0",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
# Without S3 credentials, data-has-bucket should not be present
res = self.testapp.get(f"/s/{shop.id}", status=200)
self.assertNotIn('data-has-bucket="1"', res.text)
# Save S3 credentials
self.testapp.post(
"/u/settings/storage",
{
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"s3_region": "nyc3",
"s3_bucket": "test-bucket",
"s3_access_key": "TESTKEY",
"s3_secret_key": "TESTSECRET",
},
status=302,
)
# Now data-has-bucket="1" should be present
res = self.testapp.get(f"/s/{shop.id}", status=200)
self.assertIn('data-has-bucket="1"', res.text)

View file

@ -223,6 +223,47 @@ def _daily_bounce_rate(dbsession, all_sessions_filter, days=28):
return buckets
def _daily_referrer_counts(dbsession, base_filter, days=28):
"""Bucket external referrer sessions into daily counts for a line chart.
Excludes direct (class=0) and internal (class=3) traffic.
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
"""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
func.count().label("cnt"),
)
.filter(
base_filter,
PageSession.created_timestamp > cutoff,
PageSession.referrer_domain.isnot(None),
PageSession.referrer_class != 0, # exclude direct
PageSession.referrer_class != 3, # exclude internal
)
.group_by(day_offset_expr)
.all()
)
counts = {r.day_offset: r.cnt for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
val = counts.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"value": val,
"tooltip": str(val),
})
return buckets
def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
"""Top internal search keywords for a shop, last N days."""
from ..models.shop_search_request import ShopSearchRequest
@ -736,6 +777,9 @@ def shop_analytics(request):
top_referrer_domains = _top_referrer_domains(db, _view_base, 21, 21)
top_referrer_queries = _top_referrer_queries(db, _view_base, 21, 21)
# --- Daily External Referrer Trend (28 days) ---
daily_referrers = _daily_referrer_counts(db, _view_base, 28)
return {
"overview": overview,
"ring_size": ring_size,
@ -758,6 +802,8 @@ def shop_analytics(request):
"top_keywords": top_keywords,
"top_referrer_domains": top_referrer_domains,
"top_referrer_queries": top_referrer_queries,
"daily_referrers": daily_referrers,
"daily_referrers_max": max((b["value"] for b in daily_referrers), default=0),
"traffic": [
{
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
@ -1005,6 +1051,9 @@ def product_analytics(request):
top_referrer_domains = _top_referrer_domains(db, _pf_base, 21, 21)
top_referrer_queries = _top_referrer_queries(db, _pf_base, 21, 21)
# --- Daily External Referrer Trend (28 days) ---
daily_referrers = _daily_referrer_counts(db, _pf_base, 28)
return {
"product": product,
"product_url": product_url,
@ -1025,4 +1074,6 @@ def product_analytics(request):
"price_changes": price_changes,
"top_referrer_domains": top_referrer_domains,
"top_referrer_queries": top_referrer_queries,
"daily_referrers": daily_referrers,
"daily_referrers_max": max((b["value"] for b in daily_referrers), default=0),
}