feat(locations): delete assignment record for mis-clicks / duplicates

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:11:29 +00:00
parent 3f0ec8f30b
commit f13158e7bf
2 changed files with 121 additions and 0 deletions
+86
View File
@@ -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,
+35
View File
@@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button type="button"
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start}${end}')"
title="Delete this assignment record (for mis-clicks / duplicates)"
class="text-gray-400 hover:text-red-600 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`;
@@ -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');
}