{comment.message}
+diff --git a/.build_number b/.build_number index 9a69b8d..47edee8 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0403261902 +0703261729 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 089592a..82e0321 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0403261902" -CHANGELOG = '2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0703261729" +CHANGELOG = '2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/db.py b/backend/app/db.py index b1c45d5..dbd5bb5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -20,6 +20,7 @@ SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3 SQLITE_BUSY_TIMEOUT_MS = 5_000 SQLITE_CACHE_SIZE_KIB = 32_768 SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024 +_DB_UNSET = object() def _db_path() -> str: @@ -349,6 +350,43 @@ def init_db() -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS portal_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + media_type TEXT, + year INTEGER, + external_ref TEXT, + source_system TEXT, + source_request_id INTEGER, + status TEXT NOT NULL, + priority TEXT NOT NULL, + created_by_username TEXT NOT NULL, + created_by_id INTEGER, + assignee_username TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_activity_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS portal_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + author_username TEXT NOT NULL, + author_role TEXT NOT NULL, + message TEXT NOT NULL, + is_internal INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE + ) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at @@ -409,6 +447,30 @@ def init_db() -> None: ON password_reset_tokens (expires_at) """ ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_portal_items_kind_status + ON portal_items (kind, status, updated_at DESC, id DESC) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_portal_items_creator + ON portal_items (created_by_username, updated_at DESC, id DESC) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_portal_items_status + ON portal_items (status, updated_at DESC, id DESC) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created + ON portal_comments (item_id, created_at DESC, id DESC) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS user_activity ( @@ -2879,6 +2941,417 @@ def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) ) +def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]: + return { + "id": row[0], + "kind": row[1], + "title": row[2], + "description": row[3], + "media_type": row[4], + "year": row[5], + "external_ref": row[6], + "source_system": row[7], + "source_request_id": row[8], + "status": row[9], + "priority": row[10], + "created_by_username": row[11], + "created_by_id": row[12], + "assignee_username": row[13], + "created_at": row[14], + "updated_at": row[15], + "last_activity_at": row[16], + } + + +def _portal_comment_from_row(row: tuple[Any, ...]) -> Dict[str, Any]: + return { + "id": row[0], + "item_id": row[1], + "author_username": row[2], + "author_role": row[3], + "message": row[4], + "is_internal": bool(row[5]), + "created_at": row[6], + } + + +def create_portal_item( + *, + kind: str, + title: str, + description: str, + created_by_username: str, + created_by_id: Optional[int], + media_type: Optional[str] = None, + year: Optional[int] = None, + external_ref: Optional[str] = None, + source_system: Optional[str] = None, + source_request_id: Optional[int] = None, + status: str = "new", + priority: str = "normal", + assignee_username: Optional[str] = None, +) -> Dict[str, Any]: + now = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + INSERT INTO portal_items ( + kind, + title, + description, + media_type, + year, + external_ref, + source_system, + source_request_id, + status, + priority, + created_by_username, + created_by_id, + assignee_username, + created_at, + updated_at, + last_activity_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + kind, + title, + description, + media_type, + year, + external_ref, + source_system, + source_request_id, + status, + priority, + created_by_username, + created_by_id, + assignee_username, + now, + now, + now, + ), + ) + item_id = cursor.lastrowid + created = get_portal_item(item_id) + if not created: + raise RuntimeError("Portal item could not be loaded after insert.") + logger.info( + "portal item created id=%s kind=%s status=%s priority=%s created_by=%s", + created["id"], + created["kind"], + created["status"], + created["priority"], + created["created_by_username"], + ) + return created + + +def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + """ + SELECT + id, + kind, + title, + description, + media_type, + year, + external_ref, + source_system, + source_request_id, + status, + priority, + created_by_username, + created_by_id, + assignee_username, + created_at, + updated_at, + last_activity_at + FROM portal_items + WHERE id = ? + """, + (item_id,), + ).fetchone() + return _portal_item_from_row(row) if row else None + + +def list_portal_items( + *, + kind: Optional[str] = None, + status: Optional[str] = None, + mine_username: Optional[str] = None, + search: Optional[str] = None, + limit: int = 100, + offset: int = 0, +) -> list[Dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if isinstance(kind, str) and kind.strip(): + clauses.append("kind = ?") + params.append(kind.strip().lower()) + if isinstance(status, str) and status.strip(): + clauses.append("status = ?") + params.append(status.strip().lower()) + if isinstance(mine_username, str) and mine_username.strip(): + clauses.append("created_by_username = ?") + params.append(mine_username.strip()) + if isinstance(search, str) and search.strip(): + token = f"%{search.strip().lower()}%" + clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)") + params.extend([token, token, search.strip()]) + where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else "" + safe_limit = max(1, min(int(limit), 500)) + safe_offset = max(0, int(offset)) + params.extend([safe_limit, safe_offset]) + with _connect() as conn: + rows = conn.execute( + f""" + SELECT + id, + kind, + title, + description, + media_type, + year, + external_ref, + source_system, + source_request_id, + status, + priority, + created_by_username, + created_by_id, + assignee_username, + created_at, + updated_at, + last_activity_at + FROM portal_items + {where_sql} + ORDER BY last_activity_at DESC, id DESC + LIMIT ? OFFSET ? + """, + tuple(params), + ).fetchall() + return [_portal_item_from_row(row) for row in rows] + + +def count_portal_items( + *, + kind: Optional[str] = None, + status: Optional[str] = None, + mine_username: Optional[str] = None, + search: Optional[str] = None, +) -> int: + clauses: list[str] = [] + params: list[Any] = [] + if isinstance(kind, str) and kind.strip(): + clauses.append("kind = ?") + params.append(kind.strip().lower()) + if isinstance(status, str) and status.strip(): + clauses.append("status = ?") + params.append(status.strip().lower()) + if isinstance(mine_username, str) and mine_username.strip(): + clauses.append("created_by_username = ?") + params.append(mine_username.strip()) + if isinstance(search, str) and search.strip(): + token = f"%{search.strip().lower()}%" + clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)") + params.extend([token, token, search.strip()]) + where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else "" + with _connect() as conn: + row = conn.execute( + f"SELECT COUNT(*) FROM portal_items {where_sql}", + tuple(params), + ).fetchone() + return int(row[0] or 0) if row else 0 + + +def update_portal_item( + item_id: int, + *, + title: Any = _DB_UNSET, + description: Any = _DB_UNSET, + status: Any = _DB_UNSET, + priority: Any = _DB_UNSET, + assignee_username: Any = _DB_UNSET, + media_type: Any = _DB_UNSET, + year: Any = _DB_UNSET, + external_ref: Any = _DB_UNSET, + source_system: Any = _DB_UNSET, + source_request_id: Any = _DB_UNSET, +) -> Optional[Dict[str, Any]]: + updates: list[str] = [] + params: list[Any] = [] + if title is not _DB_UNSET: + updates.append("title = ?") + params.append(title) + if description is not _DB_UNSET: + updates.append("description = ?") + params.append(description) + if status is not _DB_UNSET: + updates.append("status = ?") + params.append(status) + if priority is not _DB_UNSET: + updates.append("priority = ?") + params.append(priority) + if assignee_username is not _DB_UNSET: + updates.append("assignee_username = ?") + params.append(assignee_username) + if media_type is not _DB_UNSET: + updates.append("media_type = ?") + params.append(media_type) + if year is not _DB_UNSET: + updates.append("year = ?") + params.append(year) + if external_ref is not _DB_UNSET: + updates.append("external_ref = ?") + params.append(external_ref) + if source_system is not _DB_UNSET: + updates.append("source_system = ?") + params.append(source_system) + if source_request_id is not _DB_UNSET: + updates.append("source_request_id = ?") + params.append(source_request_id) + if not updates: + return get_portal_item(item_id) + now = datetime.now(timezone.utc).isoformat() + updates.append("updated_at = ?") + updates.append("last_activity_at = ?") + params.extend([now, now, item_id]) + with _connect() as conn: + changed = conn.execute( + f""" + UPDATE portal_items + SET {', '.join(updates)} + WHERE id = ? + """, + tuple(params), + ).rowcount + if not changed: + return None + updated = get_portal_item(item_id) + if updated: + logger.info( + "portal item updated id=%s status=%s priority=%s assignee=%s", + updated["id"], + updated["status"], + updated["priority"], + updated["assignee_username"], + ) + return updated + + +def add_portal_comment( + item_id: int, + *, + author_username: str, + author_role: str, + message: str, + is_internal: bool = False, +) -> Dict[str, Any]: + now = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + INSERT INTO portal_comments ( + item_id, + author_username, + author_role, + message, + is_internal, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + item_id, + author_username, + author_role, + message, + 1 if is_internal else 0, + now, + ), + ) + conn.execute( + """ + UPDATE portal_items + SET last_activity_at = ?, updated_at = ? + WHERE id = ? + """, + (now, now, item_id), + ) + comment_id = cursor.lastrowid + row = conn.execute( + """ + SELECT id, item_id, author_username, author_role, message, is_internal, created_at + FROM portal_comments + WHERE id = ? + """, + (comment_id,), + ).fetchone() + if not row: + raise RuntimeError("Portal comment could not be loaded after insert.") + comment = _portal_comment_from_row(row) + logger.info( + "portal comment created id=%s item_id=%s author=%s internal=%s", + comment["id"], + comment["item_id"], + comment["author_username"], + comment["is_internal"], + ) + return comment + + +def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: int = 200) -> list[Dict[str, Any]]: + clauses = ["item_id = ?"] + params: list[Any] = [item_id] + if not include_internal: + clauses.append("is_internal = 0") + safe_limit = max(1, min(int(limit), 500)) + params.append(safe_limit) + with _connect() as conn: + rows = conn.execute( + f""" + SELECT id, item_id, author_username, author_role, message, is_internal, created_at + FROM portal_comments + WHERE {' AND '.join(clauses)} + ORDER BY created_at ASC, id ASC + LIMIT ? + """, + tuple(params), + ).fetchall() + return [_portal_comment_from_row(row) for row in rows] + + +def get_portal_overview() -> Dict[str, Any]: + with _connect() as conn: + kind_rows = conn.execute( + """ + SELECT kind, COUNT(*) + FROM portal_items + GROUP BY kind + """ + ).fetchall() + status_rows = conn.execute( + """ + SELECT status, COUNT(*) + FROM portal_items + GROUP BY status + """ + ).fetchall() + total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone() + total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone() + return { + "total_items": int(total_items_row[0] or 0) if total_items_row else 0, + "total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0, + "by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows}, + "by_status": {str(row[0]): int(row[1] or 0) for row in status_rows}, + } + + def run_integrity_check() -> str: with _connect() as conn: row = conn.execute("PRAGMA integrity_check").fetchone() @@ -2922,6 +3395,8 @@ def get_database_diagnostics() -> Dict[str, Any]: "snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0), "seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0), "password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0), + "portal_items": int(conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()[0] or 0), + "portal_comments": int(conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()[0] or 0), } row_count_ms = round((perf_counter() - row_count_started) * 1000, 1) diff --git a/backend/app/main.py b/backend/app/main.py index 2736606..a07105e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ from .routers.status import router as status_router from .routers.feedback import router as feedback_router from .routers.site import router as site_router from .routers.events import router as events_router +from .routers.portal import router as portal_router from .services.jellyfin_sync import run_daily_jellyfin_sync from .logging_config import ( bind_request_id, @@ -228,3 +229,4 @@ app.include_router(status_router) app.include_router(feedback_router) app.include_router(site_router) app.include_router(events_router) +app.include_router(portal_router) diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py new file mode 100644 index 0000000..fc566db --- /dev/null +++ b/backend/app/routers/portal.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from ..auth import get_current_user +from ..db import ( + add_portal_comment, + count_portal_items, + create_portal_item, + get_portal_item, + get_portal_overview, + list_portal_comments, + list_portal_items, + update_portal_item, +) +from ..services.notifications import send_portal_notification + +router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)]) +logger = logging.getLogger(__name__) + +PORTAL_KINDS = {"request", "issue", "feature"} +PORTAL_STATUSES = { + "new", + "triaging", + "planned", + "in_progress", + "blocked", + "done", + "declined", + "closed", +} +PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"} +PORTAL_MEDIA_TYPES = {"movie", "tv"} + + +def _clean_text(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + trimmed = value.strip() + return trimmed if trimmed else None + return str(value) + + +def _require_text(value: Any, field: str, *, max_length: int = 5000) -> str: + normalized = _clean_text(value) + if not normalized: + raise HTTPException(status_code=400, detail=f"{field} is required") + if len(normalized) > max_length: + raise HTTPException( + status_code=400, + detail=f"{field} is too long (max {max_length} characters)", + ) + return normalized + + +def _normalize_choice( + value: Any, + *, + field: str, + allowed: set[str], + default: Optional[str] = None, + allow_empty: bool = False, +) -> Optional[str]: + if value is None: + return default + normalized = _clean_text(value) + if not normalized: + return None if allow_empty else default + candidate = normalized.lower() + if candidate not in allowed: + allowed_values = ", ".join(sorted(allowed)) + raise HTTPException(status_code=400, detail=f"Invalid {field}. Allowed: {allowed_values}") + return candidate + + +def _normalize_year(value: Any, *, allow_empty: bool = True) -> Optional[int]: + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None if allow_empty else 0 + value = stripped + try: + year = int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="year must be an integer") from None + if year < 1800 or year > 2100: + raise HTTPException(status_code=400, detail="year must be between 1800 and 2100") + return year + + +def _normalize_int(value: Any, field: str, *, allow_empty: bool = True) -> Optional[int]: + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None if allow_empty else 0 + value = stripped + try: + return int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{field} must be an integer") from None + + +def _normalize_bool(value: Any, *, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + candidate = value.strip().lower() + if candidate in {"1", "true", "yes", "on"}: + return True + if candidate in {"0", "false", "no", "off"}: + return False + raise HTTPException(status_code=400, detail="Boolean value expected") + + +def _is_admin(user: Dict[str, Any]) -> bool: + return str(user.get("role") or "").strip().lower() == "admin" + + +def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool: + return str(user.get("username") or "") == str(item.get("created_by_username") or "") + + +def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: + is_admin = _is_admin(user) + is_owner = _is_owner(user, item) + serialized = dict(item) + serialized["permissions"] = { + "can_edit": is_admin or is_owner, + "can_comment": True, + "can_moderate": is_admin, + } + return serialized + + +async def _notify( + *, + event_type: str, + item: Dict[str, Any], + user: Dict[str, Any], + note: Optional[str] = None, +) -> None: + try: + result = await send_portal_notification( + event_type=event_type, + item=item, + actor_username=str(user.get("username") or "unknown"), + actor_role=str(user.get("role") or "user"), + note=note, + ) + logger.info( + "portal notification dispatched event=%s item_id=%s status=%s", + event_type, + item.get("id"), + result.get("status"), + ) + except Exception: + logger.exception( + "portal notification failed event=%s item_id=%s", + event_type, + item.get("id"), + ) + + +@router.get("/overview") +async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + mine = count_portal_items(mine_username=str(current_user.get("username") or "")) + return { + "overview": get_portal_overview(), + "my_items": mine, + } + + +@router.get("/items") +async def portal_list_items( + kind: Optional[str] = None, + status: Optional[str] = None, + mine: bool = False, + search: Optional[str] = None, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + kind_value = _normalize_choice( + kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True + ) + status_value = _normalize_choice( + status, field="status", allowed=PORTAL_STATUSES, allow_empty=True + ) + mine_username = str(current_user.get("username") or "") if mine else None + items = list_portal_items( + kind=kind_value, + status=status_value, + mine_username=mine_username, + search=_clean_text(search), + limit=limit, + offset=offset, + ) + total = count_portal_items( + kind=kind_value, + status=status_value, + mine_username=mine_username, + search=_clean_text(search), + ) + return { + "items": [_serialize_item(item, current_user) for item in items], + "total": total, + "limit": limit, + "offset": offset, + "has_more": offset + len(items) < total, + "filters": { + "kind": kind_value, + "status": status_value, + "mine": mine, + "search": _clean_text(search), + }, + } + + +@router.post("/items") +async def portal_create_item( + payload: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + is_admin = _is_admin(current_user) + kind = _normalize_choice( + payload.get("kind"), + field="kind", + allowed=PORTAL_KINDS, + default="request", + ) + title = _require_text(payload.get("title"), "title", max_length=220) + description = _require_text(payload.get("description"), "description", max_length=10000) + media_type = _normalize_choice( + payload.get("media_type"), + field="media_type", + allowed=PORTAL_MEDIA_TYPES, + allow_empty=True, + ) + year = _normalize_year(payload.get("year")) + external_ref = _clean_text(payload.get("external_ref")) + source_system = _clean_text(payload.get("source_system")) if is_admin else None + source_request_id = ( + _normalize_int(payload.get("source_request_id"), "source_request_id") + if is_admin + else None + ) + status = _normalize_choice( + payload.get("status") if is_admin else None, + field="status", + allowed=PORTAL_STATUSES, + default="new", + ) + priority = _normalize_choice( + payload.get("priority"), + field="priority", + allowed=PORTAL_PRIORITIES, + default="normal", + ) + assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None + + created = create_portal_item( + kind=kind or "request", + title=title, + description=description, + created_by_username=str(current_user.get("username") or "unknown"), + created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"), + media_type=media_type, + year=year, + external_ref=external_ref, + source_system=source_system, + source_request_id=source_request_id, + status=status or "new", + priority=priority or "normal", + assignee_username=assignee_username, + ) + initial_comment = _clean_text(payload.get("comment")) + if initial_comment: + add_portal_comment( + int(created["id"]), + author_username=str(current_user.get("username") or "unknown"), + author_role=str(current_user.get("role") or "user"), + message=initial_comment, + is_internal=False, + ) + comments = list_portal_comments(int(created["id"]), include_internal=is_admin) + await _notify( + event_type="portal_item_created", + item=created, + user=current_user, + note=f"kind={created.get('kind')} priority={created.get('priority')}", + ) + return { + "item": _serialize_item(created, current_user), + "comments": comments, + } + + +@router.get("/items/{item_id}") +async def portal_get_item( + item_id: int, + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + comments = list_portal_comments(item_id, include_internal=_is_admin(current_user)) + return { + "item": _serialize_item(item, current_user), + "comments": comments, + } + + +@router.patch("/items/{item_id}") +async def portal_update_item( + item_id: int, + payload: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + is_admin = _is_admin(current_user) + is_owner = _is_owner(current_user, item) + if not (is_admin or is_owner): + raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item") + + editable_owner_fields = {"title", "description", "media_type", "year", "external_ref"} + editable_admin_fields = { + "status", + "priority", + "assignee_username", + "source_system", + "source_request_id", + } + provided_fields = set(payload.keys()) + unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields) + if unknown_fields: + unknown = ", ".join(sorted(unknown_fields)) + raise HTTPException(status_code=400, detail=f"Unsupported fields: {unknown}") + if not is_admin: + forbidden = provided_fields - editable_owner_fields + if forbidden: + forbidden_text = ", ".join(sorted(forbidden)) + raise HTTPException( + status_code=403, detail=f"Admin access required to update: {forbidden_text}" + ) + + updates: Dict[str, Any] = {} + if "title" in payload: + updates["title"] = _require_text(payload.get("title"), "title", max_length=220) + if "description" in payload: + updates["description"] = _require_text( + payload.get("description"), "description", max_length=10000 + ) + if "media_type" in payload: + updates["media_type"] = _normalize_choice( + payload.get("media_type"), + field="media_type", + allowed=PORTAL_MEDIA_TYPES, + allow_empty=True, + ) + if "year" in payload: + updates["year"] = _normalize_year(payload.get("year")) + if "external_ref" in payload: + updates["external_ref"] = _clean_text(payload.get("external_ref")) + if is_admin: + if "status" in payload: + updates["status"] = _normalize_choice( + payload.get("status"), + field="status", + allowed=PORTAL_STATUSES, + default=item.get("status") or "new", + ) + if "priority" in payload: + updates["priority"] = _normalize_choice( + payload.get("priority"), + field="priority", + allowed=PORTAL_PRIORITIES, + default=item.get("priority") or "normal", + ) + if "assignee_username" in payload: + updates["assignee_username"] = _clean_text(payload.get("assignee_username")) + if "source_system" in payload: + updates["source_system"] = _clean_text(payload.get("source_system")) + if "source_request_id" in payload: + updates["source_request_id"] = _normalize_int( + payload.get("source_request_id"), "source_request_id" + ) + + if not updates: + comments = list_portal_comments(item_id, include_internal=is_admin) + return { + "item": _serialize_item(item, current_user), + "comments": comments, + } + + updated = update_portal_item(item_id, **updates) + if not updated: + raise HTTPException(status_code=404, detail="Portal item not found") + + changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)] + if changed_fields: + await _notify( + event_type="portal_item_updated", + item=updated, + user=current_user, + note=f"changed={','.join(sorted(changed_fields))}", + ) + comments = list_portal_comments(item_id, include_internal=is_admin) + return { + "item": _serialize_item(updated, current_user), + "comments": comments, + } + + +@router.get("/items/{item_id}/comments") +async def portal_get_comments( + item_id: int, + limit: int = Query(default=200, ge=1, le=500), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + comments = list_portal_comments( + item_id, + include_internal=_is_admin(current_user), + limit=limit, + ) + return {"comments": comments} + + +@router.post("/items/{item_id}/comments") +async def portal_create_comment( + item_id: int, + payload: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + is_admin = _is_admin(current_user) + message = _require_text(payload.get("message"), "message", max_length=10000) + is_internal = _normalize_bool(payload.get("is_internal"), default=False) + if is_internal and not is_admin: + raise HTTPException(status_code=403, detail="Only admins can add internal comments") + comment = add_portal_comment( + item_id, + author_username=str(current_user.get("username") or "unknown"), + author_role=str(current_user.get("role") or "user"), + message=message, + is_internal=is_internal, + ) + updated_item = get_portal_item(item_id) + if updated_item: + await _notify( + event_type="portal_comment_added", + item=updated_item, + user=current_user, + note=f"internal={is_internal}", + ) + return {"comment": comment} diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index e273017..cec7d41 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -1165,6 +1165,38 @@ async def send_templated_email( } +async def send_generic_email( + *, + recipient_email: str, + subject: str, + body_text: str, + body_html: str = "", +) -> Dict[str, str]: + ready, detail = smtp_email_config_ready() + if not ready: + raise RuntimeError(detail) + resolved_email = _normalize_email(recipient_email) + if not resolved_email: + raise RuntimeError("A valid recipient email is required.") + receipt = await asyncio.to_thread( + _send_email_sync, + recipient_email=resolved_email, + subject=subject.strip() or f"{env_settings.app_name} notification", + body_text=body_text.strip(), + body_html=body_html.strip(), + ) + logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject) + return { + "recipient_email": resolved_email, + "subject": subject.strip() or f"{env_settings.app_name} notification", + **{ + key: value + for key, value in receipt.items() + if key in {"provider_message_id", "provider_internal_id", "data_response"} + }, + } + + async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]: ready, detail = smtp_email_config_ready() if not ready: diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..2b47319 --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional +from urllib.parse import quote + +import httpx + +from ..config import settings as env_settings +from ..db import get_setting +from ..runtime import get_runtime_settings +from .invite_email import send_generic_email + +logger = logging.getLogger(__name__) + + +def _clean_text(value: Any, fallback: str = "") -> str: + if value is None: + return fallback + if isinstance(value, str): + trimmed = value.strip() + return trimmed if trimmed else fallback + return str(value) + + +def _split_emails(value: str) -> list[str]: + if not value: + return [] + parts = [entry.strip() for entry in value.replace(";", ",").split(",")] + return [entry for entry in parts if entry and "@" in entry] + + +def _resolve_app_url() -> str: + runtime = get_runtime_settings() + for candidate in ( + runtime.magent_application_url, + runtime.magent_proxy_base_url, + env_settings.cors_allow_origin, + ): + normalized = _clean_text(candidate) + if normalized: + return normalized.rstrip("/") + port = int(getattr(runtime, "magent_application_port", 3000) or 3000) + return f"http://localhost:{port}" + + +def _portal_item_url(item_id: int) -> str: + return f"{_resolve_app_url()}/portal?item={item_id}" + + +async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]: + async with httpx.AsyncClient(timeout=12.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + try: + body = response.json() + except ValueError: + body = response.text + return {"status_code": response.status_code, "body": body} + + +async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]: + runtime = get_runtime_settings() + webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text( + runtime.discord_webhook_url + ) + if not webhook: + return {"status": "skipped", "detail": "Discord webhook not configured."} + data = { + "content": f"**{title}**\n{message}", + "embeds": [ + { + "title": title, + "description": message, + "fields": [ + {"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True}, + {"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True}, + {"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True}, + ], + "url": _clean_text(payload.get("item_url")), + } + ], + } + result = await _http_post_json(webhook, data) + return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."} + + +async def _send_telegram(title: str, message: str) -> Dict[str, Any]: + runtime = get_runtime_settings() + bot_token = _clean_text(runtime.magent_notify_telegram_bot_token) + chat_id = _clean_text(runtime.magent_notify_telegram_chat_id) + if not bot_token or not chat_id: + return {"status": "skipped", "detail": "Telegram is not configured."} + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True} + result = await _http_post_json(url, payload) + return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."} + + +async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]: + runtime = get_runtime_settings() + webhook = _clean_text(runtime.magent_notify_webhook_url) + if not webhook: + return {"status": "skipped", "detail": "Generic webhook is not configured."} + result = await _http_post_json(webhook, payload) + return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."} + + +async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]: + runtime = get_runtime_settings() + provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower() + base_url = _clean_text(runtime.magent_notify_push_base_url) + token = _clean_text(runtime.magent_notify_push_token) + topic = _clean_text(runtime.magent_notify_push_topic) + if provider == "ntfy": + if not base_url or not topic: + return {"status": "skipped", "detail": "ntfy needs base URL and topic."} + url = f"{base_url.rstrip('/')}/{quote(topic)}" + headers = {"Title": title, "Tags": "magent,portal"} + async with httpx.AsyncClient(timeout=12.0) as client: + response = await client.post(url, content=message.encode("utf-8"), headers=headers) + response.raise_for_status() + return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."} + if provider == "gotify": + if not base_url or not token: + return {"status": "skipped", "detail": "Gotify needs base URL and token."} + url = f"{base_url.rstrip('/')}/message?token={quote(token)}" + body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}} + result = await _http_post_json(url, body) + return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."} + if provider == "pushover": + user_key = _clean_text(runtime.magent_notify_push_user_key) + if not token or not user_key: + return {"status": "skipped", "detail": "Pushover needs token and user key."} + form = {"token": token, "user": user_key, "title": title, "message": message} + async with httpx.AsyncClient(timeout=12.0) as client: + response = await client.post("https://api.pushover.net/1/messages.json", data=form) + response.raise_for_status() + return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."} + if provider == "discord": + return await _send_discord(title, message, payload) + if provider == "telegram": + return await _send_telegram(title, message) + if provider == "webhook": + return await _send_webhook(payload) + return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."} + + +async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]: + runtime = get_runtime_settings() + recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients"))) + fallback = _clean_text(runtime.magent_notify_email_from_address) + if fallback and fallback not in recipients: + recipients.append(fallback) + if not recipients: + return {"status": "skipped", "detail": "No portal notification recipient is configured."} + + body_text = ( + f"{title}\n\n" + f"{message}\n\n" + f"Kind: {_clean_text(payload.get('kind'))}\n" + f"Status: {_clean_text(payload.get('status'))}\n" + f"Priority: {_clean_text(payload.get('priority'))}\n" + f"Requested by: {_clean_text(payload.get('requested_by'))}\n" + f"Open: {_clean_text(payload.get('item_url'))}\n" + ) + body_html = ( + "
{message}
" + "| Kind | {_clean_text(payload.get('kind'))} |
| Status | {_clean_text(payload.get('status'))} |
| Priority | {_clean_text(payload.get('priority'))} |
| Requested by | {_clean_text(payload.get('requested_by'))} |
+ Raise requests, issues, and feature ideas. Track progress and keep discussion in one place. +
++ Use Request for new content, Issue for broken behavior, and Feature for improvements. +
+ ++ {totalItems} total + {hasMore ? ' (showing first 60)' : ''} +
++ Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)} +
+{comment.message}
+