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:
parent
38bb64b97b
commit
e662f29e86
4 changed files with 341 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue