diff --git a/backend/app/db.py b/backend/app/db.py index 50a0301..e2d50a8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -3519,6 +3519,23 @@ def update_portal_item( return updated +def delete_portal_item(item_id: int) -> bool: + with _connect() as conn: + conn.execute( + "UPDATE portal_items SET related_item_id = NULL WHERE related_item_id = ?", + (item_id,), + ) + conn.execute("DELETE FROM portal_comments WHERE item_id = ?", (item_id,)) + conn.execute("DELETE FROM portal_item_activity WHERE item_id = ?", (item_id,)) + deleted = conn.execute( + "DELETE FROM portal_items WHERE id = ?", + (item_id,), + ).rowcount + if deleted: + logger.info("portal item deleted id=%s", item_id) + return bool(deleted) + + def add_portal_comment( item_id: int, *, diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py index 27ce1f1..ef948b3 100644 --- a/backend/app/routers/portal.py +++ b/backend/app/routers/portal.py @@ -15,6 +15,7 @@ from ..db import ( add_portal_comment, count_portal_items, create_portal_item, + delete_portal_item, get_portal_item, get_portal_overview, list_portal_comments, @@ -487,6 +488,7 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any "can_edit": is_admin or is_owner, "can_comment": True, "can_moderate": is_admin, + "can_delete": is_admin and str(item.get("kind") or "").lower() == "issue", "can_raise_issue": str(item.get("kind") or "") == "request", "can_confirm_resolution": ( str(item.get("kind") or "").lower() == "issue" @@ -1176,6 +1178,29 @@ async def portal_get_item( } +@router.delete("/items/{item_id}") +async def portal_delete_item( + item_id: int, + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + if not _is_admin(current_user): + raise HTTPException(status_code=403, detail="Admin access required") + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + if str(item.get("kind") or "").lower() != "issue": + raise HTTPException(status_code=400, detail="Only issues can be deleted here") + if not delete_portal_item(item_id): + raise HTTPException(status_code=404, detail="Issue not found") + logger.info( + "portal issue deleted id=%s title=%s actor=%s", + item_id, + item.get("title"), + current_user.get("username"), + ) + return {"status": "deleted", "item_id": item_id} + + @router.patch("/items/{item_id}") async def portal_update_item( item_id: int, diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 493e5d5..09d6c10 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1682,6 +1682,76 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase): self.assertEqual(pending_count, 1) +class PortalIssueDeletionTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + def _create_issue(self) -> dict: + issue = db.create_portal_item( + kind="issue", + title="Delete this issue", + description="No longer required", + created_by_username="reporter", + created_by_id=None, + issue_type="playback", + ) + db.add_portal_comment( + int(issue["id"]), + author_username="reporter", + author_role="user", + message="Issue detail", + ) + db.add_portal_item_activity( + int(issue["id"]), + event_type="item_created", + actor_username="reporter", + actor_role="user", + message="Issue created", + ) + return issue + + async def test_only_admin_can_delete_an_issue(self) -> None: + issue = self._create_issue() + + with self.assertRaises(HTTPException) as context: + await portal_router.portal_delete_item( + int(issue["id"]), + current_user={"username": "reporter", "role": "user"}, + ) + + self.assertEqual(context.exception.status_code, 403) + self.assertIsNotNone(db.get_portal_item(int(issue["id"]))) + + async def test_delete_issue_removes_its_comments_and_activity(self) -> None: + issue = self._create_issue() + issue_id = int(issue["id"]) + + result = await portal_router.portal_delete_item( + issue_id, + current_user={"username": "admin", "role": "admin"}, + ) + + self.assertEqual(result, {"status": "deleted", "item_id": issue_id}) + self.assertIsNone(db.get_portal_item(issue_id)) + self.assertEqual(db.list_portal_comments(issue_id), []) + self.assertEqual(db.list_portal_item_activity(issue_id), []) + + async def test_delete_endpoint_will_not_delete_a_request(self) -> None: + request_item = db.create_portal_item( + kind="request", + title="Keep this request", + description="The media workflow must remain intact", + created_by_username="reporter", + created_by_id=None, + ) + + with self.assertRaises(HTTPException) as context: + await portal_router.portal_delete_item( + int(request_item["id"]), + current_user={"username": "admin", "role": "admin"}, + ) + + self.assertEqual(context.exception.status_code, 400) + self.assertIsNotNone(db.get_portal_item(int(request_item["id"]))) + + class IssueResolutionWorkflowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): def _create_issue(self, *, status: str = "in_progress") -> dict: return db.create_portal_item( diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 3b4896e..cca0750 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2761,6 +2761,46 @@ button:disabled, gap: 2px; } +.issue-modal-toolbar > .issue-modal-toolbar-actions { + display: flex; + grid-auto-flow: column; + gap: 8px; +} + +.issue-delete-confirmation { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 16px; + border: 1px solid rgba(255, 86, 113, 0.42); + border-radius: var(--ops-radius-lg); + background: linear-gradient(120deg, rgba(255, 86, 113, 0.11), rgba(255, 86, 113, 0.035)); +} + +.issue-delete-confirmation > div:first-child { + display: grid; + gap: 5px; +} + +.issue-delete-confirmation > div:last-child { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.issue-delete-confirmation h3, +.issue-delete-confirmation p { + margin: 0; +} + +.issue-delete-confirmation p { + max-width: 720px; + color: var(--ops-muted); + font-size: 0.74rem; + line-height: 1.5; +} + .issue-pipeline-card { display: grid; gap: 16px; @@ -3785,6 +3825,10 @@ button:disabled, padding-right: 13px; padding-left: 13px; } + .issue-modal-toolbar > .issue-modal-toolbar-actions { display: flex; } + .issue-delete-confirmation { align-items: stretch; flex-direction: column; } + .issue-delete-confirmation > div:last-child { display: grid; } + .issue-delete-confirmation button { width: 100%; } .issue-pipeline-card > header { flex-direction: column; } .issue-pipeline-card > ol { grid-template-columns: 1fr; gap: 8px; } .issue-pipeline-card li { grid-template-columns: 25px minmax(0, 1fr); align-items: center; justify-items: start; text-align: left; } diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index bbbf307..f948b06 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -8,6 +8,7 @@ type PortalPermissions = { can_edit?: boolean can_comment?: boolean can_moderate?: boolean + can_delete?: boolean can_confirm_resolution?: boolean } @@ -433,6 +434,8 @@ export default function PortalClient({ workspace }: PortalClientProps) { const [saving, setSaving] = useState(false) const [commenting, setCommenting] = useState(false) const [respondingResolution, setRespondingResolution] = useState(false) + const [deleteConfirming, setDeleteConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) const [status, setStatus] = useState(null) const [totalItems, setTotalItems] = useState(0) @@ -1382,14 +1385,49 @@ export default function PortalClient({ workspace }: PortalClientProps) { } } + const deleteIssue = async () => { + if (selectedItem?.kind !== 'issue' || !selectedItem.permissions?.can_delete) return + setDeleting(true) + setError(null) + setStatus(null) + const issueId = selectedItem.id + try { + const response = await authFetch(`${getApiBase()}/portal/items/${issueId}`, { + method: 'DELETE', + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const payload = await response.json().catch(() => null) + throw new Error(payload?.detail || `Could not delete issue (${response.status})`) + } + closeIssueModal() + setStatus(`Issue #${issueId} was deleted. The linked media request was not changed.`) + await Promise.all([loadItems(), loadOverview()]) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not delete the issue.') + } finally { + setDeleting(false) + } + } + const closeIssueModal = () => { setSelectedItemId(null) setSelectedItem(null) setComments([]) setActivity([]) setCommentText('') + setDeleteConfirming(false) } + useEffect(() => { + setDeleteConfirming(false) + }, [selectedItemId]) + useEffect(() => { if (workspace !== 'issue' || selectedItemId == null) return const previousOverflow = document.body.style.overflow @@ -2098,9 +2136,21 @@ export default function PortalClient({ workspace }: PortalClientProps) { {selectedItem ? `Issue #${selectedItem.id}` : 'Issue details'} - +
+ {selectedItem?.permissions?.can_delete ? ( + + ) : null} + +
) : null} {!selectedItemId ? ( @@ -2145,6 +2195,31 @@ export default function PortalClient({ workspace }: PortalClientProps) { + {selectedItem.kind === 'issue' && deleteConfirming ? ( +
+
+ Permanent deletion +

Delete issue #{selectedItem.id}?

+

+ This removes the issue, its comments, and its activity history. The linked media request and collected content will not be changed. +

+
+
+ + +
+
+ ) : null} + {selectedItem.kind === 'issue' ? : null} {selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (