Compare commits
5 Commits
4e64f79e64
...
dev-1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| f830fc1296 | |||
| 3989e90a9a | |||
| 4e2b902760 | |||
| 494b79ed26 | |||
| d30a2473ce |
@@ -1 +1 @@
|
|||||||
0403261321
|
0803262216
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,24 @@ class JellyseerrClient(ApiClient):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
media_type: str,
|
||||||
|
media_id: int,
|
||||||
|
seasons: Optional[list[int]] = None,
|
||||||
|
is_4k: Optional[bool] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"mediaType": media_type,
|
||||||
|
"mediaId": media_id,
|
||||||
|
}
|
||||||
|
if isinstance(seasons, list) and seasons:
|
||||||
|
payload["seasons"] = seasons
|
||||||
|
if isinstance(is_4k, bool):
|
||||||
|
payload["is4k"] = is_4k
|
||||||
|
return await self.post("/api/v1/request", payload=payload)
|
||||||
|
|
||||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/api/v1/user",
|
"/api/v1/user",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
|
|||||||
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
||||||
SQLITE_CACHE_SIZE_KIB = 32_768
|
SQLITE_CACHE_SIZE_KIB = 32_768
|
||||||
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
||||||
|
_DB_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
def _db_path() -> str:
|
def _db_path() -> str:
|
||||||
@@ -349,6 +350,49 @@ 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,
|
||||||
|
related_item_id INTEGER,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
workflow_request_status TEXT,
|
||||||
|
workflow_media_status TEXT,
|
||||||
|
issue_type TEXT,
|
||||||
|
issue_resolved_at TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
||||||
@@ -409,6 +453,48 @@ def init_db() -> None:
|
|||||||
ON password_reset_tokens (expires_at)
|
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)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||||
|
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||||
|
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
|
||||||
|
ON portal_comments (item_id, created_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS user_activity (
|
CREATE TABLE IF NOT EXISTS user_activity (
|
||||||
@@ -491,6 +577,48 @@ def init_db() -> None:
|
|||||||
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||||
|
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||||
|
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -2879,6 +3007,535 @@ 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],
|
||||||
|
"related_item_id": row[9],
|
||||||
|
"status": row[10],
|
||||||
|
"workflow_request_status": row[11],
|
||||||
|
"workflow_media_status": row[12],
|
||||||
|
"issue_type": row[13],
|
||||||
|
"issue_resolved_at": row[14],
|
||||||
|
"metadata_json": row[15],
|
||||||
|
"priority": row[16],
|
||||||
|
"created_by_username": row[17],
|
||||||
|
"created_by_id": row[18],
|
||||||
|
"assignee_username": row[19],
|
||||||
|
"created_at": row[20],
|
||||||
|
"updated_at": row[21],
|
||||||
|
"last_activity_at": row[22],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
related_item_id: Optional[int] = None,
|
||||||
|
status: str = "new",
|
||||||
|
workflow_request_status: Optional[str] = None,
|
||||||
|
workflow_media_status: Optional[str] = None,
|
||||||
|
issue_type: Optional[str] = None,
|
||||||
|
issue_resolved_at: Optional[str] = None,
|
||||||
|
metadata_json: Optional[str] = None,
|
||||||
|
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,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
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,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
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,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
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,
|
||||||
|
workflow_request_status: Optional[str] = None,
|
||||||
|
workflow_media_status: Optional[str] = None,
|
||||||
|
source_system: Optional[str] = None,
|
||||||
|
source_request_id: Optional[int] = None,
|
||||||
|
related_item_id: Optional[int] = 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(workflow_request_status, str) and workflow_request_status.strip():
|
||||||
|
clauses.append("workflow_request_status = ?")
|
||||||
|
params.append(workflow_request_status.strip().lower())
|
||||||
|
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
|
||||||
|
clauses.append("workflow_media_status = ?")
|
||||||
|
params.append(workflow_media_status.strip().lower())
|
||||||
|
if isinstance(source_system, str) and source_system.strip():
|
||||||
|
clauses.append("source_system = ?")
|
||||||
|
params.append(source_system.strip().lower())
|
||||||
|
if isinstance(source_request_id, int):
|
||||||
|
clauses.append("source_request_id = ?")
|
||||||
|
params.append(source_request_id)
|
||||||
|
if isinstance(related_item_id, int):
|
||||||
|
clauses.append("related_item_id = ?")
|
||||||
|
params.append(related_item_id)
|
||||||
|
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,
|
||||||
|
related_item_id,
|
||||||
|
status,
|
||||||
|
workflow_request_status,
|
||||||
|
workflow_media_status,
|
||||||
|
issue_type,
|
||||||
|
issue_resolved_at,
|
||||||
|
metadata_json,
|
||||||
|
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,
|
||||||
|
workflow_request_status: Optional[str] = None,
|
||||||
|
workflow_media_status: Optional[str] = None,
|
||||||
|
source_system: Optional[str] = None,
|
||||||
|
source_request_id: Optional[int] = None,
|
||||||
|
related_item_id: Optional[int] = 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(workflow_request_status, str) and workflow_request_status.strip():
|
||||||
|
clauses.append("workflow_request_status = ?")
|
||||||
|
params.append(workflow_request_status.strip().lower())
|
||||||
|
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
|
||||||
|
clauses.append("workflow_media_status = ?")
|
||||||
|
params.append(workflow_media_status.strip().lower())
|
||||||
|
if isinstance(source_system, str) and source_system.strip():
|
||||||
|
clauses.append("source_system = ?")
|
||||||
|
params.append(source_system.strip().lower())
|
||||||
|
if isinstance(source_request_id, int):
|
||||||
|
clauses.append("source_request_id = ?")
|
||||||
|
params.append(source_request_id)
|
||||||
|
if isinstance(related_item_id, int):
|
||||||
|
clauses.append("related_item_id = ?")
|
||||||
|
params.append(related_item_id)
|
||||||
|
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,
|
||||||
|
related_item_id: Any = _DB_UNSET,
|
||||||
|
workflow_request_status: Any = _DB_UNSET,
|
||||||
|
workflow_media_status: Any = _DB_UNSET,
|
||||||
|
issue_type: Any = _DB_UNSET,
|
||||||
|
issue_resolved_at: Any = _DB_UNSET,
|
||||||
|
metadata_json: 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 related_item_id is not _DB_UNSET:
|
||||||
|
updates.append("related_item_id = ?")
|
||||||
|
params.append(related_item_id)
|
||||||
|
if workflow_request_status is not _DB_UNSET:
|
||||||
|
updates.append("workflow_request_status = ?")
|
||||||
|
params.append(workflow_request_status)
|
||||||
|
if workflow_media_status is not _DB_UNSET:
|
||||||
|
updates.append("workflow_media_status = ?")
|
||||||
|
params.append(workflow_media_status)
|
||||||
|
if issue_type is not _DB_UNSET:
|
||||||
|
updates.append("issue_type = ?")
|
||||||
|
params.append(issue_type)
|
||||||
|
if issue_resolved_at is not _DB_UNSET:
|
||||||
|
updates.append("issue_resolved_at = ?")
|
||||||
|
params.append(issue_resolved_at)
|
||||||
|
if metadata_json is not _DB_UNSET:
|
||||||
|
updates.append("metadata_json = ?")
|
||||||
|
params.append(metadata_json)
|
||||||
|
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()
|
||||||
|
request_workflow_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(workflow_request_status, ''),
|
||||||
|
COALESCE(workflow_media_status, ''),
|
||||||
|
COUNT(*)
|
||||||
|
FROM portal_items
|
||||||
|
WHERE kind = 'request'
|
||||||
|
GROUP BY workflow_request_status, workflow_media_status
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
|
||||||
|
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
|
||||||
|
request_workflow: Dict[str, Dict[str, int]] = {}
|
||||||
|
for row in request_workflow_rows:
|
||||||
|
request_status = str(row[0] or "")
|
||||||
|
media_status = str(row[1] or "")
|
||||||
|
request_workflow.setdefault(request_status, {})
|
||||||
|
request_workflow[request_status][media_status] = int(row[2] or 0)
|
||||||
|
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},
|
||||||
|
"request_workflow": request_workflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_integrity_check() -> str:
|
def run_integrity_check() -> str:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||||
@@ -2922,6 +3579,8 @@ def get_database_diagnostics() -> Dict[str, Any]:
|
|||||||
"snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0),
|
"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),
|
"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),
|
"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)
|
row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .routers.status import router as status_router
|
|||||||
from .routers.feedback import router as feedback_router
|
from .routers.feedback import router as feedback_router
|
||||||
from .routers.site import router as site_router
|
from .routers.site import router as site_router
|
||||||
from .routers.events import router as events_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 .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .logging_config import (
|
from .logging_config import (
|
||||||
bind_request_id,
|
bind_request_id,
|
||||||
@@ -228,3 +229,4 @@ app.include_router(status_router)
|
|||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
|
app.include_router(portal_router)
|
||||||
|
|||||||
1056
backend/app/routers/portal.py
Normal file
1056
backend/app/routers/portal.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -421,6 +421,34 @@ def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Option
|
|||||||
return tmdb_id, media_type
|
return tmdb_id, media_type
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_media_type(value: Any) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in {"movie", "tv"}:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_seasons(value: Any) -> list[int]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise HTTPException(status_code=400, detail="seasons must be an array of positive integers")
|
||||||
|
normalized: list[int] = []
|
||||||
|
for raw in value:
|
||||||
|
try:
|
||||||
|
season = int(raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="seasons must contain only positive integers"
|
||||||
|
) from exc
|
||||||
|
if season <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="seasons must contain only positive integers")
|
||||||
|
normalized.append(season)
|
||||||
|
return sorted(set(normalized))
|
||||||
|
|
||||||
|
|
||||||
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
|
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
|
||||||
poster_path, backdrop_path = _extract_artwork_paths(payload)
|
poster_path, backdrop_path = _extract_artwork_paths(payload)
|
||||||
tmdb_id, media_type = _extract_tmdb_lookup(payload)
|
tmdb_id, media_type = _extract_tmdb_lookup(payload)
|
||||||
@@ -1864,12 +1892,135 @@ async def search_requests(
|
|||||||
"statusLabel": status_label,
|
"statusLabel": status_label,
|
||||||
"requestedBy": requested_by,
|
"requestedBy": requested_by,
|
||||||
"accessible": accessible,
|
"accessible": accessible,
|
||||||
|
"posterPath": item.get("posterPath") or item.get("poster_path"),
|
||||||
|
"backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"results": results}
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_request(
|
||||||
|
payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
|
if not client.configured():
|
||||||
|
raise HTTPException(status_code=400, detail="Seerr not configured")
|
||||||
|
|
||||||
|
media_type = _normalize_media_type(
|
||||||
|
payload.get("mediaType") or payload.get("type") or payload.get("media_type")
|
||||||
|
)
|
||||||
|
if media_type is None:
|
||||||
|
raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'")
|
||||||
|
|
||||||
|
raw_tmdb_id = payload.get("tmdbId")
|
||||||
|
if raw_tmdb_id is None:
|
||||||
|
raw_tmdb_id = payload.get("mediaId")
|
||||||
|
if raw_tmdb_id is None:
|
||||||
|
raw_tmdb_id = payload.get("id")
|
||||||
|
try:
|
||||||
|
tmdb_id = int(raw_tmdb_id)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc
|
||||||
|
if tmdb_id <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
|
||||||
|
|
||||||
|
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
|
||||||
|
raw_is_4k = payload.get("is4k")
|
||||||
|
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
|
||||||
|
raise HTTPException(status_code=400, detail="is4k must be true or false")
|
||||||
|
is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id))
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
|
||||||
|
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
|
||||||
|
|
||||||
|
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
|
||||||
|
requests_list = media_info.get("requests")
|
||||||
|
existing_request: Optional[Dict[str, Any]] = None
|
||||||
|
if isinstance(requests_list, list) and requests_list:
|
||||||
|
first_request = requests_list[0]
|
||||||
|
if isinstance(first_request, dict):
|
||||||
|
existing_request = first_request
|
||||||
|
|
||||||
|
title = details.get("title") or details.get("name")
|
||||||
|
year: Optional[int] = None
|
||||||
|
date_value = details.get("releaseDate") or details.get("firstAirDate")
|
||||||
|
if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit():
|
||||||
|
year = int(date_value[:4])
|
||||||
|
|
||||||
|
if isinstance(existing_request, dict):
|
||||||
|
existing_request_id = _quality_profile_id(existing_request.get("id"))
|
||||||
|
existing_status = existing_request.get("status")
|
||||||
|
if existing_request_id is not None:
|
||||||
|
request_payload = await _get_request_details(client, existing_request_id)
|
||||||
|
if isinstance(request_payload, dict):
|
||||||
|
parsed_payload = _parse_request_payload(request_payload)
|
||||||
|
upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload))
|
||||||
|
_cache_set(f"request:{existing_request_id}", request_payload)
|
||||||
|
title = parsed_payload.get("title") or title
|
||||||
|
year = parsed_payload.get("year") or year
|
||||||
|
return {
|
||||||
|
"status": "exists",
|
||||||
|
"requestId": existing_request_id,
|
||||||
|
"type": media_type,
|
||||||
|
"tmdbId": tmdb_id,
|
||||||
|
"title": title,
|
||||||
|
"year": year,
|
||||||
|
"statusCode": existing_status,
|
||||||
|
"statusLabel": _status_label(existing_status),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await client.create_request(
|
||||||
|
media_type=media_type,
|
||||||
|
media_id=tmdb_id,
|
||||||
|
seasons=seasons if media_type == "tv" else None,
|
||||||
|
is_4k=is_4k,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
|
||||||
|
|
||||||
|
if not isinstance(created, dict):
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
|
||||||
|
|
||||||
|
parsed = _parse_request_payload(created)
|
||||||
|
request_id = _quality_profile_id(parsed.get("request_id"))
|
||||||
|
status_code = parsed.get("status")
|
||||||
|
title = parsed.get("title") or title
|
||||||
|
year = parsed.get("year") or year
|
||||||
|
|
||||||
|
if request_id is not None:
|
||||||
|
upsert_request_cache(**_build_request_cache_record(parsed, created))
|
||||||
|
_cache_set(f"request:{request_id}", created)
|
||||||
|
_recent_cache["updated_at"] = None
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
str(request_id),
|
||||||
|
"request_created",
|
||||||
|
"Create request",
|
||||||
|
"ok",
|
||||||
|
f"{media_type} request created from discovery by {user.get('username')}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "created",
|
||||||
|
"requestId": request_id,
|
||||||
|
"type": media_type,
|
||||||
|
"tmdbId": tmdb_id,
|
||||||
|
"title": title,
|
||||||
|
"year": year,
|
||||||
|
"statusCode": status_code,
|
||||||
|
"statusLabel": _status_label(status_code),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/ai/triage", response_model=TriageResult)
|
@router.post("/{request_id}/ai/triage", response_model=TriageResult)
|
||||||
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
|
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from email.policy import SMTP as SMTP_POLICY
|
|||||||
from email.utils import formataddr, formatdate, make_msgid
|
from email.utils import formataddr, formatdate, make_msgid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from ..build_info import BUILD_NUMBER
|
from ..build_info import BUILD_NUMBER
|
||||||
from ..config import settings as env_settings
|
from ..config import settings as env_settings
|
||||||
@@ -512,6 +513,40 @@ def _build_default_base_url() -> str:
|
|||||||
return f"http://localhost:{port}"
|
return f"http://localhost:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_mail_hostname(*, from_address: str) -> str:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
candidates = (
|
||||||
|
runtime.magent_application_url,
|
||||||
|
runtime.magent_proxy_base_url,
|
||||||
|
env_settings.cors_allow_origin,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
normalized = _normalize_display_text(candidate)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
|
||||||
|
hostname = _normalize_display_text(parsed.hostname)
|
||||||
|
if hostname and "." in hostname:
|
||||||
|
return hostname
|
||||||
|
domain = _normalize_display_text(from_address.split("@", 1)[1] if "@" in from_address else None)
|
||||||
|
if domain and "." in domain:
|
||||||
|
return domain
|
||||||
|
return "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_transactional_headers(
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
from_name: str,
|
||||||
|
from_address: str,
|
||||||
|
) -> None:
|
||||||
|
message["Reply-To"] = formataddr((from_name, from_address))
|
||||||
|
message["Organization"] = env_settings.app_name
|
||||||
|
message["X-Mailer"] = f"{env_settings.app_name}/{BUILD_NUMBER}"
|
||||||
|
message["Auto-Submitted"] = "auto-generated"
|
||||||
|
message["X-Auto-Response-Suppress"] = "All"
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_full_html_document(value: str) -> bool:
|
def _looks_like_full_html_document(value: str) -> bool:
|
||||||
probe = value.lstrip().lower()
|
probe = value.lstrip().lower()
|
||||||
return probe.startswith("<!doctype") or probe.startswith("<html") or "<body" in probe[:300]
|
return probe.startswith("<!doctype") or probe.startswith("<html") or "<body" in probe[:300]
|
||||||
@@ -918,8 +953,10 @@ def smtp_email_delivery_warning() -> Optional[str]:
|
|||||||
if host.endswith(".mail.protection.outlook.com") and not (username and password):
|
if host.endswith(".mail.protection.outlook.com") and not (username and password):
|
||||||
return (
|
return (
|
||||||
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
|
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
|
||||||
"confirm mailbox delivery. For reliable delivery, use smtp.office365.com:587 with "
|
"confirm mailbox delivery, and suspicious messages can still be filtered. For reliable "
|
||||||
"SMTP credentials or configure a verified Exchange relay connector."
|
"delivery, use smtp.office365.com:587 with SMTP credentials or configure a verified "
|
||||||
|
"Exchange relay connector and make sure SPF, DKIM, and DMARC are healthy for the "
|
||||||
|
"sender domain."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -986,8 +1023,9 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
delivery_warning = smtp_email_delivery_warning()
|
delivery_warning = smtp_email_delivery_warning()
|
||||||
if not host or not from_address:
|
if not host or not from_address:
|
||||||
raise RuntimeError("SMTP email settings are incomplete.")
|
raise RuntimeError("SMTP email settings are incomplete.")
|
||||||
|
local_hostname = _derive_mail_hostname(from_address=from_address)
|
||||||
logger.info(
|
logger.info(
|
||||||
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s",
|
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
|
||||||
recipient_email,
|
recipient_email,
|
||||||
from_address,
|
from_address,
|
||||||
host,
|
host,
|
||||||
@@ -996,6 +1034,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
use_ssl,
|
use_ssl,
|
||||||
bool(username and password),
|
bool(username and password),
|
||||||
subject,
|
subject,
|
||||||
|
local_hostname,
|
||||||
)
|
)
|
||||||
if delivery_warning:
|
if delivery_warning:
|
||||||
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
||||||
@@ -1009,6 +1048,11 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
|
message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
|
||||||
else:
|
else:
|
||||||
message["Message-ID"] = make_msgid()
|
message["Message-ID"] = make_msgid()
|
||||||
|
_add_transactional_headers(
|
||||||
|
message,
|
||||||
|
from_name=from_name,
|
||||||
|
from_address=from_address,
|
||||||
|
)
|
||||||
message.set_content(body_text or _strip_html_for_text(body_html))
|
message.set_content(body_text or _strip_html_for_text(body_html))
|
||||||
if body_html.strip():
|
if body_html.strip():
|
||||||
message.add_alternative(body_html, subtype="html")
|
message.add_alternative(body_html, subtype="html")
|
||||||
@@ -1027,7 +1071,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
)
|
)
|
||||||
|
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
|
with smtplib.SMTP_SSL(host, port, timeout=20, local_hostname=local_hostname) as smtp:
|
||||||
logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
|
logger.debug("smtp ssl connection opened host=%s port=%s", host, port)
|
||||||
if username and password:
|
if username and password:
|
||||||
smtp.login(username, password)
|
smtp.login(username, password)
|
||||||
@@ -1047,7 +1091,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
)
|
)
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
with smtplib.SMTP(host, port, timeout=20) as smtp:
|
with smtplib.SMTP(host, port, timeout=20, local_hostname=local_hostname) as smtp:
|
||||||
logger.debug("smtp connection opened host=%s port=%s", host, port)
|
logger.debug("smtp connection opened host=%s port=%s", host, port)
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
if use_tls:
|
if use_tls:
|
||||||
@@ -1121,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]:
|
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
|
||||||
ready, detail = smtp_email_config_ready()
|
ready, detail = smtp_email_config_ready()
|
||||||
if not ready:
|
if not ready:
|
||||||
|
|||||||
276
backend/app/services/notifications.py
Normal file
276
backend/app/services/notifications.py
Normal file
@@ -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 = (
|
||||||
|
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
||||||
|
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
||||||
|
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
||||||
|
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
||||||
|
"</table>"
|
||||||
|
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
deliveries: list[Dict[str, Any]] = []
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
result = await send_generic_email(
|
||||||
|
recipient_email=recipient,
|
||||||
|
subject=title,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
)
|
||||||
|
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
||||||
|
except Exception as exc:
|
||||||
|
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
||||||
|
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
||||||
|
if successful:
|
||||||
|
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
||||||
|
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_portal_notification(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
item: Dict[str, Any],
|
||||||
|
actor_username: str,
|
||||||
|
actor_role: str,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
if not runtime.magent_notify_enabled:
|
||||||
|
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
||||||
|
|
||||||
|
item_id = int(item.get("id") or 0)
|
||||||
|
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
||||||
|
message_lines = [
|
||||||
|
f"Event: {event_type}",
|
||||||
|
f"Actor: {actor_username} ({actor_role})",
|
||||||
|
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
||||||
|
]
|
||||||
|
if note:
|
||||||
|
message_lines.append(f"Note: {note}")
|
||||||
|
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
||||||
|
message = "\n".join(message_lines)
|
||||||
|
payload = {
|
||||||
|
"type": "portal.notification",
|
||||||
|
"event": event_type,
|
||||||
|
"item_id": item_id,
|
||||||
|
"item_url": _portal_item_url(item_id),
|
||||||
|
"kind": _clean_text(item.get("kind")),
|
||||||
|
"status": _clean_text(item.get("status")),
|
||||||
|
"priority": _clean_text(item.get("priority")),
|
||||||
|
"requested_by": _clean_text(item.get("created_by_username")),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"actor_role": actor_role,
|
||||||
|
"note": note or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
channels: Dict[str, Dict[str, Any]] = {}
|
||||||
|
if runtime.magent_notify_discord_enabled:
|
||||||
|
try:
|
||||||
|
channels["discord"] = await _send_discord(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["discord"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_telegram_enabled:
|
||||||
|
try:
|
||||||
|
channels["telegram"] = await _send_telegram(title, message)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_webhook_enabled:
|
||||||
|
try:
|
||||||
|
channels["webhook"] = await _send_webhook(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_push_enabled:
|
||||||
|
try:
|
||||||
|
channels["push"] = await _send_push(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["push"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_email_enabled:
|
||||||
|
try:
|
||||||
|
channels["email"] = await _send_email(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["email"] = {"status": "error", "detail": str(exc)}
|
||||||
|
|
||||||
|
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
||||||
|
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
||||||
|
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
||||||
|
logger.info(
|
||||||
|
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
||||||
|
event_type,
|
||||||
|
item_id,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
)
|
||||||
|
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
||||||
|
if not channels:
|
||||||
|
overall = "skipped"
|
||||||
|
return {"status": overall, "channels": channels}
|
||||||
@@ -9,6 +9,7 @@ from starlette.requests import Request
|
|||||||
from backend.app import db
|
from backend.app import db
|
||||||
from backend.app.config import settings
|
from backend.app.config import settings
|
||||||
from backend.app.routers import auth as auth_router
|
from backend.app.routers import auth as auth_router
|
||||||
|
from backend.app.routers import portal as portal_router
|
||||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||||
from backend.app.services import password_reset
|
from backend.app.services import password_reset
|
||||||
|
|
||||||
@@ -143,3 +144,58 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
|||||||
context.exception.detail,
|
context.exception.detail,
|
||||||
"recipient_email is required and must be a valid email address.",
|
"recipient_email is required and must be a valid email address.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
|
def test_legacy_request_status_maps_to_workflow(self) -> None:
|
||||||
|
item = {"kind": "request", "status": "in_progress"}
|
||||||
|
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
|
||||||
|
workflow = serialized.get("workflow") or {}
|
||||||
|
self.assertEqual(workflow.get("request_status"), "approved")
|
||||||
|
self.assertEqual(workflow.get("media_status"), "processing")
|
||||||
|
|
||||||
|
def test_invalid_pipeline_transition_is_rejected(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
portal_router._validate_pipeline_transition(
|
||||||
|
"approved",
|
||||||
|
"processing",
|
||||||
|
"pending",
|
||||||
|
"pending",
|
||||||
|
)
|
||||||
|
self.assertEqual(context.exception.status_code, 400)
|
||||||
|
|
||||||
|
def test_portal_workflow_filters(self) -> None:
|
||||||
|
db.create_portal_item(
|
||||||
|
kind="request",
|
||||||
|
title="Request A",
|
||||||
|
description="A",
|
||||||
|
created_by_username="alpha",
|
||||||
|
created_by_id=None,
|
||||||
|
status="processing",
|
||||||
|
workflow_request_status="approved",
|
||||||
|
workflow_media_status="processing",
|
||||||
|
)
|
||||||
|
db.create_portal_item(
|
||||||
|
kind="request",
|
||||||
|
title="Request B",
|
||||||
|
description="B",
|
||||||
|
created_by_username="bravo",
|
||||||
|
created_by_id=None,
|
||||||
|
status="pending",
|
||||||
|
workflow_request_status="pending",
|
||||||
|
workflow_media_status="pending",
|
||||||
|
)
|
||||||
|
processing = db.list_portal_items(
|
||||||
|
kind="request",
|
||||||
|
workflow_request_status="approved",
|
||||||
|
workflow_media_status="processing",
|
||||||
|
limit=10,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
pending_count = db.count_portal_items(
|
||||||
|
kind="request",
|
||||||
|
workflow_request_status="pending",
|
||||||
|
workflow_media_status="pending",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(processing), 1)
|
||||||
|
self.assertEqual(pending_count, 1)
|
||||||
|
|||||||
@@ -6558,3 +6558,329 @@ textarea {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Portal */
|
||||||
|
.portal-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-overview-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-overview-card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-overview-card strong {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-create-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 140px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-media {
|
||||||
|
width: 56px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-media img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-main p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-field-span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 180px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-toolbar label span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search-filter input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mine-toggle {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-list-panel,
|
||||||
|
.portal-detail-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 900px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-row {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-row.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-row-title {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-row p {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-row-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-comments-block {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-comment-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-comment-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-comment-card header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-comment-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-comment-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.portal-overview-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-toolbar {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search-filter,
|
||||||
|
.portal-mine-toggle {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mine-toggle {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-item-list {
|
||||||
|
max-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-item {
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-actions {
|
||||||
|
grid-column: span 2;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.portal-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-field-span-2 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-overview-grid,
|
||||||
|
.portal-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search-filter,
|
||||||
|
.portal-mine-toggle {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-media {
|
||||||
|
width: 72px;
|
||||||
|
height: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-actions {
|
||||||
|
grid-column: span 1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1141
frontend/app/portal/page.tsx
Normal file
1141
frontend/app/portal/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@ export default function HeaderActions() {
|
|||||||
<div className="header-actions-right">
|
<div className="header-actions-right">
|
||||||
<a href="/">Requests</a>
|
<a href="/">Requests</a>
|
||||||
<a href="/profile/invites">Invites</a>
|
<a href="/profile/invites">Invites</a>
|
||||||
|
<a href="/portal">Portal</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,2 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0403261321",
|
"version": "0803262216",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0403261321",
|
"version": "0803262216",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0403261321",
|
"version": "0803262216",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user