Add admin issue deletion workflow
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s

This commit is contained in:
2026-09-01 14:58:58 +12:00
parent 16876e1cf0
commit 5de14b1cb7
5 changed files with 234 additions and 3 deletions
+17
View File
@@ -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,
*,
+25
View File
@@ -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,
+70
View File
@@ -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(
+44
View File
@@ -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; }
+76 -1
View File
@@ -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<string | null>(null)
const [status, setStatus] = useState<string | null>(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,10 +2136,22 @@ export default function PortalClient({ workspace }: PortalClientProps) {
{selectedItem ? `Issue #${selectedItem.id}` : 'Issue details'}
</strong>
</div>
<button type="button" className="ghost-button" onClick={closeIssueModal}>
<div className="issue-modal-toolbar-actions">
{selectedItem?.permissions?.can_delete ? (
<button
type="button"
className="danger-button"
disabled={deleting}
onClick={() => setDeleteConfirming(true)}
>
Delete issue
</button>
) : null}
<button type="button" className="ghost-button" disabled={deleting} onClick={closeIssueModal}>
Close
</button>
</div>
</div>
) : null}
{!selectedItemId ? (
<div className="status-banner">
@@ -2145,6 +2195,31 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</div>
{selectedItem.kind === 'issue' && deleteConfirming ? (
<section className="issue-delete-confirmation" aria-live="polite">
<div>
<span className="section-kicker">Permanent deletion</span>
<h3>Delete issue #{selectedItem.id}?</h3>
<p>
This removes the issue, its comments, and its activity history. The linked media request and collected content will not be changed.
</p>
</div>
<div>
<button
type="button"
className="ghost-button"
disabled={deleting}
onClick={() => setDeleteConfirming(false)}
>
Cancel
</button>
<button type="button" className="danger-button" disabled={deleting} onClick={() => void deleteIssue()}>
{deleting ? 'Deleting…' : 'Delete permanently'}
</button>
</div>
</section>
) : null}
{selectedItem.kind === 'issue' ? <IssuePipeline item={selectedItem} /> : null}
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (