feat(unit-detail): editable deployment timeline

Each assignment row in the timeline now gets an inline edit (pencil)
that opens a modal with `assigned_at`, `assigned_until`, and notes.
Save calls the existing `PATCH /api/projects/{pid}/assignments/{aid}`;
delete (for misclicks) calls the existing `DELETE`.  Open-ended
checkbox clears `assigned_until` and the endpoint flips status back
to "active".

Adds an "+ Add deployment record" button at the top of the timeline
for backfilling historical windows when orphan events sit outside any
assignment.  Modal: project → location → assigned_at → assigned_until
(optional open-ended) → notes.

Backend: the `/locations/{loc}/assign` endpoint now accepts an
`assigned_at` form field and a closed-window assignment.  The previous
blanket "location already has an active assignment" check is replaced
with same-location overlap detection — closed historical windows that
don't overlap an existing assignment are accepted (which is exactly
the backfill case).

After any save/delete the timeline reloads and the SFM-events list
re-fetches so previously-orphaned events flip to "attributed" when
their timestamp now falls inside an assignment window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 06:32:11 +00:00
parent 6d37bd759e
commit 472c25372d
2 changed files with 451 additions and 21 deletions
+63 -17
View File
@@ -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"),
)