Process 1 build 0803262038
This commit is contained in:
@@ -1 +1 @@
|
||||
0703261729
|
||||
0803262038
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -362,7 +362,13 @@ def init_db() -> None:
|
||||
external_ref TEXT,
|
||||
source_system TEXT,
|
||||
source_request_id INTEGER,
|
||||
related_item_id INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
workflow_request_status TEXT,
|
||||
workflow_media_status TEXT,
|
||||
issue_type TEXT,
|
||||
issue_resolved_at TEXT,
|
||||
metadata_json TEXT,
|
||||
priority TEXT NOT NULL,
|
||||
created_by_username TEXT NOT NULL,
|
||||
created_by_id INTEGER,
|
||||
@@ -465,6 +471,24 @@ def init_db() -> None:
|
||||
ON portal_items (status, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
|
||||
@@ -553,6 +577,48 @@ def init_db() -> None:
|
||||
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -2952,14 +3018,20 @@ def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
|
||||
"external_ref": row[6],
|
||||
"source_system": row[7],
|
||||
"source_request_id": row[8],
|
||||
"status": row[9],
|
||||
"priority": row[10],
|
||||
"created_by_username": row[11],
|
||||
"created_by_id": row[12],
|
||||
"assignee_username": row[13],
|
||||
"created_at": row[14],
|
||||
"updated_at": row[15],
|
||||
"last_activity_at": row[16],
|
||||
"related_item_id": row[9],
|
||||
"status": row[10],
|
||||
"workflow_request_status": row[11],
|
||||
"workflow_media_status": row[12],
|
||||
"issue_type": row[13],
|
||||
"issue_resolved_at": row[14],
|
||||
"metadata_json": row[15],
|
||||
"priority": row[16],
|
||||
"created_by_username": row[17],
|
||||
"created_by_id": row[18],
|
||||
"assignee_username": row[19],
|
||||
"created_at": row[20],
|
||||
"updated_at": row[21],
|
||||
"last_activity_at": row[22],
|
||||
}
|
||||
|
||||
|
||||
@@ -2987,7 +3059,13 @@ def create_portal_item(
|
||||
external_ref: Optional[str] = None,
|
||||
source_system: Optional[str] = None,
|
||||
source_request_id: Optional[int] = None,
|
||||
related_item_id: Optional[int] = None,
|
||||
status: str = "new",
|
||||
workflow_request_status: Optional[str] = None,
|
||||
workflow_media_status: Optional[str] = None,
|
||||
issue_type: Optional[str] = None,
|
||||
issue_resolved_at: Optional[str] = None,
|
||||
metadata_json: Optional[str] = None,
|
||||
priority: str = "normal",
|
||||
assignee_username: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -3004,7 +3082,13 @@ def create_portal_item(
|
||||
external_ref,
|
||||
source_system,
|
||||
source_request_id,
|
||||
related_item_id,
|
||||
status,
|
||||
workflow_request_status,
|
||||
workflow_media_status,
|
||||
issue_type,
|
||||
issue_resolved_at,
|
||||
metadata_json,
|
||||
priority,
|
||||
created_by_username,
|
||||
created_by_id,
|
||||
@@ -3013,7 +3097,7 @@ def create_portal_item(
|
||||
updated_at,
|
||||
last_activity_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
kind,
|
||||
@@ -3024,7 +3108,13 @@ def create_portal_item(
|
||||
external_ref,
|
||||
source_system,
|
||||
source_request_id,
|
||||
related_item_id,
|
||||
status,
|
||||
workflow_request_status,
|
||||
workflow_media_status,
|
||||
issue_type,
|
||||
issue_resolved_at,
|
||||
metadata_json,
|
||||
priority,
|
||||
created_by_username,
|
||||
created_by_id,
|
||||
@@ -3063,7 +3153,13 @@ def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
|
||||
external_ref,
|
||||
source_system,
|
||||
source_request_id,
|
||||
related_item_id,
|
||||
status,
|
||||
workflow_request_status,
|
||||
workflow_media_status,
|
||||
issue_type,
|
||||
issue_resolved_at,
|
||||
metadata_json,
|
||||
priority,
|
||||
created_by_username,
|
||||
created_by_id,
|
||||
@@ -3083,6 +3179,11 @@ def list_portal_items(
|
||||
*,
|
||||
kind: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
workflow_request_status: Optional[str] = None,
|
||||
workflow_media_status: Optional[str] = None,
|
||||
source_system: Optional[str] = None,
|
||||
source_request_id: Optional[int] = None,
|
||||
related_item_id: Optional[int] = None,
|
||||
mine_username: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
@@ -3096,6 +3197,21 @@ def list_portal_items(
|
||||
if isinstance(status, str) and status.strip():
|
||||
clauses.append("status = ?")
|
||||
params.append(status.strip().lower())
|
||||
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
|
||||
clauses.append("workflow_request_status = ?")
|
||||
params.append(workflow_request_status.strip().lower())
|
||||
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
|
||||
clauses.append("workflow_media_status = ?")
|
||||
params.append(workflow_media_status.strip().lower())
|
||||
if isinstance(source_system, str) and source_system.strip():
|
||||
clauses.append("source_system = ?")
|
||||
params.append(source_system.strip().lower())
|
||||
if isinstance(source_request_id, int):
|
||||
clauses.append("source_request_id = ?")
|
||||
params.append(source_request_id)
|
||||
if isinstance(related_item_id, int):
|
||||
clauses.append("related_item_id = ?")
|
||||
params.append(related_item_id)
|
||||
if isinstance(mine_username, str) and mine_username.strip():
|
||||
clauses.append("created_by_username = ?")
|
||||
params.append(mine_username.strip())
|
||||
@@ -3120,7 +3236,13 @@ def list_portal_items(
|
||||
external_ref,
|
||||
source_system,
|
||||
source_request_id,
|
||||
related_item_id,
|
||||
status,
|
||||
workflow_request_status,
|
||||
workflow_media_status,
|
||||
issue_type,
|
||||
issue_resolved_at,
|
||||
metadata_json,
|
||||
priority,
|
||||
created_by_username,
|
||||
created_by_id,
|
||||
@@ -3142,6 +3264,11 @@ def count_portal_items(
|
||||
*,
|
||||
kind: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
workflow_request_status: Optional[str] = None,
|
||||
workflow_media_status: Optional[str] = None,
|
||||
source_system: Optional[str] = None,
|
||||
source_request_id: Optional[int] = None,
|
||||
related_item_id: Optional[int] = None,
|
||||
mine_username: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> int:
|
||||
@@ -3153,6 +3280,21 @@ def count_portal_items(
|
||||
if isinstance(status, str) and status.strip():
|
||||
clauses.append("status = ?")
|
||||
params.append(status.strip().lower())
|
||||
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
|
||||
clauses.append("workflow_request_status = ?")
|
||||
params.append(workflow_request_status.strip().lower())
|
||||
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
|
||||
clauses.append("workflow_media_status = ?")
|
||||
params.append(workflow_media_status.strip().lower())
|
||||
if isinstance(source_system, str) and source_system.strip():
|
||||
clauses.append("source_system = ?")
|
||||
params.append(source_system.strip().lower())
|
||||
if isinstance(source_request_id, int):
|
||||
clauses.append("source_request_id = ?")
|
||||
params.append(source_request_id)
|
||||
if isinstance(related_item_id, int):
|
||||
clauses.append("related_item_id = ?")
|
||||
params.append(related_item_id)
|
||||
if isinstance(mine_username, str) and mine_username.strip():
|
||||
clauses.append("created_by_username = ?")
|
||||
params.append(mine_username.strip())
|
||||
@@ -3182,6 +3324,12 @@ def update_portal_item(
|
||||
external_ref: Any = _DB_UNSET,
|
||||
source_system: Any = _DB_UNSET,
|
||||
source_request_id: Any = _DB_UNSET,
|
||||
related_item_id: Any = _DB_UNSET,
|
||||
workflow_request_status: Any = _DB_UNSET,
|
||||
workflow_media_status: Any = _DB_UNSET,
|
||||
issue_type: Any = _DB_UNSET,
|
||||
issue_resolved_at: Any = _DB_UNSET,
|
||||
metadata_json: Any = _DB_UNSET,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
@@ -3215,6 +3363,24 @@ def update_portal_item(
|
||||
if source_request_id is not _DB_UNSET:
|
||||
updates.append("source_request_id = ?")
|
||||
params.append(source_request_id)
|
||||
if related_item_id is not _DB_UNSET:
|
||||
updates.append("related_item_id = ?")
|
||||
params.append(related_item_id)
|
||||
if workflow_request_status is not _DB_UNSET:
|
||||
updates.append("workflow_request_status = ?")
|
||||
params.append(workflow_request_status)
|
||||
if workflow_media_status is not _DB_UNSET:
|
||||
updates.append("workflow_media_status = ?")
|
||||
params.append(workflow_media_status)
|
||||
if issue_type is not _DB_UNSET:
|
||||
updates.append("issue_type = ?")
|
||||
params.append(issue_type)
|
||||
if issue_resolved_at is not _DB_UNSET:
|
||||
updates.append("issue_resolved_at = ?")
|
||||
params.append(issue_resolved_at)
|
||||
if metadata_json is not _DB_UNSET:
|
||||
updates.append("metadata_json = ?")
|
||||
params.append(metadata_json)
|
||||
if not updates:
|
||||
return get_portal_item(item_id)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -3342,13 +3508,31 @@ def get_portal_overview() -> Dict[str, Any]:
|
||||
GROUP BY status
|
||||
"""
|
||||
).fetchall()
|
||||
request_workflow_rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(workflow_request_status, ''),
|
||||
COALESCE(workflow_media_status, ''),
|
||||
COUNT(*)
|
||||
FROM portal_items
|
||||
WHERE kind = 'request'
|
||||
GROUP BY workflow_request_status, workflow_media_status
|
||||
"""
|
||||
).fetchall()
|
||||
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
|
||||
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
|
||||
request_workflow: Dict[str, Dict[str, int]] = {}
|
||||
for row in request_workflow_rows:
|
||||
request_status = str(row[0] or "")
|
||||
media_status = str(row[1] or "")
|
||||
request_workflow.setdefault(request_status, {})
|
||||
request_workflow[request_status][media_status] = int(row[2] or 0)
|
||||
return {
|
||||
"total_items": int(total_items_row[0] or 0) if total_items_row else 0,
|
||||
"total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0,
|
||||
"by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows},
|
||||
"by_status": {str(row[0]): int(row[1] or 0) for row in status_rows},
|
||||
"request_workflow": request_workflow,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
@@ -23,6 +24,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PORTAL_KINDS = {"request", "issue", "feature"}
|
||||
PORTAL_STATUSES = {
|
||||
# Existing generic statuses
|
||||
"new",
|
||||
"triaging",
|
||||
"planned",
|
||||
@@ -31,9 +33,66 @@ PORTAL_STATUSES = {
|
||||
"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]:
|
||||
@@ -124,6 +183,154 @@ def _normalize_bool(value: Any, *, default: bool = False) -> bool:
|
||||
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"
|
||||
|
||||
@@ -140,7 +347,24 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
||||
"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
|
||||
|
||||
|
||||
@@ -186,6 +410,11 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
|
||||
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),
|
||||
@@ -198,10 +427,24 @@ async def portal_list_items(
|
||||
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,
|
||||
@@ -210,6 +453,11 @@ async def portal_list_items(
|
||||
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),
|
||||
)
|
||||
@@ -222,6 +470,59 @@ async def portal_list_items(
|
||||
"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),
|
||||
},
|
||||
@@ -251,17 +552,45 @@ async def portal_create_item(
|
||||
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
|
||||
)
|
||||
status = _normalize_choice(
|
||||
payload.get("status") if is_admin else None,
|
||||
field="status",
|
||||
allowed=PORTAL_STATUSES,
|
||||
default="new",
|
||||
)
|
||||
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",
|
||||
@@ -269,6 +598,7 @@ async def portal_create_item(
|
||||
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",
|
||||
@@ -281,7 +611,13 @@ async def portal_create_item(
|
||||
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,
|
||||
)
|
||||
@@ -307,6 +643,174 @@ async def portal_create_item(
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
@@ -343,6 +847,12 @@ async def portal_update_item(
|
||||
"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)
|
||||
@@ -376,13 +886,7 @@ async def portal_update_item(
|
||||
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",
|
||||
)
|
||||
kind = str(item.get("kind") or "").lower()
|
||||
if "priority" in payload:
|
||||
updates["priority"] = _normalize_choice(
|
||||
payload.get("priority"),
|
||||
@@ -393,11 +897,89 @@ async def portal_update_item(
|
||||
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"))
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ from starlette.requests import Request
|
||||
from backend.app import db
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import auth as auth_router
|
||||
from backend.app.routers import portal as portal_router
|
||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||
from backend.app.services import password_reset
|
||||
|
||||
@@ -143,3 +144,58 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
context.exception.detail,
|
||||
"recipient_email is required and must be a valid email address.",
|
||||
)
|
||||
|
||||
|
||||
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_legacy_request_status_maps_to_workflow(self) -> None:
|
||||
item = {"kind": "request", "status": "in_progress"}
|
||||
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
|
||||
workflow = serialized.get("workflow") or {}
|
||||
self.assertEqual(workflow.get("request_status"), "approved")
|
||||
self.assertEqual(workflow.get("media_status"), "processing")
|
||||
|
||||
def test_invalid_pipeline_transition_is_rejected(self) -> None:
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
portal_router._validate_pipeline_transition(
|
||||
"approved",
|
||||
"processing",
|
||||
"pending",
|
||||
"pending",
|
||||
)
|
||||
self.assertEqual(context.exception.status_code, 400)
|
||||
|
||||
def test_portal_workflow_filters(self) -> None:
|
||||
db.create_portal_item(
|
||||
kind="request",
|
||||
title="Request A",
|
||||
description="A",
|
||||
created_by_username="alpha",
|
||||
created_by_id=None,
|
||||
status="processing",
|
||||
workflow_request_status="approved",
|
||||
workflow_media_status="processing",
|
||||
)
|
||||
db.create_portal_item(
|
||||
kind="request",
|
||||
title="Request B",
|
||||
description="B",
|
||||
created_by_username="bravo",
|
||||
created_by_id=None,
|
||||
status="pending",
|
||||
workflow_request_status="pending",
|
||||
workflow_media_status="pending",
|
||||
)
|
||||
processing = db.list_portal_items(
|
||||
kind="request",
|
||||
workflow_request_status="approved",
|
||||
workflow_media_status="processing",
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
pending_count = db.count_portal_items(
|
||||
kind="request",
|
||||
workflow_request_status="pending",
|
||||
workflow_media_status="pending",
|
||||
)
|
||||
self.assertEqual(len(processing), 1)
|
||||
self.assertEqual(pending_count, 1)
|
||||
|
||||
@@ -28,6 +28,18 @@ type PortalItem = {
|
||||
updated_at: string
|
||||
last_activity_at: string
|
||||
permissions?: PortalPermissions
|
||||
workflow?: {
|
||||
request_status?: string
|
||||
media_status?: string
|
||||
stage_label?: string
|
||||
is_terminal?: boolean
|
||||
}
|
||||
issue?: {
|
||||
issue_type?: string
|
||||
related_item_id?: number | null
|
||||
is_resolved?: boolean
|
||||
resolved_at?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type PortalComment = {
|
||||
@@ -68,10 +80,31 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'in_progress', label: 'In progress' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'pending', label: 'Pending approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'partially_available', label: 'Partially available' },
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'declined', label: 'Declined' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
] as const
|
||||
|
||||
const REQUEST_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'declined', label: 'Declined' },
|
||||
] as const
|
||||
|
||||
const MEDIA_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'partially_available', label: 'Partially available' },
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'unknown', label: 'Unknown' },
|
||||
] as const
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
@@ -135,6 +168,8 @@ export default function PortalPage() {
|
||||
const [editYear, setEditYear] = useState('')
|
||||
const [editExternalRef, setEditExternalRef] = useState('')
|
||||
const [editStatus, setEditStatus] = useState('new')
|
||||
const [editRequestStatus, setEditRequestStatus] = useState('pending')
|
||||
const [editMediaStatus, setEditMediaStatus] = useState('pending')
|
||||
const [editPriority, setEditPriority] = useState('normal')
|
||||
const [editAssignee, setEditAssignee] = useState('')
|
||||
|
||||
@@ -312,6 +347,8 @@ export default function PortalPage() {
|
||||
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
|
||||
setEditExternalRef(selectedItem.external_ref ?? '')
|
||||
setEditStatus(selectedItem.status ?? 'new')
|
||||
setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
|
||||
setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
|
||||
setEditPriority(selectedItem.priority ?? 'normal')
|
||||
setEditAssignee(selectedItem.assignee_username ?? '')
|
||||
}, [selectedItem])
|
||||
@@ -382,7 +419,12 @@ export default function PortalPage() {
|
||||
external_ref: editExternalRef || null,
|
||||
}
|
||||
if (selectedItem.permissions?.can_moderate) {
|
||||
payload.status = editStatus
|
||||
if (selectedItem.kind === 'request') {
|
||||
payload.request_status = editRequestStatus
|
||||
payload.media_status = editMediaStatus
|
||||
} else {
|
||||
payload.status = editStatus
|
||||
}
|
||||
payload.priority = editPriority
|
||||
payload.assignee_username = editAssignee || null
|
||||
}
|
||||
@@ -661,7 +703,12 @@ export default function PortalPage() {
|
||||
<p>{item.description}</p>
|
||||
<div className="portal-item-row-meta">
|
||||
<span>#{item.id}</span>
|
||||
<span>Status: {item.status}</span>
|
||||
<span>
|
||||
Status:{' '}
|
||||
{item.kind === 'request'
|
||||
? item.workflow?.stage_label ?? item.status
|
||||
: item.status}
|
||||
</span>
|
||||
<span>By: {item.created_by_username}</span>
|
||||
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
||||
</div>
|
||||
@@ -687,6 +734,16 @@ export default function PortalPage() {
|
||||
<p className="lede">
|
||||
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
|
||||
</p>
|
||||
{selectedItem.kind === 'request' && (
|
||||
<p className="lede">
|
||||
Pipeline:{' '}
|
||||
<strong>
|
||||
{selectedItem.workflow?.request_status ?? 'pending'} /{' '}
|
||||
{selectedItem.workflow?.media_status ?? 'pending'}
|
||||
</strong>{' '}
|
||||
({selectedItem.workflow?.stage_label ?? 'Pending'})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -741,16 +798,47 @@ export default function PortalPage() {
|
||||
</label>
|
||||
{selectedItem.permissions?.can_moderate && (
|
||||
<>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedItem.kind === 'request' ? (
|
||||
<>
|
||||
<label>
|
||||
<span>Request status</span>
|
||||
<select
|
||||
value={editRequestStatus}
|
||||
onChange={(event) => setEditRequestStatus(event.target.value)}
|
||||
>
|
||||
{REQUEST_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Media status</span>
|
||||
<select
|
||||
value={editMediaStatus}
|
||||
onChange={(event) => setEditMediaStatus(event.target.value)}
|
||||
>
|
||||
{MEDIA_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span>Priority</span>
|
||||
<select
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"version": "0703261729",
|
||||
"version": "0803262038",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magent-frontend",
|
||||
"version": "0703261729",
|
||||
"version": "0803262038",
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0703261729",
|
||||
"version": "0803262038",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
Reference in New Issue
Block a user