Add issue resolution confirmation workflow
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user