Process 1 build 0703261729

This commit is contained in:
2026-03-07 17:30:58 +13:00
parent 494b79ed26
commit 4e2b902760
13 changed files with 2328 additions and 6 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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:

View 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}