summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-20 23:57:38 -0500
committerBen Sima <ben@bsima.me>2025-11-22 06:40:00 -0500
commitf894391a7f38a03de3a11b06c95e8bbec291837a (patch)
treea3a04773767b70d9c4f9b53b89eaf55c2094ee61 /Biz/PodcastItLater
parent7107e038ec661e5e121e226250f85771b0fd5ff4 (diff)
feat: implement t-1fbDyr2
Diffstat (limited to 'Biz/PodcastItLater')
-rw-r--r--Biz/PodcastItLater/Core.py81
-rw-r--r--Biz/PodcastItLater/UI.py204
-rw-r--r--Biz/PodcastItLater/Web.py85
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,
],
)