diff --git a/backend/app/config.py b/backend/app/config.py index c234a47..1fcfa97 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -85,6 +85,15 @@ class Settings(BaseSettings): requests_data_source: str = Field( default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE") ) + issue_confirmation_contact_attempts: int = Field( + default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS") + ) + issue_confirmation_interval_value: int = Field( + default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE") + ) + issue_confirmation_interval_unit: str = Field( + default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT") + ) artwork_cache_mode: str = Field( default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE") ) diff --git a/backend/app/db.py b/backend/app/db.py index 7d9ef33..50a0301 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -400,6 +400,27 @@ def init_db() -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS portal_item_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + event_type TEXT NOT NULL, + actor_username TEXT NOT NULL, + actor_role TEXT NOT NULL, + message TEXT NOT NULL, + metadata_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item + ON portal_item_activity (item_id, created_at ASC, id ASC) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at @@ -3580,6 +3601,91 @@ def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: return [_portal_comment_from_row(row) for row in rows] +def add_portal_item_activity( + item_id: int, + *, + event_type: str, + actor_username: str, + actor_role: str, + message: str, + metadata_json: Optional[str] = None, +) -> Dict[str, Any]: + now = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + INSERT INTO portal_item_activity ( + item_id, + event_type, + actor_username, + actor_role, + message, + metadata_json, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + item_id, + event_type, + actor_username, + actor_role, + message, + metadata_json, + now, + ), + ) + activity_id = cursor.lastrowid + row = conn.execute( + """ + SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at + FROM portal_item_activity + WHERE id = ? + """, + (activity_id,), + ).fetchone() + if not row: + raise RuntimeError("Portal activity could not be loaded after insert.") + return { + "id": row[0], + "item_id": row[1], + "event_type": row[2], + "actor_username": row[3], + "actor_role": row[4], + "message": row[5], + "metadata_json": row[6], + "created_at": row[7], + } + + +def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]: + safe_limit = max(1, min(int(limit), 500)) + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at + FROM portal_item_activity + WHERE item_id = ? + ORDER BY created_at ASC, id ASC + LIMIT ? + """, + (item_id, safe_limit), + ).fetchall() + return [ + { + "id": row[0], + "item_id": row[1], + "event_type": row[2], + "actor_username": row[3], + "actor_role": row[4], + "message": row[5], + "metadata_json": row[6], + "created_at": row[7], + } + for row in rows + ] + + def get_portal_overview() -> Dict[str, Any]: with _connect() as conn: kind_rows = conn.execute( diff --git a/backend/app/main.py b/backend/app/main.py index 823ef73..43f6e8a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,6 +27,7 @@ from .routers.events import router as events_router from .routers.portal import router as portal_router from .routers.operations import router as operations_router from .services.jellyfin_sync import run_daily_jellyfin_sync +from .services.issue_resolution import run_issue_confirmation_loop from .services.operation_progress import ( begin_operation, finish_operation, @@ -255,6 +256,7 @@ async def startup() -> None: _launch_background_task("requests-delta-loop", run_requests_delta_loop) _launch_background_task("requests-full-sync", run_daily_requests_full_sync) _launch_background_task("db-cleanup", run_daily_db_cleanup) + _launch_background_task("issue-confirmation", run_issue_confirmation_loop) logger.info("startup complete") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2333808..5f1a8ee 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -234,6 +234,9 @@ SETTING_KEYS: List[str] = [ "requests_cleanup_time", "requests_cleanup_days", "requests_data_source", + "issue_confirmation_contact_attempts", + "issue_confirmation_interval_value", + "issue_confirmation_interval_unit", "site_banner_enabled", "site_banner_message", "site_banner_tone", @@ -661,6 +664,26 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: changed_keys.append(key) continue value_to_store = str(value).strip() if isinstance(value, str) else str(value) + if key == "issue_confirmation_contact_attempts": + try: + attempts = int(value_to_store) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc + if attempts < 0 or attempts > 10: + raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10") + value_to_store = str(attempts) + if key == "issue_confirmation_interval_value": + try: + interval_value = int(value_to_store) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc + if interval_value < 1 or interval_value > 365: + raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365") + value_to_store = str(interval_value) + if key == "issue_confirmation_interval_unit": + value_to_store = value_to_store.lower() + if value_to_store not in {"days", "weeks", "months"}: + raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months") if key in URL_SETTING_KEYS and value_to_store: try: value_to_store = _normalize_service_url(value_to_store) diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py index da78b88..319da68 100644 --- a/backend/app/routers/portal.py +++ b/backend/app/routers/portal.py @@ -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( diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 0cad4b8..e672855 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -19,6 +19,8 @@ from ..auth import get_current_user from ..runtime import get_runtime_settings from .images import cache_tmdb_image, is_tmdb_cached from ..db import ( + add_portal_item_activity, + get_portal_item, save_action, get_recent_actions, get_recent_snapshots, @@ -1796,6 +1798,46 @@ def _replacement_file_payload( } +def _linked_issue_for_replacement( + issue_id: Any, + *, + request_id: str, + user: Dict[str, str], +) -> Optional[Dict[str, Any]]: + if issue_id is None: + return None + if not isinstance(issue_id, int) or issue_id <= 0: + raise HTTPException(status_code=400, detail="A valid linked issue is required") + issue = get_portal_item(issue_id) + if not issue or str(issue.get("kind") or "").lower() != "issue": + raise HTTPException(status_code=404, detail="Linked issue not found") + if str(issue.get("external_ref") or "") != f"/requests/{request_id}": + raise HTTPException(status_code=409, detail="The issue is not linked to this request") + is_admin = str(user.get("role") or "").lower() == "admin" + is_owner = str(issue.get("created_by_username") or "").lower() == str(user.get("username") or "").lower() + if not (is_admin or is_owner): + raise HTTPException(status_code=403, detail="You cannot update this linked issue") + return issue + + +def _record_replacement_activity( + issue: Optional[Dict[str, Any]], + *, + user: Dict[str, str], + event_type: str, + message: str, +) -> None: + if not issue: + return + add_portal_item_activity( + int(issue["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 _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: if root_folder.isdigit(): folders = await client.get_root_folders() @@ -1915,6 +1957,11 @@ async def action_replace_media( file_id = payload.get("file_id") if not isinstance(file_id, int) or file_id <= 0: raise HTTPException(status_code=400, detail="A valid managed file is required") + linked_issue = _linked_issue_for_replacement( + payload.get("issue_id"), + request_id=request_id, + user=user, + ) runtime = get_runtime_settings() seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) @@ -1976,7 +2023,13 @@ async def action_replace_media( await sonarr.search_episodes(episode_ids) else: raise HTTPException(status_code=400, detail="Unknown request type") - except HTTPException: + except HTTPException as exc: + _record_replacement_activity( + linked_issue, + user=user, + event_type="replacement_failed", + message=f"The media replacement could not be started: {exc.detail}", + ) raise except Exception as exc: logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id) @@ -1992,6 +2045,12 @@ async def action_replace_media( "failed", detail, ) + _record_replacement_activity( + linked_issue, + user=user, + event_type="replacement_failed", + message=detail, + ) raise HTTPException(status_code=502, detail=detail) from exc message = f"{collector} removed {target_name} and started a replacement search." @@ -2003,6 +2062,12 @@ async def action_replace_media( "ok", message, ) + _record_replacement_activity( + linked_issue, + user=user, + event_type="replacement_started", + message=message, + ) return { "status": "ok", "message": message, diff --git a/backend/app/runtime.py b/backend/app/runtime.py index f75c1f0..9f55e16 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -19,6 +19,8 @@ _INT_FIELDS = { "requests_poll_interval_seconds", "requests_delta_sync_interval_minutes", "requests_cleanup_days", + "issue_confirmation_contact_attempts", + "issue_confirmation_interval_value", "magent_notify_email_smtp_port", } _BOOL_FIELDS = { diff --git a/backend/app/services/issue_resolution.py b/backend/app/services/issue_resolution.py new file mode 100644 index 0000000..ab6a36e --- /dev/null +++ b/backend/app/services/issue_resolution.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from html import escape +import json +import logging +from typing import Any, Dict, Optional + +from ..config import settings as env_settings +from ..db import ( + add_portal_item_activity, + get_portal_item, + get_user_by_username, + list_portal_items, + update_portal_item, +) +from ..runtime import get_runtime_settings +from .invite_email import resolve_user_delivery_email, send_generic_email + + +logger = logging.getLogger(__name__) +_SYSTEM_USER = "Magent" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_datetime(value: Any) -> Optional[datetime]: + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc) + + +def _metadata(item: Dict[str, Any]) -> Dict[str, Any]: + raw = item.get("metadata_json") + if not isinstance(raw, str) or not raw.strip(): + return {} + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]: + state = _metadata(item).get("resolutionConfirmation") + return dict(state) if isinstance(state, dict) else {} + + +def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str: + metadata = _metadata(item) + metadata["resolutionConfirmation"] = state + return json.dumps(metadata, separators=(",", ":"), sort_keys=True) + + +def _interval_delta(value: int, unit: str) -> timedelta: + safe_value = max(1, min(int(value), 365)) + normalized_unit = str(unit or "days").strip().lower() + if normalized_unit == "weeks": + return timedelta(weeks=safe_value) + if normalized_unit == "months": + return timedelta(days=30 * safe_value) + return timedelta(days=safe_value) + + +def _workflow_settings() -> tuple[int, int, str]: + runtime = get_runtime_settings() + attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10)) + interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365)) + interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower() + if interval_unit not in {"days", "weeks", "months"}: + interval_unit = "days" + return attempts, interval_value, interval_unit + + +def _app_url() -> str: + runtime = get_runtime_settings() + for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin): + candidate = str(value or "").strip() + if candidate: + return candidate.rstrip("/") + return f"http://localhost:{int(runtime.magent_application_port or 3000)}" + + +def _issue_url(item_id: int) -> str: + return f"{_app_url()}/portal/issues?item={item_id}" + + +def _activity( + item_id: int, + event_type: str, + message: str, + *, + actor_username: str = _SYSTEM_USER, + actor_role: str = "system", + metadata: Optional[Dict[str, Any]] = None, +) -> None: + add_portal_item_activity( + item_id, + event_type=event_type, + actor_username=actor_username, + actor_role=actor_role, + message=message, + metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None, + ) + + +def _close_issue( + item: Dict[str, Any], + *, + reason: str, + confirmed: bool, + actor_username: str = _SYSTEM_USER, + actor_role: str = "system", +) -> Dict[str, Any]: + now = _now().isoformat() + state = issue_resolution_state(item) + state.update( + { + "status": "confirmed" if confirmed else "auto_closed", + "confirmedAt": now if confirmed else state.get("confirmedAt"), + "closedAt": now, + "nextContactAt": None, + "closedReason": reason, + } + ) + updated = update_portal_item( + int(item["id"]), + status="closed", + issue_resolved_at=now, + metadata_json=_metadata_with_resolution(item, state), + ) + if not updated: + raise RuntimeError("Issue could not be closed") + _activity( + int(item["id"]), + "resolution_confirmed" if confirmed else "issue_auto_closed", + reason, + actor_username=actor_username, + actor_role=actor_role, + ) + return updated + + +async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]: + maximum, interval_value, interval_unit = _workflow_settings() + state = issue_resolution_state(item) + attempts = max(0, int(state.get("attemptsSent") or 0)) + if maximum <= 0: + return _close_issue( + item, + reason="Issue closed automatically because reporter confirmation emails are disabled.", + confirmed=False, + ) + if attempts >= maximum: + return _close_issue( + item, + reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.", + confirmed=False, + ) + + attempt_number = attempts + 1 + reporter = get_user_by_username(str(item.get("created_by_username") or "")) + recipient = resolve_user_delivery_email(reporter) + issue_url = _issue_url(int(item["id"])) + sent = False + delivery_error: Optional[str] = None + if recipient: + subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip() + body_text = ( + f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n" + f"Issue: {item.get('title') or 'Untitled issue'}\n" + f"Confirmation request: {attempt_number} of {maximum}\n\n" + f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n" + "If you do not respond, Magent will close the issue automatically after the configured confirmation period." + ) + body_html = ( + '
' + '

Is your issue fixed?

' + f'

We have marked issue #{int(item["id"])} as fixed and need your confirmation.

' + f'

{escape(str(item.get("title") or "Untitled issue"))}
' + f'Confirmation request {attempt_number} of {maximum}

' + f'Confirm the outcome' + '

If you do not respond, Magent will close the issue automatically after the configured confirmation period.

' + '
' + ) + try: + await send_generic_email( + recipient_email=recipient, + subject=subject, + body_text=body_text, + body_html=body_html, + ) + sent = True + except Exception as exc: + delivery_error = str(exc) + logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number) + else: + delivery_error = "No email address is stored for the reporter." + + now = _now() + state.update( + { + "status": "awaiting_confirmation", + "attemptsSent": attempt_number, + "maximumAttempts": maximum, + "lastContactAt": now.isoformat(), + "nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(), + "intervalValue": interval_value, + "intervalUnit": interval_unit, + "lastDeliverySucceeded": sent, + "lastDeliveryError": delivery_error, + } + ) + updated = update_portal_item( + int(item["id"]), + metadata_json=_metadata_with_resolution(item, state), + ) + if not updated: + raise RuntimeError("Issue confirmation schedule could not be saved") + if sent: + message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter." + else: + message = f"Confirmation email {attempt_number} of {maximum} could not be delivered." + _activity( + int(item["id"]), + "confirmation_email_sent" if sent else "confirmation_email_failed", + message, + metadata={ + "attempt": attempt_number, + "maximum": maximum, + "nextContactAt": state["nextContactAt"], + "deliveryError": delivery_error, + }, + ) + return updated + + +async def begin_issue_confirmation( + item_id: int, + *, + actor_username: str, + actor_role: str, +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item or str(item.get("kind") or "").lower() != "issue": + raise ValueError("Issue not found") + now = _now().isoformat() + maximum, interval_value, interval_unit = _workflow_settings() + state = { + "status": "awaiting_confirmation", + "startedAt": now, + "attemptsSent": 0, + "maximumAttempts": maximum, + "lastContactAt": None, + "nextContactAt": now, + "intervalValue": interval_value, + "intervalUnit": interval_unit, + "confirmedAt": None, + "closedAt": None, + } + updated = update_portal_item( + item_id, + status="awaiting_confirmation", + issue_resolved_at=None, + metadata_json=_metadata_with_resolution(item, state), + ) + if not updated: + raise RuntimeError("Issue confirmation workflow could not be started") + _activity( + item_id, + "resolution_proposed", + "The issue was marked fixed and sent to the reporter for confirmation.", + actor_username=actor_username, + actor_role=actor_role, + metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit}, + ) + return await _contact_reporter(updated) + + +def respond_to_issue_confirmation( + item_id: int, + *, + resolved: bool, + actor_username: str, + actor_role: str, +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item or str(item.get("kind") or "").lower() != "issue": + raise ValueError("Issue not found") + if str(item.get("status") or "").lower() != "awaiting_confirmation": + raise ValueError("This issue is not waiting for resolution confirmation") + if resolved: + return _close_issue( + item, + reason="The reporter confirmed that the issue is fixed.", + confirmed=True, + actor_username=actor_username, + actor_role=actor_role, + ) + + now = _now().isoformat() + state = issue_resolution_state(item) + state.update( + { + "status": "reported_still_broken", + "reporterResponseAt": now, + "nextContactAt": None, + "closedAt": None, + } + ) + updated = update_portal_item( + item_id, + status="in_progress", + issue_resolved_at=None, + metadata_json=_metadata_with_resolution(item, state), + ) + if not updated: + raise RuntimeError("Issue could not be reopened") + _activity( + item_id, + "resolution_rejected", + "The reporter said the issue is still happening. The issue was returned to In progress.", + actor_username=actor_username, + actor_role=actor_role, + ) + return updated + + +async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]: + current = (now or _now()).astimezone(timezone.utc) + items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500) + result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0} + maximum, _, _ = _workflow_settings() + for item in items: + state = issue_resolution_state(item) + due_at = _parse_datetime(state.get("nextContactAt")) + if due_at and due_at > current: + continue + try: + attempts = max(0, int(state.get("attemptsSent") or 0)) + if maximum <= 0 or attempts >= maximum: + _close_issue( + item, + reason=( + "Issue closed automatically because reporter confirmation emails are disabled." + if maximum <= 0 + else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response." + ), + confirmed=False, + ) + result["closed"] += 1 + else: + await _contact_reporter(item) + result["contacted"] += 1 + except Exception: + result["failed"] += 1 + logger.exception("issue confirmation processing failed item_id=%s", item.get("id")) + return result + + +async def run_issue_confirmation_loop() -> None: + while True: + try: + result = await process_due_issue_confirmations() + if result["contacted"] or result["closed"] or result["failed"]: + logger.info("issue confirmation sweep complete result=%s", result) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("issue confirmation sweep failed") + await asyncio.sleep(15 * 60) diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 2e9451d..bde257a 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -23,6 +23,7 @@ from backend.app.routers import site as site_router from backend.app.routers import status as status_router from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.services import password_reset +from backend.app.services import issue_resolution from backend.app.services.operation_progress import ( begin_operation, finish_operation, @@ -1290,16 +1291,29 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(requests_router, "save_action"), + patch.object( + requests_router, + "get_portal_item", + return_value={ + "id": 12, + "kind": "issue", + "external_ref": "/requests/3914", + "created_by_username": "admin", + }, + ), + patch.object(requests_router, "add_portal_item_activity") as add_activity, ): result = await requests_router.action_replace_media( "3914", - {"file_id": 77, "confirmed": True}, + {"file_id": 77, "confirmed": True, "issue_id": 12}, {"username": "admin", "role": "admin", "auto_search_enabled": True}, ) self.assertEqual(result["status"], "ok") radarr.delete_movie_file.assert_awaited_once_with(77) radarr.search.assert_awaited_once_with(44) + add_activity.assert_called_once() + self.assertEqual(add_activity.call_args.kwargs["event_type"], "replacement_started") async def test_tv_replacement_options_return_only_safe_file_details(self) -> None: snapshot = Snapshot( @@ -1460,3 +1474,106 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase): ) self.assertEqual(len(processing), 1) self.assertEqual(pending_count, 1) + + +class IssueResolutionWorkflowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + def _create_issue(self, *, status: str = "in_progress") -> dict: + return db.create_portal_item( + kind="issue", + title="Missing content: Test movie", + description="The title is missing.", + created_by_username="reporter", + created_by_id=None, + status=status, + issue_type="missing_content", + ) + + async def test_marking_issue_fixed_records_contact_and_confirmation(self) -> None: + issue = self._create_issue() + reporter = {"username": "reporter", "email": "reporter@example.com"} + with ( + patch.object(issue_resolution, "_workflow_settings", return_value=(2, 3, "days")), + patch.object(issue_resolution, "get_user_by_username", return_value=reporter), + patch.object(issue_resolution, "resolve_user_delivery_email", return_value="reporter@example.com"), + patch.object(issue_resolution, "send_generic_email", new=AsyncMock(return_value=None)) as send_email, + ): + waiting = await issue_resolution.begin_issue_confirmation( + int(issue["id"]), + actor_username="admin", + actor_role="admin", + ) + + self.assertEqual(waiting["status"], "awaiting_confirmation") + state = issue_resolution.issue_resolution_state(waiting) + self.assertEqual(state["attemptsSent"], 1) + self.assertEqual(state["maximumAttempts"], 2) + send_email.assert_awaited_once() + + activity = db.list_portal_item_activity(int(issue["id"])) + self.assertEqual( + [event["event_type"] for event in activity], + ["resolution_proposed", "confirmation_email_sent"], + ) + + closed = issue_resolution.respond_to_issue_confirmation( + int(issue["id"]), + resolved=True, + actor_username="reporter", + actor_role="user", + ) + self.assertEqual(closed["status"], "closed") + self.assertIsNotNone(closed["issue_resolved_at"]) + self.assertEqual(db.list_portal_item_activity(int(issue["id"]))[-1]["event_type"], "resolution_confirmed") + + async def test_zero_confirmation_emails_closes_issue_immediately(self) -> None: + issue = self._create_issue() + with patch.object(issue_resolution, "_workflow_settings", return_value=(0, 1, "days")): + closed = await issue_resolution.begin_issue_confirmation( + int(issue["id"]), + actor_username="admin", + actor_role="admin", + ) + + self.assertEqual(closed["status"], "closed") + self.assertEqual( + [event["event_type"] for event in db.list_portal_item_activity(int(issue["id"]))], + ["resolution_proposed", "issue_auto_closed"], + ) + + async def test_saving_waiting_issue_does_not_restart_confirmation_cycle(self) -> None: + issue = self._create_issue(status="awaiting_confirmation") + user = {"username": "admin", "role": "admin"} + with patch.object(portal_router, "begin_issue_confirmation", new=AsyncMock()) as begin_confirmation: + result = await portal_router.portal_update_item( + int(issue["id"]), + {"title": "Updated title", "status": "awaiting_confirmation"}, + user, + ) + + self.assertEqual(result["item"]["title"], "Updated title") + begin_confirmation.assert_not_awaited() + + def test_public_activity_hides_internal_notes_and_admin_identity(self) -> None: + issue = self._create_issue() + db.add_portal_item_activity( + int(issue["id"]), + event_type="internal_note_added", + actor_username="private-admin-name", + actor_role="admin", + message="Internal diagnostic detail", + ) + db.add_portal_item_activity( + int(issue["id"]), + event_type="status_changed", + actor_username="private-admin-name", + actor_role="admin", + message="Status changed to in progress.", + metadata_json='{"private":true}', + ) + + public_activity = portal_router._activity_payload(issue) + + self.assertNotIn("Internal diagnostic detail", str(public_activity)) + self.assertNotIn("private-admin-name", str(public_activity)) + self.assertNotIn("metadata_json", public_activity[-1]) + self.assertEqual(public_activity[-1]["actor_username"], "Support team") diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 16abf91..77644d2 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -41,6 +41,7 @@ const SECTION_LABELS: Record = { logs: 'Activity log', maintenance: 'Maintenance', requests: 'Request pipeline', + 'issue-workflow': 'Issue workflow', site: 'Site & login', } @@ -95,6 +96,8 @@ const NUMBER_SETTINGS = new Set([ 'requests_poll_interval_seconds', 'requests_delta_sync_interval_minutes', 'requests_cleanup_days', + 'issue_confirmation_contact_attempts', + 'issue_confirmation_interval_value', ]) const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] @@ -115,6 +118,7 @@ const SECTION_DESCRIPTIONS: Record = { prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.', qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.', requests: 'Control how often requests are refreshed and cleaned up.', + 'issue-workflow': 'Control reporter confirmation, reminder timing, and automatic issue closure.', logs: 'Control log output and inspect recent application activity for troubleshooting.', maintenance: 'Repair cached data, clean historical records, and run recovery operations.', site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.', @@ -133,6 +137,7 @@ const SETTINGS_SECTION_MAP: Record = { prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', requests: 'requests', + 'issue-workflow': 'issue', cache: null, logs: 'log', maintenance: null, @@ -390,6 +395,18 @@ const STANDARD_SECTION_GROUPS: Record< keys: ['requests_cleanup_time', 'requests_cleanup_days'], }, ], + 'issue-workflow': [ + { + key: 'issues-resolution-confirmation', + title: 'Resolution confirmation', + description: 'Choose how often Magent asks a reporter to confirm a fix before the issue is closed automatically.', + keys: [ + 'issue_confirmation_contact_attempts', + 'issue_confirmation_interval_value', + 'issue_confirmation_interval_unit', + ], + }, + ], logs: [ { key: 'logs-output', @@ -413,6 +430,9 @@ const STANDARD_SECTION_GROUPS: Record< } const SETTING_LABEL_OVERRIDES: Record = { + issue_confirmation_contact_attempts: 'Confirmation emails before auto-close', + issue_confirmation_interval_value: 'Confirmation interval', + issue_confirmation_interval_unit: 'Interval unit', jellyseerr_base_url: 'Seerr base URL', jellyseerr_api_key: 'Seerr API key', magent_application_url: 'Application URL', @@ -896,6 +916,12 @@ export default function SettingsPage({ section }: SettingsPageProps) { }, [artworkPrefetch]) const settingDescriptions: Record = { + issue_confirmation_contact_attempts: + 'Number of confirmation emails sent after an issue is marked fixed. Set 0 to send none and close immediately.', + issue_confirmation_interval_value: + 'Amount of time between confirmation emails, and the final waiting period before automatic closure.', + issue_confirmation_interval_unit: + 'Unit used for the confirmation interval: days, weeks, or months.', magent_application_url: 'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).', magent_application_port: @@ -2404,7 +2430,8 @@ export default function SettingsPage({ section }: SettingsPageProps) { @@ -2417,6 +2444,32 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) } + if (setting.key === 'issue_confirmation_interval_unit') { + return ( + + ) + } if (setting.key === 'requests_data_source') { return (