From d98cc29446eb14647d62d0372124fb0be08ec5ae Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:17:35 -0500 Subject: feat: implement t-144gQry --- Biz/PodcastItLater/Web.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 7e8e969..e80b0b4 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -36,6 +36,7 @@ import re import sys import tempfile import typing +from unittest.mock import patch import urllib.parse import uvicorn from datetime import datetime @@ -3164,6 +3165,68 @@ class TestUsageLimits(BaseWebTest): self.assertEqual(usage["articles"], 20) +class TestAccountPage(BaseWebTest): + """Test account page functionality.""" + + def setUp(self) -> None: + """Set up test client with logged-in user.""" + super().setUp() + self.user_id, _ = Core.Database.create_user( + "test@example.com", + status="active", + ) + self.client.post("/login", data={"email": "test@example.com"}) + + def test_account_page_logged_in(self) -> None: + """Account page should render for logged-in users.""" + response = self.client.get("/account") + self.assertEqual(response.status_code, 200) + self.assertIn("Account Management", response.text) + self.assertIn("test@example.com", response.text) + + def test_account_page_logged_out(self) -> None: + """Account page should redirect logged-out users.""" + self.client.post("/logout") + response = self.client.get("/account", follow_redirects=False) + self.assertEqual(response.status_code, 307) + self.assertEqual( + response.headers["location"], + "/?error=login_required", + ) + + def test_logout(self) -> None: + """Logout should clear session.""" + response = self.client.post("/logout", follow_redirects=False) + self.assertEqual(response.status_code, 303) + self.assertEqual(response.headers["location"], "/") + + # Verify session cleared + response = self.client.get("/account", follow_redirects=False) + self.assertEqual(response.status_code, 307) + + def test_billing_portal_redirect(self) -> None: + """Billing portal should redirect to Stripe.""" + # First set a customer ID + Core.Database.set_user_stripe_customer(self.user_id, "cus_test") + + # Mock the create_portal_session method + with patch( + "Biz.PodcastItLater.Billing.create_portal_session", + ) as mock_portal: + mock_portal.return_value = "https://billing.stripe.com/test" + + response = self.client.post( + "/billing/portal", + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "https://billing.stripe.com/test", + ) + + def test() -> None: """Run all tests for the web module.""" Test.run( @@ -3180,6 +3243,7 @@ def test() -> None: TestEpisodeDeduplication, TestMetricsTracking, TestUsageLimits, + TestAccountPage, ], ) -- cgit v1.2.3 From 13b5194c37be47e441dfc337181a3f443e912224 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:23:35 -0500 Subject: feat: implement t-1f9RIzd --- Biz/PodcastItLater/Web.py | 256 ++++++++++++++++++---------------------------- 1 file changed, 98 insertions(+), 158 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index e80b0b4..b7845b3 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -1004,6 +1004,57 @@ def upgrade(request: Request) -> RedirectResponse: return RedirectResponse(url="/pricing?error=checkout_failed") +@app.get("/account") +def account(request: Request) -> UI.AccountPage | RedirectResponse: + """Display account management page.""" + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse(url="/?error=login_required") + + user = Core.Database.get_user_by_id(user_id) + if not user: + request.session.clear() + return RedirectResponse(url="/?error=user_not_found") + + # Get usage stats + period_start, period_end = Billing.get_period_boundaries(user) + usage = Billing.get_usage(user_id, period_start, period_end) + + # Get limits + tier = user.get("plan_tier", "free") + limits = Billing.TIER_LIMITS.get(tier, Billing.TIER_LIMITS["free"]) + + return UI.AccountPage( + user=user, + usage=usage, + limits=limits, + portal_url="/billing/portal" if tier == "paid" else None, + ) + + +@app.post("/logout") +def logout(request: Request) -> RedirectResponse: + """Log out user.""" + request.session.clear() + return RedirectResponse(url="/", status_code=303) + + +@app.post("/billing/portal") +def billing_portal(request: Request) -> RedirectResponse: + """Redirect to Stripe billing portal.""" + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse(url="/?error=login_required") + + try: + portal_url = Billing.create_portal_session(user_id, BASE_URL) + return RedirectResponse(url=portal_url, status_code=303) + except ValueError as e: + logger.warning("Failed to create portal session: %s", e) + # If user has no customer ID (e.g. free tier), redirect to pricing + return RedirectResponse(url="/pricing") + + def _handle_test_login(email: str, request: Request) -> Response: """Handle login in test mode.""" # Special handling for demo account @@ -1149,7 +1200,7 @@ def verify_magic_link(request: Request) -> Response: @app.get("/account") -def account_page(request: Request) -> UI.PageLayout | RedirectResponse: +def account_page(request: Request) -> UI.AccountPage | RedirectResponse: """Account management page.""" user_id = request.session.get("user_id") if not user_id: @@ -1159,165 +1210,19 @@ def account_page(request: Request) -> UI.PageLayout | RedirectResponse: if not user: return RedirectResponse(url="/?error=user_not_found") - # Get subscription details + # Get usage stats + period_start, period_end = Billing.get_period_boundaries(user) + usage = Billing.get_usage(user["id"], period_start, period_end) + + # Get limits tier = user.get("plan_tier", "free") - tier_info = Billing.get_tier_info(tier) - subscription_status = user.get("subscription_status", "") - cancel_at_period_end = user.get("cancel_at_period_end", 0) == 1 - - return UI.PageLayout( - html.h2( - html.i( - classes=["bi", "bi-person-circle", "me-2"], - ), - "Account Management", - classes=["mb-4"], - ), - html.div( - html.h4( - html.i(classes=["bi", "bi-envelope-fill", "me-2"]), - "Account Information", - classes=["card-header", "bg-transparent"], - ), - html.div( - html.div( - html.strong("Email: "), - user["email"], - classes=["mb-2"], - ), - html.div( - html.strong("Account Created: "), - user["created_at"], - classes=["mb-2"], - ), - classes=["card-body"], - ), - classes=["card", "mb-4"], - ), - html.div( - html.h4( - html.i( - classes=["bi", "bi-credit-card-fill", "me-2"], - ), - "Subscription", - classes=["card-header", "bg-transparent"], - ), - html.div( - html.div( - html.strong("Plan: "), - tier_info["name"], - f" ({tier_info['price']})", - classes=["mb-2"], - ), - html.div( - html.strong("Status: "), - subscription_status.title() - if subscription_status - else "Active", - classes=["mb-2"], - ) - if tier == "paid" - else html.div(), - html.div( - html.i( - classes=[ - "bi", - "bi-info-circle", - "me-1", - ], - ), - "Your subscription will cancel at the end " - "of the billing period.", - classes=[ - "alert", - "alert-warning", - "mt-2", - "mb-2", - ], - ) - if cancel_at_period_end - else html.div(), - html.div( - html.strong("Features: "), - tier_info["description"], - classes=["mb-3"], - ), - html.div( - html.a( - html.i( - classes=[ - "bi", - "bi-arrow-up-circle", - "me-1", - ], - ), - "Upgrade to Paid Plan", - href="#", - hx_post="/billing/checkout", - hx_vals='{"tier": "paid"}', - classes=[ - "btn", - "btn-success", - "me-2", - ], - ) - if tier == "free" - else html.form( - html.button( - html.i( - classes=[ - "bi", - "bi-gear-fill", - "me-1", - ], - ), - "Manage Subscription", - type="submit", - classes=[ - "btn", - "btn-primary", - "me-2", - ], - ), - method="post", - action="/billing/portal", - ), - ), - classes=["card-body"], - ), - classes=["card", "mb-4"], - ), - html.div( - html.h4( - html.i(classes=["bi", "bi-sliders", "me-2"]), - "Actions", - classes=["card-header", "bg-transparent"], - ), - html.div( - html.a( - html.i( - classes=[ - "bi", - "bi-box-arrow-right", - "me-1", - ], - ), - "Logout", - href="/logout", - classes=[ - "btn", - "btn-outline-secondary", - "mb-2", - "me-2", - ], - ), - classes=["card-body"], - ), - classes=["card", "mb-4"], - ), + limits = Billing.TIER_LIMITS.get(tier, Billing.TIER_LIMITS["free"]) + + return UI.AccountPage( user=user, - current_page="account", - error=None, + usage=usage, + limits=limits, + portal_url="/billing/portal" if tier == "paid" else None, ) @@ -3169,7 +3074,11 @@ class TestAccountPage(BaseWebTest): """Test account page functionality.""" def setUp(self) -> None: +<<<<<<< Updated upstream """Set up test client with logged-in user.""" +======= + """Set up test with user.""" +>>>>>>> Stashed changes super().setUp() self.user_id, _ = Core.Database.create_user( "test@example.com", @@ -3177,6 +3086,7 @@ class TestAccountPage(BaseWebTest): ) self.client.post("/login", data={"email": "test@example.com"}) +<<<<<<< Updated upstream def test_account_page_logged_in(self) -> None: """Account page should render for logged-in users.""" response = self.client.get("/account") @@ -3225,6 +3135,36 @@ class TestAccountPage(BaseWebTest): response.headers["location"], "https://billing.stripe.com/test", ) +======= + def test_account_page_shows_usage(self) -> None: + """Account page should show usage stats.""" + # Create some usage + ep_id = Core.Database.create_episode( + title="Test Episode", + audio_url="https://example.com/audio.mp3", + duration=300, + content_length=1000, + user_id=self.user_id, + author="Test Author", + original_url="https://example.com/article", + original_url_hash=Core.hash_url("https://example.com/article"), + ) + Core.Database.add_episode_to_user(self.user_id, ep_id) + + response = self.client.get("/account") + + self.assertEqual(response.status_code, 200) + self.assertIn("1 / 10", response.text) # Usage / Limit for free tier + self.assertIn("My Account", response.text) + self.assertIn("test@example.com", response.text) + + def test_account_page_login_required(self) -> None: + """Should redirect to login if not logged in.""" + self.client.get("/logout") + response = self.client.get("/account", follow_redirects=False) + self.assertEqual(response.status_code, 307) + self.assertEqual(response.headers["location"], "/?error=login_required") +>>>>>>> Stashed changes def test() -> None: -- cgit v1.2.3 From a79fb0fe60ce44b73acfe03fb2a8183a63276e9c Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:30:03 -0500 Subject: feat: implement t-1f9SnU7 --- Biz/PodcastItLater/Web.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index b7845b3..6134a98 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -379,6 +379,11 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): badge_class = status_classes.get(item["status"], "bg-secondary") icon_class = status_icons.get(item["status"], "bi-question-circle") + # Get queue position for pending items + queue_pos = None + if item["status"] == "pending": + queue_pos = Core.Database.get_queue_position(item["id"]) + queue_items.append( html.div( html.div( @@ -430,6 +435,14 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): f"Created: {item['created_at']}", classes=["text-muted", "d-block", "mt-1"], ), + # Display queue position if available + html.small( + html.i(classes=["bi", "bi-hourglass-split", "me-1"]), + f"Position in queue: #{queue_pos}", + classes=["text-info", "d-block", "mt-1"], + ) + if queue_pos + else html.span(), *( [ html.div( @@ -457,6 +470,27 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): ), # Add cancel button for pending jobs, remove for others html.div( + # Retry button for error items + html.button( + html.i(classes=["bi", "bi-arrow-clockwise", "me-1"]), + "Retry", + hx_post=f"/queue/{item['id']}/retry", + hx_trigger="click", + hx_on=( + "htmx:afterRequest: " + "if(event.detail.successful) " + "htmx.trigger('body', 'queue-updated')" + ), + classes=[ + "btn", + "btn-sm", + "btn-outline-primary", + "mt-2", + "me-2", + ], + ) + if item["status"] == "error" + else html.span(), html.button( html.i(classes=["bi", "bi-x-lg", "me-1"]), "Cancel", -- cgit v1.2.3 From f455a5449c8c72c536ed898f2a4a69e736e3e86f Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:38:11 -0500 Subject: feat: implement t-144gQry --- Biz/PodcastItLater/Web.py | 61 +++++++++++++++-------------------------------- 1 file changed, 19 insertions(+), 42 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 6134a98..30ebf53 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -3108,11 +3108,7 @@ class TestAccountPage(BaseWebTest): """Test account page functionality.""" def setUp(self) -> None: -<<<<<<< Updated upstream - """Set up test client with logged-in user.""" -======= """Set up test with user.""" ->>>>>>> Stashed changes super().setUp() self.user_id, _ = Core.Database.create_user( "test@example.com", @@ -3120,23 +3116,34 @@ class TestAccountPage(BaseWebTest): ) self.client.post("/login", data={"email": "test@example.com"}) -<<<<<<< Updated upstream def test_account_page_logged_in(self) -> None: """Account page should render for logged-in users.""" + # Create some usage to verify stats are shown + ep_id = Core.Database.create_episode( + title="Test Episode", + audio_url="https://example.com/audio.mp3", + duration=300, + content_length=1000, + user_id=self.user_id, + author="Test Author", + original_url="https://example.com/article", + original_url_hash=Core.hash_url("https://example.com/article"), + ) + Core.Database.add_episode_to_user(self.user_id, ep_id) + response = self.client.get("/account") + self.assertEqual(response.status_code, 200) - self.assertIn("Account Management", response.text) + self.assertIn("My Account", response.text) self.assertIn("test@example.com", response.text) + self.assertIn("1 / 10", response.text) # Usage / Limit for free tier - def test_account_page_logged_out(self) -> None: - """Account page should redirect logged-out users.""" + def test_account_page_login_required(self) -> None: + """Should redirect to login if not logged in.""" self.client.post("/logout") response = self.client.get("/account", follow_redirects=False) self.assertEqual(response.status_code, 307) - self.assertEqual( - response.headers["location"], - "/?error=login_required", - ) + self.assertEqual(response.headers["location"], "/?error=login_required") def test_logout(self) -> None: """Logout should clear session.""" @@ -3169,36 +3176,6 @@ class TestAccountPage(BaseWebTest): response.headers["location"], "https://billing.stripe.com/test", ) -======= - def test_account_page_shows_usage(self) -> None: - """Account page should show usage stats.""" - # Create some usage - ep_id = Core.Database.create_episode( - title="Test Episode", - audio_url="https://example.com/audio.mp3", - duration=300, - content_length=1000, - user_id=self.user_id, - author="Test Author", - original_url="https://example.com/article", - original_url_hash=Core.hash_url("https://example.com/article"), - ) - Core.Database.add_episode_to_user(self.user_id, ep_id) - - response = self.client.get("/account") - - self.assertEqual(response.status_code, 200) - self.assertIn("1 / 10", response.text) # Usage / Limit for free tier - self.assertIn("My Account", response.text) - self.assertIn("test@example.com", response.text) - - def test_account_page_login_required(self) -> None: - """Should redirect to login if not logged in.""" - self.client.get("/logout") - response = self.client.get("/account", follow_redirects=False) - self.assertEqual(response.status_code, 307) - self.assertEqual(response.headers["location"], "/?error=login_required") ->>>>>>> Stashed changes def test() -> None: -- cgit v1.2.3