Add issue resolution confirmation workflow
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 12s

This commit is contained in:
2026-09-01 11:40:43 +12:00
parent 393b8c2a88
commit 2adbed7259
14 changed files with 1155 additions and 9 deletions
+185 -3
View File
@@ -11,15 +11,22 @@ 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
@@ -37,6 +44,7 @@ PORTAL_STATUSES = {
"done",
"declined",
"closed",
"awaiting_confirmation",
# Seerr-style request pipeline statuses
"pending",
"approved",
@@ -389,6 +397,11 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"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":
@@ -400,15 +413,84 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"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")),
"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,
@@ -778,6 +860,16 @@ async def portal_create_item(
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(
@@ -797,6 +889,7 @@ async def portal_create_item(
return {
"item": _serialize_item(created, current_user),
"comments": comments,
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
}
@@ -849,6 +942,12 @@ async def portal_create_issue_for_request(
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(
@@ -868,6 +967,7 @@ async def portal_create_issue_for_request(
return {
"item": _serialize_item(created, current_user),
"comments": comments,
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
"linked_request_id": item_id,
}
@@ -980,6 +1080,7 @@ async def portal_get_item(
return {
"item": _serialize_item(item, current_user),
"comments": comments,
"activity": _activity_payload(item, include_internal=_is_admin(current_user)),
}
@@ -994,6 +1095,7 @@ async def portal_update_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")
@@ -1043,7 +1145,7 @@ async def portal_update_item(
if "external_ref" in payload:
updates["external_ref"] = _clean_text(payload.get("external_ref"))
if is_admin:
kind = str(item.get("kind") or "").lower()
kind = item_kind
if "priority" in payload:
updates["priority"] = _normalize_choice(
payload.get("priority"),
@@ -1133,9 +1235,9 @@ async def portal_update_item(
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"}:
if next_status == "closed":
updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}:
updates.setdefault("issue_resolved_at", None)
if not updates:
@@ -1143,14 +1245,47 @@ async def portal_update_item(
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,
@@ -1161,6 +1296,42 @@ async def portal_update_item(
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)),
}
@@ -1202,6 +1373,17 @@ async def portal_create_comment(
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(