diff options
| author | Ben Sima <ben@bensima.com> | 2025-11-22 09:22:42 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-11-22 09:22:42 -0500 |
| commit | 820f0a03c5df1359f08ece9ad9d7383b91527608 (patch) | |
| tree | 1aa24c7d7a6bb88dccef421a721003e8539553cc | |
| parent | 26b8a657d00e76d1e54eeaf78246b1bf4b03fb43 (diff) | |
| parent | 0eafc36823e72ce70083cf55be8533087c0b1139 (diff) | |
Merge task t-rWbMpxaBk: Metrics view in Admin dashboard
| -rw-r--r-- | .merge_file_Akj4Wc.lock | 0 | ||||
| -rw-r--r-- | .merge_file_LGcQlo.lock | 0 | ||||
| -rw-r--r-- | .merge_file_xbLQDn.lock | 0 | ||||
| -rw-r--r-- | .tasks/tasks.jsonl | 1 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin.py | 53 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 63 | ||||
| -rw-r--r-- | Biz/PodcastItLater/TestMetricsView.py | 121 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 55 |
8 files changed, 280 insertions, 13 deletions
diff --git a/.merge_file_Akj4Wc.lock b/.merge_file_Akj4Wc.lock new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.merge_file_Akj4Wc.lock diff --git a/.merge_file_LGcQlo.lock b/.merge_file_LGcQlo.lock new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.merge_file_LGcQlo.lock diff --git a/.merge_file_xbLQDn.lock b/.merge_file_xbLQDn.lock new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.merge_file_xbLQDn.lock diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index c81b70f..4ceb9d7 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -199,3 +199,4 @@ {"taskCreatedAt":"2025-11-22T13:03:21.434586142Z","taskDependencies":[],"taskDescription":"Move detailed documentation (Task Manager, Bild, Git Workflow) to separate README files in their respective namespaces. Keep AGENTS.md focused on critical rules, cheat sheets, and pointers to the detailed docs. Goal is to reduce token usage.","taskId":"t-1o2bkufixnc","taskNamespace":"Omni.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Refactor and condense AGENTS.md","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:04:41.243757221Z"} {"taskCreatedAt":"2025-11-21T04:37:55.163249193Z","taskDependencies":[{"depId":"t-144gqry","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rwadhwrzt","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Fix bild failure for Biz/PodcastItLater/Web.py","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:37:55.163249193Z"} {"taskCreatedAt":"2025-11-21T05:28:31.973657907Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rwagbsb6w","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Add error handling tests for Worker","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:40:59.255645021Z"} +{"taskCreatedAt":"2025-11-22T10:39:11.364170862Z","taskDependencies":[{"depId":"t-rwbmpxabk","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rwcm6todb","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Fix failing tests in Biz/PodcastItLater/Web.py (UsageLimits and EpisodeDetail)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:39:11.364170862Z"} diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py index 6faf7fb..6f60948 100644 --- a/Biz/PodcastItLater/Admin.py +++ b/Biz/PodcastItLater/Admin.py @@ -157,6 +157,59 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]): return UI.PageLayout( html.div( html.h2( + html.i(classes=["bi", "bi-people", "me-2"]), + "Growth & Usage", + classes=["mb-4"], + ), + # Growth & Usage cards + html.div( + html.div( + html.div( + MetricCard( + title="Total Users", + value=metrics.get("total_users", 0), + icon="bi-people", + ), + classes=["card", "shadow-sm"], + ), + classes=["col-md-3"], + ), + html.div( + html.div( + MetricCard( + title="Active Subs", + value=metrics.get("active_subscriptions", 0), + icon="bi-credit-card", + ), + classes=["card", "shadow-sm"], + ), + classes=["col-md-3"], + ), + html.div( + html.div( + MetricCard( + title="Submissions (24h)", + value=metrics.get("submissions_24h", 0), + icon="bi-activity", + ), + classes=["card", "shadow-sm"], + ), + classes=["col-md-3"], + ), + html.div( + html.div( + MetricCard( + title="Submissions (7d)", + value=metrics.get("submissions_7d", 0), + icon="bi-calendar-week", + ), + classes=["card", "shadow-sm"], + ), + classes=["col-md-3"], + ), + classes=["row", "g-3", "mb-5"], + ), + html.h2( html.i(classes=["bi", "bi-graph-up", "me-2"]), "Episode Metrics", classes=["mb-4"], diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 0bb7251..2f05db3 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -391,7 +391,7 @@ class Database: # noqa: PLR0904 cursor.execute( """ SELECT id, title, audio_url, duration, created_at, - content_length, author, original_url, user_id + content_length, author, original_url, user_id, is_public FROM episodes WHERE id = ? """, @@ -1176,6 +1176,10 @@ class Database: # noqa: PLR0904 - most_played: List of top 10 most played episodes - most_downloaded: List of top 10 most downloaded episodes - most_added: List of top 10 most added episodes + - total_users: Total number of users + - active_subscriptions: Number of active subscriptions + - submissions_24h: Submissions in last 24 hours + - submissions_7d: Submissions in last 7 days """ with Database.get_connection() as conn: cursor = conn.cursor() @@ -1245,6 +1249,29 @@ class Database: # noqa: PLR0904 ) most_added = [dict(row) for row in cursor.fetchall()] + # Get user metrics + cursor.execute("SELECT COUNT(*) as count FROM users") + total_users = cursor.fetchone()["count"] + + cursor.execute( + "SELECT COUNT(*) as count FROM users " + "WHERE subscription_status = 'active'", + ) + active_subscriptions = cursor.fetchone()["count"] + + # Get recent submission metrics + cursor.execute( + "SELECT COUNT(*) as count FROM queue " + "WHERE created_at >= datetime('now', '-1 day')", + ) + submissions_24h = cursor.fetchone()["count"] + + cursor.execute( + "SELECT COUNT(*) as count FROM queue " + "WHERE created_at >= datetime('now', '-7 days')", + ) + submissions_7d = cursor.fetchone()["count"] + return { "total_episodes": total_episodes, "total_plays": total_plays, @@ -1253,6 +1280,10 @@ class Database: # noqa: PLR0904 "most_played": most_played, "most_downloaded": most_downloaded, "most_added": most_added, + "total_users": total_users, + "active_subscriptions": active_subscriptions, + "submissions_24h": submissions_24h, + "submissions_7d": submissions_7d, } @staticmethod @@ -1553,6 +1584,36 @@ class TestDatabase(Test.TestCase): # Test completed successfully - migration worked self.assertIsNotNone(conn) + def test_get_metrics_summary_extended(self) -> None: + """Verify extended metrics summary.""" + # Create some data + user_id, _ = Database.create_user("test@example.com") + Database.create_episode( + "Test Article", + "url", + 100, + 1000, + user_id, + ) + + # Create a queue item + Database.add_to_queue( + "https://example.com", + "test@example.com", + user_id, + ) + + metrics = Database.get_metrics_summary() + + self.assertIn("total_users", metrics) + self.assertIn("active_subscriptions", metrics) + self.assertIn("submissions_24h", metrics) + self.assertIn("submissions_7d", metrics) + + self.assertEqual(metrics["total_users"], 1) + self.assertEqual(metrics["submissions_24h"], 1) + self.assertEqual(metrics["submissions_7d"], 1) + class TestUserManagement(Test.TestCase): """Test user management functionality.""" diff --git a/Biz/PodcastItLater/TestMetricsView.py b/Biz/PodcastItLater/TestMetricsView.py new file mode 100644 index 0000000..b452feb --- /dev/null +++ b/Biz/PodcastItLater/TestMetricsView.py @@ -0,0 +1,121 @@ +"""Tests for Admin metrics view.""" + +# : out podcastitlater-test-metrics +# : dep pytest +# : dep starlette +# : dep httpx +# : dep ludic +# : dep feedgen +# : dep itsdangerous +# : dep uvicorn +# : dep stripe +# : dep sqids + +import Biz.PodcastItLater.Core as Core +import Biz.PodcastItLater.Web as Web +import Omni.Test as Test +from starlette.testclient import TestClient + + +class BaseWebTest(Test.TestCase): + """Base class for web tests.""" + + def setUp(self) -> None: + """Set up test database and client.""" + Core.Database.init_db() + self.client = TestClient(Web.app) + + @staticmethod + def tearDown() -> None: + """Clean up test database.""" + Core.Database.teardown() + + +class TestMetricsView(BaseWebTest): + """Test Admin Metrics View.""" + + def test_admin_metrics_view_access(self) -> None: + """Admin user should be able to access metrics view.""" + # Create admin user + _admin_id, _ = Core.Database.create_user("ben@bensima.com") + self.client.post("/login", data={"email": "ben@bensima.com"}) + + response = self.client.get("/admin/metrics") + self.assertEqual(response.status_code, 200) + self.assertIn("Growth & Usage", response.text) + self.assertIn("Total Users", response.text) + + def test_admin_metrics_data(self) -> None: + """Metrics view should show correct data.""" + # Create admin user + admin_id, _ = Core.Database.create_user("ben@bensima.com") + self.client.post("/login", data={"email": "ben@bensima.com"}) + + # Create some data + # 1. Users + Core.Database.create_user("user1@example.com") + user2_id, _ = Core.Database.create_user("user2@example.com") + + # 2. Subscriptions (simulate by setting subscription_status) + with Core.Database.get_connection() as conn: + conn.execute( + "UPDATE users SET subscription_status = 'active' WHERE id = ?", + (user2_id,), + ) + conn.commit() + + # 3. Submissions + Core.Database.add_to_queue( + "http://example.com/1", + "user1@example.com", + admin_id, + ) + + # Get metrics page + response = self.client.get("/admin/metrics") + self.assertEqual(response.status_code, 200) + + # Check labels + self.assertIn("Total Users", response.text) + self.assertIn("Active Subs", response.text) + self.assertIn("Submissions (24h)", response.text) + + # Check values (metrics dict is passed to template, + # we check rendered HTML) + # Total users: 3 (admin + user1 + user2) + # Active subs: 1 (user2) + # Submissions 24h: 1 + + # Check for values in HTML + # Note: This is a bit brittle, but effective for quick verification + self.assertIn('<h3 class="mb-0">3</h3>', response.text) + self.assertIn('<h3 class="mb-0">1</h3>', response.text) + + def test_non_admin_access_denied(self) -> None: + """Non-admin users should be denied access.""" + # Create regular user + Core.Database.create_user("regular@example.com") + self.client.post("/login", data={"email": "regular@example.com"}) + + response = self.client.get("/admin/metrics") + # Should redirect to /?error=forbidden + self.assertEqual(response.status_code, 302) + self.assertIn("error=forbidden", response.headers["Location"]) + + def test_anonymous_access_redirect(self) -> None: + """Anonymous users should be redirected to login.""" + response = self.client.get("/admin/metrics") + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/") + + +def test() -> None: + """Run the tests.""" + Test.run( + Web.area, + [TestMetricsView], + ) + + +if __name__ == "__main__": + test() diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 4d03f6a..06145d9 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -1868,7 +1868,7 @@ def add_episode_to_feed(request: Request, episode_id: int) -> Response: Core.Database.add_episode_to_user(user_id, episode_id) # Track the "added" event - Core.Database.track_episode_metric(episode_id, "added", user_id) + Core.Database.track_episode_event(episode_id, "added", user_id) # Reload the current page to show updated button state # Check referer to determine where to redirect @@ -1899,7 +1899,7 @@ def track_episode( user_id = request.session.get("user_id") # Track the event - Core.Database.track_episode_metric(episode_id, event_type, user_id) + Core.Database.track_episode_event(episode_id, event_type, user_id) return Response("", status_code=200) @@ -2416,7 +2416,7 @@ class TestMetricsDashboard(BaseWebTest): self.client.post("/login", data={"email": "user@example.com"}) # Try to access metrics - response = self.client.get("/admin/metrics") + response = self.client.get("/admin/metrics", follow_redirects=False) # Should redirect self.assertEqual(response.status_code, 302) @@ -2426,7 +2426,7 @@ class TestMetricsDashboard(BaseWebTest): """Verify unauthenticated users are redirected.""" self.client.get("/logout") - response = self.client.get("/admin/metrics") + response = self.client.get("/admin/metrics", follow_redirects=False) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], "/") @@ -2443,10 +2443,10 @@ class TestMetricsDashboard(BaseWebTest): Core.Database.add_episode_to_user(self.user_id, episode_id) # Track some events - Core.Database.track_episode_metric(episode_id, "played") - Core.Database.track_episode_metric(episode_id, "played") - Core.Database.track_episode_metric(episode_id, "downloaded") - Core.Database.track_episode_metric(episode_id, "added", self.user_id) + Core.Database.track_episode_event(episode_id, "played") + Core.Database.track_episode_event(episode_id, "played") + Core.Database.track_episode_event(episode_id, "downloaded") + Core.Database.track_episode_event(episode_id, "added", self.user_id) # Get metrics page response = self.client.get("/admin/metrics") @@ -2455,6 +2455,37 @@ class TestMetricsDashboard(BaseWebTest): self.assertIn("Episode Metrics", response.text) self.assertIn("Total Episodes", response.text) self.assertIn("Total Plays", response.text) + + def test_growth_metrics_display(self) -> None: + """Verify growth and usage metrics are displayed.""" + # Create an active subscriber + user2_id, _ = Core.Database.create_user("active@example.com") + Core.Database.update_user_subscription( + user2_id, + subscription_id="sub_test", + status="active", + period_start=datetime.now(timezone.utc), + period_end=datetime.now(timezone.utc), + tier="paid", + cancel_at_period_end=False, + ) + + # Create a queue item + Core.Database.add_to_queue( + "https://example.com/new", + "active@example.com", + user2_id, + ) + + # Get metrics page + response = self.client.get("/admin/metrics") + + self.assertEqual(response.status_code, 200) + self.assertIn("Growth & Usage", response.text) + self.assertIn("Total Users", response.text) + self.assertIn("Active Subs", response.text) + self.assertIn("Submissions (24h)", response.text) + self.assertIn("Total Downloads", response.text) self.assertIn("Total Adds", response.text) @@ -2480,13 +2511,13 @@ class TestMetricsDashboard(BaseWebTest): # Track events - more for episode1 for _ in range(5): - Core.Database.track_episode_metric(episode1, "played") + Core.Database.track_episode_event(episode1, "played") for _ in range(2): - Core.Database.track_episode_metric(episode2, "played") + Core.Database.track_episode_event(episode2, "played") for _ in range(3): - Core.Database.track_episode_metric(episode1, "downloaded") - Core.Database.track_episode_metric(episode2, "downloaded") + Core.Database.track_episode_event(episode1, "downloaded") + Core.Database.track_episode_event(episode2, "downloaded") # Get metrics page response = self.client.get("/admin/metrics") |
