diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-17 13:29:40 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-17 13:29:40 -0500 |
| commit | ab01b34bf563990e0f491ada646472aaade97610 (patch) | |
| tree | 5e46a1a157bb846b0c3a090a83153c788da2b977 /Biz | |
| parent | e112d3ce07fa24f31a281e521a554cc881a76c7b (diff) | |
| parent | 337648981cc5a55935116141341521f4fce83214 (diff) | |
Merge Ava deployment changes
Diffstat (limited to 'Biz')
| -rwxr-xr-x | Biz/EmailAgent.py | 7 | ||||
| -rw-r--r-- | Biz/Packages.nix | 5 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin.py | 94 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Core.py | 95 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Handlers.py | 28 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Views.py | 107 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/__init__.py | 1 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 156 | ||||
| -rw-r--r-- | Biz/PodcastItLater/INFRASTRUCTURE.md | 46 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 248 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.nix | 4 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 220 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker/Core.py (renamed from Biz/PodcastItLater/Worker.py) | 5 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker/Jobs.py | 2 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Worker/Processor.py | 18 |
15 files changed, 868 insertions, 168 deletions
diff --git a/Biz/EmailAgent.py b/Biz/EmailAgent.py index 6ac4c95..ca42de3 100755 --- a/Biz/EmailAgent.py +++ b/Biz/EmailAgent.py @@ -31,7 +31,7 @@ def send_email( Send an email using the provided parameters. Args: - to_addr: Recipient email addresses + to_addrs: Recipient email addresses from_addr: Sender email address smtp_server: SMTP server hostname password: Password for authentication @@ -56,8 +56,9 @@ def send_email( with body_html.open(encoding="utf-*") as html: msg.add_alternative(html.read(), subtype="html") with smtplib.SMTP(smtp_server, port) as server: - server.starttls() - server.login(from_addr, password) + if password: + server.starttls() + server.login(from_addr, password) return server.send_message( msg, from_addr=from_addr, diff --git a/Biz/Packages.nix b/Biz/Packages.nix index 6b17fe5..492671f 100644 --- a/Biz/Packages.nix +++ b/Biz/Packages.nix @@ -10,6 +10,9 @@ {bild ? import ../Omni/Bild.nix {}}: { storybook = bild.run ../Biz/Storybook.py; podcastitlater-web = bild.run ../Biz/PodcastItLater/Web.py; - podcastitlater-worker = bild.run ../Biz/PodcastItLater/Worker.py; + podcastitlater-worker = bild.run ../Biz/PodcastItLater/Worker/Core.py; dragons-analysis = bild.run ../Biz/Dragons/Analysis.hs; + # Mini-PaaS deployer + biz-deployer = bild.run ../Omni/Deploy/Deployer.hs; + deploy-manifest = bild.run ../Omni/Deploy/Manifest.hs; } diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py deleted file mode 100644 index 3fc6f61..0000000 --- a/Biz/PodcastItLater/Admin.py +++ /dev/null @@ -1,94 +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 - -# 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 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 - -# Export all symbols for backward compatibility -__all__ = [ - # Views - "ActionButtons", - "ActionButtonsAttrs", - "AdminUsers", - "AdminUsersAttrs", - "AdminView", - "AdminViewAttrs", - "EpisodeTableRow", - "EpisodeTableRowAttrs", - "MetricCard", - "MetricCardAttrs", - "MetricsAttrs", - "MetricsDashboard", - "QueueTableRow", - "QueueTableRowAttrs", - "StatusBadge", - "StatusBadgeAttrs", - "TopEpisodesTable", - "TopEpisodesTableAttrs", - "TruncatedText", - "TruncatedTextAttrs", - "UserTableRow", - "UserTableRowAttrs", - # Handlers - "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/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..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"], ), @@ -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/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.""" diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 3a88f22..05ed153 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, @@ -774,6 +777,42 @@ class Database: # noqa: PLR0904 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.""" with Database.get_connection() as conn: @@ -1073,6 +1112,18 @@ class Database: # noqa: PLR0904 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.""" with Database.get_connection() as conn: @@ -1189,6 +1240,111 @@ class Database: # noqa: PLR0904 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 int(cursor.fetchone()["count"]) + + @staticmethod def get_metrics_summary() -> dict[str, Any]: """Get aggregate metrics summary for admin dashboard. 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 e9ef27d..d4b60a9 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", ), @@ -531,7 +539,6 @@ class AccountPage(Component[AnyChildren, AccountPageAttrs]): classes=[ "card", "card-body", - "bg-light", ], ), classes=["mb-5"], @@ -621,6 +628,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, + 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, + 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.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 30b5236..c472819 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( @@ -167,7 +169,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 +182,9 @@ If you didn't request this, please ignore this email. Best, PodcastItLater -""") +""", + encoding="utf-8", + ) try: Biz.EmailAgent.send_email( @@ -189,6 +194,7 @@ PodcastItLater password=SMTP_PASSWORD, subject=subject, body_text=body_text_path, + port=SMTP_PORT, ) finally: # Clean up temporary file @@ -900,20 +906,126 @@ 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, as 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"], + "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( + "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 in a natural, human-sounding voice 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"], + ), + 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, @@ -1033,6 +1145,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(str(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 +2021,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, diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker/Core.py index ecef2c0..e536785 100644 --- a/Biz/PodcastItLater/Worker.py +++ b/Biz/PodcastItLater/Worker/Core.py @@ -18,7 +18,6 @@ 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 @@ -32,7 +31,9 @@ from datetime import timezone from typing import Any logger = logging.getLogger(__name__) -Log.setup(logger) +logging.basicConfig( + level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s" +) # Configuration from environment variables OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") 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"] |
