v0.11.0 #50
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user