from __future__ import annotations import logging import re import time from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple import httpx from fastapi import APIRouter, Depends, HTTPException, Query from ..auth import get_current_user from ..clients.jellyfin import JellyfinClient from ..db import ( add_portal_item_activity, add_portal_comment, count_portal_items, create_portal_item, delete_portal_item, get_portal_item, get_portal_overview, list_portal_comments as _list_portal_comments, get_all_users, list_portal_item_activity, list_portal_items, update_portal_item, ) from ..services.issue_resolution import ( begin_issue_confirmation, issue_resolution_state, respond_to_issue_confirmation, ) from ..services.notifications import send_portal_notification from ..runtime import get_runtime_settings router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)]) logger = logging.getLogger(__name__) PORTAL_KINDS = {"request", "issue", "feature"} PORTAL_STATUSES = { # Existing generic statuses "new", "triaging", "planned", "in_progress", "blocked", "done", "declined", "closed", "awaiting_confirmation", # 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", "transcode", "service_unavailable", "broken_media", "wrong_content", "audio", "subtitle", "quality", "metadata", "missing_content", "other", } _MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None} _MEDIA_STATUS_CACHE_SECONDS = 15.0 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]: 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 _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" ISSUE_WORKFLOW_STAGES = ( ("reported", "Reported"), ("review", "Under review"), ("planned", "Fix planned"), ("repair", "Fix underway"), ("confirmation", "Confirm fix"), ("resolved", "Resolved"), ) ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = { "new": ( 0, "Issue received", "Your report has been logged and is waiting for the support team to review it.", "active", ), "triaging": ( 1, "Being investigated", "The support team is checking the report and identifying the right fix.", "active", ), "planned": ( 2, "Fix ready to begin", "The problem has been reviewed and the next action has been selected.", "active", ), "in_progress": ( 3, "Fix in progress", "Work is underway on the affected content or service.", "active", ), "blocked": ( 3, "Fix needs attention", "Work has paused because the support team needs another service, resource, or decision before continuing.", "attention", ), "awaiting_confirmation": ( 4, "Waiting for confirmation", "A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.", "active", ), "done": ( 5, "Issue resolved", "The reported problem has been fixed and the issue is complete.", "complete", ), "closed": ( 5, "Issue resolved", "The reported problem has been fixed and the issue is closed.", "complete", ), } def _issue_workflow_payload(status: Any) -> Dict[str, Any]: normalized_status = str(status or "new").strip().lower() stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get( normalized_status, ISSUE_STATUS_TO_STAGE["new"], ) steps = [] for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES): step_state = ( "complete" if index < stage_index or (index == stage_index and state == "complete") else "active" if index == stage_index else "waiting" ) if index == stage_index and state == "attention": step_state = "attention" steps.append({"key": key, "label": label, "state": step_state}) return { "current_step": stage_index + 1, "total_steps": len(ISSUE_WORKFLOW_STAGES), "stage": ISSUE_WORKFLOW_STAGES[stage_index][0], "stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1], "headline": headline, "message": message, "state": state, "steps": steps, } 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" 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 _public_media_status_payload( *, status: str, headline: str, message: str, latency_ms: Optional[int] = None, version: Optional[str] = None, restart_pending: Optional[bool] = None, active_streams: Optional[int] = None, transcoding_streams: Optional[int] = None, session_check_available: bool = False, ) -> Dict[str, Any]: return { "checked_at": datetime.now(timezone.utc).isoformat(), "status": status, "headline": headline, "message": message, "latency_ms": latency_ms, "server": { "version": version, "restart_pending": restart_pending, }, "activity": { "active_streams": active_streams, "transcoding_streams": transcoding_streams, "available": session_check_available, }, } def _public_text(value: Any) -> Any: if not isinstance(value, str): return value identities = {str(u.get(key) or '').strip() for u in get_all_users() for key in ('username', 'email')} identities.discard('') if identities: pattern = r'(? Dict[str, Any]: return { 'id': comment.get('id'), 'item_id': comment.get('item_id'), 'author_username': 'Support team' if comment.get('author_role') == 'admin' else 'Reporter', 'author_role': comment.get('author_role'), 'created_at': comment.get('created_at'), 'message': _public_text(comment.get('message')), 'is_internal': False, } def list_portal_comments(*args, **kwargs): comments = _list_portal_comments(*args, **kwargs) return comments if kwargs.get('include_internal') else [_public_comment(c) for c in comments if not c.get('is_internal')] 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, "can_delete": is_admin and str(item.get("kind") or "").lower() == "issue", "can_raise_issue": str(item.get("kind") or "") == "request", "can_confirm_resolution": ( str(item.get("kind") or "").lower() == "issue" and str(item.get("status") or "").lower() == "awaiting_confirmation" and (is_admin or is_owner) ), } 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": resolution = issue_resolution_state(item) 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")), "workflow": _issue_workflow_payload(item.get("status")), "confirmation": { "status": resolution.get("status"), "attempts_sent": int(resolution.get("attemptsSent") or 0), "maximum_attempts": int(resolution.get("maximumAttempts") or 0), "last_contact_at": resolution.get("lastContactAt"), "next_contact_at": resolution.get("nextContactAt"), "interval_value": resolution.get("intervalValue"), "interval_unit": resolution.get("intervalUnit"), "last_delivery_succeeded": resolution.get("lastDeliverySucceeded"), }, } if not is_admin: serialized = {key: value for key, value in serialized.items() if key in { 'id', 'kind', 'title', 'description', 'media_type', 'year', 'source_request_id', 'related_item_id', 'status', 'workflow_request_status', 'workflow_media_status', 'issue_type', 'issue_resolved_at', 'priority', 'created_at', 'updated_at', 'last_activity_at', 'permissions', 'workflow', 'issue', }} serialized['created_by_username'] = user.get('username') if is_owner else 'Another member' serialized['title'] = _public_text(serialized.get('title')) serialized['description'] = _public_text(serialized.get('description')) return serialized def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -> list[Dict[str, Any]]: activity = [ entry for entry in list_portal_item_activity(int(item["id"]), limit=300) if include_internal or entry.get("event_type") != "internal_note_added" ] if not any(entry.get("event_type") == "item_created" for entry in activity): activity.insert( 0, { "id": f"created-{item['id']}", "item_id": item["id"], "event_type": "item_created", "actor_username": item.get("created_by_username") or "unknown", "actor_role": "user", "message": ( "Issue raised and added to the support queue." if str(item.get("kind") or "").lower() == "issue" else "Portal item created." ), "metadata_json": None, "created_at": item.get("created_at"), }, ) if include_internal: return activity public_activity: list[Dict[str, Any]] = [] for entry in activity: public_entry = {key: value for key, value in entry.items() if key != "metadata_json"} actor_role = str(entry.get("actor_role") or "user").lower() public_entry["actor_username"] = ( "Magent" if actor_role == "system" else "Support team" if actor_role == "admin" else "Reporter" ) public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user" public_entry['message'] = _public_text(public_entry.get('message')) public_activity.append(public_entry) return public_activity def _record_activity( item_id: int, *, event_type: str, message: str, user: Dict[str, Any], ) -> None: add_portal_item_activity( item_id, event_type=event_type, actor_username=str(user.get("username") or "unknown"), actor_role=str(user.get("role") or "user"), message=message, ) 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("/issues/media-status") async def portal_media_status() -> Dict[str, Any]: """Return a short, privacy-safe Jellyfin health check for guided issue reporting.""" now = time.monotonic() cached_payload = _MEDIA_STATUS_CACHE.get("payload") if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0): return cached_payload runtime = get_runtime_settings() jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not jellyfin.configured(): payload = _public_media_status_payload( status="not_configured", headline="Media server status is unavailable", message="Magent cannot run a playback check right now. Your report can still be submitted.", ) _MEDIA_STATUS_CACHE.update( expires_at=now + _MEDIA_STATUS_CACHE_SECONDS, payload=payload, ) return payload started_at = time.perf_counter() try: system_info = await jellyfin.get_system_info() except (httpx.HTTPError, RuntimeError, ValueError): latency_ms = round((time.perf_counter() - started_at) * 1000) payload = _public_media_status_payload( status="down", headline="The media server is not responding", message=( "This looks broader than one title. The report will include the failed server check " "so an administrator can investigate the service first." ), latency_ms=latency_ms, ) _MEDIA_STATUS_CACHE.update( expires_at=now + _MEDIA_STATUS_CACHE_SECONDS, payload=payload, ) return payload except Exception: logger.exception("guided issue Jellyfin system check failed") latency_ms = round((time.perf_counter() - started_at) * 1000) payload = _public_media_status_payload( status="down", headline="The media server check failed", message="Your report can still be submitted and will include this failed service check.", latency_ms=latency_ms, ) _MEDIA_STATUS_CACHE.update( expires_at=now + _MEDIA_STATUS_CACHE_SECONDS, payload=payload, ) return payload latency_ms = round((time.perf_counter() - started_at) * 1000) info = system_info if isinstance(system_info, dict) else {} version_value = info.get("Version") version = str(version_value).strip() if version_value is not None else None restart_pending = bool(info.get("HasPendingRestart")) session_check_available = False active_streams: Optional[int] = None transcoding_streams: Optional[int] = None try: sessions = await jellyfin.get_sessions() if isinstance(sessions, list): session_check_available = True active_streams = sum( 1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem") ) transcoding_streams = sum( 1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem") and session.get("TranscodingInfo") ) except Exception: logger.warning("guided issue Jellyfin session check unavailable", exc_info=True) if restart_pending: status = "degraded" headline = "Media server is online but needs attention" message = "Jellyfin is responding, but it reports that a restart is pending." elif session_check_available and active_streams: status = "up" headline = "Media server is online and actively streaming" message = ( "Other playback is currently working, so this is more likely specific to the title, " "audio track, subtitle, client, or transcode path." ) else: status = "up" headline = "Media server is online" message = "Jellyfin responded normally. Continue with the report if playback is still failing." payload = _public_media_status_payload( status=status, headline=headline, message=message, latency_ms=latency_ms, version=version, restart_pending=restart_pending, active_streams=active_streams, transcoding_streams=transcoding_streams, session_check_available=session_check_available, ) _MEDIA_STATUS_CACHE.update( expires_at=now + _MEDIA_STATUS_CACHE_SECONDS, payload=payload, ) return payload @router.get("/items") 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), 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 ) 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, offset=offset, ) 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), ) 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, "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), }, } @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 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 ) 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", allowed=PORTAL_PRIORITIES, 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", 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, 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, ) _record_activity( int(created["id"]), event_type="item_created", message=( "Issue raised and added to the support queue." if created.get("kind") == "issue" else f"{str(created.get('kind') or 'Portal item').capitalize()} created." ), user=current_user, ) 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, "activity": _activity_payload(created, include_internal=_is_admin(current_user)), } @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, ) _record_activity( int(created["id"]), event_type="item_created", message=f"Issue raised and linked to collection request #{item_id}.", user=current_user, ) 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, "activity": _activity_payload(created, include_internal=_is_admin(current_user)), "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, 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, "activity": _activity_payload(item, include_internal=_is_admin(current_user)), } @router.delete("/items/{item_id}") async def portal_delete_item( item_id: int, 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 item not found") if str(item.get("kind") or "").lower() != "issue": raise HTTPException(status_code=400, detail="Only issues can be deleted here") if not delete_portal_item(item_id): raise HTTPException(status_code=404, detail="Issue not found") logger.info( "portal issue deleted id=%s title=%s actor=%s", item_id, item.get("title"), current_user.get("username"), ) return {"status": "deleted", "item_id": item_id} @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) item_kind = str(item.get("kind") or "").lower() 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", "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) 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: kind = item_kind 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: 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 == "closed": updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat()) elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}: updates.setdefault("issue_resolved_at", None) if not updates: comments = list_portal_comments(item_id, include_internal=is_admin) return { "item": _serialize_item(item, current_user), "comments": comments, "activity": _activity_payload(item, include_internal=is_admin), } updated = update_portal_item(item_id, **updates) if not updated: raise HTTPException(status_code=404, detail="Portal item not found") requested_issue_status = str(updates.get("status") or "").lower() should_start_confirmation = item_kind == "issue" and ( (requested_issue_status == "done" and str(item.get("status") or "").lower() != "done") or ( requested_issue_status == "awaiting_confirmation" and str(item.get("status") or "").lower() != "awaiting_confirmation" ) ) if should_start_confirmation: try: updated = await begin_issue_confirmation( item_id, actor_username=str(current_user.get("username") or "unknown"), actor_role=str(current_user.get("role") or "admin"), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)] if changed_fields: if item_kind == "issue" and not should_start_confirmation: old_status = str(item.get("status") or "unknown").replace("_", " ") new_status = str(updated.get("status") or "unknown").replace("_", " ") activity_message = ( f"Status changed from {old_status} to {new_status}." if item.get("status") != updated.get("status") else f"Issue details updated: {', '.join(sorted(changed_fields))}." ) _record_activity( item_id, event_type="status_changed" if item.get("status") != updated.get("status") else "issue_updated", message=activity_message, user=current_user, ) 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, "activity": _activity_payload(updated, include_internal=is_admin), } @router.post("/issues/{item_id}/resolution-response") async def portal_issue_resolution_response( 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 or str(item.get("kind") or "").lower() != "issue": raise HTTPException(status_code=404, detail="Issue not found") if not (_is_admin(current_user) or _is_owner(current_user, item)): raise HTTPException(status_code=403, detail="Only the reporter or an admin can confirm this resolution") if not isinstance(payload.get("resolved"), bool): raise HTTPException(status_code=400, detail="resolved must be true or false") try: updated = respond_to_issue_confirmation( item_id, resolved=payload["resolved"], actor_username=str(current_user.get("username") or "unknown"), actor_role=str(current_user.get("role") or "user"), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc await _notify( event_type="portal_issue_resolution_confirmed" if payload["resolved"] else "portal_issue_resolution_rejected", item=updated, user=current_user, note="resolved=true" if payload["resolved"] else "resolved=false", ) return { "item": _serialize_item(updated, current_user), "comments": list_portal_comments(item_id, include_internal=_is_admin(current_user)), "activity": _activity_payload(updated, include_internal=_is_admin(current_user)), } @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, ) if str(item.get("kind") or "").lower() == "issue": _record_activity( item_id, event_type="internal_note_added" if is_internal else "comment_added", message=( f"Internal troubleshooting note: {message[:240]}" if is_internal else f"Support update: {message[:240]}" ), user=current_user, ) 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 if is_admin else _public_comment(comment)}