Process 1 build 0803262038

This commit is contained in:
2026-03-08 20:40:18 +13:00
parent 4e2b902760
commit 3989e90a9a
8 changed files with 952 additions and 42 deletions

View File

@@ -1 +1 @@
0703261729
0803262038

File diff suppressed because one or more lines are too long

View File

@@ -362,7 +362,13 @@ def init_db() -> None:
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,
@@ -465,6 +471,24 @@ def init_db() -> None:
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
@@ -553,6 +577,48 @@ def init_db() -> None:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
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:
conn.execute(
"""
@@ -2952,14 +3018,20 @@ def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
"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],
"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],
}
@@ -2987,7 +3059,13 @@ def create_portal_item(
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]:
@@ -3004,7 +3082,13 @@ def create_portal_item(
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,
@@ -3013,7 +3097,7 @@ def create_portal_item(
updated_at,
last_activity_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
kind,
@@ -3024,7 +3108,13 @@ def create_portal_item(
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,
@@ -3063,7 +3153,13 @@ def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
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,
@@ -3083,6 +3179,11 @@ 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,
@@ -3096,6 +3197,21 @@ def list_portal_items(
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())
@@ -3120,7 +3236,13 @@ def list_portal_items(
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,
@@ -3142,6 +3264,11 @@ 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:
@@ -3153,6 +3280,21 @@ def count_portal_items(
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())
@@ -3182,6 +3324,12 @@ def update_portal_item(
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] = []
@@ -3215,6 +3363,24 @@ def update_portal_item(
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()
@@ -3342,13 +3508,31 @@ def get_portal_overview() -> Dict[str, Any]:
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,
}

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -23,6 +24,7 @@ logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
PORTAL_STATUSES = {
# Existing generic statuses
"new",
"triaging",
"planned",
@@ -31,9 +33,66 @@ PORTAL_STATUSES = {
"done",
"declined",
"closed",
# Seerr-style request pipeline statuses
"pending",
"approved",
"processing",
"partially_available",
"available",
"failed",
}
PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"}
PORTAL_MEDIA_TYPES = {"movie", "tv"}
PORTAL_REQUEST_STATUSES = {"pending", "approved", "declined"}
PORTAL_MEDIA_STATUSES = {
"unknown",
"pending",
"processing",
"partially_available",
"available",
"failed",
}
PORTAL_ISSUE_TYPES = {
"general",
"playback",
"subtitle",
"quality",
"metadata",
"missing_content",
"other",
}
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
"pending": {"pending", "approved", "declined"},
"approved": {"approved", "declined"},
"declined": {"declined", "pending", "approved"},
}
MEDIA_STATUS_TRANSITIONS: Dict[str, set[str]] = {
"unknown": {"unknown", "pending", "processing", "failed"},
"pending": {"pending", "processing", "partially_available", "available", "failed"},
"processing": {"processing", "partially_available", "available", "failed"},
"partially_available": {"partially_available", "processing", "available", "failed"},
"available": {"available", "processing"},
"failed": {"failed", "processing", "available"},
}
LEGACY_STATUS_TO_WORKFLOW: Dict[str, Tuple[str, str]] = {
"new": ("pending", "pending"),
"triaging": ("pending", "pending"),
"planned": ("approved", "pending"),
"in_progress": ("approved", "processing"),
"blocked": ("approved", "failed"),
"done": ("approved", "available"),
"closed": ("approved", "available"),
"pending": ("pending", "pending"),
"approved": ("approved", "pending"),
"declined": ("declined", "unknown"),
"processing": ("approved", "processing"),
"partially_available": ("approved", "partially_available"),
"available": ("approved", "available"),
"failed": ("approved", "failed"),
}
def _clean_text(value: Any) -> Optional[str]:
@@ -124,6 +183,154 @@ def _normalize_bool(value: Any, *, default: bool = False) -> bool:
raise HTTPException(status_code=400, detail="Boolean value expected")
def _workflow_to_item_status(request_status: str, media_status: str) -> str:
if request_status == "declined":
return "declined"
if request_status == "pending":
return "pending"
if media_status == "available":
return "available"
if media_status == "partially_available":
return "partially_available"
if media_status == "failed":
return "failed"
if media_status == "processing":
return "processing"
return "approved"
def _item_status_to_workflow(item: Dict[str, Any]) -> Tuple[str, str]:
request_status = _normalize_choice(
item.get("workflow_request_status"),
field="request_status",
allowed=PORTAL_REQUEST_STATUSES,
allow_empty=True,
)
media_status = _normalize_choice(
item.get("workflow_media_status"),
field="media_status",
allowed=PORTAL_MEDIA_STATUSES,
allow_empty=True,
)
if request_status and media_status:
return request_status, media_status
status = _clean_text(item.get("status"))
if status:
mapped = LEGACY_STATUS_TO_WORKFLOW.get(status.lower())
if mapped:
return mapped
return "pending", "pending"
def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
if request_status == "declined":
return "Declined"
if request_status == "pending":
return "Waiting for approval"
if media_status == "available":
return "Ready to watch"
if media_status == "partially_available":
return "Partially available"
if media_status == "processing":
return "Working on it"
if media_status == "failed":
return "Needs attention"
return "Approved"
def _normalize_request_pipeline(
request_status: Optional[str],
media_status: Optional[str],
*,
fallback_request_status: str = "pending",
fallback_media_status: str = "pending",
) -> Tuple[str, str]:
normalized_request = _normalize_choice(
request_status,
field="request_status",
allowed=PORTAL_REQUEST_STATUSES,
default=fallback_request_status,
)
normalized_media = _normalize_choice(
media_status,
field="media_status",
allowed=PORTAL_MEDIA_STATUSES,
default=fallback_media_status,
)
request_value = normalized_request or fallback_request_status
media_value = normalized_media or fallback_media_status
if request_value == "declined":
return request_value, "unknown"
if request_value == "pending":
if media_value not in {"pending", "unknown"}:
media_value = "pending"
return request_value, media_value
if media_value == "unknown":
media_value = "pending"
return request_value, media_value
def _validate_pipeline_transition(
current_request: str,
current_media: str,
requested_request: str,
requested_media: str,
) -> Tuple[str, str]:
allowed_request = REQUEST_STATUS_TRANSITIONS.get(current_request, {current_request})
if requested_request not in allowed_request:
allowed_text = ", ".join(sorted(allowed_request))
raise HTTPException(
status_code=400,
detail=(
f"Invalid request_status transition: {current_request} -> {requested_request}. "
f"Allowed: {allowed_text}"
),
)
normalized_request, normalized_media = _normalize_request_pipeline(
requested_request,
requested_media,
fallback_request_status=current_request,
fallback_media_status=current_media,
)
if normalized_request != "approved":
return normalized_request, normalized_media
if current_request != "approved":
allowed_media = PORTAL_MEDIA_STATUSES - {"unknown"}
else:
allowed_media = MEDIA_STATUS_TRANSITIONS.get(current_media, {current_media})
if normalized_media not in allowed_media:
allowed_text = ", ".join(sorted(allowed_media))
raise HTTPException(
status_code=400,
detail=(
f"Invalid media_status transition: {current_media} -> {normalized_media}. "
f"Allowed: {allowed_text}"
),
)
return normalized_request, normalized_media
def _ensure_item_exists(item_id: Optional[int], *, field: str = "related_item_id") -> None:
if item_id is None:
return
target = get_portal_item(item_id)
if not target:
raise HTTPException(status_code=400, detail=f"{field} references an unknown portal item")
def _sanitize_metadata_json(value: Any) -> Optional[str]:
text = _clean_text(value)
if text is None:
return None
if len(text) > 50000:
raise HTTPException(status_code=400, detail="metadata_json is too long (max 50000 characters)")
return text
def _is_admin(user: Dict[str, Any]) -> bool:
return str(user.get("role") or "").strip().lower() == "admin"
@@ -140,7 +347,24 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"can_edit": is_admin or is_owner,
"can_comment": True,
"can_moderate": is_admin,
"can_raise_issue": str(item.get("kind") or "") == "request",
}
kind = str(item.get("kind") or "").strip().lower()
if kind == "request":
request_status, media_status = _item_status_to_workflow(item)
serialized["workflow"] = {
"request_status": request_status,
"media_status": media_status,
"stage_label": _stage_label_for_workflow(request_status, media_status),
"is_terminal": media_status in {"available", "failed"} or request_status == "declined",
}
elif kind == "issue":
serialized["issue"] = {
"issue_type": _clean_text(item.get("issue_type")) or "general",
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
"resolved_at": _clean_text(item.get("issue_resolved_at")),
}
return serialized
@@ -186,6 +410,11 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
async def portal_list_items(
kind: Optional[str] = None,
status: Optional[str] = None,
request_status: Optional[str] = None,
media_status: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
mine: bool = False,
search: Optional[str] = None,
limit: int = Query(default=50, ge=1, le=200),
@@ -198,10 +427,24 @@ async def portal_list_items(
status_value = _normalize_choice(
status, field="status", allowed=PORTAL_STATUSES, allow_empty=True
)
request_status_value = _normalize_choice(
request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True
)
media_status_value = _normalize_choice(
media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True
)
source_system_value = _clean_text(source_system)
if source_system_value:
source_system_value = source_system_value.lower()
mine_username = str(current_user.get("username") or "") if mine else None
items = list_portal_items(
kind=kind_value,
status=status_value,
workflow_request_status=request_status_value,
workflow_media_status=media_status_value,
source_system=source_system_value,
source_request_id=source_request_id,
related_item_id=related_item_id,
mine_username=mine_username,
search=_clean_text(search),
limit=limit,
@@ -210,6 +453,11 @@ async def portal_list_items(
total = count_portal_items(
kind=kind_value,
status=status_value,
workflow_request_status=request_status_value,
workflow_media_status=media_status_value,
source_system=source_system_value,
source_request_id=source_request_id,
related_item_id=related_item_id,
mine_username=mine_username,
search=_clean_text(search),
)
@@ -222,6 +470,59 @@ async def portal_list_items(
"filters": {
"kind": kind_value,
"status": status_value,
"request_status": request_status_value,
"media_status": media_status_value,
"source_system": source_system_value,
"source_request_id": source_request_id,
"related_item_id": related_item_id,
"mine": mine,
"search": _clean_text(search),
},
}
@router.get("/requests")
async def portal_list_requests(
request_status: Optional[str] = None,
media_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]:
mine_username = str(current_user.get("username") or "") if mine else None
request_status_value = _normalize_choice(
request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True
)
media_status_value = _normalize_choice(
media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True
)
items = list_portal_items(
kind="request",
workflow_request_status=request_status_value,
workflow_media_status=media_status_value,
mine_username=mine_username,
search=_clean_text(search),
limit=limit,
offset=offset,
)
total = count_portal_items(
kind="request",
workflow_request_status=request_status_value,
workflow_media_status=media_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": {
"request_status": request_status_value,
"media_status": media_status_value,
"mine": mine,
"search": _clean_text(search),
},
@@ -251,17 +552,45 @@ async def portal_create_item(
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
if source_system:
source_system = source_system.lower()
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",
)
related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id")
_ensure_item_exists(related_item_id)
workflow_request_status: Optional[str] = None
workflow_media_status: Optional[str] = None
issue_type: Optional[str] = None
issue_resolved_at: Optional[str] = None
status: Optional[str] = None
if kind == "request":
workflow_request_status, workflow_media_status = _normalize_request_pipeline(
payload.get("request_status"),
payload.get("media_status"),
fallback_request_status="pending",
fallback_media_status="pending",
)
status = _workflow_to_item_status(workflow_request_status, workflow_media_status)
else:
status = _normalize_choice(
payload.get("status") if is_admin else None,
field="status",
allowed=PORTAL_STATUSES,
default="new",
)
if kind == "issue":
issue_type = _normalize_choice(
payload.get("issue_type"),
field="issue_type",
allowed=PORTAL_ISSUE_TYPES,
default="general",
)
if related_item_id is not None and not source_system:
source_system = "portal_request"
source_request_id = related_item_id
priority = _normalize_choice(
payload.get("priority"),
field="priority",
@@ -269,6 +598,7 @@ async def portal_create_item(
default="normal",
)
assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None
metadata_json = _sanitize_metadata_json(payload.get("metadata_json")) if is_admin else None
created = create_portal_item(
kind=kind or "request",
@@ -281,7 +611,13 @@ async def portal_create_item(
external_ref=external_ref,
source_system=source_system,
source_request_id=source_request_id,
related_item_id=related_item_id,
status=status or "new",
workflow_request_status=workflow_request_status,
workflow_media_status=workflow_media_status,
issue_type=issue_type,
issue_resolved_at=issue_resolved_at,
metadata_json=metadata_json,
priority=priority or "normal",
assignee_username=assignee_username,
)
@@ -307,6 +643,174 @@ async def portal_create_item(
}
@router.post("/requests/{item_id}/issues")
async def portal_create_issue_for_request(
item_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
request_item = get_portal_item(item_id)
if not request_item:
raise HTTPException(status_code=404, detail="Portal request not found")
if str(request_item.get("kind") or "").lower() != "request":
raise HTTPException(status_code=400, detail="Only request items can have linked issues")
title = _require_text(payload.get("title"), "title", max_length=220)
description = _require_text(payload.get("description"), "description", max_length=10000)
issue_type = _normalize_choice(
payload.get("issue_type"),
field="issue_type",
allowed=PORTAL_ISSUE_TYPES,
default="general",
)
status = _normalize_choice(
payload.get("status"),
field="status",
allowed=PORTAL_STATUSES,
default="new",
)
priority = _normalize_choice(
payload.get("priority"),
field="priority",
allowed=PORTAL_PRIORITIES,
default="normal",
)
created = create_portal_item(
kind="issue",
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=request_item.get("media_type"),
year=request_item.get("year"),
external_ref=_clean_text(payload.get("external_ref")),
source_system="portal_request",
source_request_id=item_id,
related_item_id=item_id,
status=status or "new",
issue_type=issue_type,
priority=priority or "normal",
assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
)
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(current_user))
await _notify(
event_type="portal_issue_created",
item=created,
user=current_user,
note=f"linked_request_id={item_id}",
)
return {
"item": _serialize_item(created, current_user),
"comments": comments,
"linked_request_id": item_id,
}
@router.get("/requests/{item_id}/issues")
async def portal_list_request_issues(
item_id: int,
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]:
request_item = get_portal_item(item_id)
if not request_item:
raise HTTPException(status_code=404, detail="Portal request not found")
if str(request_item.get("kind") or "").lower() != "request":
raise HTTPException(status_code=400, detail="Only request items can have linked issues")
items = list_portal_items(
kind="issue",
related_item_id=item_id,
limit=limit,
offset=offset,
)
total = count_portal_items(kind="issue", related_item_id=item_id)
return {
"items": [_serialize_item(item, current_user) for item in items],
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + len(items) < total,
"linked_request_id": item_id,
}
@router.patch("/requests/{item_id}/pipeline")
async def portal_update_request_pipeline(
item_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
if not _is_admin(current_user):
raise HTTPException(status_code=403, detail="Admin access required")
item = get_portal_item(item_id)
if not item:
raise HTTPException(status_code=404, detail="Portal request not found")
if str(item.get("kind") or "").lower() != "request":
raise HTTPException(status_code=400, detail="Only request items support pipeline updates")
current_request_status, current_media_status = _item_status_to_workflow(item)
requested_request = _normalize_choice(
payload.get("request_status"),
field="request_status",
allowed=PORTAL_REQUEST_STATUSES,
default=current_request_status,
) or current_request_status
requested_media = _normalize_choice(
payload.get("media_status"),
field="media_status",
allowed=PORTAL_MEDIA_STATUSES,
default=current_media_status,
) or current_media_status
next_request_status, next_media_status = _validate_pipeline_transition(
current_request_status,
current_media_status,
requested_request,
requested_media,
)
next_status = _workflow_to_item_status(next_request_status, next_media_status)
updated = update_portal_item(
item_id,
status=next_status,
workflow_request_status=next_request_status,
workflow_media_status=next_media_status,
)
if not updated:
raise HTTPException(status_code=404, detail="Portal request not found")
comment_text = _clean_text(payload.get("comment"))
if comment_text:
add_portal_comment(
item_id,
author_username=str(current_user.get("username") or "unknown"),
author_role=str(current_user.get("role") or "admin"),
message=comment_text,
is_internal=_normalize_bool(payload.get("is_internal"), default=False),
)
await _notify(
event_type="portal_request_pipeline_updated",
item=updated,
user=current_user,
note=f"{current_request_status}/{current_media_status} -> {next_request_status}/{next_media_status}",
)
comments = list_portal_comments(item_id, include_internal=True)
return {
"item": _serialize_item(updated, current_user),
"comments": comments,
}
@router.get("/items/{item_id}")
async def portal_get_item(
item_id: int,
@@ -343,6 +847,12 @@ async def portal_update_item(
"assignee_username",
"source_system",
"source_request_id",
"related_item_id",
"request_status",
"media_status",
"issue_type",
"issue_resolved_at",
"metadata_json",
}
provided_fields = set(payload.keys())
unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields)
@@ -376,13 +886,7 @@ async def portal_update_item(
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",
)
kind = str(item.get("kind") or "").lower()
if "priority" in payload:
updates["priority"] = _normalize_choice(
payload.get("priority"),
@@ -393,11 +897,89 @@ async def portal_update_item(
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"))
source_system = _clean_text(payload.get("source_system"))
updates["source_system"] = source_system.lower() if source_system else None
if "source_request_id" in payload:
updates["source_request_id"] = _normalize_int(
payload.get("source_request_id"), "source_request_id"
)
if "related_item_id" in payload:
related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id")
_ensure_item_exists(related_item_id)
updates["related_item_id"] = related_item_id
if "metadata_json" in payload:
updates["metadata_json"] = _sanitize_metadata_json(payload.get("metadata_json"))
if kind == "request":
current_request_status, current_media_status = _item_status_to_workflow(item)
request_status_input = payload.get("request_status")
media_status_input = payload.get("media_status")
explicit_status = payload.get("status")
if explicit_status is not None and request_status_input is None and media_status_input is None:
explicit_status_normalized = _normalize_choice(
explicit_status,
field="status",
allowed=PORTAL_STATUSES,
default=item.get("status") or "pending",
)
request_status_input, media_status_input = LEGACY_STATUS_TO_WORKFLOW.get(
explicit_status_normalized or "pending",
(current_request_status, current_media_status),
)
if request_status_input is not None or media_status_input is not None:
requested_request = _normalize_choice(
request_status_input,
field="request_status",
allowed=PORTAL_REQUEST_STATUSES,
default=current_request_status,
) or current_request_status
requested_media = _normalize_choice(
media_status_input,
field="media_status",
allowed=PORTAL_MEDIA_STATUSES,
default=current_media_status,
) or current_media_status
next_request_status, next_media_status = _validate_pipeline_transition(
current_request_status,
current_media_status,
requested_request,
requested_media,
)
updates["workflow_request_status"] = next_request_status
updates["workflow_media_status"] = next_media_status
updates["status"] = _workflow_to_item_status(next_request_status, next_media_status)
elif "status" in payload:
updates["status"] = _normalize_choice(
payload.get("status"),
field="status",
allowed=PORTAL_STATUSES,
default=item.get("status") or "pending",
)
else:
if "status" in payload:
updates["status"] = _normalize_choice(
payload.get("status"),
field="status",
allowed=PORTAL_STATUSES,
default=item.get("status") or "new",
)
if kind == "issue":
if "issue_type" in payload:
updates["issue_type"] = _normalize_choice(
payload.get("issue_type"),
field="issue_type",
allowed=PORTAL_ISSUE_TYPES,
default=item.get("issue_type") or "general",
)
if "issue_resolved_at" in payload:
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
if "status" in payload:
next_status = str(updates.get("status") or item.get("status") or "").lower()
if next_status in {"done", "closed"}:
updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
updates.setdefault("issue_resolved_at", None)
if not updates:
comments = list_portal_comments(item_id, include_internal=is_admin)

View File

@@ -9,6 +9,7 @@ from starlette.requests import Request
from backend.app import db
from backend.app.config import settings
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.services import password_reset
@@ -143,3 +144,58 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
context.exception.detail,
"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)

View File

@@ -28,6 +28,18 @@ type PortalItem = {
updated_at: string
last_activity_at: string
permissions?: PortalPermissions
workflow?: {
request_status?: string
media_status?: string
stage_label?: string
is_terminal?: boolean
}
issue?: {
issue_type?: string
related_item_id?: number | null
is_resolved?: boolean
resolved_at?: string | null
}
}
type PortalComment = {
@@ -68,10 +80,31 @@ const STATUS_OPTIONS = [
{ value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' },
{ value: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'processing', label: 'Processing' },
{ value: 'partially_available', label: 'Partially available' },
{ value: 'available', label: 'Available' },
{ value: 'failed', label: 'Failed' },
{ value: 'declined', label: 'Declined' },
{ value: 'closed', label: 'Closed' },
] as const
const REQUEST_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'declined', label: 'Declined' },
] as const
const MEDIA_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'processing', label: 'Processing' },
{ value: 'partially_available', label: 'Partially available' },
{ value: 'available', label: 'Available' },
{ value: 'failed', label: 'Failed' },
{ value: 'unknown', label: 'Unknown' },
] as const
const PRIORITY_OPTIONS = [
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
@@ -135,6 +168,8 @@ export default function PortalPage() {
const [editYear, setEditYear] = useState('')
const [editExternalRef, setEditExternalRef] = useState('')
const [editStatus, setEditStatus] = useState('new')
const [editRequestStatus, setEditRequestStatus] = useState('pending')
const [editMediaStatus, setEditMediaStatus] = useState('pending')
const [editPriority, setEditPriority] = useState('normal')
const [editAssignee, setEditAssignee] = useState('')
@@ -312,6 +347,8 @@ export default function PortalPage() {
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
setEditExternalRef(selectedItem.external_ref ?? '')
setEditStatus(selectedItem.status ?? 'new')
setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
setEditPriority(selectedItem.priority ?? 'normal')
setEditAssignee(selectedItem.assignee_username ?? '')
}, [selectedItem])
@@ -382,7 +419,12 @@ export default function PortalPage() {
external_ref: editExternalRef || null,
}
if (selectedItem.permissions?.can_moderate) {
payload.status = editStatus
if (selectedItem.kind === 'request') {
payload.request_status = editRequestStatus
payload.media_status = editMediaStatus
} else {
payload.status = editStatus
}
payload.priority = editPriority
payload.assignee_username = editAssignee || null
}
@@ -661,7 +703,12 @@ export default function PortalPage() {
<p>{item.description}</p>
<div className="portal-item-row-meta">
<span>#{item.id}</span>
<span>Status: {item.status}</span>
<span>
Status:{' '}
{item.kind === 'request'
? item.workflow?.stage_label ?? item.status
: item.status}
</span>
<span>By: {item.created_by_username}</span>
<span>Updated: {formatDate(item.last_activity_at)}</span>
</div>
@@ -687,6 +734,16 @@ export default function PortalPage() {
<p className="lede">
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
</p>
{selectedItem.kind === 'request' && (
<p className="lede">
Pipeline:{' '}
<strong>
{selectedItem.workflow?.request_status ?? 'pending'} /{' '}
{selectedItem.workflow?.media_status ?? 'pending'}
</strong>{' '}
({selectedItem.workflow?.stage_label ?? 'Pending'})
</p>
)}
</div>
</div>
@@ -741,16 +798,47 @@ export default function PortalPage() {
</label>
{selectedItem.permissions?.can_moderate && (
<>
<label>
<span>Status</span>
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{selectedItem.kind === 'request' ? (
<>
<label>
<span>Request status</span>
<select
value={editRequestStatus}
onChange={(event) => setEditRequestStatus(event.target.value)}
>
{REQUEST_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Media status</span>
<select
value={editMediaStatus}
onChange={(event) => setEditMediaStatus(event.target.value)}
>
{MEDIA_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</>
) : (
<label>
<span>Status</span>
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)}
<label>
<span>Priority</span>
<select

View File

@@ -1,12 +1,12 @@
{
"name": "magent-frontend",
"version": "0703261729",
"version": "0803262038",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
"version": "0703261729",
"version": "0803262038",
"dependencies": {
"next": "16.1.6",
"react": "19.2.4",

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "0703261729",
"version": "0803262038",
"scripts": {
"dev": "next dev",
"build": "next build",