475 lines
15 KiB
Python
475 lines
15 KiB
Python
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}
|