diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 1aa5e85..3382dfc 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -723,6 +723,19 @@ async def assign_unit_to_location( ): """ Assign a unit to a monitoring location. + + Accepts form fields: + - unit_id — required + - assigned_at — optional ISO datetime; defaults to now. Set this + when backfilling a historical deployment whose + events landed in the orphan bucket. + - assigned_until — optional ISO datetime; absent = open-ended / + active. + - notes — optional free text + + Refuses only when the *new window would overlap* an existing active + open-ended assignment at the same location. Closed historical windows + that don't overlap are allowed (and required for orphan-event backfill). """ location = db.query(MonitoringLocation).filter_by( id=location_id, @@ -748,23 +761,55 @@ async def assign_unit_to_location( detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", ) - # Check if location already has an active assignment (active = assigned_until IS NULL) - existing_assignment = db.query(UnitAssignment).filter( - and_( - UnitAssignment.location_id == location_id, - UnitAssignment.assigned_until == None, - ) - ).first() - - if existing_assignment: - raise HTTPException( - status_code=400, - detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.", - ) - - # Create new assignment + # Parse dates. + assigned_at_str = form_data.get("assigned_at") assigned_until_str = form_data.get("assigned_until") - assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None + try: + assigned_at = ( + datetime.fromisoformat(assigned_at_str) + if assigned_at_str else datetime.utcnow() + ) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, detail=f"Invalid assigned_at: {assigned_at_str!r}" + ) + try: + assigned_until = ( + datetime.fromisoformat(assigned_until_str) + if assigned_until_str else None + ) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, detail=f"Invalid assigned_until: {assigned_until_str!r}" + ) + if assigned_until is not None and assigned_until <= assigned_at: + raise HTTPException( + status_code=400, detail="assigned_until must be after assigned_at.", + ) + + # Reject only if the new window overlaps an existing assignment at the + # SAME location. Closed historical windows that sit before the current + # active assignment are fine — that's the backfill case. + new_end_for_overlap = assigned_until or datetime.utcnow() + existing = db.query(UnitAssignment).filter( + UnitAssignment.location_id == location_id + ).all() + for other in existing: + other_start = other.assigned_at + other_end = other.assigned_until or datetime.utcnow() + if assigned_at < other_end and new_end_for_overlap > other_start: + other_window = ( + f"{other.assigned_at:%Y-%m-%d}" + + (f" → {other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present") + ) + raise HTTPException( + status_code=400, + detail=( + f"New window overlaps an existing assignment at this " + f"location ({other.unit_id} {other_window}). Use swap or " + f"edit that record instead." + ), + ) assignment = UnitAssignment( id=str(uuid.uuid4()), @@ -772,8 +817,9 @@ async def assign_unit_to_location( location_id=location_id, project_id=project_id, device_type=unit.device_type, + assigned_at=assigned_at, assigned_until=assigned_until, - status="active", + status="active" if assigned_until is None else "completed", notes=form_data.get("notes"), ) diff --git a/templates/unit_detail.html b/templates/unit_detail.html index c2eb60f..35f7d8f 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -281,11 +281,16 @@
-
+

Deployment Timeline

- +
+ + +
+ + + + +