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:
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user