diff --git a/make_post_sell/templates/analytics.j2 b/make_post_sell/templates/analytics.j2
index 79c0e30..781725e 100644
--- a/make_post_sell/templates/analytics.j2
+++ b/make_post_sell/templates/analytics.j2
@@ -425,6 +425,13 @@
{% endif %}
+ {# --- Daily External Referrer Trend (28 days) --- #}
+ {% if daily_referrers_max > 0 %}
+
External Referrer Trend (last 28 days)
+ Daily count of visits from external referrers (excludes direct and internal traffic).
+ {{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)") }}
+ {% endif %}
+
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
Top Referrer Domains (last 21 days)
diff --git a/make_post_sell/templates/analytics_product.j2 b/make_post_sell/templates/analytics_product.j2
index 7c8b645..48af8e4 100644
--- a/make_post_sell/templates/analytics_product.j2
+++ b/make_post_sell/templates/analytics_product.j2
@@ -218,6 +218,13 @@
{% endif %}
+ {# --- Daily External Referrer Trend (28 days) --- #}
+ {% if daily_referrers_max > 0 %}
+ External Referrer Trend (last 28 days)
+ Daily count of visits from external referrers (excludes direct and internal traffic).
+ {{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)") }}
+ {% endif %}
+
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
Top Referrer Domains (last 21 days)
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 9226080..5b3817c 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -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)
diff --git a/make_post_sell/views/analytics.py b/make_post_sell/views/analytics.py
index d565c1a..f25633a 100644
--- a/make_post_sell/views/analytics.py
+++ b/make_post_sell/views/analytics.py
@@ -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),
}