From f13158e7bf92abd75e19e56696d8977aeffb0cd6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 14 May 2026 23:11:29 +0000 Subject: [PATCH] feat(locations): delete assignment record for mis-clicks / duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an operator accidentally clicks Assign multiple times on the same location (or assigns the wrong unit), the resulting bogus assignment rows cluttered the location's deployment history with no way to clean them up — Unassign just sets assigned_until to now, which preserves the row. New DELETE /api/projects/{p}/assignments/{a} endpoint hard-deletes the row entirely, intended for mis-clicks that never represented a real deployment. Safety: - Refuses if any MonitoringSession exists in the assignment's window for the same (unit, location). If there's a recording session backing it, this isn't a mis-click — operator should Edit or Unassign instead. - Records UnitHistory `assignment_deleted` so the unit's deployment timeline still shows the deletion happened, even though the row itself is gone. UI: trash icon added next to the existing pencil (Edit) icon on each row of the vibration location's "Deployment History" panel. Confirms intent with a descriptive prompt that explains the consequence (attribution becomes unattributed for that window) and points to Edit/Unassign as alternatives. Co-Authored-By: Claude Opus 4.7 --- backend/routers/project_locations.py | 86 ++++++++++++++++++++++++ templates/vibration_location_detail.html | 35 ++++++++++ 2 files changed, 121 insertions(+) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 7fda2eb..d62d6ff 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -830,6 +830,92 @@ async def update_assignment( } +@router.delete("/assignments/{assignment_id}") +async def delete_assignment( + project_id: str, + assignment_id: str, + db: Session = Depends(get_db), +): + """ + Hard-delete an assignment record. + + Use case: operator clicked Assign by mistake (or 8 times in a row) and + wants the bogus records gone — not just closed with an `assigned_until` + timestamp. The standard close-via-unassign path is for legitimate + deployments that ended; this is for mis-clicks that never actually + happened. + + Safety: + - Refuses if any MonitoringSession exists for the same (unit, location) + within this assignment's window — that suggests the deployment was + real, and the operator should use unassign instead. + - Refuses if the assignment is the ONLY active assignment for a unit + currently shown as deployed AND a recording session is in progress. + + Audit: + - Records UnitHistory `assignment_deleted` so the unit's deployment + timeline shows the deletion happened (even though the row itself + is gone). + """ + assignment = db.query(UnitAssignment).filter_by( + id=assignment_id, + project_id=project_id, + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Safety: is there a real recording history for this (unit, location) + # within the assignment's time window? If so, this isn't a mis-click — + # the operator should close it via unassign, not delete it. + window_start = assignment.assigned_at + window_end = assignment.assigned_until or datetime.utcnow() + real_sessions = db.query(MonitoringSession).filter( + and_( + MonitoringSession.location_id == assignment.location_id, + MonitoringSession.unit_id == assignment.unit_id, + MonitoringSession.start_time >= window_start, + MonitoringSession.start_time <= window_end, + ) + ).count() + + if real_sessions > 0: + raise HTTPException( + status_code=400, + detail=( + f"Cannot delete this assignment — {real_sessions} monitoring " + f"session(s) were recorded under it. Use Unassign to close " + f"the window instead, which preserves the audit trail." + ), + ) + + # Resolve location name for audit log before deletion. + location = db.query(MonitoringLocation).filter_by( + id=assignment.location_id + ).first() + location_label = location.name if location else assignment.location_id + + _record_assignment_history( + db, + unit_id=assignment.unit_id, + change_type="assignment_deleted", + old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → " + f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})", + new_value="deleted", + notes=( + "Assignment row removed — created in error or accidental duplicate." + ), + ) + + db.delete(assignment) + db.commit() + + return { + "success": True, + "message": "Assignment deleted.", + } + + @router.post("/locations/{location_id}/swap") async def swap_unit_on_location( project_id: str, diff --git a/templates/vibration_location_detail.html b/templates/vibration_location_detail.html index 452447b..b8754bb 100644 --- a/templates/vibration_location_detail.html +++ b/templates/vibration_location_detail.html @@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) { + ${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'} `; @@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) { document.getElementById('assignment-edit-modal').classList.remove('hidden'); } +async function deleteAssignment(assignmentId, unitId, windowLabel) { + // For mis-clicks / accidental duplicate assignments. Backend refuses + // if there's a real recording session inside the window — those should + // go through Edit or Unassign instead. + const msg = `Delete this assignment?\n\n` + + `Unit: ${unitId}\n` + + `Window: ${windowLabel}\n\n` + + `This is for assignments created in error. Events that fell ` + + `in this window will become unattributed. The unit's deployment ` + + `history will log the deletion for audit.\n\n` + + `If the unit actually was deployed here, use Edit or Unassign instead.`; + if (!confirm(msg)) return; + + try { + const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, { + method: 'DELETE', + }); + if (!r.ok) { + const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status})); + throw new Error(err.detail || 'HTTP ' + r.status); + } + await loadLocationEvents(); // Refresh stats + table without this assignment. + } catch (err) { + alert(err.message || 'Failed to delete assignment.'); + } +} + function closeAssignmentEditModal() { document.getElementById('assignment-edit-modal').classList.add('hidden'); }