summaryrefslogtreecommitdiff
path: root/Biz
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 /Biz
parent26b8a657d00e76d1e54eeaf78246b1bf4b03fb43 (diff)
parent0eafc36823e72ce70083cf55be8533087c0b1139 (diff)
Merge task t-rWbMpxaBk: Metrics view in Admin dashboard
Diffstat (limited to 'Biz')
-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
4 files changed, 279 insertions, 13 deletions
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")