diff options
Diffstat (limited to 'Biz/PodcastItLater')
| -rw-r--r-- | Biz/PodcastItLater/Admin.py | 23 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 44 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Test.py | 49 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 681 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 390 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker.py | 3 |
6 files changed, 698 insertions, 492 deletions
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py index 10a8e58..6faf7fb 100644 --- a/Biz/PodcastItLater/Admin.py +++ b/Biz/PodcastItLater/Admin.py @@ -795,7 +795,7 @@ def admin_queue_status(request: Request) -> AdminView | Response | html.div: def retry_queue_item(request: Request, job_id: int) -> Response: """Retry a failed queue item.""" try: - # Check if user owns this job + # Check if user owns this job or is admin user_id = request.session.get("user_id") if not user_id: return Response("Unauthorized", status_code=401) @@ -803,15 +803,30 @@ def retry_queue_item(request: Request, job_id: int) -> Response: job = Core.Database.get_job_by_id( job_id, ) - if job is None or job.get("user_id") != user_id: + if job is None: + return Response("Job not found", status_code=404) + + # Check ownership or admin status + user = Core.Database.get_user_by_id(user_id) + if job.get("user_id") != user_id and not Core.is_admin(user): return Response("Forbidden", status_code=403) Core.Database.retry_job(job_id) - # Redirect back to admin view + + # Check if request is from admin page via referer header + is_from_admin = "/admin" in request.headers.get("referer", "") + + # Redirect to admin if from admin page, trigger update otherwise + if is_from_admin: + return Response( + "", + status_code=200, + headers={"HX-Redirect": "/admin"}, + ) return Response( "", status_code=200, - headers={"HX-Redirect": "/admin"}, + headers={"HX-Trigger": "queue-updated"}, ) except (ValueError, KeyError) as e: return Response( diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 2a9f85a..cc94659 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -373,7 +373,10 @@ class Database: # noqa: PLR0904 SELECT id, url, email, status, created_at, error_message, title, author FROM queue - WHERE status IN ('pending', 'processing', 'error') + WHERE status IN ( + 'pending', 'processing', 'extracting', + 'synthesizing', 'uploading', 'error' + ) ORDER BY created_at DESC LIMIT 20 """) @@ -888,7 +891,10 @@ class Database: # noqa: PLR0904 title, author FROM queue WHERE user_id = ? AND - status IN ('pending', 'processing', 'error') + status IN ( + 'pending', 'processing', 'extracting', + 'synthesizing', 'uploading', 'error' + ) ORDER BY created_at DESC LIMIT 20 """, @@ -1866,6 +1872,40 @@ class TestQueueOperations(Test.TestCase): self.assertEqual(counts.get("processing", 0), 1) self.assertEqual(counts.get("error", 0), 1) + def test_queue_position(self) -> None: + """Verify queue position calculation.""" + # Add multiple pending jobs + job1 = Database.add_to_queue( + "https://example.com/1", + "test@example.com", + self.user_id, + ) + time.sleep(0.01) + job2 = Database.add_to_queue( + "https://example.com/2", + "test@example.com", + self.user_id, + ) + time.sleep(0.01) + job3 = Database.add_to_queue( + "https://example.com/3", + "test@example.com", + self.user_id, + ) + + # Check positions + self.assertEqual(Database.get_queue_position(job1), 1) + self.assertEqual(Database.get_queue_position(job2), 2) + self.assertEqual(Database.get_queue_position(job3), 3) + + # Move job 2 to processing + Database.update_job_status(job2, "processing") + + # Check positions (job 3 should now be 2nd pending job) + self.assertEqual(Database.get_queue_position(job1), 1) + self.assertIsNone(Database.get_queue_position(job2)) + self.assertEqual(Database.get_queue_position(job3), 2) + class TestEpisodeManagement(Test.TestCase): """Test episode management functionality.""" diff --git a/Biz/PodcastItLater/Test.py b/Biz/PodcastItLater/Test.py index b2a1d24..ee638f1 100644 --- a/Biz/PodcastItLater/Test.py +++ b/Biz/PodcastItLater/Test.py @@ -19,6 +19,7 @@ # : out podcastitlater-e2e-test # : run ffmpeg import Biz.PodcastItLater.Core as Core +import Biz.PodcastItLater.UI as UI import Biz.PodcastItLater.Web as Web import Biz.PodcastItLater.Worker as Worker import Omni.App as App @@ -208,12 +209,60 @@ class TestEndToEnd(BaseWebTest): self.assertIn("Other User's Article", response.text) +class TestUI(Test.TestCase): + """Test UI components.""" + + def test_render_navbar(self) -> None: + """Test navbar rendering.""" + user = {"email": "test@example.com", "id": 1} + layout = UI.PageLayout( + user=user, + current_page="home", + error=None, + page_title="Test", + meta_tags=[], + ) + navbar = layout._render_navbar(user, "home") # noqa: SLF001 + html_output = navbar.to_html() + + # Check basic structure + self.assertIn("navbar", html_output) + self.assertIn("Home", html_output) + self.assertIn("Public Feed", html_output) + self.assertIn("Pricing", html_output) + self.assertIn("Manage Account", html_output) + + # Check active state + self.assertIn("active", html_output) + + # Check non-admin user doesn't see admin menu + self.assertNotIn("Admin", html_output) + + def test_render_navbar_admin(self) -> None: + """Test navbar rendering for admin.""" + user = {"email": "ben@bensima.com", "id": 1} # Admin email + layout = UI.PageLayout( + user=user, + current_page="admin", + error=None, + page_title="Test", + meta_tags=[], + ) + navbar = layout._render_navbar(user, "admin") # noqa: SLF001 + html_output = navbar.to_html() + + # Check admin menu present + self.assertIn("Admin", html_output) + self.assertIn("Queue Status", html_output) + + def test() -> None: """Run all end-to-end tests.""" Test.run( App.Area.Test, [ TestEndToEnd, + TestUI, ], ) diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index 905aba4..00cf5e3 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -6,6 +6,7 @@ Common UI components and utilities shared across web pages. # : out podcastitlater-ui # : dep ludic +import Biz.PodcastItLater.Core as Core import ludic.html as html import typing from ludic.attrs import Attrs @@ -90,7 +91,7 @@ def create_auto_dark_mode_style() -> html.style: /* Navbar dark mode */ .navbar.bg-body-tertiary { - background-color: #2b3035 !important; + background-color: #2b3035 !important; } .navbar .navbar-text { @@ -127,16 +128,6 @@ def create_bootstrap_js() -> html.script: ) -def is_admin(user: dict[str, typing.Any] | None) -> bool: - """Check if user is an admin based on email whitelist.""" - if not user: - return False - admin_emails = ["ben@bensima.com", "admin@example.com"] - return user.get("email", "").lower() in [ - email.lower() for email in admin_emails - ] - - class PageLayoutAttrs(Attrs): """Attributes for PageLayout component.""" @@ -151,6 +142,78 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): """Reusable page layout with header and navbar.""" @staticmethod + def _render_nav_item( + label: str, + href: str, + icon: str, + *, + is_active: bool, + ) -> html.li: + return html.li( + html.a( + html.i(classes=["bi", f"bi-{icon}", "me-1"]), + label, + href=href, + classes=[ + "nav-link", + "active" if is_active else "", + ], + ), + classes=["nav-item"], + ) + + @staticmethod + def _render_admin_dropdown( + is_active_func: typing.Callable[[str], bool], + ) -> html.li: + is_active = is_active_func("admin") or is_active_func("admin-users") + return html.li( + html.a( # type: ignore[call-arg] + html.i(classes=["bi", "bi-gear-fill", "me-1"]), + "Admin", + href="#", + id="adminDropdown", + role="button", + data_bs_toggle="dropdown", + aria_expanded="false", + classes=[ + "nav-link", + "dropdown-toggle", + "active" if is_active else "", + ], + ), + html.ul( # type: ignore[call-arg] + html.li( + html.a( + html.i(classes=["bi", "bi-list-task", "me-2"]), + "Queue Status", + href="/admin", + classes=["dropdown-item"], + ), + ), + html.li( + html.a( + html.i(classes=["bi", "bi-people-fill", "me-2"]), + "Manage Users", + href="/admin/users", + classes=["dropdown-item"], + ), + ), + html.li( + html.a( + html.i(classes=["bi", "bi-graph-up", "me-2"]), + "Metrics", + href="/admin/metrics", + classes=["dropdown-item"], + ), + ), + classes=["dropdown-menu"], + aria_labelledby="adminDropdown", + ), + classes=["nav-item", "dropdown"], + ) + + @staticmethod def _render_navbar( user: dict[str, typing.Any] | None, current_page: str, @@ -174,151 +237,32 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): ), html.div( html.ul( - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-house-fill", - "me-1", - ], - ), - "Home", - href="/", - classes=[ - "nav-link", - "active" if is_active("home") else "", - ], - ), - classes=["nav-item"], + PageLayout._render_nav_item( + "Home", + "/", + "house-fill", + is_active=is_active("home"), ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-globe", - "me-1", - ], - ), - "Public Feed", - href="/public", - classes=[ - "nav-link", - "active" if is_active("public") else "", - ], - ), - classes=["nav-item"], + PageLayout._render_nav_item( + "Public Feed", + "/public", + "globe", + is_active=is_active("public"), ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-stars", - "me-1", - ], - ), - "Pricing", - href="/pricing", - classes=[ - "nav-link", - "active" if is_active("pricing") else "", - ], - ), - classes=["nav-item"], + PageLayout._render_nav_item( + "Pricing", + "/pricing", + "stars", + is_active=is_active("pricing"), ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-person-circle", - "me-1", - ], - ), - "Manage Account", - href="/account", - classes=[ - "nav-link", - "active" if is_active("account") else "", - ], - ), - classes=["nav-item"], + PageLayout._render_nav_item( + "Manage Account", + "/account", + "person-circle", + is_active=is_active("account"), ), - html.li( - html.a( # type: ignore[call-arg] - html.i( - classes=[ - "bi", - "bi-gear-fill", - "me-1", - ], - ), - "Admin", - href="#", - id="adminDropdown", - role="button", - data_bs_toggle="dropdown", - aria_expanded="false", - classes=[ - "nav-link", - "dropdown-toggle", - "active" - if is_active("admin") - or is_active("admin-users") - else "", - ], - ), - html.ul( # type: ignore[call-arg] - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-list-task", - "me-2", - ], - ), - "Queue Status", - href="/admin", - classes=["dropdown-item"], - ), - ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-people-fill", - "me-2", - ], - ), - "Manage Users", - href="/admin/users", - classes=["dropdown-item"], - ), - ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-graph-up", - "me-2", - ], - ), - "Metrics", - href="/admin/metrics", - classes=["dropdown-item"], - ), - ), - classes=["dropdown-menu"], - aria_labelledby="adminDropdown", - ), - classes=["nav-item", "dropdown"], - ) - if user and is_admin(user) + PageLayout._render_admin_dropdown(is_active) + if user and Core.is_admin(user) else html.span(), classes=["navbar-nav"], ), @@ -407,139 +351,388 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): ) -class PricingPageAttrs(Attrs): - """Attributes for PricingPage component.""" +class AccountPageAttrs(Attrs): + """Attributes for AccountPage component.""" - user: dict[str, typing.Any] | None + user: dict[str, typing.Any] + usage: dict[str, int] + limits: dict[str, int | None] + portal_url: str | None -class PricingPage(Component[AnyChildren, PricingPageAttrs]): - """Pricing page component.""" +class AccountPage(Component[AnyChildren, AccountPageAttrs]): + """Account management page component.""" @override def render(self) -> PageLayout: - user = self.attrs.get("user") - current_tier = user.get("plan_tier", "free") if user else "free" + user = self.attrs["user"] + usage = self.attrs["usage"] + limits = self.attrs["limits"] + portal_url = self.attrs["portal_url"] + + plan_tier = user.get("plan_tier", "free") + is_paid = plan_tier == "paid" + + article_limit = limits.get("articles_per_period") + article_usage = usage.get("articles", 0) + + limit_text = ( + "Unlimited" if article_limit is None else str(article_limit) + ) + + usage_percent = 0 + if article_limit: + usage_percent = min(100, int((article_usage / article_limit) * 100)) + + progress_style = ( + {"width": f"{usage_percent}%"} if article_limit else {"width": "0%"} + ) return PageLayout( html.div( - html.h2("Simple Pricing", classes=["text-center", "mb-5"]), html.div( - # Free Tier html.div( html.div( html.div( - html.h3("Free", classes=["card-title"]), - html.h4( - "$0", - classes=[ - "card-subtitle", - "mb-3", - "text-muted", - ], + html.h2( + html.i( + classes=[ + "bi", + "bi-person-circle", + "me-2", + ], + ), + "My Account", + classes=["card-title", "mb-4"], + ), + # User Info Section + html.div( + html.h5("Profile", classes=["mb-3"]), + html.p( + html.strong("Email: "), + user.get("email", ""), + classes=["mb-2"], + ), + html.p( + html.strong("Member since: "), + user.get("created_at", "").split("T")[ + 0 + ], + classes=["mb-4"], + ), + classes=["mb-5"], + ), + # Subscription Section + html.div( + html.h5("Subscription", classes=["mb-3"]), + html.div( + html.div( + html.strong("Current Plan"), + html.span( + plan_tier.title(), + classes=[ + "badge", + "bg-success" + if is_paid + else "bg-secondary", + "ms-2", + ], + ), + classes=[ + "d-flex", + "align-items-center", + "mb-3", + ], + ), + # Usage Stats + html.div( + html.p( + "Usage this period:", + classes=["mb-2", "text-muted"], + ), + html.div( + html.div( + f"{article_usage} / " + f"{limit_text}", + classes=["mb-1"], + ), + html.div( + html.div( + classes=[ + "progress-bar", + ], + role="progressbar", + style=progress_style, + ), + classes=[ + "progress", + "mb-3", + ], + style={"height": "10px"}, + ) + if article_limit + else html.div(), + classes=["mb-3"], + ), + ), + # Actions + html.div( + html.form( + html.button( + html.i( + classes=[ + "bi", + "bi-credit-card", + "me-2", + ], + ), + "Manage Subscription", + type="submit", + classes=[ + "btn", + "btn-outline-primary", + ], + ), + method="post", + action=portal_url, + ) + if is_paid and portal_url + else html.a( + html.i( + classes=[ + "bi", + "bi-star-fill", + "me-2", + ], + ), + "Upgrade to Pro", + href="/pricing", + classes=["btn", "btn-primary"], + ), + classes=["d-flex", "gap-2"], + ), + classes=[ + "card", + "card-body", + "bg-light", + ], + ), + classes=["mb-5"], ), - html.p( - "10 articles total", - classes=["card-text"], + # Logout Section + html.div( + html.form( + html.button( + html.i( + classes=[ + "bi", + "bi-box-arrow-right", + "me-2", + ], + ), + "Log Out", + type="submit", + classes=[ + "btn", + "btn-outline-danger", + ], + ), + action="/logout", + method="post", + ), + classes=["border-top", "pt-4"], ), - html.ul( - html.li("Convert 10 articles"), - html.li("Basic features"), - classes=["list-unstyled", "mb-4"], + # Delete Account Section + html.div( + html.h5( + "Danger Zone", + classes=["text-danger", "mb-3"], + ), + html.div( + html.h6("Delete Account"), + html.p( + "Once you delete your account, " + "there is no going back. " + "Please be certain.", + classes=["card-text"], + ), + html.button( + html.i( + classes=[ + "bi", + "bi-trash", + "me-2", + ], + ), + "Delete Account", + hx_delete="/account", + hx_confirm=( + "Are you absolutely sure you " + "want to delete your account? " + "This action cannot be undone." + ), + classes=["btn", "btn-danger"], + ), + classes=[ + "card", + "card-body", + "border-danger", + ], + ), + classes=["mt-5", "pt-4", "border-top"], ), - html.button( - "Current Plan", - classes=[ - "btn", - "btn-outline-primary", - "w-100", - ], - disabled=True, - ) - if current_tier == "free" - else html.div(), - classes=["card-body"], + classes=["card-body", "p-4"], ), - classes=["card", "mb-4", "shadow-sm", "h-100"], + classes=["card", "shadow-sm"], ), - classes=["col-md-6"], + classes=["col-lg-8", "mx-auto"], ), - # Paid Tier + classes=["row"], + ), + ), + user=user, + current_page="account", + page_title="Account - PodcastItLater", + error=None, + meta_tags=[], + ) + + +class PricingPageAttrs(Attrs): + """Attributes for PricingPage component.""" + + user: dict[str, typing.Any] | None + + +class PricingPage(Component[AnyChildren, PricingPageAttrs]): + """Pricing page component.""" + + @override + def render(self) -> PageLayout: + user = self.attrs.get("user") + current_tier = user.get("plan_tier", "free") if user else "free" + + return PageLayout( + html.div( + html.div( + html.h2("Simple Pricing", classes=["text-center", "mb-5"]), html.div( + # Free Tier html.div( html.div( - html.h3( - "Unlimited", - classes=["card-title"], - ), - html.h4( - "$12/mo", - classes=[ - "card-subtitle", - "mb-3", - "text-muted", - ], - ), - html.p( - "Unlimited articles", - classes=["card-text"], - ), - html.ul( - html.li("Unlimited conversions"), - html.li("Priority processing"), - html.li("Support independent software"), - classes=["list-unstyled", "mb-4"], - ), - html.form( - html.button( - "Upgrade Now", - type="submit", + html.div( + html.h3("Free", classes=["card-title"]), + html.h4( + "$0", classes=[ - "btn", - "btn-primary", - "w-100", + "card-subtitle", + "mb-3", + "text-muted", ], ), - action="/upgrade", - method="post", - ) - if user and current_tier == "free" - else ( + html.p( + "10 articles total", + classes=["card-text"], + ), + html.ul( + html.li("Convert 10 articles"), + html.li("Basic features"), + classes=["list-unstyled", "mb-4"], + ), html.button( "Current Plan", classes=[ "btn", - "btn-success", + "btn-outline-primary", "w-100", ], disabled=True, ) - if user and current_tier == "paid" - else html.a( - "Login to Upgrade", - href="/", + if current_tier == "free" + else html.div(), + classes=["card-body"], + ), + classes=["card", "mb-4", "shadow-sm", "h-100"], + ), + classes=["col-md-6"], + ), + # Paid Tier + html.div( + html.div( + html.div( + html.h3( + "Unlimited", + classes=["card-title"], + ), + html.h4( + "$12/mo", classes=[ - "btn", - "btn-primary", - "w-100", + "card-subtitle", + "mb-3", + "text-muted", ], + ), + html.p( + "Unlimited articles", + classes=["card-text"], + ), + html.ul( + html.li("Unlimited conversions"), + html.li("Priority processing"), + html.li("Support independent software"), + classes=["list-unstyled", "mb-4"], + ), + html.form( + html.button( + "Upgrade Now", + type="submit", + classes=[ + "btn", + "btn-primary", + "w-100", + ], + ), + action="/upgrade", + method="POST", ) + if user and current_tier == "free" + else ( + html.button( + "Current Plan", + classes=[ + "btn", + "btn-success", + "w-100", + ], + disabled=True, + ) + if user and current_tier == "paid" + else html.a( + "Login to Upgrade", + href="/", + classes=[ + "btn", + "btn-primary", + "w-100", + ], + ) + ), + classes=["card-body"], ), - classes=["card-body"], + classes=[ + "card", + "mb-4", + "shadow-sm", + "border-primary", + "h-100", + ], ), - classes=[ - "card", - "mb-4", - "shadow-sm", - "border-primary", - "h-100", - ], + classes=["col-md-6"], ), - classes=["col-md-6"], + classes=["row"], ), - classes=["row"], + classes=["container", "py-3"], ), - classes=["container", "py-3"], ), user=user, current_page="pricing", diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 903af17..348c847 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -54,6 +54,7 @@ from starlette.middleware.sessions import SessionMiddleware from starlette.responses import RedirectResponse from starlette.testclient import TestClient from typing import override +from unittest.mock import patch logger = logging.getLogger(__name__) Log.setup(logger) @@ -362,6 +363,9 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): status_classes = { "pending": "bg-warning text-dark", "processing": "bg-primary", + "extracting": "bg-info text-dark", + "synthesizing": "bg-primary", + "uploading": "bg-success", "error": "bg-danger", "cancelled": "bg-secondary", } @@ -369,6 +373,9 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): status_icons = { "pending": "bi-clock", "processing": "bi-arrow-repeat", + "extracting": "bi-file-text", + "synthesizing": "bi-mic", + "uploading": "bi-cloud-arrow-up", "error": "bi-exclamation-triangle", "cancelled": "bi-x-circle", } @@ -378,6 +385,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( @@ -429,6 +441,16 @@ 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( @@ -456,6 +478,33 @@ 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", @@ -1003,6 +1052,29 @@ def upgrade(request: Request) -> RedirectResponse: return RedirectResponse(url="/pricing?error=checkout_failed") +@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 @@ -1148,7 +1220,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: @@ -1158,191 +1230,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"], - ), - html.div( - html.h4( - html.i(classes=["bi", "bi-exclamation-triangle-fill", "me-2"]), - "Danger Zone", - classes=["card-header", "bg-transparent", "text-danger"], - ), - html.div( - html.p( - "Once you delete your account, there is no going back. " - "Please be certain.", - classes=["card-text"], - ), - html.button( - html.i(classes=["bi", "bi-trash", "me-2"]), - "Delete Account", - hx_delete="/account", - hx_confirm=( - "Are you absolutely sure you want to delete " - "your account? This action cannot be undone." - ), - classes=["btn", "btn-outline-danger"], - ), - classes=["card-body"], - ), - classes=["card", "border-danger", "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, ) @@ -1362,17 +1262,6 @@ def delete_account(request: Request) -> Response: ) -@app.get("/logout") -def logout(request: Request) -> Response: - """Handle logout.""" - request.session.clear() - return Response( - "", - status_code=302, - headers={"Location": "/"}, - ) - - @app.post("/submit") def submit_article( # noqa: PLR0911, PLR0914 request: Request, @@ -1747,21 +1636,6 @@ def billing_checkout(request: Request, data: FormData) -> Response: return Response(f"Error: {e!s}", status_code=400) -@app.post("/billing/portal") -def billing_portal(request: Request) -> Response | RedirectResponse: - """Create Stripe Billing Portal session.""" - user_id = request.session.get("user_id") - if not user_id: - return Response("Unauthorized", status_code=401) - - try: - portal_url = Billing.create_portal_session(user_id, BASE_URL) - return RedirectResponse(url=portal_url, status_code=303) - except Exception: - logger.exception("Portal error - ensure Stripe portal is configured") - return Response("Portal not configured", status_code=500) - - @app.post("/stripe/webhook") async def stripe_webhook(request: Request) -> Response: """Handle Stripe webhook events.""" @@ -3206,46 +3080,78 @@ class TestUsageLimits(BaseWebTest): self.assertEqual(usage["articles"], 20) -class TestAccountDeletion(BaseWebTest): - """Test account deletion functionality.""" +class TestAccountPage(BaseWebTest): + """Test account page functionality.""" def setUp(self) -> None: - """Set up test user.""" + """Set up test with user.""" super().setUp() - self.user_id, _ = Core.Database.create_user("delete@example.com") - Core.Database.update_user_status(self.user_id, "active") - self.client.post("/login", data={"email": "delete@example.com"}) + self.user_id, _ = Core.Database.create_user( + "test@example.com", + status="active", + ) + self.client.post("/login", data={"email": "test@example.com"}) - def test_delete_account(self) -> None: - """User can delete their own account.""" - # Create some data - Core.Database.add_to_queue( - "https://example.com", - "delete@example.com", - self.user_id, + 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) - # Delete account - response = self.client.delete("/account") + response = self.client.get("/account") self.assertEqual(response.status_code, 200) - self.assertIn("Account deleted", response.text) - self.assertIn("HX-Redirect", response.headers) - - # Verify user is gone - user = Core.Database.get_user_by_id(self.user_id) - self.assertIsNone(user) - - # Verify session is cleared - response = self.client.get("/") - self.assertNotIn("Logged in as", response.text) - - def test_delete_requires_auth(self) -> None: - """Cannot delete account without login.""" - self.client.get("/logout") - response = self.client.delete("/account") + 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_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") + + 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.assertIn("/?error=login_required", response.headers["location"]) + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "https://billing.stripe.com/test", + ) def test() -> None: @@ -3264,7 +3170,7 @@ def test() -> None: TestEpisodeDeduplication, TestMetricsTracking, TestUsageLimits, - TestAccountDeletion, + TestAccountPage, ], ) diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker.py index 5203490..94db30e 100644 --- a/Biz/PodcastItLater/Worker.py +++ b/Biz/PodcastItLater/Worker.py @@ -620,6 +620,7 @@ class ArticleProcessor: return # Step 1: Extract article content + Core.Database.update_job_status(job_id, "extracting") title, content, author, pub_date = ( ArticleProcessor.extract_article_content(url) ) @@ -630,6 +631,7 @@ class ArticleProcessor: return # Step 2: Generate audio with metadata + Core.Database.update_job_status(job_id, "synthesizing") audio_data = self.text_to_speech(content, title, author, pub_date) if self.shutdown_handler.is_shutdown_requested(): @@ -638,6 +640,7 @@ class ArticleProcessor: return # Step 3: Upload to S3 + Core.Database.update_job_status(job_id, "uploading") filename = ArticleProcessor.generate_filename(job_id, title) audio_url = self.upload_to_s3(audio_data, filename) |
