Add admin issue deletion workflow
This commit is contained in:
@@ -3519,6 +3519,23 @@ def update_portal_item(
|
|||||||
return updated
|
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(
|
def add_portal_comment(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..db import (
|
|||||||
add_portal_comment,
|
add_portal_comment,
|
||||||
count_portal_items,
|
count_portal_items,
|
||||||
create_portal_item,
|
create_portal_item,
|
||||||
|
delete_portal_item,
|
||||||
get_portal_item,
|
get_portal_item,
|
||||||
get_portal_overview,
|
get_portal_overview,
|
||||||
list_portal_comments,
|
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_edit": is_admin or is_owner,
|
||||||
"can_comment": True,
|
"can_comment": True,
|
||||||
"can_moderate": is_admin,
|
"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_raise_issue": str(item.get("kind") or "") == "request",
|
||||||
"can_confirm_resolution": (
|
"can_confirm_resolution": (
|
||||||
str(item.get("kind") or "").lower() == "issue"
|
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}")
|
@router.patch("/items/{item_id}")
|
||||||
async def portal_update_item(
|
async def portal_update_item(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
|
|||||||
@@ -1682,6 +1682,76 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
self.assertEqual(pending_count, 1)
|
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):
|
class IssueResolutionWorkflowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||||
def _create_issue(self, *, status: str = "in_progress") -> dict:
|
def _create_issue(self, *, status: str = "in_progress") -> dict:
|
||||||
return db.create_portal_item(
|
return db.create_portal_item(
|
||||||
|
|||||||
@@ -2761,6 +2761,46 @@ button:disabled,
|
|||||||
gap: 2px;
|
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 {
|
.issue-pipeline-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -3785,6 +3825,10 @@ button:disabled,
|
|||||||
padding-right: 13px;
|
padding-right: 13px;
|
||||||
padding-left: 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 > header { flex-direction: column; }
|
||||||
.issue-pipeline-card > ol { grid-template-columns: 1fr; gap: 8px; }
|
.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; }
|
.issue-pipeline-card li { grid-template-columns: 25px minmax(0, 1fr); align-items: center; justify-items: start; text-align: left; }
|
||||||
|
|||||||
@@ -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_delete?: boolean
|
||||||
can_confirm_resolution?: boolean
|
can_confirm_resolution?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +434,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
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 [respondingResolution, setRespondingResolution] = useState(false)
|
||||||
|
const [deleteConfirming, setDeleteConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = 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)
|
||||||
@@ -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 = () => {
|
const closeIssueModal = () => {
|
||||||
setSelectedItemId(null)
|
setSelectedItemId(null)
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
setComments([])
|
setComments([])
|
||||||
setActivity([])
|
setActivity([])
|
||||||
setCommentText('')
|
setCommentText('')
|
||||||
|
setDeleteConfirming(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDeleteConfirming(false)
|
||||||
|
}, [selectedItemId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace !== 'issue' || selectedItemId == null) return
|
if (workspace !== 'issue' || selectedItemId == null) return
|
||||||
const previousOverflow = document.body.style.overflow
|
const previousOverflow = document.body.style.overflow
|
||||||
@@ -2098,10 +2136,22 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
{selectedItem ? `Issue #${selectedItem.id}` : 'Issue details'}
|
{selectedItem ? `Issue #${selectedItem.id}` : 'Issue details'}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</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
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!selectedItemId ? (
|
{!selectedItemId ? (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
@@ -2145,6 +2195,31 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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' ? <IssuePipeline item={selectedItem} /> : null}
|
||||||
|
|
||||||
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
|
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user