diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index f0aa1ed..7fade31 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -453,6 +453,134 @@ async def unassign_unit( return {"success": True, "message": "Unit unassigned successfully"} +@router.patch("/assignments/{assignment_id}") +async def update_assignment( + project_id: str, + assignment_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Update an assignment's date window and/or notes. + + Common use case: backdate a deployment so events emitted before the + operator created the assignment in terra-view (e.g. a unit that was + physically deployed in December but only recorded in the system today) + get correctly attributed to the location. + + Accepts JSON body with optional fields: + - assigned_at: ISO datetime (or empty string to leave unchanged) + - assigned_until: ISO datetime, or null/"" to mark indefinite (active) + - notes: string + + Sets `status` to "active" when assigned_until is cleared, "completed" + when it's set in the past. + """ + 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") + + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + # Parse new values (None = unchanged, explicit None/"" for assigned_until = clear) + new_assigned_at = assignment.assigned_at + new_assigned_until = assignment.assigned_until + new_notes = assignment.notes + + if "assigned_at" in payload: + raw = payload["assigned_at"] + if raw is None or raw == "": + raise HTTPException( + status_code=400, + detail="assigned_at is required; cannot be cleared.", + ) + try: + # Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO. + new_assigned_at = datetime.fromisoformat(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail=f"Invalid assigned_at datetime: {raw!r}", + ) + + if "assigned_until" in payload: + raw = payload["assigned_until"] + if raw is None or raw == "": + new_assigned_until = None + else: + try: + new_assigned_until = datetime.fromisoformat(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail=f"Invalid assigned_until datetime: {raw!r}", + ) + + if "notes" in payload: + raw = payload["notes"] + new_notes = (raw or "").strip() or None + + # Validation: end must be after start if both set. + if new_assigned_until is not None and new_assigned_until <= new_assigned_at: + raise HTTPException( + status_code=400, + detail="assigned_until must be after assigned_at.", + ) + + # Sanity: reject creating an overlap with another assignment of the SAME + # unit at the SAME location. Different units at the same location can + # legitimately overlap during a swap window (rare but valid). + new_end_for_overlap = new_assigned_until or datetime.utcnow() + overlapping = ( + db.query(UnitAssignment) + .filter(UnitAssignment.location_id == assignment.location_id) + .filter(UnitAssignment.unit_id == assignment.unit_id) + .filter(UnitAssignment.id != assignment.id) + .all() + ) + for other in overlapping: + other_start = other.assigned_at + other_end = other.assigned_until or datetime.utcnow() + if new_assigned_at < other_end and new_end_for_overlap > other_start: + raise HTTPException( + status_code=400, + detail=( + f"This window overlaps with another assignment for the " + f"same unit ({other.assigned_at:%Y-%m-%d} → " + f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})." + ), + ) + + # Apply. + assignment.assigned_at = new_assigned_at + assignment.assigned_until = new_assigned_until + assignment.notes = new_notes + assignment.status = "completed" if new_assigned_until is not None else "active" + + db.commit() + db.refresh(assignment) + + return { + "success": True, + "assignment": { + "id": assignment.id, + "unit_id": assignment.unit_id, + "location_id": assignment.location_id, + "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None, + "assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None, + "status": assignment.status, + "notes": assignment.notes, + }, + } + + @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 e0ff8f6..23d91aa 100644 --- a/templates/vibration_location_detail.html +++ b/templates/vibration_location_detail.html @@ -220,6 +220,66 @@
++ ✎ + Click the pencil to backdate a deployment so historical events get attributed to this location. +
+ + + ++ — +
+