diff options
Diffstat (limited to 'Biz/PodcastItLater')
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 81 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 204 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 85 |
3 files changed, 267 insertions, 103 deletions
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 8d31956..2a9f85a 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -948,6 +948,52 @@ class Database: # noqa: PLR0904 logger.info("Updated user %s status to %s", user_id, status) @staticmethod + def delete_user(user_id: int) -> None: + """Delete user and all associated data.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + + # 1. Get owned episode IDs + cursor.execute( + "SELECT id FROM episodes WHERE user_id = ?", + (user_id,), + ) + owned_episode_ids = [row[0] for row in cursor.fetchall()] + + # 2. Delete references to owned episodes + if owned_episode_ids: + # Construct placeholders for IN clause + placeholders = ",".join("?" * len(owned_episode_ids)) + + # Delete from user_episodes where these episodes are referenced + query = f"DELETE FROM user_episodes WHERE episode_id IN ({placeholders})" # noqa: S608, E501 + cursor.execute(query, tuple(owned_episode_ids)) + + # Delete metrics for these episodes + query = f"DELETE FROM episode_metrics WHERE episode_id IN ({placeholders})" # noqa: S608, E501 + cursor.execute(query, tuple(owned_episode_ids)) + + # 3. Delete owned episodes + cursor.execute("DELETE FROM episodes WHERE user_id = ?", (user_id,)) + + # 4. Delete user's data referencing others or themselves + cursor.execute( + "DELETE FROM user_episodes WHERE user_id = ?", + (user_id,), + ) + cursor.execute( + "DELETE FROM episode_metrics WHERE user_id = ?", + (user_id,), + ) + cursor.execute("DELETE FROM queue WHERE user_id = ?", (user_id,)) + + # 5. Delete user + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + + conn.commit() + logger.info("Deleted user %s and all associated data", user_id) + + @staticmethod def mark_episode_public(episode_id: int) -> None: """Mark an episode as public.""" with Database.get_connection() as conn: @@ -1573,6 +1619,41 @@ class TestUserManagement(Test.TestCase): # All tokens should be unique self.assertEqual(len(tokens), 10) + def test_delete_user(self) -> None: + """Test user deletion and cleanup.""" + # Create user + user_id, _ = Database.create_user("delete_me@example.com") + + # Create some data for the user + Database.add_to_queue( + "https://example.com/article", + "delete_me@example.com", + user_id, + ) + + ep_id = Database.create_episode( + title="Test Episode", + audio_url="url", + duration=100, + content_length=1000, + user_id=user_id, + ) + Database.add_episode_to_user(user_id, ep_id) + Database.track_episode_metric(ep_id, "played", user_id) + + # Delete user + Database.delete_user(user_id) + + # Verify user is gone + self.assertIsNone(Database.get_user_by_id(user_id)) + + # Verify queue items are gone + queue = Database.get_user_queue_status(user_id) + self.assertEqual(len(queue), 0) + + # Verify episodes are gone (direct lookup) + self.assertIsNone(Database.get_episode_by_id(ep_id)) + class TestQueueOperations(Test.TestCase): """Test queue operations.""" diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index 27f5fff..905aba4 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -422,130 +422,128 @@ class PricingPage(Component[AnyChildren, PricingPageAttrs]): current_tier = user.get("plan_tier", "free") if user else "free" return PageLayout( - user=user, - current_page="pricing", - page_title="Pricing - PodcastItLater", - error=None, - meta_tags=[], - children=[ + html.div( + html.h2("Simple Pricing", classes=["text-center", "mb-5"]), html.div( - html.h2("Simple Pricing", classes=["text-center", "mb-5"]), + # 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.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-outline-primary", + "w-100", + ], + disabled=True, + ) + 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( - # Free Tier html.div( html.div( - html.div( - html.h3("Free", classes=["card-title"]), - html.h4( - "$0", + 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", classes=[ - "card-subtitle", - "mb-3", - "text-muted", + "btn", + "btn-primary", + "w-100", ], ), - html.p( - "10 articles total", - classes=["card-text"], - ), - html.ul( - html.li("Convert 10 articles"), - html.li("Basic features"), - classes=["list-unstyled", "mb-4"], - ), + action="/upgrade", + method="post", + ) + if user and current_tier == "free" + else ( html.button( "Current Plan", classes=[ "btn", - "btn-outline-primary", + "btn-success", "w-100", ], disabled=True, ) - 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", + if user and current_tier == "paid" + else html.a( + "Login to Upgrade", + href="/", classes=[ - "card-subtitle", - "mb-3", - "text-muted", + "btn", + "btn-primary", + "w-100", ], - ), - 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", - "mb-4", - "shadow-sm", - "border-primary", - "h-100", - ], + classes=["card-body"], ), - classes=["col-md-6"], + classes=[ + "card", + "mb-4", + "shadow-sm", + "border-primary", + "h-100", + ], ), - classes=["row"], + classes=["col-md-6"], ), - classes=["container", "py-3"], + classes=["row"], ), - ], + classes=["container", "py-3"], + ), + user=user, + current_page="pricing", + page_title="Pricing - PodcastItLater", + error=None, + meta_tags=[], ) diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 7e8e969..903af17 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -1314,12 +1314,54 @@ def account_page(request: Request) -> UI.PageLayout | RedirectResponse: ), 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"], + ), user=user, current_page="account", error=None, ) +@app.delete("/account") +def delete_account(request: Request) -> Response: + """Delete user account.""" + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse(url="/?error=login_required") + + Core.Database.delete_user(user_id) + request.session.clear() + + return Response( + "Account deleted", + headers={"HX-Redirect": "/?message=account_deleted"}, + ) + + @app.get("/logout") def logout(request: Request) -> Response: """Handle logout.""" @@ -3164,6 +3206,48 @@ class TestUsageLimits(BaseWebTest): self.assertEqual(usage["articles"], 20) +class TestAccountDeletion(BaseWebTest): + """Test account deletion functionality.""" + + def setUp(self) -> None: + """Set up test 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"}) + + 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, + ) + + # Delete account + response = self.client.delete("/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("/?error=login_required", response.headers["location"]) + + def test() -> None: """Run all tests for the web module.""" Test.run( @@ -3180,6 +3264,7 @@ def test() -> None: TestEpisodeDeduplication, TestMetricsTracking, TestUsageLimits, + TestAccountDeletion, ], ) |
