From 9e65e80276aeb33c0f917d005e621a18158fffee Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 2 Dec 2025 15:51:42 -0500 Subject: Fix Admin.py imports for bild local dep detection - Change 'from Biz.X import Y' to 'import Biz.X as X' style - bild only recognizes 'import X as Y' for local dep detection - Add setuptools to Python deps (required by newer nixpkgs) Amp-Thread-ID: https://ampcode.com/threads/T-fe8328a9-7709-4544-9d31-b099f04aa120 Co-authored-by: Amp --- Biz/PodcastItLater/Admin.py | 73 +++++++++++++++++------------------- Biz/PodcastItLater/Admin/__init__.py | 1 - 2 files changed, 34 insertions(+), 40 deletions(-) delete mode 100644 Biz/PodcastItLater/Admin/__init__.py (limited to 'Biz/PodcastItLater') diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py index 3fc6f61..29332df 100644 --- a/Biz/PodcastItLater/Admin.py +++ b/Biz/PodcastItLater/Admin.py @@ -12,48 +12,44 @@ Admin pages and functionality for managing users and queue items. # : dep pytest-asyncio # : dep pytest-mock -# i need to import these unused because bild cannot get local transitive python -# dependencies yet -import Omni.App as App # noqa: F401 -import Omni.Log as Log # noqa: F401 -import Omni.Test as Test # noqa: F401 +import Biz.PodcastItLater.Admin.Handlers as Handlers +import Biz.PodcastItLater.Admin.Views as Views import sys -from Biz.PodcastItLater.Admin.Handlers import admin_metrics -from Biz.PodcastItLater.Admin.Handlers import admin_queue_status -from Biz.PodcastItLater.Admin.Handlers import admin_users -from Biz.PodcastItLater.Admin.Handlers import delete_queue_item -from Biz.PodcastItLater.Admin.Handlers import retry_queue_item -from Biz.PodcastItLater.Admin.Handlers import toggle_episode_public -from Biz.PodcastItLater.Admin.Handlers import update_user_status -# Import all views and handlers from the new modules -from Biz.PodcastItLater.Admin.Views import ActionButtons -from Biz.PodcastItLater.Admin.Views import ActionButtonsAttrs -from Biz.PodcastItLater.Admin.Views import AdminUsers -from Biz.PodcastItLater.Admin.Views import AdminUsersAttrs -from Biz.PodcastItLater.Admin.Views import AdminView -from Biz.PodcastItLater.Admin.Views import AdminViewAttrs -from Biz.PodcastItLater.Admin.Views import EpisodeTableRow -from Biz.PodcastItLater.Admin.Views import EpisodeTableRowAttrs -from Biz.PodcastItLater.Admin.Views import MetricCard -from Biz.PodcastItLater.Admin.Views import MetricCardAttrs -from Biz.PodcastItLater.Admin.Views import MetricsAttrs -from Biz.PodcastItLater.Admin.Views import MetricsDashboard -from Biz.PodcastItLater.Admin.Views import QueueTableRow -from Biz.PodcastItLater.Admin.Views import QueueTableRowAttrs -from Biz.PodcastItLater.Admin.Views import StatusBadge -from Biz.PodcastItLater.Admin.Views import StatusBadgeAttrs -from Biz.PodcastItLater.Admin.Views import TopEpisodesTable -from Biz.PodcastItLater.Admin.Views import TopEpisodesTableAttrs -from Biz.PodcastItLater.Admin.Views import TruncatedText -from Biz.PodcastItLater.Admin.Views import TruncatedTextAttrs -from Biz.PodcastItLater.Admin.Views import UserTableRow -from Biz.PodcastItLater.Admin.Views import UserTableRowAttrs -from Biz.PodcastItLater.Admin.Views import create_table_header +# Re-export all symbols for backward compatibility +ActionButtons = Views.ActionButtons +ActionButtonsAttrs = Views.ActionButtonsAttrs +AdminUsers = Views.AdminUsers +AdminUsersAttrs = Views.AdminUsersAttrs +AdminView = Views.AdminView +AdminViewAttrs = Views.AdminViewAttrs +EpisodeTableRow = Views.EpisodeTableRow +EpisodeTableRowAttrs = Views.EpisodeTableRowAttrs +MetricCard = Views.MetricCard +MetricCardAttrs = Views.MetricCardAttrs +MetricsAttrs = Views.MetricsAttrs +MetricsDashboard = Views.MetricsDashboard +QueueTableRow = Views.QueueTableRow +QueueTableRowAttrs = Views.QueueTableRowAttrs +StatusBadge = Views.StatusBadge +StatusBadgeAttrs = Views.StatusBadgeAttrs +TopEpisodesTable = Views.TopEpisodesTable +TopEpisodesTableAttrs = Views.TopEpisodesTableAttrs +TruncatedText = Views.TruncatedText +TruncatedTextAttrs = Views.TruncatedTextAttrs +UserTableRow = Views.UserTableRow +UserTableRowAttrs = Views.UserTableRowAttrs +create_table_header = Views.create_table_header + +admin_metrics = Handlers.admin_metrics +admin_queue_status = Handlers.admin_queue_status +admin_users = Handlers.admin_users +delete_queue_item = Handlers.delete_queue_item +retry_queue_item = Handlers.retry_queue_item +toggle_episode_public = Handlers.toggle_episode_public +update_user_status = Handlers.update_user_status -# Export all symbols for backward compatibility __all__ = [ - # Views "ActionButtons", "ActionButtonsAttrs", "AdminUsers", @@ -76,7 +72,6 @@ __all__ = [ "TruncatedTextAttrs", "UserTableRow", "UserTableRowAttrs", - # Handlers "admin_metrics", "admin_queue_status", "admin_users", diff --git a/Biz/PodcastItLater/Admin/__init__.py b/Biz/PodcastItLater/Admin/__init__.py deleted file mode 100644 index 04e3e32..0000000 --- a/Biz/PodcastItLater/Admin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""PodcastItLater Admin package.""" -- cgit v1.2.3 From b60fc6f95e68c8581e2cec48f8d99e7c467a1db2 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 2 Dec 2025 15:52:27 -0500 Subject: Remove pyproject=true, use format=setuptools, add toggle_episode_public --- Biz/PodcastItLater/Core.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'Biz/PodcastItLater') diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 3a88f22..62eead8 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -1072,6 +1072,18 @@ class Database: # noqa: PLR0904 conn.commit() logger.info("Unmarked episode %s as public", episode_id) + @staticmethod + def toggle_episode_public(episode_id: int) -> None: + """Toggle an episode's public status.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE episodes SET is_public = NOT is_public WHERE id = ?", + (episode_id,), + ) + conn.commit() + logger.info("Toggled public status for episode %s", episode_id) + @staticmethod def get_public_episodes(limit: int = 50) -> list[dict[str, Any]]: """Get public episodes for public feed.""" -- cgit v1.2.3 From 0baab1972e30c0e4629e67152838e660b02a2537 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Mon, 15 Dec 2025 08:47:02 -0500 Subject: t-265.6: Add feedback collection endpoint for PIL - Add feedback table with migration in Core.py - Add FeedbackForm and FeedbackPage UI components - Add /feedback GET/POST routes and /api/feedback JSON endpoint - Add admin feedback view at /admin/feedback - Create Omni/Agent/Tools/Feedback.hs with feedback_list tool - Wire feedback tool into Telegram agent --- Biz/PodcastItLater/Admin.py | 6 + Biz/PodcastItLater/Admin/Handlers.py | 28 ++++ Biz/PodcastItLater/Admin/Views.py | 89 +++++++++++++ Biz/PodcastItLater/Core.py | 144 ++++++++++++++++++++ Biz/PodcastItLater/UI.py | 247 +++++++++++++++++++++++++++++++++++ Biz/PodcastItLater/Web.py | 85 +++++++++++- 6 files changed, 597 insertions(+), 2 deletions(-) (limited to 'Biz/PodcastItLater') diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py index 29332df..10ea7f6 100644 --- a/Biz/PodcastItLater/Admin.py +++ b/Biz/PodcastItLater/Admin.py @@ -40,7 +40,10 @@ TruncatedTextAttrs = Views.TruncatedTextAttrs UserTableRow = Views.UserTableRow UserTableRowAttrs = Views.UserTableRowAttrs create_table_header = Views.create_table_header +AdminFeedback = Views.AdminFeedback +AdminFeedbackAttrs = Views.AdminFeedbackAttrs +admin_feedback = Handlers.admin_feedback admin_metrics = Handlers.admin_metrics admin_queue_status = Handlers.admin_queue_status admin_users = Handlers.admin_users @@ -52,6 +55,8 @@ update_user_status = Handlers.update_user_status __all__ = [ "ActionButtons", "ActionButtonsAttrs", + "AdminFeedback", + "AdminFeedbackAttrs", "AdminUsers", "AdminUsersAttrs", "AdminView", @@ -72,6 +77,7 @@ __all__ = [ "TruncatedTextAttrs", "UserTableRow", "UserTableRowAttrs", + "admin_feedback", "admin_metrics", "admin_queue_status", "admin_users", diff --git a/Biz/PodcastItLater/Admin/Handlers.py b/Biz/PodcastItLater/Admin/Handlers.py index b98c551..9e06fe6 100644 --- a/Biz/PodcastItLater/Admin/Handlers.py +++ b/Biz/PodcastItLater/Admin/Handlers.py @@ -296,3 +296,31 @@ def admin_metrics(request: Request) -> Views.MetricsDashboard | Response: metrics = Core.Database.get_metrics_summary() return Views.MetricsDashboard(metrics=metrics, user=user) + + +def admin_feedback(request: Request) -> Views.AdminFeedback | Response: + """Admin feedback view.""" + # Check if user is logged in and is admin + user_id = request.session.get("user_id") + if not user_id: + return Response( + "", + status_code=302, + headers={"Location": "/"}, + ) + + user = Core.Database.get_user_by_id( + user_id, + ) + if not user or not Core.is_admin(user): + return Response( + "", + status_code=302, + headers={"Location": "/?error=forbidden"}, + ) + + # Get feedback data + feedback = Core.Database.get_feedback(limit=100) + count = Core.Database.get_feedback_count() + + return Views.AdminFeedback(feedback=feedback, count=count, user=user) diff --git a/Biz/PodcastItLater/Admin/Views.py b/Biz/PodcastItLater/Admin/Views.py index 7cc71a5..7834340 100644 --- a/Biz/PodcastItLater/Admin/Views.py +++ b/Biz/PodcastItLater/Admin/Views.py @@ -742,3 +742,92 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): ), ), ) + + +class AdminFeedbackAttrs(Attrs): + """Attributes for AdminFeedback component.""" + + feedback: list[dict[str, typing.Any]] + count: int + user: dict[str, typing.Any] + + +class AdminFeedback(Component[AnyChildren, AdminFeedbackAttrs]): + """Admin feedback listing page.""" + + @override + def render(self) -> UI.PageLayout: + feedback = self.attrs["feedback"] + count = self.attrs["count"] + user = self.attrs["user"] + + rows = [ + html.tr( + html.td(html.small(fb["id"][:8], classes=["text-muted"])), + html.td(fb.get("email") or "-"), + html.td( + html.span( + fb.get("source") or "-", + classes=["badge", "bg-secondary"], + ), + ), + html.td(str(fb.get("rating") or "-")), + html.td( + TruncatedText( + text=fb.get("use_case") or "-", + max_length=50, + ), + ), + html.td( + TruncatedText( + text=fb.get("feedback_text") or "-", + max_length=50, + ), + ), + html.td( + html.small( + fb["created_at"], + classes=["text-muted"], + ), + ), + ) + for fb in feedback + ] + + return UI.PageLayout( + html.div( + html.h2( + html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]), + f"Feedback ({count})", + classes=["mb-4"], + ), + html.div( + html.table( + create_table_header([ + "ID", + "Email", + "Source", + "Rating", + "Use Case", + "Feedback", + "Created", + ]), + html.tbody(*rows), + classes=["table", "table-hover", "table-sm"], + ), + classes=["table-responsive"], + ) + if rows + else html.div( + html.p( + "No feedback yet.", + classes=["text-muted", "text-center", "py-5"], + ), + ), + ), + user=user, + current_page="admin", + page_title="Admin Feedback - PodcastItLater", + error=None, + meta_tags=[], + ) diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 62eead8..d0ed2f0 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -202,6 +202,9 @@ class Database: # noqa: PLR0904 # Run migration to add public feed features Database.migrate_add_public_feed() + # Run migration to add feedback table + Database.migrate_add_feedback_table() + @staticmethod def add_to_queue( url: str, @@ -773,6 +776,42 @@ class Database: # noqa: PLR0904 conn.commit() logger.info("Database migrated for public feed feature") + @staticmethod + def migrate_add_feedback_table() -> None: + """Add feedback table for collecting user research data.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS feedback ( + id TEXT PRIMARY KEY, + email TEXT, + source TEXT, + campaign_id TEXT, + rating INTEGER, + feedback_text TEXT, + use_case TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create indexes for querying feedback + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_feedback_source " + "ON feedback(source)", + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_feedback_campaign " + "ON feedback(campaign_id)", + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_feedback_created " + "ON feedback(created_at)", + ) + + conn.commit() + logger.info("Created feedback table") + @staticmethod def migrate_add_default_titles() -> None: """Add default titles to queue items that have None titles.""" @@ -1200,6 +1239,111 @@ class Database: # noqa: PLR0904 row = cursor.fetchone() return dict(row) if row is not None else None + @staticmethod + def create_feedback( # noqa: PLR0913, PLR0917 + feedback_id: str, + email: str | None, + source: str | None, + campaign_id: str | None, + rating: int | None, + feedback_text: str | None, + use_case: str | None, + ) -> str: + """Create a new feedback entry. + + Args: + feedback_id: Unique ID for the feedback + email: Optional email address + source: How they heard about PIL (outreach, organic, trial) + campaign_id: Optional link to outreach draft ID + rating: Optional 1-5 rating + feedback_text: Optional general feedback + use_case: What they want to use PIL for + + Returns: + The feedback ID + """ + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO feedback + (id, email, source, campaign_id, rating, + feedback_text, use_case) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + feedback_id, + email, + source, + campaign_id, + rating, + feedback_text, + use_case, + ), + ) + conn.commit() + logger.info("Created feedback %s", feedback_id) + return feedback_id + + @staticmethod + def get_feedback( + limit: int = 50, + since: str | None = None, + ) -> list[dict[str, Any]]: + """Get feedback entries. + + Args: + limit: Maximum number of entries to return + since: Optional ISO date to filter by (get entries after this date) + + Returns: + List of feedback entries + """ + with Database.get_connection() as conn: + cursor = conn.cursor() + if since: + cursor.execute( + """ + SELECT * FROM feedback + WHERE created_at >= ? + ORDER BY created_at DESC + LIMIT ? + """, + (since, limit), + ) + else: + cursor.execute( + """ + SELECT * FROM feedback + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ) + rows = cursor.fetchall() + return [dict(row) for row in rows] + + @staticmethod + def get_feedback_by_id(feedback_id: str) -> dict[str, Any] | None: + """Get a single feedback entry by ID.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM feedback WHERE id = ?", + (feedback_id,), + ) + row = cursor.fetchone() + return dict(row) if row is not None else None + + @staticmethod + def get_feedback_count() -> int: + """Get total count of feedback entries.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) as count FROM feedback") + return cursor.fetchone()["count"] + @staticmethod def get_metrics_summary() -> dict[str, Any]: """Get aggregate metrics summary for admin dashboard. diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index e9ef27d..b243ae7 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -207,6 +207,14 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): classes=["dropdown-item"], ), ), + html.li( + html.a( + html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]), + "Feedback", + href="/admin/feedback", + classes=["dropdown-item"], + ), + ), classes=["dropdown-menu"], aria_labelledby="adminDropdown", ), @@ -621,6 +629,245 @@ class PricingPageAttrs(Attrs): user: dict[str, typing.Any] | None +class FeedbackFormAttrs(Attrs): + """Attributes for FeedbackForm component.""" + + campaign_id: str | None + success: bool + + +class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]): + """Feedback collection form component.""" + + @override + def render(self) -> html.div: + campaign_id = self.attrs.get("campaign_id") + success = self.attrs.get("success", False) + + if success: + return html.div( + html.div( + html.div( + html.h3( + html.i( + classes=["bi", "bi-check-circle-fill", "me-2"] + ), + "Thank You!", + classes=["text-success", "mb-3"], + ), + html.p( + "Your feedback has been submitted. " + "We really appreciate you taking the time to help us " + "improve PodcastItLater.", + classes=["lead"], + ), + html.a( + html.i(classes=["bi", "bi-arrow-left", "me-2"]), + "Back to home", + href="/", + classes=["btn", "btn-primary", "mt-3"], + ), + classes=["card-body", "text-center", "py-5"], + ), + classes=["card", "shadow-sm"], + ), + ) + + source_options = [ + ("", "Select one..."), + ("outreach", "Email from Ben/Ava"), + ("search", "Search engine"), + ("social", "Social media"), + ("friend", "Friend/colleague"), + ("other", "Other"), + ] + + return html.div( + html.div( + html.div( + html.h3( + html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]), + "Share Your Feedback", + classes=["card-title", "mb-4"], + ), + html.p( + "Help us make PodcastItLater better! " + "All fields are optional.", + classes=["text-muted", "mb-4"], + ), + html.form( + html.input( + type="hidden", + name="campaign_id", + value=campaign_id or "", + ) + if campaign_id + else html.div(), + # Email + html.div( + html.label( + "Email (optional)", + for_="email", + classes=["form-label"], + ), + html.input( + type="email", + name="email", + id="email", + placeholder="you@example.com", + classes=["form-control"], + ), + html.div( + "If you'd like us to follow up with you", + classes=["form-text"], + ), + classes=["mb-3"], + ), + # Source dropdown + html.div( + html.label( + "How did you hear about us?", + for_="source", + classes=["form-label"], + ), + html.select( + *[ + html.option(label, value=value) + for value, label in source_options + ], + name="source", + id="source", + classes=["form-select"], + ), + classes=["mb-3"], + ), + # Use case + html.div( + html.label( + "What would you use PodcastItLater for?", + for_="use_case", + classes=["form-label"], + ), + html.textarea( + name="use_case", + id="use_case", + rows="3", # type: ignore[call-arg] + placeholder=( + "e.g., catching up on articles during commute, " + "listening to research papers while exercising..." + ), + classes=["form-control"], + ), + classes=["mb-3"], + ), + # General feedback + html.div( + html.label( + "Any other feedback?", + for_="feedback_text", + classes=["form-label"], + ), + html.textarea( + name="feedback_text", + id="feedback_text", + rows="3", # type: ignore[call-arg] + placeholder="Suggestions, issues, feature requests...", + classes=["form-control"], + ), + classes=["mb-3"], + ), + # Rating + html.div( + html.label( + "How likely are you to recommend PIL? (1-5)", + for_="rating", + classes=["form-label"], + ), + html.div( + *[ + html.div( + html.input( + type="radio", + name="rating", + id=f"rating{i}", + value=str(i), + classes=["btn-check"], + ), + html.label( + str(i), + for_=f"rating{i}", + classes=[ + "btn", + "btn-outline-primary", + ], + ), + classes=["me-2"], + ) + for i in range(1, 6) + ], + classes=["d-flex"], + ), + html.div( + "1 = Not likely, 5 = Very likely", + classes=["form-text"], + ), + classes=["mb-4"], + ), + # Submit + html.button( + html.i(classes=["bi", "bi-send-fill", "me-2"]), + "Submit Feedback", + type="submit", + classes=["btn", "btn-primary", "btn-lg"], + ), + action="/feedback", + method="post", + ), + classes=["card-body", "p-4"], + ), + classes=["card", "shadow-sm"], + ), + ) + + +class FeedbackPageAttrs(Attrs): + """Attributes for FeedbackPage component.""" + + user: dict[str, typing.Any] | None + campaign_id: str | None + success: bool + + +class FeedbackPage(Component[AnyChildren, FeedbackPageAttrs]): + """Feedback page with layout.""" + + @override + def render(self) -> PageLayout: + user = self.attrs.get("user") + campaign_id = self.attrs.get("campaign_id") + success = self.attrs.get("success", False) + + return PageLayout( + html.div( + html.div( + html.div( + FeedbackForm( + campaign_id=campaign_id, + success=success, + ), + classes=["col-lg-8", "mx-auto"], + ), + classes=["row"], + ), + ), + user=user, + current_page="feedback", + page_title="Feedback - PodcastItLater", + error=None, + meta_tags=[], + ) + + class PricingPage(Component[AnyChildren, PricingPageAttrs]): """Pricing page component.""" diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 30b5236..076eb95 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -167,7 +167,8 @@ def send_magic_link(email: str, token: str) -> None: # Create email body magic_link = f"{BASE_URL}/auth/verify?token={token}" - body_text_path.write_text(f""" + body_text_path.write_text( + f""" Hello, Click this link to login to PodcastItLater: @@ -179,7 +180,9 @@ If you didn't request this, please ignore this email. Best, PodcastItLater -""") +""", + encoding="utf-8", + ) try: Biz.EmailAgent.send_email( @@ -1033,6 +1036,83 @@ def pricing(request: Request) -> UI.PricingPage: ) +@app.get("/feedback") +def feedback_form(request: Request) -> UI.FeedbackPage: + """Display feedback form.""" + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + campaign_id = request.query_params.get("campaign") + + return UI.FeedbackPage( + user=user, + campaign_id=campaign_id, + success=False, + ) + + +@app.post("/feedback") +async def submit_feedback(request: Request) -> UI.FeedbackPage: + """Submit feedback form.""" + import secrets + + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + + form_data = await request.form() + + email = form_data.get("email") + source = form_data.get("source") + campaign_id = form_data.get("campaign_id") + rating_str = form_data.get("rating") + feedback_text = form_data.get("feedback_text") + use_case = form_data.get("use_case") + + rating = int(rating_str) if rating_str else None + + feedback_id = secrets.token_urlsafe(16) + + Core.Database.create_feedback( + feedback_id=feedback_id, + email=str(email) if email else None, + source=str(source) if source else None, + campaign_id=str(campaign_id) if campaign_id else None, + rating=rating, + feedback_text=str(feedback_text) if feedback_text else None, + use_case=str(use_case) if use_case else None, + ) + + logger.info( + "Received feedback %s from %s", feedback_id, email or "anonymous" + ) + + return UI.FeedbackPage( + user=user, + campaign_id=None, + success=True, + ) + + +@app.get("/api/feedback") +def api_feedback(request: Request) -> Response: + """Return feedback entries as JSON for agent tools.""" + import json + + limit_str = request.query_params.get("limit", "20") + since = request.query_params.get("since") + + try: + limit = min(100, max(1, int(limit_str))) + except ValueError: + limit = 20 + + feedback = Core.Database.get_feedback(limit=limit, since=since) + + return Response( + json.dumps(feedback), + media_type="application/json", + ) + + @app.post("/upgrade") def upgrade(request: Request) -> RedirectResponse: """Start upgrade checkout flow.""" @@ -1832,6 +1912,7 @@ def cancel_queue_item(request: Request, job_id: int) -> Response: app.delete("/queue/{job_id}")(Admin.delete_queue_item) app.get("/admin/users")(Admin.admin_users) app.get("/admin/metrics")(Admin.admin_metrics) +app.get("/admin/feedback")(Admin.admin_feedback) app.post("/admin/users/{user_id}/status")(Admin.update_user_status) app.post("/admin/episode/{episode_id}/toggle-public")( Admin.toggle_episode_public, -- cgit v1.2.3 From a7dcb30c7a465d9fce72b7fc3e605470b2b59814 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 16 Dec 2025 08:06:09 -0500 Subject: feat(deploy): Complete mini-PaaS deployment system (t-266) - Add Omni/Deploy/ with Manifest, Deployer, Systemd, Caddy modules - Manifest CLI: show, update, add-service, list, rollback commands - Deployer: polls S3 manifest, pulls closures, manages systemd units - Caddy integration for dynamic reverse proxy routes - bild: auto-cache to S3, outputs STORE_PATH for push.sh - push.sh: supports both NixOS and service deploys - Biz.nix: simplified to base OS + deployer only - Services (podcastitlater-web/worker) now deployer-managed - Documentation: README.md with operations guide --- Biz/PodcastItLater/Admin.py | 95 ------------ Biz/PodcastItLater/Admin/Core.py | 95 ++++++++++++ Biz/PodcastItLater/Core.py | 2 +- Biz/PodcastItLater/INFRASTRUCTURE.md | 46 ++---- Biz/PodcastItLater/UI.py | 4 +- Biz/PodcastItLater/Web.nix | 4 +- Biz/PodcastItLater/Web.py | 13 +- Biz/PodcastItLater/Worker.py | 271 -------------------------------- Biz/PodcastItLater/Worker/Core.py | 272 +++++++++++++++++++++++++++++++++ Biz/PodcastItLater/Worker/Jobs.py | 2 +- Biz/PodcastItLater/Worker/Processor.py | 18 +-- 11 files changed, 406 insertions(+), 416 deletions(-) delete mode 100644 Biz/PodcastItLater/Admin.py create mode 100644 Biz/PodcastItLater/Admin/Core.py delete mode 100644 Biz/PodcastItLater/Worker.py create mode 100644 Biz/PodcastItLater/Worker/Core.py (limited to 'Biz/PodcastItLater') diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py deleted file mode 100644 index 10ea7f6..0000000 --- a/Biz/PodcastItLater/Admin.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -PodcastItLater Admin Interface. - -Admin pages and functionality for managing users and queue items. -""" - -# : out podcastitlater-admin -# : dep ludic -# : dep httpx -# : dep starlette -# : dep pytest -# : dep pytest-asyncio -# : dep pytest-mock - -import Biz.PodcastItLater.Admin.Handlers as Handlers -import Biz.PodcastItLater.Admin.Views as Views -import sys - -# Re-export all symbols for backward compatibility -ActionButtons = Views.ActionButtons -ActionButtonsAttrs = Views.ActionButtonsAttrs -AdminUsers = Views.AdminUsers -AdminUsersAttrs = Views.AdminUsersAttrs -AdminView = Views.AdminView -AdminViewAttrs = Views.AdminViewAttrs -EpisodeTableRow = Views.EpisodeTableRow -EpisodeTableRowAttrs = Views.EpisodeTableRowAttrs -MetricCard = Views.MetricCard -MetricCardAttrs = Views.MetricCardAttrs -MetricsAttrs = Views.MetricsAttrs -MetricsDashboard = Views.MetricsDashboard -QueueTableRow = Views.QueueTableRow -QueueTableRowAttrs = Views.QueueTableRowAttrs -StatusBadge = Views.StatusBadge -StatusBadgeAttrs = Views.StatusBadgeAttrs -TopEpisodesTable = Views.TopEpisodesTable -TopEpisodesTableAttrs = Views.TopEpisodesTableAttrs -TruncatedText = Views.TruncatedText -TruncatedTextAttrs = Views.TruncatedTextAttrs -UserTableRow = Views.UserTableRow -UserTableRowAttrs = Views.UserTableRowAttrs -create_table_header = Views.create_table_header -AdminFeedback = Views.AdminFeedback -AdminFeedbackAttrs = Views.AdminFeedbackAttrs - -admin_feedback = Handlers.admin_feedback -admin_metrics = Handlers.admin_metrics -admin_queue_status = Handlers.admin_queue_status -admin_users = Handlers.admin_users -delete_queue_item = Handlers.delete_queue_item -retry_queue_item = Handlers.retry_queue_item -toggle_episode_public = Handlers.toggle_episode_public -update_user_status = Handlers.update_user_status - -__all__ = [ - "ActionButtons", - "ActionButtonsAttrs", - "AdminFeedback", - "AdminFeedbackAttrs", - "AdminUsers", - "AdminUsersAttrs", - "AdminView", - "AdminViewAttrs", - "EpisodeTableRow", - "EpisodeTableRowAttrs", - "MetricCard", - "MetricCardAttrs", - "MetricsAttrs", - "MetricsDashboard", - "QueueTableRow", - "QueueTableRowAttrs", - "StatusBadge", - "StatusBadgeAttrs", - "TopEpisodesTable", - "TopEpisodesTableAttrs", - "TruncatedText", - "TruncatedTextAttrs", - "UserTableRow", - "UserTableRowAttrs", - "admin_feedback", - "admin_metrics", - "admin_queue_status", - "admin_users", - "create_table_header", - "delete_queue_item", - "retry_queue_item", - "toggle_episode_public", - "update_user_status", -] - - -def main() -> None: - """Admin tests are currently in Web.""" - if "test" in sys.argv: - sys.exit(0) diff --git a/Biz/PodcastItLater/Admin/Core.py b/Biz/PodcastItLater/Admin/Core.py new file mode 100644 index 0000000..10ea7f6 --- /dev/null +++ b/Biz/PodcastItLater/Admin/Core.py @@ -0,0 +1,95 @@ +""" +PodcastItLater Admin Interface. + +Admin pages and functionality for managing users and queue items. +""" + +# : out podcastitlater-admin +# : dep ludic +# : dep httpx +# : dep starlette +# : dep pytest +# : dep pytest-asyncio +# : dep pytest-mock + +import Biz.PodcastItLater.Admin.Handlers as Handlers +import Biz.PodcastItLater.Admin.Views as Views +import sys + +# Re-export all symbols for backward compatibility +ActionButtons = Views.ActionButtons +ActionButtonsAttrs = Views.ActionButtonsAttrs +AdminUsers = Views.AdminUsers +AdminUsersAttrs = Views.AdminUsersAttrs +AdminView = Views.AdminView +AdminViewAttrs = Views.AdminViewAttrs +EpisodeTableRow = Views.EpisodeTableRow +EpisodeTableRowAttrs = Views.EpisodeTableRowAttrs +MetricCard = Views.MetricCard +MetricCardAttrs = Views.MetricCardAttrs +MetricsAttrs = Views.MetricsAttrs +MetricsDashboard = Views.MetricsDashboard +QueueTableRow = Views.QueueTableRow +QueueTableRowAttrs = Views.QueueTableRowAttrs +StatusBadge = Views.StatusBadge +StatusBadgeAttrs = Views.StatusBadgeAttrs +TopEpisodesTable = Views.TopEpisodesTable +TopEpisodesTableAttrs = Views.TopEpisodesTableAttrs +TruncatedText = Views.TruncatedText +TruncatedTextAttrs = Views.TruncatedTextAttrs +UserTableRow = Views.UserTableRow +UserTableRowAttrs = Views.UserTableRowAttrs +create_table_header = Views.create_table_header +AdminFeedback = Views.AdminFeedback +AdminFeedbackAttrs = Views.AdminFeedbackAttrs + +admin_feedback = Handlers.admin_feedback +admin_metrics = Handlers.admin_metrics +admin_queue_status = Handlers.admin_queue_status +admin_users = Handlers.admin_users +delete_queue_item = Handlers.delete_queue_item +retry_queue_item = Handlers.retry_queue_item +toggle_episode_public = Handlers.toggle_episode_public +update_user_status = Handlers.update_user_status + +__all__ = [ + "ActionButtons", + "ActionButtonsAttrs", + "AdminFeedback", + "AdminFeedbackAttrs", + "AdminUsers", + "AdminUsersAttrs", + "AdminView", + "AdminViewAttrs", + "EpisodeTableRow", + "EpisodeTableRowAttrs", + "MetricCard", + "MetricCardAttrs", + "MetricsAttrs", + "MetricsDashboard", + "QueueTableRow", + "QueueTableRowAttrs", + "StatusBadge", + "StatusBadgeAttrs", + "TopEpisodesTable", + "TopEpisodesTableAttrs", + "TruncatedText", + "TruncatedTextAttrs", + "UserTableRow", + "UserTableRowAttrs", + "admin_feedback", + "admin_metrics", + "admin_queue_status", + "admin_users", + "create_table_header", + "delete_queue_item", + "retry_queue_item", + "toggle_episode_public", + "update_user_status", +] + + +def main() -> None: + """Admin tests are currently in Web.""" + if "test" in sys.argv: + sys.exit(0) diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index d0ed2f0..05ed153 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -1342,7 +1342,7 @@ class Database: # noqa: PLR0904 with Database.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT COUNT(*) as count FROM feedback") - return cursor.fetchone()["count"] + return int(cursor.fetchone()["count"]) @staticmethod def get_metrics_summary() -> dict[str, Any]: diff --git a/Biz/PodcastItLater/INFRASTRUCTURE.md b/Biz/PodcastItLater/INFRASTRUCTURE.md index 1c61618..0d6392b 100644 --- a/Biz/PodcastItLater/INFRASTRUCTURE.md +++ b/Biz/PodcastItLater/INFRASTRUCTURE.md @@ -1,38 +1,24 @@ # Infrastructure Setup for PodcastItLater -## Mailgun Setup +## Email Delivery via Mailgun -Since PodcastItLater requires sending transactional emails (magic links), we use Mailgun. +PodcastItLater sends transactional emails (magic links for login) via Mailgun for reliable deliverability. -### 1. Sign up for Mailgun -Sign up at [mailgun.com](https://www.mailgun.com/). +### Setup Steps -### 2. Add Domain -Add `podcastitlater.com` (or `mg.podcastitlater.com`) to Mailgun. -We recommend using the root domain `podcastitlater.com` if you want emails to come from `@podcastitlater.com`. +1. **Add domain to Mailgun**: Add `bensima.com` at [mailgun.com](https://app.mailgun.com/mg/sending/new) -### 3. Configure DNS -Mailgun will provide DNS records to verify the domain and authorize email sending. You must add these to your DNS provider (e.g., Cloudflare, Namecheap). +2. **Configure DNS**: Add the records Mailgun provides: + - **TXT** (SPF): Update existing to include `include:mailgun.org` + - **TXT** (DKIM): Add the DKIM record Mailgun provides + - **CNAME** (tracking, optional): For open/click tracking -Required records usually include: -- **TXT** (SPF): `v=spf1 include:mailgun.org ~all` -- **TXT** (DKIM): `k=rsa; p=...` (Provided by Mailgun) -- **MX** (if receiving email, optional for just sending): `10 mxa.mailgun.org`, `10 mxb.mailgun.org` -- **CNAME** (for tracking, optional): `email.podcastitlater.com` -> `mailgun.org` +3. **Get SMTP credentials**: Go to Sending → Domain Settings → SMTP Credentials -### 4. Verify Domain -Click "Verify DNS Settings" in Mailgun dashboard. This may take up to 24 hours but is usually instant. - -### 5. Generate API Key / SMTP Credentials -Go to "Sending" -> "Domain Settings" -> "SMTP Credentials". -Create a new SMTP user (e.g., `postmaster@podcastitlater.com`). -**Save the password immediately.** - -### 6. Update Secrets -Update the production secrets file on the server (`/run/podcastitlater/env`): - -```bash -SMTP_SERVER=smtp.mailgun.org -SMTP_PASSWORD=your-new-smtp-password -EMAIL_FROM=noreply@podcastitlater.com -``` +4. **Update production secrets** in `/run/podcastitlater/env`: + ```bash + EMAIL_FROM=noreply@bensima.com + SMTP_SERVER=smtp.mailgun.org + SMTP_PORT=587 + SMTP_PASSWORD=your-mailgun-smtp-password + ``` diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index b243ae7..5c65ca4 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -751,7 +751,7 @@ class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]): html.textarea( name="use_case", id="use_case", - rows="3", # type: ignore[call-arg] + rows=3, placeholder=( "e.g., catching up on articles during commute, " "listening to research papers while exercising..." @@ -770,7 +770,7 @@ class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]): html.textarea( name="feedback_text", id="feedback_text", - rows="3", # type: ignore[call-arg] + rows=3, placeholder="Suggestions, issues, feature requests...", classes=["form-control"], ), diff --git a/Biz/PodcastItLater/Web.nix b/Biz/PodcastItLater/Web.nix index 7533ca4..0980f5b 100644 --- a/Biz/PodcastItLater/Web.nix +++ b/Biz/PodcastItLater/Web.nix @@ -5,7 +5,7 @@ ... }: let cfg = config.services.podcastitlater-web; - rootDomain = "podcastitlater.com"; + rootDomain = "podcastitlater.bensima.com"; ports = import ../../Omni/Cloud/Ports.nix; in { options.services.podcastitlater-web = { @@ -39,7 +39,7 @@ in { # Manual step: create this file with secrets # SECRET_KEY=your-secret-key-for-sessions # SESSION_SECRET=your-session-secret - # EMAIL_FROM=noreply@podcastitlater.com + # EMAIL_FROM=noreply@bensima.com # SMTP_SERVER=smtp.mailgun.org # SMTP_PASSWORD=your-smtp-password # STRIPE_SECRET_KEY=sk_live_your_stripe_secret_key diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 076eb95..257938f 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -18,7 +18,7 @@ Provides ludic + htmx interface and RSS feed generation. # : dep stripe # : dep sqids import Biz.EmailAgent -import Biz.PodcastItLater.Admin as Admin +import Biz.PodcastItLater.Admin.Core as Admin import Biz.PodcastItLater.Billing as Billing import Biz.PodcastItLater.Core as Core import Biz.PodcastItLater.Episode as Episode @@ -28,7 +28,6 @@ import httpx import logging import ludic.html as html import Omni.App as App -import Omni.Log as Log import Omni.Test as Test import os import pathlib @@ -57,7 +56,9 @@ from typing import override from unittest.mock import patch logger = logging.getLogger(__name__) -Log.setup(logger) +logging.basicConfig( + level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s" +) # Configuration @@ -86,9 +87,10 @@ def decode_episode_id(sqid: str) -> int | None: # Authentication configuration MAGIC_LINK_MAX_AGE = 3600 # 1 hour SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days -EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@podcastitlater.com") +EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@bensima.com") SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.mailgun.org") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) # Initialize serializer for magic links magic_link_serializer = URLSafeTimedSerializer( @@ -192,6 +194,7 @@ PodcastItLater password=SMTP_PASSWORD, subject=subject, body_text=body_text_path, + port=SMTP_PORT, ) finally: # Clean up temporary file @@ -1067,7 +1070,7 @@ async def submit_feedback(request: Request) -> UI.FeedbackPage: feedback_text = form_data.get("feedback_text") use_case = form_data.get("use_case") - rating = int(rating_str) if rating_str else None + rating = int(str(rating_str)) if rating_str else None feedback_id = secrets.token_urlsafe(16) diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker.py deleted file mode 100644 index ecef2c0..0000000 --- a/Biz/PodcastItLater/Worker.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Background worker for processing article-to-podcast conversions.""" - -# : dep boto3 -# : dep botocore -# : dep openai -# : dep psutil -# : dep pydub -# : dep pytest -# : dep pytest-asyncio -# : dep pytest-mock -# : dep trafilatura -# : out podcastitlater-worker -# : run ffmpeg -import Biz.PodcastItLater.Core as Core -import Biz.PodcastItLater.Worker.Jobs as Jobs -import Biz.PodcastItLater.Worker.Processor as Processor -import Biz.PodcastItLater.Worker.TextProcessing as TextProcessing -import json -import logging -import Omni.App as App -import Omni.Log as Log -import Omni.Test as Test -import os -import pytest -import signal -import sys -import threading -import unittest.mock -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Any - -logger = logging.getLogger(__name__) -Log.setup(logger) - -# Configuration from environment variables -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -area = App.from_env() - -# Worker configuration -MAX_ARTICLE_SIZE = 500_000 # 500KB character limit for articles -MEMORY_THRESHOLD = 80 # Percentage threshold for memory usage - - -class ShutdownHandler: - """Handles graceful shutdown of the worker.""" - - def __init__(self) -> None: - """Initialize shutdown handler.""" - self.shutdown_requested = threading.Event() - self.current_job_id: int | None = None - self.lock = threading.Lock() - - # Register signal handlers - signal.signal(signal.SIGTERM, self._handle_signal) - signal.signal(signal.SIGINT, self._handle_signal) - - def _handle_signal(self, signum: int, _frame: Any) -> None: - """Handle shutdown signals.""" - logger.info( - "Received signal %d, initiating graceful shutdown...", - signum, - ) - self.shutdown_requested.set() - - def is_shutdown_requested(self) -> bool: - """Check if shutdown has been requested.""" - return self.shutdown_requested.is_set() - - def set_current_job(self, job_id: int | None) -> None: - """Set the currently processing job.""" - with self.lock: - self.current_job_id = job_id - - def get_current_job(self) -> int | None: - """Get the currently processing job.""" - with self.lock: - return self.current_job_id - - -def move() -> None: - """Make the worker move.""" - try: - # Initialize database - Core.Database.init_db() - - # Start main processing loop - shutdown_handler = ShutdownHandler() - processor = Processor.ArticleProcessor(shutdown_handler) - Jobs.main_loop(shutdown_handler, processor) - - except KeyboardInterrupt: - logger.info("Worker stopped by user") - except Exception: - logger.exception("Worker crashed") - raise - - -class TestMemoryEfficiency(Test.TestCase): - """Test memory-efficient processing.""" - - def test_large_article_size_limit(self) -> None: - """Test that articles exceeding size limits are rejected.""" - huge_text = "x" * (MAX_ARTICLE_SIZE + 1000) # Exceed limit - - with ( - unittest.mock.patch( - "trafilatura.fetch_url", - return_value=huge_text * 4, # Simulate large HTML - ), - pytest.raises(ValueError, match="Article too large") as cm, - ): - Processor.ArticleProcessor.extract_article_content( - "https://example.com" - ) - - self.assertIn("Article too large", str(cm.value)) - - def test_content_truncation(self) -> None: - """Test that oversized content is truncated.""" - large_content = "Content " * 100_000 # Create large content - mock_result = json.dumps({ - "title": "Large Article", - "text": large_content, - }) - - with ( - unittest.mock.patch( - "trafilatura.fetch_url", - return_value="content", - ), - unittest.mock.patch( - "trafilatura.extract", - return_value=mock_result, - ), - ): - title, content, _author, _pub_date = ( - Processor.ArticleProcessor.extract_article_content( - "https://example.com", - ) - ) - - self.assertEqual(title, "Large Article") - self.assertLessEqual(len(content), MAX_ARTICLE_SIZE) - - def test_memory_usage_check(self) -> None: - """Test memory usage monitoring.""" - usage = Processor.check_memory_usage() - self.assertIsInstance(usage, float) - self.assertGreaterEqual(usage, 0.0) - self.assertLessEqual(usage, 100.0) - - -class TestWorkerErrorHandling(Test.TestCase): - """Test worker error handling and recovery.""" - - def setUp(self) -> None: - """Set up test environment.""" - Core.Database.init_db() - self.user_id, _ = Core.Database.create_user("test@example.com") - self.job_id = Core.Database.add_to_queue( - "https://example.com", - "test@example.com", - self.user_id, - ) - self.shutdown_handler = ShutdownHandler() - - # Mock environment for processor - self.env_patcher = unittest.mock.patch.dict( - os.environ, - {"OPENAI_API_KEY": "test-key"}, - ) - self.env_patcher.start() - self.processor = Processor.ArticleProcessor(self.shutdown_handler) - - def tearDown(self) -> None: - """Clean up.""" - self.env_patcher.stop() - Core.Database.teardown() - - def test_process_pending_jobs_exception_handling(self) -> None: - """Test that process_pending_jobs handles exceptions.""" - - def side_effect(job: dict[str, Any]) -> None: - # Simulate process_job starting and setting status to processing - Core.Database.update_job_status(job["id"], "processing") - msg = "Unexpected Error" - raise ValueError(msg) - - with ( - unittest.mock.patch.object( - self.processor, - "process_job", - side_effect=side_effect, - ), - unittest.mock.patch( - "Biz.PodcastItLater.Core.Database.update_job_status", - side_effect=Core.Database.update_job_status, - ) as _mock_update, - ): - Jobs.process_pending_jobs(self.processor) - - # Job should be marked as error - job = Core.Database.get_job_by_id(self.job_id) - self.assertIsNotNone(job) - if job: - self.assertEqual(job["status"], "error") - self.assertIn("Unexpected Error", job["error_message"]) - - def test_process_retryable_jobs_success(self) -> None: - """Test processing of retryable jobs.""" - # Set up a retryable job - Core.Database.update_job_status(self.job_id, "error", "Fail 1") - - # Modify created_at to be in the past to satisfy backoff - with Core.Database.get_connection() as conn: - conn.execute( - "UPDATE queue SET created_at = ? WHERE id = ?", - ( - ( - datetime.now(tz=timezone.utc) - timedelta(minutes=5) - ).isoformat(), - self.job_id, - ), - ) - conn.commit() - - Jobs.process_retryable_jobs() - - job = Core.Database.get_job_by_id(self.job_id) - self.assertIsNotNone(job) - if job: - self.assertEqual(job["status"], "pending") - - def test_process_retryable_jobs_not_ready(self) -> None: - """Test that jobs are not retried before backoff period.""" - # Set up a retryable job that just failed - Core.Database.update_job_status(self.job_id, "error", "Fail 1") - - # created_at is now, so backoff should prevent retry - Jobs.process_retryable_jobs() - - job = Core.Database.get_job_by_id(self.job_id) - self.assertIsNotNone(job) - if job: - self.assertEqual(job["status"], "error") - - -def test() -> None: - """Run the tests.""" - Test.run( - App.Area.Test, - [ - Processor.TestArticleExtraction, - Processor.TestTextToSpeech, - Processor.TestIntroOutro, - TestMemoryEfficiency, - Jobs.TestJobProcessing, - TestWorkerErrorHandling, - TextProcessing.TestTextChunking, - ], - ) - - -def main() -> None: - """Entry point for the worker.""" - if "test" in sys.argv: - test() - else: - move() diff --git a/Biz/PodcastItLater/Worker/Core.py b/Biz/PodcastItLater/Worker/Core.py new file mode 100644 index 0000000..e536785 --- /dev/null +++ b/Biz/PodcastItLater/Worker/Core.py @@ -0,0 +1,272 @@ +"""Background worker for processing article-to-podcast conversions.""" + +# : dep boto3 +# : dep botocore +# : dep openai +# : dep psutil +# : dep pydub +# : dep pytest +# : dep pytest-asyncio +# : dep pytest-mock +# : dep trafilatura +# : out podcastitlater-worker +# : run ffmpeg +import Biz.PodcastItLater.Core as Core +import Biz.PodcastItLater.Worker.Jobs as Jobs +import Biz.PodcastItLater.Worker.Processor as Processor +import Biz.PodcastItLater.Worker.TextProcessing as TextProcessing +import json +import logging +import Omni.App as App +import Omni.Test as Test +import os +import pytest +import signal +import sys +import threading +import unittest.mock +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from typing import Any + +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s" +) + +# Configuration from environment variables +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +area = App.from_env() + +# Worker configuration +MAX_ARTICLE_SIZE = 500_000 # 500KB character limit for articles +MEMORY_THRESHOLD = 80 # Percentage threshold for memory usage + + +class ShutdownHandler: + """Handles graceful shutdown of the worker.""" + + def __init__(self) -> None: + """Initialize shutdown handler.""" + self.shutdown_requested = threading.Event() + self.current_job_id: int | None = None + self.lock = threading.Lock() + + # Register signal handlers + signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) + + def _handle_signal(self, signum: int, _frame: Any) -> None: + """Handle shutdown signals.""" + logger.info( + "Received signal %d, initiating graceful shutdown...", + signum, + ) + self.shutdown_requested.set() + + def is_shutdown_requested(self) -> bool: + """Check if shutdown has been requested.""" + return self.shutdown_requested.is_set() + + def set_current_job(self, job_id: int | None) -> None: + """Set the currently processing job.""" + with self.lock: + self.current_job_id = job_id + + def get_current_job(self) -> int | None: + """Get the currently processing job.""" + with self.lock: + return self.current_job_id + + +def move() -> None: + """Make the worker move.""" + try: + # Initialize database + Core.Database.init_db() + + # Start main processing loop + shutdown_handler = ShutdownHandler() + processor = Processor.ArticleProcessor(shutdown_handler) + Jobs.main_loop(shutdown_handler, processor) + + except KeyboardInterrupt: + logger.info("Worker stopped by user") + except Exception: + logger.exception("Worker crashed") + raise + + +class TestMemoryEfficiency(Test.TestCase): + """Test memory-efficient processing.""" + + def test_large_article_size_limit(self) -> None: + """Test that articles exceeding size limits are rejected.""" + huge_text = "x" * (MAX_ARTICLE_SIZE + 1000) # Exceed limit + + with ( + unittest.mock.patch( + "trafilatura.fetch_url", + return_value=huge_text * 4, # Simulate large HTML + ), + pytest.raises(ValueError, match="Article too large") as cm, + ): + Processor.ArticleProcessor.extract_article_content( + "https://example.com" + ) + + self.assertIn("Article too large", str(cm.value)) + + def test_content_truncation(self) -> None: + """Test that oversized content is truncated.""" + large_content = "Content " * 100_000 # Create large content + mock_result = json.dumps({ + "title": "Large Article", + "text": large_content, + }) + + with ( + unittest.mock.patch( + "trafilatura.fetch_url", + return_value="content", + ), + unittest.mock.patch( + "trafilatura.extract", + return_value=mock_result, + ), + ): + title, content, _author, _pub_date = ( + Processor.ArticleProcessor.extract_article_content( + "https://example.com", + ) + ) + + self.assertEqual(title, "Large Article") + self.assertLessEqual(len(content), MAX_ARTICLE_SIZE) + + def test_memory_usage_check(self) -> None: + """Test memory usage monitoring.""" + usage = Processor.check_memory_usage() + self.assertIsInstance(usage, float) + self.assertGreaterEqual(usage, 0.0) + self.assertLessEqual(usage, 100.0) + + +class TestWorkerErrorHandling(Test.TestCase): + """Test worker error handling and recovery.""" + + def setUp(self) -> None: + """Set up test environment.""" + Core.Database.init_db() + self.user_id, _ = Core.Database.create_user("test@example.com") + self.job_id = Core.Database.add_to_queue( + "https://example.com", + "test@example.com", + self.user_id, + ) + self.shutdown_handler = ShutdownHandler() + + # Mock environment for processor + self.env_patcher = unittest.mock.patch.dict( + os.environ, + {"OPENAI_API_KEY": "test-key"}, + ) + self.env_patcher.start() + self.processor = Processor.ArticleProcessor(self.shutdown_handler) + + def tearDown(self) -> None: + """Clean up.""" + self.env_patcher.stop() + Core.Database.teardown() + + def test_process_pending_jobs_exception_handling(self) -> None: + """Test that process_pending_jobs handles exceptions.""" + + def side_effect(job: dict[str, Any]) -> None: + # Simulate process_job starting and setting status to processing + Core.Database.update_job_status(job["id"], "processing") + msg = "Unexpected Error" + raise ValueError(msg) + + with ( + unittest.mock.patch.object( + self.processor, + "process_job", + side_effect=side_effect, + ), + unittest.mock.patch( + "Biz.PodcastItLater.Core.Database.update_job_status", + side_effect=Core.Database.update_job_status, + ) as _mock_update, + ): + Jobs.process_pending_jobs(self.processor) + + # Job should be marked as error + job = Core.Database.get_job_by_id(self.job_id) + self.assertIsNotNone(job) + if job: + self.assertEqual(job["status"], "error") + self.assertIn("Unexpected Error", job["error_message"]) + + def test_process_retryable_jobs_success(self) -> None: + """Test processing of retryable jobs.""" + # Set up a retryable job + Core.Database.update_job_status(self.job_id, "error", "Fail 1") + + # Modify created_at to be in the past to satisfy backoff + with Core.Database.get_connection() as conn: + conn.execute( + "UPDATE queue SET created_at = ? WHERE id = ?", + ( + ( + datetime.now(tz=timezone.utc) - timedelta(minutes=5) + ).isoformat(), + self.job_id, + ), + ) + conn.commit() + + Jobs.process_retryable_jobs() + + job = Core.Database.get_job_by_id(self.job_id) + self.assertIsNotNone(job) + if job: + self.assertEqual(job["status"], "pending") + + def test_process_retryable_jobs_not_ready(self) -> None: + """Test that jobs are not retried before backoff period.""" + # Set up a retryable job that just failed + Core.Database.update_job_status(self.job_id, "error", "Fail 1") + + # created_at is now, so backoff should prevent retry + Jobs.process_retryable_jobs() + + job = Core.Database.get_job_by_id(self.job_id) + self.assertIsNotNone(job) + if job: + self.assertEqual(job["status"], "error") + + +def test() -> None: + """Run the tests.""" + Test.run( + App.Area.Test, + [ + Processor.TestArticleExtraction, + Processor.TestTextToSpeech, + Processor.TestIntroOutro, + TestMemoryEfficiency, + Jobs.TestJobProcessing, + TestWorkerErrorHandling, + TextProcessing.TestTextChunking, + ], + ) + + +def main() -> None: + """Entry point for the worker.""" + if "test" in sys.argv: + test() + else: + move() diff --git a/Biz/PodcastItLater/Worker/Jobs.py b/Biz/PodcastItLater/Worker/Jobs.py index 630aaf0..3511b63 100644 --- a/Biz/PodcastItLater/Worker/Jobs.py +++ b/Biz/PodcastItLater/Worker/Jobs.py @@ -179,7 +179,7 @@ class TestJobProcessing(Test.TestCase): def setUp(self) -> None: """Set up test environment.""" # Import here to avoid circular dependencies - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker Core.Database.init_db() diff --git a/Biz/PodcastItLater/Worker/Processor.py b/Biz/PodcastItLater/Worker/Processor.py index bdda3e5..9d3b61f 100644 --- a/Biz/PodcastItLater/Worker/Processor.py +++ b/Biz/PodcastItLater/Worker/Processor.py @@ -865,7 +865,7 @@ class TestTextToSpeech(Test.TestCase): def test_tts_generation(self) -> None: """Generate audio from text.""" # Import ShutdownHandler dynamically to avoid circular import - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker # Mock the export to write test audio data def mock_export(buffer: io.BytesIO, **_kwargs: typing.Any) -> None: @@ -901,7 +901,7 @@ class TestTextToSpeech(Test.TestCase): def test_tts_chunking(self) -> None: """Handle long articles with chunking.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker long_text = "Long content " * 1000 chunks = ["Chunk 1", "Chunk 2", "Chunk 3"] @@ -945,7 +945,7 @@ class TestTextToSpeech(Test.TestCase): def test_tts_empty_text(self) -> None: """Handle empty input.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker with unittest.mock.patch( "Biz.PodcastItLater.Worker.TextProcessing.prepare_text_for_tts", @@ -960,7 +960,7 @@ class TestTextToSpeech(Test.TestCase): def test_tts_special_characters(self) -> None: """Handle unicode and special chars.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker special_text = 'Unicode: 你好世界 Émojis: 🎙️📰 Special: <>&"' @@ -1029,7 +1029,7 @@ class TestTextToSpeech(Test.TestCase): def test_chunk_concatenation(self) -> None: """Verify audio joining.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker # Mock multiple audio segments chunks = ["Chunk 1", "Chunk 2"] @@ -1069,7 +1069,7 @@ class TestTextToSpeech(Test.TestCase): def test_parallel_tts_generation(self) -> None: """Test parallel TTS processing.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker chunks = ["Chunk 1", "Chunk 2", "Chunk 3", "Chunk 4"] @@ -1128,7 +1128,7 @@ class TestTextToSpeech(Test.TestCase): def test_parallel_tts_high_memory_fallback(self) -> None: """Test fallback to serial processing when memory is high.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker chunks = ["Chunk 1", "Chunk 2"] @@ -1171,7 +1171,7 @@ class TestTextToSpeech(Test.TestCase): @staticmethod def test_parallel_tts_error_handling() -> None: """Test error handling in parallel TTS processing.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker chunks = ["Chunk 1", "Chunk 2"] @@ -1208,7 +1208,7 @@ class TestTextToSpeech(Test.TestCase): def test_parallel_tts_order_preservation(self) -> None: """Test that chunks are combined in the correct order.""" - import Biz.PodcastItLater.Worker as Worker + import Biz.PodcastItLater.Worker.Core as Worker chunks = ["First", "Second", "Third", "Fourth", "Fifth"] -- cgit v1.2.3 From 451b3421313a53b3e7ab15d95fd4b1231f5b7773 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 16 Dec 2025 16:17:47 -0500 Subject: Update homepage with marketing copy and fix push.sh --- Biz/PodcastItLater/Web.py | 119 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 110 insertions(+), 9 deletions(-) (limited to 'Biz/PodcastItLater') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 257938f..bc12000 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -906,19 +906,114 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): error = self.attrs.get("error") if not user: - # Show public feed with login form for logged-out users + # Marketing landing page for logged-out users return UI.PageLayout( - LoginForm(error=error), + # Hero section html.div( - html.h4( - html.i(classes=["bi", "bi-broadcast", "me-2"]), - "Public Feed", - classes=["mb-3", "mt-4"], + html.h1( + "Your Reading List, Now a Podcast", + classes=["display-5", "fw-bold", "mb-3"], ), html.p( - "Featured articles converted to audio. " - "Sign up to create your own personal feed!", - classes=["text-muted", "mb-3"], + "Convert web articles to audio. " + "Listen during your commute, workout, or chores.", + classes=["lead", "text-muted", "mb-4"], + ), + html.a( + "Start Listening", + href="#login", + classes=["btn", "btn-primary", "btn-lg"], + ), + classes=["text-center", "py-5", "mb-5"], + ), + # How it works section + html.div( + html.h3( + "How It Works", + classes=["text-center", "mb-4"], + ), + html.div( + html.div( + html.div( + html.div( + html.span( + "1", + classes=[ + "badge", + "bg-primary", + "rounded-circle", + "fs-5", + "mb-3", + ], + ), + html.h5("Paste any article URL"), + html.p( + "Copy the link to any article you want to listen to.", + classes=["text-muted", "small"], + ), + classes=["card-body", "text-center"], + ), + classes=["card", "h-100"], + ), + classes=["col-md-4", "mb-3"], + ), + html.div( + html.div( + html.div( + html.span( + "2", + classes=[ + "badge", + "bg-primary", + "rounded-circle", + "fs-5", + "mb-3", + ], + ), + html.h5("We convert it to audio"), + html.p( + "Our AI reads the article and creates a podcast episode.", + classes=["text-muted", "small"], + ), + classes=["card-body", "text-center"], + ), + classes=["card", "h-100"], + ), + classes=["col-md-4", "mb-3"], + ), + html.div( + html.div( + html.div( + html.span( + "3", + classes=[ + "badge", + "bg-primary", + "rounded-circle", + "fs-5", + "mb-3", + ], + ), + html.h5("Listen in your podcast app"), + html.p( + "Subscribe to your personal RSS feed in any podcast app.", + classes=["text-muted", "small"], + ), + classes=["card-body", "text-center"], + ), + classes=["card", "h-100"], + ), + classes=["col-md-4", "mb-3"], + ), + classes=["row"], + ), + classes=["mb-5"], + ), + # Social proof section + html.div( + html.h4( + "Join others who've converted their reading backlog", + classes=["text-center", "mb-4"], ), EpisodeList( episodes=episodes, @@ -926,6 +1021,12 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): user=None, viewing_own_feed=False, ), + classes=["mb-5"], + ), + # Login form + html.div( + LoginForm(error=error), + id="login", ), user=None, current_page="home", -- cgit v1.2.3 From 32c2bb198007ab85095c14be544cfca9d083a7cd Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Wed, 17 Dec 2025 11:00:29 -0500 Subject: Improve PodcastItLater homepage and dark mode support Homepage changes: - New marketing-focused landing page for logged-out users - Hero: 'Your Reading List, as a Podcast' with clear value prop - How It Works: 3-step process cards (paste URL, convert, listen) - Social proof: 'Join others' section with public feed - Login form moved below social proof for better conversion flow Dark mode fixes: - Remove bg-light from Current Plan card on /account page - Remove bg-white from metrics card headers on /admin/metrics Layout improvements: - Stack metrics tables vertically (full-width) instead of horizontal - Add spacing between metric cards for better readability --- Biz/PodcastItLater/Admin/Views.py | 18 +++++++++--------- Biz/PodcastItLater/UI.py | 1 - Biz/PodcastItLater/Web.py | 25 +++++++++++++++---------- 3 files changed, 24 insertions(+), 20 deletions(-) (limited to 'Biz/PodcastItLater') diff --git a/Biz/PodcastItLater/Admin/Views.py b/Biz/PodcastItLater/Admin/Views.py index 7834340..057c5e0 100644 --- a/Biz/PodcastItLater/Admin/Views.py +++ b/Biz/PodcastItLater/Admin/Views.py @@ -263,16 +263,16 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]): "Most Played", classes=["card-title", "mb-0"], ), - classes=["card-header", "bg-white"], + classes=["card-header"], ), TopEpisodesTable( episodes=metrics["most_played"], metric_name="Plays", count_key="play_count", ), - classes=["card", "shadow-sm"], + classes=["card", "shadow-sm", "mb-3"], ), - classes=["col-lg-4"], + classes=["col-12"], ), html.div( html.div( @@ -288,16 +288,16 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]): "Most Downloaded", classes=["card-title", "mb-0"], ), - classes=["card-header", "bg-white"], + classes=["card-header"], ), TopEpisodesTable( episodes=metrics["most_downloaded"], metric_name="Downloads", count_key="download_count", ), - classes=["card", "shadow-sm"], + classes=["card", "shadow-sm", "mb-3"], ), - classes=["col-lg-4"], + classes=["col-12"], ), html.div( html.div( @@ -313,16 +313,16 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]): "Most Added to Feeds", classes=["card-title", "mb-0"], ), - classes=["card-header", "bg-white"], + classes=["card-header"], ), TopEpisodesTable( episodes=metrics["most_added"], metric_name="Adds", count_key="add_count", ), - classes=["card", "shadow-sm"], + classes=["card", "shadow-sm", "mb-3"], ), - classes=["col-lg-4"], + classes=["col-12"], ), classes=["row", "g-3"], ), diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index 5c65ca4..d4b60a9 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -539,7 +539,6 @@ class AccountPage(Component[AnyChildren, AccountPageAttrs]): classes=[ "card", "card-body", - "bg-light", ], ), classes=["mb-5"], diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index bc12000..c472819 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -911,12 +911,13 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): # Hero section html.div( html.h1( - "Your Reading List, Now a Podcast", + "Your Reading List, as a Podcast", classes=["display-5", "fw-bold", "mb-3"], ), html.p( - "Convert web articles to audio. " - "Listen during your commute, workout, or chores.", + "Natural-sounding audio for your articles. " + "Delivered to your podcast app. " + "Listen while commuting, cooking, or working out.", classes=["lead", "text-muted", "mb-4"], ), html.a( @@ -972,7 +973,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): ), html.h5("We convert it to audio"), html.p( - "Our AI reads the article and creates a podcast episode.", + "Our AI reads the article in a natural, human-sounding voice and creates a podcast episode.", classes=["text-muted", "small"], ), classes=["card-body", "text-center"], @@ -1015,18 +1016,22 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "Join others who've converted their reading backlog", classes=["text-center", "mb-4"], ), + classes=["mb-4"], + ), + # Login form + html.div( + LoginForm(error=error), + id="login", + classes=["mb-5"], + ), + # Recent episodes + html.div( EpisodeList( episodes=episodes, rss_url=None, user=None, viewing_own_feed=False, ), - classes=["mb-5"], - ), - # Login form - html.div( - LoginForm(error=error), - id="login", ), user=None, current_page="home", -- cgit v1.2.3