v0.11.0 #50

Merged
serversdown merged 13 commits from release/0.11.0 into main 2026-05-15 19:16:43 -04:00
2 changed files with 121 additions and 0 deletions
Showing only changes of commit f13158e7bf - Show all commits
+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');
}