Add issue resolution confirmation workflow
This commit is contained in:
@@ -85,6 +85,15 @@ class Settings(BaseSettings):
|
|||||||
requests_data_source: str = Field(
|
requests_data_source: str = Field(
|
||||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
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(
|
artwork_cache_mode: str = Field(
|
||||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
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]
|
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]:
|
def get_portal_overview() -> Dict[str, Any]:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
kind_rows = conn.execute(
|
kind_rows = conn.execute(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .routers.events import router as events_router
|
|||||||
from .routers.portal import router as portal_router
|
from .routers.portal import router as portal_router
|
||||||
from .routers.operations import router as operations_router
|
from .routers.operations import router as operations_router
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
|
from .services.issue_resolution import run_issue_confirmation_loop
|
||||||
from .services.operation_progress import (
|
from .services.operation_progress import (
|
||||||
begin_operation,
|
begin_operation,
|
||||||
finish_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-delta-loop", run_requests_delta_loop)
|
||||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
||||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
||||||
|
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||||
logger.info("startup complete")
|
logger.info("startup complete")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ SETTING_KEYS: List[str] = [
|
|||||||
"requests_cleanup_time",
|
"requests_cleanup_time",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"requests_data_source",
|
"requests_data_source",
|
||||||
|
"issue_confirmation_contact_attempts",
|
||||||
|
"issue_confirmation_interval_value",
|
||||||
|
"issue_confirmation_interval_unit",
|
||||||
"site_banner_enabled",
|
"site_banner_enabled",
|
||||||
"site_banner_message",
|
"site_banner_message",
|
||||||
"site_banner_tone",
|
"site_banner_tone",
|
||||||
@@ -661,6 +664,26 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
changed_keys.append(key)
|
changed_keys.append(key)
|
||||||
continue
|
continue
|
||||||
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
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:
|
if key in URL_SETTING_KEYS and value_to_store:
|
||||||
try:
|
try:
|
||||||
value_to_store = _normalize_service_url(value_to_store)
|
value_to_store = _normalize_service_url(value_to_store)
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import (
|
from ..db import (
|
||||||
|
add_portal_item_activity,
|
||||||
add_portal_comment,
|
add_portal_comment,
|
||||||
count_portal_items,
|
count_portal_items,
|
||||||
create_portal_item,
|
create_portal_item,
|
||||||
get_portal_item,
|
get_portal_item,
|
||||||
get_portal_overview,
|
get_portal_overview,
|
||||||
list_portal_comments,
|
list_portal_comments,
|
||||||
|
list_portal_item_activity,
|
||||||
list_portal_items,
|
list_portal_items,
|
||||||
update_portal_item,
|
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 ..services.notifications import send_portal_notification
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
@@ -37,6 +44,7 @@ PORTAL_STATUSES = {
|
|||||||
"done",
|
"done",
|
||||||
"declined",
|
"declined",
|
||||||
"closed",
|
"closed",
|
||||||
|
"awaiting_confirmation",
|
||||||
# Seerr-style request pipeline statuses
|
# Seerr-style request pipeline statuses
|
||||||
"pending",
|
"pending",
|
||||||
"approved",
|
"approved",
|
||||||
@@ -389,6 +397,11 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
|||||||
"can_comment": True,
|
"can_comment": True,
|
||||||
"can_moderate": is_admin,
|
"can_moderate": is_admin,
|
||||||
"can_raise_issue": str(item.get("kind") or "") == "request",
|
"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()
|
kind = str(item.get("kind") or "").strip().lower()
|
||||||
if kind == "request":
|
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",
|
"is_terminal": media_status in {"available", "failed"} or request_status == "declined",
|
||||||
}
|
}
|
||||||
elif kind == "issue":
|
elif kind == "issue":
|
||||||
|
resolution = issue_resolution_state(item)
|
||||||
serialized["issue"] = {
|
serialized["issue"] = {
|
||||||
"issue_type": _clean_text(item.get("issue_type")) or "general",
|
"issue_type": _clean_text(item.get("issue_type")) or "general",
|
||||||
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
|
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
|
||||||
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
|
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
|
||||||
"resolved_at": _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
|
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(
|
async def _notify(
|
||||||
*,
|
*,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
@@ -778,6 +860,16 @@ async def portal_create_item(
|
|||||||
priority=priority or "normal",
|
priority=priority or "normal",
|
||||||
assignee_username=assignee_username,
|
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"))
|
initial_comment = _clean_text(payload.get("comment"))
|
||||||
if initial_comment:
|
if initial_comment:
|
||||||
add_portal_comment(
|
add_portal_comment(
|
||||||
@@ -797,6 +889,7 @@ async def portal_create_item(
|
|||||||
return {
|
return {
|
||||||
"item": _serialize_item(created, current_user),
|
"item": _serialize_item(created, current_user),
|
||||||
"comments": comments,
|
"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",
|
priority=priority or "normal",
|
||||||
assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
|
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"))
|
initial_comment = _clean_text(payload.get("comment"))
|
||||||
if initial_comment:
|
if initial_comment:
|
||||||
add_portal_comment(
|
add_portal_comment(
|
||||||
@@ -868,6 +967,7 @@ async def portal_create_issue_for_request(
|
|||||||
return {
|
return {
|
||||||
"item": _serialize_item(created, current_user),
|
"item": _serialize_item(created, current_user),
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
|
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
|
||||||
"linked_request_id": item_id,
|
"linked_request_id": item_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,6 +1080,7 @@ async def portal_get_item(
|
|||||||
return {
|
return {
|
||||||
"item": _serialize_item(item, current_user),
|
"item": _serialize_item(item, current_user),
|
||||||
"comments": comments,
|
"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")
|
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||||
is_admin = _is_admin(current_user)
|
is_admin = _is_admin(current_user)
|
||||||
is_owner = _is_owner(current_user, item)
|
is_owner = _is_owner(current_user, item)
|
||||||
|
item_kind = str(item.get("kind") or "").lower()
|
||||||
if not (is_admin or is_owner):
|
if not (is_admin or is_owner):
|
||||||
raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
|
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:
|
if "external_ref" in payload:
|
||||||
updates["external_ref"] = _clean_text(payload.get("external_ref"))
|
updates["external_ref"] = _clean_text(payload.get("external_ref"))
|
||||||
if is_admin:
|
if is_admin:
|
||||||
kind = str(item.get("kind") or "").lower()
|
kind = item_kind
|
||||||
if "priority" in payload:
|
if "priority" in payload:
|
||||||
updates["priority"] = _normalize_choice(
|
updates["priority"] = _normalize_choice(
|
||||||
payload.get("priority"),
|
payload.get("priority"),
|
||||||
@@ -1133,9 +1235,9 @@ async def portal_update_item(
|
|||||||
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
|
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
|
||||||
if "status" in payload:
|
if "status" in payload:
|
||||||
next_status = str(updates.get("status") or item.get("status") or "").lower()
|
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())
|
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)
|
updates.setdefault("issue_resolved_at", None)
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -1143,14 +1245,47 @@ async def portal_update_item(
|
|||||||
return {
|
return {
|
||||||
"item": _serialize_item(item, current_user),
|
"item": _serialize_item(item, current_user),
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
|
"activity": _activity_payload(item, include_internal=is_admin),
|
||||||
}
|
}
|
||||||
|
|
||||||
updated = update_portal_item(item_id, **updates)
|
updated = update_portal_item(item_id, **updates)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
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)]
|
changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
|
||||||
if changed_fields:
|
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(
|
await _notify(
|
||||||
event_type="portal_item_updated",
|
event_type="portal_item_updated",
|
||||||
item=updated,
|
item=updated,
|
||||||
@@ -1161,6 +1296,42 @@ async def portal_update_item(
|
|||||||
return {
|
return {
|
||||||
"item": _serialize_item(updated, current_user),
|
"item": _serialize_item(updated, current_user),
|
||||||
"comments": comments,
|
"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,
|
message=message,
|
||||||
is_internal=is_internal,
|
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)
|
updated_item = get_portal_item(item_id)
|
||||||
if updated_item:
|
if updated_item:
|
||||||
await _notify(
|
await _notify(
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from ..auth import get_current_user
|
|||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .images import cache_tmdb_image, is_tmdb_cached
|
from .images import cache_tmdb_image, is_tmdb_cached
|
||||||
from ..db import (
|
from ..db import (
|
||||||
|
add_portal_item_activity,
|
||||||
|
get_portal_item,
|
||||||
save_action,
|
save_action,
|
||||||
get_recent_actions,
|
get_recent_actions,
|
||||||
get_recent_snapshots,
|
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:
|
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
|
||||||
if root_folder.isdigit():
|
if root_folder.isdigit():
|
||||||
folders = await client.get_root_folders()
|
folders = await client.get_root_folders()
|
||||||
@@ -1915,6 +1957,11 @@ async def action_replace_media(
|
|||||||
file_id = payload.get("file_id")
|
file_id = payload.get("file_id")
|
||||||
if not isinstance(file_id, int) or file_id <= 0:
|
if not isinstance(file_id, int) or file_id <= 0:
|
||||||
raise HTTPException(status_code=400, detail="A valid managed file is required")
|
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()
|
runtime = get_runtime_settings()
|
||||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
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)
|
await sonarr.search_episodes(episode_ids)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Unknown request type")
|
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
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id)
|
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",
|
"failed",
|
||||||
detail,
|
detail,
|
||||||
)
|
)
|
||||||
|
_record_replacement_activity(
|
||||||
|
linked_issue,
|
||||||
|
user=user,
|
||||||
|
event_type="replacement_failed",
|
||||||
|
message=detail,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=502, detail=detail) from exc
|
raise HTTPException(status_code=502, detail=detail) from exc
|
||||||
|
|
||||||
message = f"{collector} removed {target_name} and started a replacement search."
|
message = f"{collector} removed {target_name} and started a replacement search."
|
||||||
@@ -2003,6 +2062,12 @@ async def action_replace_media(
|
|||||||
"ok",
|
"ok",
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
|
_record_replacement_activity(
|
||||||
|
linked_issue,
|
||||||
|
user=user,
|
||||||
|
event_type="replacement_started",
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": message,
|
"message": message,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ _INT_FIELDS = {
|
|||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
|
"issue_confirmation_contact_attempts",
|
||||||
|
"issue_confirmation_interval_value",
|
||||||
"magent_notify_email_smtp_port",
|
"magent_notify_email_smtp_port",
|
||||||
}
|
}
|
||||||
_BOOL_FIELDS = {
|
_BOOL_FIELDS = {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
|
||||||
|
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
|
||||||
|
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
|
||||||
|
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
|
||||||
|
f'Confirmation request {attempt_number} of {maximum}</p>'
|
||||||
|
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
|
||||||
|
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -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.routers import status as status_router
|
||||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||||
from backend.app.services import password_reset
|
from backend.app.services import password_reset
|
||||||
|
from backend.app.services import issue_resolution
|
||||||
from backend.app.services.operation_progress import (
|
from backend.app.services.operation_progress import (
|
||||||
begin_operation,
|
begin_operation,
|
||||||
finish_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, "build_snapshot", new=AsyncMock(return_value=snapshot)),
|
||||||
patch.object(requests_router, "RadarrClient", return_value=radarr),
|
patch.object(requests_router, "RadarrClient", return_value=radarr),
|
||||||
patch.object(requests_router, "save_action"),
|
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(
|
result = await requests_router.action_replace_media(
|
||||||
"3914",
|
"3914",
|
||||||
{"file_id": 77, "confirmed": True},
|
{"file_id": 77, "confirmed": True, "issue_id": 12},
|
||||||
{"username": "admin", "role": "admin", "auto_search_enabled": True},
|
{"username": "admin", "role": "admin", "auto_search_enabled": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["status"], "ok")
|
self.assertEqual(result["status"], "ok")
|
||||||
radarr.delete_movie_file.assert_awaited_once_with(77)
|
radarr.delete_movie_file.assert_awaited_once_with(77)
|
||||||
radarr.search.assert_awaited_once_with(44)
|
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:
|
async def test_tv_replacement_options_return_only_safe_file_details(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
@@ -1460,3 +1474,106 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(len(processing), 1)
|
self.assertEqual(len(processing), 1)
|
||||||
self.assertEqual(pending_count, 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")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const SECTION_LABELS: Record<string, string> = {
|
|||||||
logs: 'Activity log',
|
logs: 'Activity log',
|
||||||
maintenance: 'Maintenance',
|
maintenance: 'Maintenance',
|
||||||
requests: 'Request pipeline',
|
requests: 'Request pipeline',
|
||||||
|
'issue-workflow': 'Issue workflow',
|
||||||
site: 'Site & login',
|
site: 'Site & login',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ const NUMBER_SETTINGS = new Set([
|
|||||||
'requests_poll_interval_seconds',
|
'requests_poll_interval_seconds',
|
||||||
'requests_delta_sync_interval_minutes',
|
'requests_delta_sync_interval_minutes',
|
||||||
'requests_cleanup_days',
|
'requests_cleanup_days',
|
||||||
|
'issue_confirmation_contact_attempts',
|
||||||
|
'issue_confirmation_interval_value',
|
||||||
])
|
])
|
||||||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||||||
|
|
||||||
@@ -115,6 +118,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
|
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
|
||||||
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
||||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
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.',
|
logs: 'Control log output and inspect recent application activity for troubleshooting.',
|
||||||
maintenance: 'Repair cached data, clean historical records, and run recovery operations.',
|
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.',
|
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<string, string | null> = {
|
|||||||
prowlarr: 'prowlarr',
|
prowlarr: 'prowlarr',
|
||||||
qbittorrent: 'qbittorrent',
|
qbittorrent: 'qbittorrent',
|
||||||
requests: 'requests',
|
requests: 'requests',
|
||||||
|
'issue-workflow': 'issue',
|
||||||
cache: null,
|
cache: null,
|
||||||
logs: 'log',
|
logs: 'log',
|
||||||
maintenance: null,
|
maintenance: null,
|
||||||
@@ -390,6 +395,18 @@ const STANDARD_SECTION_GROUPS: Record<
|
|||||||
keys: ['requests_cleanup_time', 'requests_cleanup_days'],
|
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: [
|
logs: [
|
||||||
{
|
{
|
||||||
key: 'logs-output',
|
key: 'logs-output',
|
||||||
@@ -413,6 +430,9 @@ const STANDARD_SECTION_GROUPS: Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
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_base_url: 'Seerr base URL',
|
||||||
jellyseerr_api_key: 'Seerr API key',
|
jellyseerr_api_key: 'Seerr API key',
|
||||||
magent_application_url: 'Application URL',
|
magent_application_url: 'Application URL',
|
||||||
@@ -896,6 +916,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [artworkPrefetch])
|
}, [artworkPrefetch])
|
||||||
|
|
||||||
const settingDescriptions: Record<string, string> = {
|
const settingDescriptions: Record<string, string> = {
|
||||||
|
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:
|
magent_application_url:
|
||||||
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
|
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
|
||||||
magent_application_port:
|
magent_application_port:
|
||||||
@@ -2404,7 +2430,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<input
|
<input
|
||||||
name={setting.key}
|
name={setting.key}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={setting.key === 'issue_confirmation_contact_attempts' ? 0 : 1}
|
||||||
|
max={setting.key === 'issue_confirmation_contact_attempts' ? 10 : setting.key === 'issue_confirmation_interval_value' ? 365 : undefined}
|
||||||
step={1}
|
step={1}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -2417,6 +2444,32 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (setting.key === 'issue_confirmation_interval_unit') {
|
||||||
|
return (
|
||||||
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
<span className="label-row">
|
||||||
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
|
<span className="meta">
|
||||||
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name={setting.key}
|
||||||
|
value={value || 'days'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[setting.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="days">Days</option>
|
||||||
|
<option value="weeks">Weeks</option>
|
||||||
|
<option value="months">Months</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (setting.key === 'requests_data_source') {
|
if (setting.key === 'requests_data_source') {
|
||||||
return (
|
return (
|
||||||
<label key={setting.key} data-helper={helperText || undefined}>
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'prowlarr',
|
'prowlarr',
|
||||||
'qbittorrent',
|
'qbittorrent',
|
||||||
'requests',
|
'requests',
|
||||||
|
'issue-workflow',
|
||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
|
|||||||
@@ -2863,6 +2863,80 @@ button:disabled,
|
|||||||
color: var(--request-green);
|
color: var(--request-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-confirmation-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.34);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: linear-gradient(120deg, rgba(14, 165, 233, 0.1), rgba(79, 70, 229, 0.07));
|
||||||
|
}
|
||||||
|
.issue-confirmation-card > div:first-child { display: grid; gap: 6px; }
|
||||||
|
.issue-confirmation-card h3,
|
||||||
|
.issue-confirmation-card p { margin: 0; }
|
||||||
|
.issue-confirmation-card p,
|
||||||
|
.issue-confirmation-card small { color: var(--ops-muted); line-height: 1.5; }
|
||||||
|
.issue-confirmation-actions { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||||
|
.issue-activity-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
}
|
||||||
|
.issue-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.issue-activity-heading h3 { margin: 0; }
|
||||||
|
.issue-activity-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.issue-activity-list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--ops-line-soft);
|
||||||
|
}
|
||||||
|
.issue-activity-list li:first-child { border-top: 0; }
|
||||||
|
.issue-activity-list i {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 2px solid rgba(126, 215, 255, 0.52);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(14, 165, 233, 0.18);
|
||||||
|
}
|
||||||
|
.issue-activity-list li > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.issue-activity-list strong { color: var(--ops-text); font-size: 0.78rem; line-height: 1.45; }
|
||||||
|
.issue-activity-list span,
|
||||||
|
.issue-activity-list time { color: var(--ops-muted); font-size: 0.67rem; }
|
||||||
|
.issue-activity-list time { white-space: nowrap; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.issue-confirmation-card {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-confirmation-actions {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-confirmation-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-activity-list li {
|
||||||
|
grid-template-columns: 12px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-activity-list time {
|
||||||
|
grid-column: 2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.issue-media-poster {
|
.issue-media-poster {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type PortalPermissions = {
|
|||||||
can_edit?: boolean
|
can_edit?: boolean
|
||||||
can_comment?: boolean
|
can_comment?: boolean
|
||||||
can_moderate?: boolean
|
can_moderate?: boolean
|
||||||
|
can_confirm_resolution?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PortalItem = {
|
type PortalItem = {
|
||||||
@@ -39,6 +40,16 @@ type PortalItem = {
|
|||||||
related_item_id?: number | null
|
related_item_id?: number | null
|
||||||
is_resolved?: boolean
|
is_resolved?: boolean
|
||||||
resolved_at?: string | null
|
resolved_at?: string | null
|
||||||
|
confirmation?: {
|
||||||
|
status?: string | null
|
||||||
|
attempts_sent?: number
|
||||||
|
maximum_attempts?: number
|
||||||
|
last_contact_at?: string | null
|
||||||
|
next_contact_at?: string | null
|
||||||
|
interval_value?: number | null
|
||||||
|
interval_unit?: string | null
|
||||||
|
last_delivery_succeeded?: boolean | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +63,16 @@ type PortalComment = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PortalActivity = {
|
||||||
|
id: number | string
|
||||||
|
item_id: number
|
||||||
|
event_type: string
|
||||||
|
actor_username: string
|
||||||
|
actor_role: string
|
||||||
|
message: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
type PortalOverview = {
|
type PortalOverview = {
|
||||||
overview?: {
|
overview?: {
|
||||||
total_items?: number
|
total_items?: number
|
||||||
@@ -208,7 +229,8 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: 'planned', label: 'Planned' },
|
{ value: 'planned', label: 'Planned' },
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
{ value: 'in_progress', label: 'In progress' },
|
||||||
{ value: 'blocked', label: 'Blocked' },
|
{ value: 'blocked', label: 'Blocked' },
|
||||||
{ value: 'done', label: 'Done' },
|
{ value: 'awaiting_confirmation', label: 'Fixed - ask reporter to confirm' },
|
||||||
|
{ value: 'done', label: 'Resolved (legacy)' },
|
||||||
{ value: 'pending', label: 'Pending approval' },
|
{ value: 'pending', label: 'Pending approval' },
|
||||||
{ value: 'approved', label: 'Approved' },
|
{ value: 'approved', label: 'Approved' },
|
||||||
{ value: 'processing', label: 'Processing' },
|
{ value: 'processing', label: 'Processing' },
|
||||||
@@ -263,7 +285,8 @@ const ISSUE_FILTER_STATUS_OPTIONS = [
|
|||||||
{ value: 'planned', label: 'Planned' },
|
{ value: 'planned', label: 'Planned' },
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
{ value: 'in_progress', label: 'In progress' },
|
||||||
{ value: 'blocked', label: 'Blocked' },
|
{ value: 'blocked', label: 'Blocked' },
|
||||||
{ value: 'done', label: 'Done' },
|
{ value: 'awaiting_confirmation', label: 'Waiting for confirmation' },
|
||||||
|
{ value: 'done', label: 'Previously resolved' },
|
||||||
{ value: 'closed', label: 'Closed' },
|
{ value: 'closed', label: 'Closed' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -274,6 +297,20 @@ const formatDate = (value?: string | null) => {
|
|||||||
return parsed.toLocaleString()
|
return parsed.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatIssueStatus = (value?: string | null) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
new: 'New',
|
||||||
|
triaging: 'Triaging',
|
||||||
|
planned: 'Planned',
|
||||||
|
in_progress: 'In progress',
|
||||||
|
blocked: 'Blocked',
|
||||||
|
awaiting_confirmation: 'Waiting for reporter confirmation',
|
||||||
|
done: 'Resolved',
|
||||||
|
closed: 'Closed',
|
||||||
|
}
|
||||||
|
return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
const formatFileSize = (value?: number | null) => {
|
const formatFileSize = (value?: number | null) => {
|
||||||
if (!value || value <= 0) return 'Size unavailable'
|
if (!value || value <= 0) return 'Size unavailable'
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
@@ -301,11 +338,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
||||||
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
|
||||||
const [comments, setComments] = useState<PortalComment[]>([])
|
const [comments, setComments] = useState<PortalComment[]>([])
|
||||||
|
const [activity, setActivity] = useState<PortalActivity[]>([])
|
||||||
const [loadingItems, setLoadingItems] = useState(true)
|
const [loadingItems, setLoadingItems] = useState(true)
|
||||||
const [loadingItem, setLoadingItem] = useState(false)
|
const [loadingItem, setLoadingItem] = useState(false)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [commenting, setCommenting] = useState(false)
|
const [commenting, setCommenting] = useState(false)
|
||||||
|
const [respondingResolution, setRespondingResolution] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
const [totalItems, setTotalItems] = useState(0)
|
const [totalItems, setTotalItems] = useState(0)
|
||||||
@@ -455,6 +494,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
setComments([])
|
setComments([])
|
||||||
|
setActivity([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to load portal item (${response.status})`)
|
throw new Error(`Failed to load portal item (${response.status})`)
|
||||||
@@ -463,6 +503,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const item = (data?.item ?? null) as PortalItem | null
|
const item = (data?.item ?? null) as PortalItem | null
|
||||||
setSelectedItem(item)
|
setSelectedItem(item)
|
||||||
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||||
|
setActivity(Array.isArray(data?.activity) ? data.activity : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError('Could not load portal item details.')
|
setError('Could not load portal item details.')
|
||||||
@@ -1021,6 +1062,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setSelectedItemId(null)
|
setSelectedItemId(null)
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
setComments([])
|
setComments([])
|
||||||
|
setActivity([])
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [workspace])
|
}, [workspace])
|
||||||
|
|
||||||
@@ -1139,6 +1181,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setSelectedItem((data?.item ?? null) as PortalItem | null)
|
setSelectedItem((data?.item ?? null) as PortalItem | null)
|
||||||
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||||
|
setActivity(Array.isArray(data?.activity) ? data.activity : [])
|
||||||
setStatus('Portal item updated.')
|
setStatus('Portal item updated.')
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadItems({ preferItemId: selectedItem.id }),
|
loadItems({ preferItemId: selectedItem.id }),
|
||||||
@@ -1197,6 +1240,40 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const respondToResolution = async (resolved: boolean) => {
|
||||||
|
if (!selectedItem) return
|
||||||
|
setRespondingResolution(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${getApiBase()}/portal/issues/${selectedItem.id}/resolution-response`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ resolved }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Could not record your confirmation.')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setSelectedItem((data?.item ?? null) as PortalItem | null)
|
||||||
|
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||||
|
setActivity(Array.isArray(data?.activity) ? data.activity : [])
|
||||||
|
setStatus(resolved ? 'Thanks. This issue has been closed.' : 'Thanks. The issue is back in progress for another look.')
|
||||||
|
await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not record your confirmation.')
|
||||||
|
} finally {
|
||||||
|
setRespondingResolution(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loadingItems && !items.length) {
|
if (loadingItems && !items.length) {
|
||||||
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
|
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
|
||||||
}
|
}
|
||||||
@@ -1809,7 +1886,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
Status:{' '}
|
Status:{' '}
|
||||||
{item.kind === 'request'
|
{item.kind === 'request'
|
||||||
? item.workflow?.stage_label ?? item.status
|
? item.workflow?.stage_label ?? item.status
|
||||||
: item.status}
|
: formatIssueStatus(item.status)}
|
||||||
</span>
|
</span>
|
||||||
<span>By: {item.created_by_username}</span>
|
<span>By: {item.created_by_username}</span>
|
||||||
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
||||||
@@ -1876,6 +1953,34 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' ? (
|
||||||
|
<section className="issue-confirmation-card" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Resolution check</span>
|
||||||
|
<h3>Has this issue been fixed?</h3>
|
||||||
|
<p>
|
||||||
|
Magent is waiting for the reporter to confirm the result.
|
||||||
|
{(selectedItem.issue?.confirmation?.maximum_attempts ?? 0) > 0
|
||||||
|
? ` ${selectedItem.issue?.confirmation?.attempts_sent ?? 0} of ${selectedItem.issue?.confirmation?.maximum_attempts ?? 0} confirmation emails have been attempted.`
|
||||||
|
: ' Confirmation emails are disabled, so this issue will close automatically.'}
|
||||||
|
</p>
|
||||||
|
{selectedItem.issue?.confirmation?.next_contact_at ? (
|
||||||
|
<small>Next reminder or automatic closure check: {formatDate(selectedItem.issue.confirmation.next_contact_at)}</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedItem.permissions?.can_confirm_resolution ? (
|
||||||
|
<div className="issue-confirmation-actions">
|
||||||
|
<button type="button" disabled={respondingResolution} onClick={() => void respondToResolution(true)}>
|
||||||
|
Yes, it is fixed
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-button" disabled={respondingResolution} onClick={() => void respondToResolution(false)}>
|
||||||
|
No, it is still happening
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||||
<label className="portal-field-span-2">
|
<label className="portal-field-span-2">
|
||||||
<span>Title</span>
|
<span>Title</span>
|
||||||
@@ -2015,6 +2120,34 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{selectedItem.kind === 'issue' ? (
|
||||||
|
<section className="issue-activity-block">
|
||||||
|
<div className="issue-activity-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Recorded work</span>
|
||||||
|
<h3>Issue activity</h3>
|
||||||
|
</div>
|
||||||
|
<span className="small-pill">{activity.length} events</span>
|
||||||
|
</div>
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<div className="status-banner">No issue activity has been recorded yet.</div>
|
||||||
|
) : (
|
||||||
|
<ol className="issue-activity-list">
|
||||||
|
{activity.map((entry) => (
|
||||||
|
<li key={entry.id}>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{entry.message}</strong>
|
||||||
|
<span>{entry.actor_username} ({entry.actor_role})</span>
|
||||||
|
</div>
|
||||||
|
<time dateTime={entry.created_at}>{formatDate(entry.created_at)}</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="portal-comments-block">
|
<div className="portal-comments-block">
|
||||||
<h3>Comments</h3>
|
<h3>Comments</h3>
|
||||||
{comments.length === 0 ? (
|
{comments.length === 0 ? (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const NAV_GROUPS = [
|
|||||||
title: 'Request Pipeline',
|
title: 'Request Pipeline',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/requests', label: 'Sync & retention' },
|
{ href: '/admin/requests', label: 'Sync & retention' },
|
||||||
|
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
|
||||||
{ href: '/admin/cache', label: 'Request cache' },
|
{ href: '/admin/cache', label: 'Request cache' },
|
||||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||||
{ href: '/admin/requests-all', label: 'All requests' },
|
{ href: '/admin/requests-all', label: 'All requests' },
|
||||||
|
|||||||
Reference in New Issue
Block a user