summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-11-22 09:22:42 -0500
committerBen Sima <ben@bensima.com>2025-11-22 09:22:42 -0500
commit820f0a03c5df1359f08ece9ad9d7383b91527608 (patch)
tree1aa24c7d7a6bb88dccef421a721003e8539553cc
parent26b8a657d00e76d1e54eeaf78246b1bf4b03fb43 (diff)
parent0eafc36823e72ce70083cf55be8533087c0b1139 (diff)
Merge task t-rWbMpxaBk: Metrics view in Admin dashboard
-rw-r--r--.merge_file_Akj4Wc.lock0
-rw-r--r--.merge_file_LGcQlo.lock0
-rw-r--r--.merge_file_xbLQDn.lock0
-rw-r--r--.tasks/tasks.jsonl1
-rw-r--r--Biz/PodcastItLater/Admin.py53
-rw-r--r--Biz/PodcastItLater/Core.py63
-rw-r--r--Biz/PodcastItLater/TestMetricsView.py121
-rw-r--r--Biz/PodcastItLater/Web.py55
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 &amp; 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")