from __future__ import annotations import logging from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple 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 = { # Existing generic statuses "new", "triaging", "planned", "in_progress", "blocked", "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]: 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" 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 _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_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 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, 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, ) 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.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, 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", "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 = str(item.get("kind") or "").lower() 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 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) 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}