diff options
| -rw-r--r-- | .tasks/tasks.jsonl | 8 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 81 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 39 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 44 |
4 files changed, 140 insertions, 32 deletions
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index 6554d6d..67504d6 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -29,7 +29,7 @@ {"taskCreatedAt":"2025-11-09T16:48:47.388960509Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144eKR1","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement usage tracking and limits","taskType":"WorkTask","taskUpdatedAt":"2025-11-19T03:27:25.707745105Z"} {"taskCreatedAt":"2025-11-09T16:48:47.589181852Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144fAWn","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Add email notifications (transactional)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T01:35:54.519545888Z"} {"taskCreatedAt":"2025-11-09T16:48:47.737218185Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144gds4","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Migrate from SQLite to PostgreSQL","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T01:35:54.70061831Z"} -{"taskCreatedAt":"2025-11-09T16:48:47.887102357Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144gQry","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Review","taskTitle":"Create basic admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:38:19.992989496Z"} +{"taskCreatedAt":"2025-11-09T16:48:47.887102357Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144gQry","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Create basic admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.733612558Z"} {"taskCreatedAt":"2025-11-09T16:48:48.072927212Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144hCMJ","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Complete comprehensive test suite","taskType":"Epic","taskUpdatedAt":"2025-11-09T16:48:48.072927212Z"} {"taskCreatedAt":"2025-11-09T17:48:34.522286485Z","taskDependencies":[],"taskDescription":null,"taskId":"t-17Z0069","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix Recent Episodes refresh to prepend instead of reload (interrupts audio playback)","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T19:42:22.105902786Z"} {"taskCreatedAt":"2025-11-09T22:19:27.303689497Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1pIV0ZF","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement billing page UI component with pricing and upgrade options","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T23:04:20.974801117Z"} @@ -45,9 +45,9 @@ {"taskCreatedAt":"2025-11-13T16:32:17.411379982Z","taskDependencies":[],"taskDescription":null,"taskId":"t-12ZeUsG","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update success/cancel URLs to redirect to / instead of /billing","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:36:41.808119038Z"} {"taskCreatedAt":"2025-11-13T16:32:17.557115348Z","taskDependencies":[],"taskDescription":null,"taskId":"t-12Zfwnf","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove 'Billing' button from navbar (paid users will use Stripe portal link in callout)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:34:44.628587871Z"} {"taskCreatedAt":"2025-11-13T16:32:17.738052991Z","taskDependencies":[],"taskDescription":null,"taskId":"t-12ZghrB","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Test the complete flow and verify all changes","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:37:49.356932049Z"} -{"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:27:07.637122837Z"} -{"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:30:19.474773695Z"} -{"taskCreatedAt":"2025-11-13T19:38:08.37344762Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9Td4U","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Navbar Styling Cleanup","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:43:03.725680217Z"} +{"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Done","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.815116309Z"} +{"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Done","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.89665814Z"} +{"taskCreatedAt":"2025-11-13T19:38:08.37344762Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9Td4U","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Done","taskTitle":"Navbar Styling Cleanup","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.977778598Z"} {"taskCreatedAt":"2025-11-13T19:38:32.95559213Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbym1M","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove BLE001 noqa for bare Exception catches - use specific exceptions","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:43:29.049855419Z"} {"taskCreatedAt":"2025-11-13T19:38:33.139120541Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbz7LV","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix PLR0913 violations - refactor functions with too many parameters","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:44:09.820023426Z"} {"taskCreatedAt":"2025-11-13T19:38:33.309222802Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbzQ1v","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Extract format_duration utility to shared UI or Core module (used only in Web.py)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:45:49.402934404Z"} diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 4a017cc..cc94659 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -954,6 +954,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: @@ -1579,6 +1625,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 cbab2a2..00cf5e3 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -542,6 +542,45 @@ class AccountPage(Component[AnyChildren, AccountPageAttrs]): ), classes=["border-top", "pt-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"], + ), classes=["card-body", "p-4"], ), classes=["card", "shadow-sm"], diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 0f095d3..348c847 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -1052,34 +1052,6 @@ 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.""" @@ -1274,6 +1246,22 @@ def account_page(request: Request) -> UI.AccountPage | RedirectResponse: ) +@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.post("/submit") def submit_article( # noqa: PLR0911, PLR0914 request: Request, |
