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), }