from __future__ import annotations import logging from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Query from ..auth import get_current_user from ..db import ( add_portal_comment, count_portal_items, create_portal_item, get_portal_item, get_portal_overview, list_portal_comments, list_portal_items, update_portal_item, ) from ..services.notifications import send_portal_notification router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)]) logger = logging.getLogger(__name__) PORTAL_KINDS = {"request", "issue", "feature"} PORTAL_STATUSES = { "new", "triaging", "planned", "in_progress", "blocked", "done", "declined", "closed", } PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"} PORTAL_MEDIA_TYPES = {"movie", "tv"} def _clean_text(value: Any) -> Optional[str]: if value is None: return None if isinstance(value, str): trimmed = value.strip() return trimmed if trimmed else None return str(value) def _require_text(value: Any, field: str, *, max_length: int = 5000) -> str: normalized = _clean_text(value) if not normalized: raise HTTPException(status_code=400, detail=f"{field} is required") if len(normalized) > max_length: raise HTTPException( status_code=400, detail=f"{field} is too long (max {max_length} characters)", ) return normalized def _normalize_choice( value: Any, *, field: str, allowed: set[str], default: Optional[str] = None, allow_empty: bool = False, ) -> Optional[str]: if value is None: return default normalized = _clean_text(value) if not normalized: return None if allow_empty else default candidate = normalized.lower() if candidate not in allowed: allowed_values = ", ".join(sorted(allowed)) raise HTTPException(status_code=400, detail=f"Invalid {field}. Allowed: {allowed_values}") return candidate def _normalize_year(value: Any, *, allow_empty: bool = True) -> Optional[int]: if value is None: return None if isinstance(value, str): stripped = value.strip() if not stripped: return None if allow_empty else 0 value = stripped try: year = int(value) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="year must be an integer") from None if year < 1800 or year > 2100: raise HTTPException(status_code=400, detail="year must be between 1800 and 2100") return year def _normalize_int(value: Any, field: str, *, allow_empty: bool = True) -> Optional[int]: if value is None: return None if isinstance(value, str): stripped = value.strip() if not stripped: return None if allow_empty else 0 value = stripped try: return int(value) except (TypeError, ValueError): raise HTTPException(status_code=400, detail=f"{field} must be an integer") from None def _normalize_bool(value: Any, *, default: bool = False) -> bool: if value is None: return default if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): candidate = value.strip().lower() if candidate in {"1", "true", "yes", "on"}: return True if candidate in {"0", "false", "no", "off"}: return False raise HTTPException(status_code=400, detail="Boolean value expected") def _is_admin(user: Dict[str, Any]) -> bool: return str(user.get("role") or "").strip().lower() == "admin" def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool: return str(user.get("username") or "") == str(item.get("created_by_username") or "") def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: is_admin = _is_admin(user) is_owner = _is_owner(user, item) serialized = dict(item) serialized["permissions"] = { "can_edit": is_admin or is_owner, "can_comment": True, "can_moderate": is_admin, } return serialized async def _notify( *, event_type: str, item: Dict[str, Any], user: Dict[str, Any], note: Optional[str] = None, ) -> None: try: result = await send_portal_notification( event_type=event_type, item=item, actor_username=str(user.get("username") or "unknown"), actor_role=str(user.get("role") or "user"), note=note, ) logger.info( "portal notification dispatched event=%s item_id=%s status=%s", event_type, item.get("id"), result.get("status"), ) except Exception: logger.exception( "portal notification failed event=%s item_id=%s", event_type, item.get("id"), ) @router.get("/overview") async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: mine = count_portal_items(mine_username=str(current_user.get("username") or "")) return { "overview": get_portal_overview(), "my_items": mine, } @router.get("/items") async def portal_list_items( kind: Optional[str] = None, status: Optional[str] = None, mine: bool = False, search: Optional[str] = None, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: kind_value = _normalize_choice( kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True ) status_value = _normalize_choice( status, field="status", allowed=PORTAL_STATUSES, allow_empty=True ) mine_username = str(current_user.get("username") or "") if mine else None items = list_portal_items( kind=kind_value, status=status_value, mine_username=mine_username, search=_clean_text(search), limit=limit, offset=offset, ) total = count_portal_items( kind=kind_value, status=status_value, mine_username=mine_username, search=_clean_text(search), ) return { "items": [_serialize_item(item, current_user) for item in items], "total": total, "limit": limit, "offset": offset, "has_more": offset + len(items) < total, "filters": { "kind": kind_value, "status": status_value, "mine": mine, "search": _clean_text(search), }, } @router.post("/items") async def portal_create_item( payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: is_admin = _is_admin(current_user) kind = _normalize_choice( payload.get("kind"), field="kind", allowed=PORTAL_KINDS, default="request", ) title = _require_text(payload.get("title"), "title", max_length=220) description = _require_text(payload.get("description"), "description", max_length=10000) media_type = _normalize_choice( payload.get("media_type"), field="media_type", allowed=PORTAL_MEDIA_TYPES, allow_empty=True, ) year = _normalize_year(payload.get("year")) external_ref = _clean_text(payload.get("external_ref")) source_system = _clean_text(payload.get("source_system")) if is_admin else None source_request_id = ( _normalize_int(payload.get("source_request_id"), "source_request_id") if is_admin else None ) status = _normalize_choice( payload.get("status") if is_admin else None, field="status", allowed=PORTAL_STATUSES, default="new", ) priority = _normalize_choice( payload.get("priority"), field="priority", allowed=PORTAL_PRIORITIES, default="normal", ) assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None created = create_portal_item( kind=kind or "request", title=title, description=description, created_by_username=str(current_user.get("username") or "unknown"), created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"), media_type=media_type, year=year, external_ref=external_ref, source_system=source_system, source_request_id=source_request_id, status=status or "new", priority=priority or "normal", assignee_username=assignee_username, ) initial_comment = _clean_text(payload.get("comment")) if initial_comment: add_portal_comment( int(created["id"]), author_username=str(current_user.get("username") or "unknown"), author_role=str(current_user.get("role") or "user"), message=initial_comment, is_internal=False, ) comments = list_portal_comments(int(created["id"]), include_internal=is_admin) await _notify( event_type="portal_item_created", item=created, user=current_user, note=f"kind={created.get('kind')} priority={created.get('priority')}", ) return { "item": _serialize_item(created, current_user), "comments": comments, } @router.get("/items/{item_id}") async def portal_get_item( item_id: int, current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: item = get_portal_item(item_id) if not item: raise HTTPException(status_code=404, detail="Portal item not found") comments = list_portal_comments(item_id, include_internal=_is_admin(current_user)) return { "item": _serialize_item(item, current_user), "comments": comments, } @router.patch("/items/{item_id}") async def portal_update_item( item_id: int, payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: item = get_portal_item(item_id) if not item: raise HTTPException(status_code=404, detail="Portal item not found") is_admin = _is_admin(current_user) is_owner = _is_owner(current_user, item) if not (is_admin or is_owner): raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item") editable_owner_fields = {"title", "description", "media_type", "year", "external_ref"} editable_admin_fields = { "status", "priority", "assignee_username", "source_system", "source_request_id", } provided_fields = set(payload.keys()) unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields) if unknown_fields: unknown = ", ".join(sorted(unknown_fields)) raise HTTPException(status_code=400, detail=f"Unsupported fields: {unknown}") if not is_admin: forbidden = provided_fields - editable_owner_fields if forbidden: forbidden_text = ", ".join(sorted(forbidden)) raise HTTPException( status_code=403, detail=f"Admin access required to update: {forbidden_text}" ) updates: Dict[str, Any] = {} if "title" in payload: updates["title"] = _require_text(payload.get("title"), "title", max_length=220) if "description" in payload: updates["description"] = _require_text( payload.get("description"), "description", max_length=10000 ) if "media_type" in payload: updates["media_type"] = _normalize_choice( payload.get("media_type"), field="media_type", allowed=PORTAL_MEDIA_TYPES, allow_empty=True, ) if "year" in payload: updates["year"] = _normalize_year(payload.get("year")) if "external_ref" in payload: updates["external_ref"] = _clean_text(payload.get("external_ref")) if is_admin: if "status" in payload: updates["status"] = _normalize_choice( payload.get("status"), field="status", allowed=PORTAL_STATUSES, default=item.get("status") or "new", ) if "priority" in payload: updates["priority"] = _normalize_choice( payload.get("priority"), field="priority", allowed=PORTAL_PRIORITIES, default=item.get("priority") or "normal", ) if "assignee_username" in payload: updates["assignee_username"] = _clean_text(payload.get("assignee_username")) if "source_system" in payload: updates["source_system"] = _clean_text(payload.get("source_system")) if "source_request_id" in payload: updates["source_request_id"] = _normalize_int( payload.get("source_request_id"), "source_request_id" ) if not updates: comments = list_portal_comments(item_id, include_internal=is_admin) return { "item": _serialize_item(item, current_user), "comments": comments, } updated = update_portal_item(item_id, **updates) if not updated: raise HTTPException(status_code=404, detail="Portal item not found") changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)] if changed_fields: await _notify( event_type="portal_item_updated", item=updated, user=current_user, note=f"changed={','.join(sorted(changed_fields))}", ) comments = list_portal_comments(item_id, include_internal=is_admin) return { "item": _serialize_item(updated, current_user), "comments": comments, } @router.get("/items/{item_id}/comments") async def portal_get_comments( item_id: int, limit: int = Query(default=200, ge=1, le=500), current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: item = get_portal_item(item_id) if not item: raise HTTPException(status_code=404, detail="Portal item not found") comments = list_portal_comments( item_id, include_internal=_is_admin(current_user), limit=limit, ) return {"comments": comments} @router.post("/items/{item_id}/comments") async def portal_create_comment( item_id: int, payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: item = get_portal_item(item_id) if not item: raise HTTPException(status_code=404, detail="Portal item not found") is_admin = _is_admin(current_user) message = _require_text(payload.get("message"), "message", max_length=10000) is_internal = _normalize_bool(payload.get("is_internal"), default=False) if is_internal and not is_admin: raise HTTPException(status_code=403, detail="Only admins can add internal comments") comment = add_portal_comment( item_id, author_username=str(current_user.get("username") or "unknown"), author_role=str(current_user.get("role") or "user"), message=message, is_internal=is_internal, ) updated_item = get_portal_item(item_id) if updated_item: await _notify( event_type="portal_comment_added", item=updated_item, user=current_user, note=f"internal={is_internal}", ) return {"comment": comment}