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:
2026-05-11 22:30:32 +00:00
parent df771a87de
commit 09db988a35
2 changed files with 275 additions and 4 deletions
+128
View File
@@ -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,