1488 lines
54 KiB
Python
1488 lines
54 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
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,
|
|
get_portal_item,
|
|
get_portal_overview,
|
|
list_portal_comments,
|
|
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",
|
|
"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 _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",
|
|
"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"),
|
|
},
|
|
}
|
|
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_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.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}
|