feat(sfm): editable UnitAssignment date windows (backdate deployments)
Operators couldn't change a unit's assigned_at / assigned_until after
creating the assignment, so a unit physically deployed in December 2025
but only recorded in terra-view today would show "deployed today" and
all its real events would be invisible on the project's location page.
Backend:
- PATCH /api/projects/{project_id}/assignments/{assignment_id}
Accepts JSON body with optional assigned_at, assigned_until, notes.
- assigned_at is required (cannot be cleared)
- assigned_until can be null to mark active / indefinite
- assigned_until must be after assigned_at
- rejects overlaps with other assignments of the same unit at the
same location (different units overlapping is fine — that's a
legitimate swap window)
- assignment.status flips to "active" when assigned_until is cleared,
"completed" when set
- 404 if the assignment doesn't belong to {project_id} (security)
Frontend (vibration_location_detail.html):
- Pencil icon next to each row in the "Seismographs deployed at this
location" card. Click to open a modal with datetime-local inputs for
From + Until (blank = active) and a Notes textarea. Save reloads the
Events tab so KPI tiles and the event table reflect the new window.
- Helper line under the assignment list explains the workflow:
"Click the pencil to backdate a deployment so historical events get
attributed to this location."
Verified end-to-end against real data: backdating BE11529's assignment
on a vibration location from 2026-04-14 to 2025-12-01 surfaced 10
additional events (24 -> 34) that were previously invisible.
Validation suite (all returning correct HTTP codes):
- assigned_until < assigned_at -> 400
- cross-project assignment_id -> 404
- assigned_at cleared -> 400
- notes-only update -> 200
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user