feat(sfm): unified deployment timeline (deprecate deployment_records)

Phase 4.  Rebuilds the seismograph "Deployment History" + "Timeline"
sections on the unit detail page as a single derived view computed from
three sources: unit_assignments (authoritative project/location windows),
unit_history (calibration/retirement/deployed state changes), and SFM
events overlaid per assignment window (count + peak PVS + last event).

Fixes the wonky-timeline symptoms: missing entries, duplicate/contradictory
rows, and no visibility into what the unit was actually doing during each
deployment window.

Backend:
- backend/services/deployment_timeline.py: new deployment_timeline_for_unit()
  helper.  Merges UnitAssignment rows (with SFM event overlay fetched
  concurrently via httpx), UnitHistory state-change rows (filtered to
  meaningful change_types and de-noised by dropping rows where
  old_value == new_value — there's noise in legacy audit log from
  record_history() being called on every save), and synthetic "gap"
  entries between assignments >= 1 day apart.  Sorts newest first.

- backend/routers/units.py: new GET /api/units/{unit_id}/deployment_timeline
  endpoint with optional include_events=false flag.

- backend/routers/project_locations.py: assign / unassign / swap /
  update endpoints now write UnitHistory rows on every assignment
  lifecycle event.  New change_types: assignment_created,
  assignment_ended, assignment_swapped, assignment_updated.  These
  surface in the unified timeline (where the assignment row itself
  shows the structural data; the audit row is filtered out to avoid
  double-rendering).  Closes a real gap — assignment changes were
  previously invisible to any audit consumer.

- backend/migrate_deprecate_deployment_records.py: non-destructive
  migration.  Adds deployment_records.deprecated_at column.  For each
  legacy row without a matching UnitAssignment, best-effort
  synthesizes one (with the free-text location_name preserved in
  notes).  Marks every processed row.  Idempotent.  DROP TABLE
  deferred to a follow-up release.

Frontend (templates/unit_detail.html):
- Removed legacy "Deployment History" card (with Log Deployment button)
  and the separate "Timeline" card.  Replaced with a single
  "Deployment Timeline" section.
- Three entry visual styles: assignment rows (orange dot, location +
  project link, event-overlay summary), gap rows (dashed outline, idle
  day count), and state_change rows (navy dot, friendly label, old →
  new value).  Active assignments get a green dot + "active" badge.
- Existing loadUnitHistory() and loadDeploymentHistory() functions kept
  as shims that delegate to loadDeploymentTimeline(), so modal-save
  callbacks that referenced them still trigger a refresh of the visible
  section.  Legacy function bodies preserved under _legacy_*_unused
  names for archeology; not called by anything.

Verified end-to-end:
- BE11529 timeline now shows 2 entries (active assignment with 24-event
  overlay + the deployed→benched state change), compared to the previous
  noisy mix that included 6 no-op state-change rows.
- Migration ran against real DB: 1 legacy row processed (had no
  project_id, marked deprecated without backfill).
- Assign / unassign / swap / edit now leave a paper trail in
  unit_history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:15:07 +00:00
parent 63bd6ad8a2
commit f1f3da8e61
5 changed files with 760 additions and 20 deletions
+86
View File
@@ -30,6 +30,7 @@ from backend.models import (
RosterUnit,
MonitoringSession,
DataFile,
UnitHistory,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
@@ -37,6 +38,42 @@ from backend.utils.timezone import local_to_utc
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
# ── Audit log helper ──────────────────────────────────────────────────────────
# Mirrors record_history() in roster_edit.py. Kept local to avoid cross-router
# imports. The four assignment endpoints below use this to write UnitHistory
# rows that the unit-detail deployment timeline (Phase 4) renders.
def _record_assignment_history(
db: Session,
unit_id: str,
change_type: str,
*,
old_value: Optional[str] = None,
new_value: Optional[str] = None,
notes: Optional[str] = None,
) -> None:
"""Append a UnitHistory row for an assignment-lifecycle event.
change_type values used:
- assignment_created — unit assigned to a location (new assignment)
- assignment_ended — unit unassigned / removed (assigned_until set)
- assignment_swapped — unit replaced by another at the same location
- assignment_updated — assignment dates / notes edited via PATCH
Caller is responsible for db.commit().
"""
db.add(UnitHistory(
unit_id=unit_id,
change_type=change_type,
field_name="unit_assignment",
old_value=old_value,
new_value=new_value,
changed_at=datetime.utcnow(),
source="manual",
notes=notes,
))
# ============================================================================
# Shared helpers
# ============================================================================
@@ -403,6 +440,13 @@ async def assign_unit_to_location(
)
db.add(assignment)
_record_assignment_history(
db,
unit_id=unit_id,
change_type="assignment_created",
new_value=f"{location.name} (project: {location.project_id})",
notes=form_data.get("notes"),
)
db.commit()
db.refresh(assignment)
@@ -448,6 +492,15 @@ async def unassign_unit(
assignment.status = "completed"
assignment.assigned_until = datetime.utcnow()
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
_record_assignment_history(
db,
unit_id=assignment.unit_id,
change_type="assignment_ended",
old_value=location.name if location else assignment.location_id,
new_value="unassigned",
)
db.commit()
return {"success": True, "message": "Unit unassigned successfully"}
@@ -558,12 +611,28 @@ async def update_assignment(
),
)
# Capture change description for audit log BEFORE mutating.
old_start = assignment.assigned_at.isoformat() if assignment.assigned_at else None
old_end = assignment.assigned_until.isoformat() if assignment.assigned_until else "active"
new_start = new_assigned_at.isoformat() if new_assigned_at else None
new_end = new_assigned_until.isoformat() if new_assigned_until else "active"
# 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"
if old_start != new_start or old_end != new_end:
_record_assignment_history(
db,
unit_id=assignment.unit_id,
change_type="assignment_updated",
old_value=f"{old_start}{old_end}",
new_value=f"{new_start}{new_end}",
notes=new_notes,
)
db.commit()
db.refresh(assignment)
@@ -631,6 +700,16 @@ async def swap_unit_on_location(
if current:
current.assigned_until = datetime.utcnow()
current.status = "completed"
# If the swap is replacing a different unit, that unit's deployment ended.
if current.unit_id != unit_id:
_record_assignment_history(
db,
unit_id=current.unit_id,
change_type="assignment_swapped",
old_value=location.name,
new_value=f"swapped out → {unit_id}",
notes=notes,
)
# Create new assignment
new_assignment = UnitAssignment(
@@ -644,6 +723,13 @@ async def swap_unit_on_location(
notes=notes,
)
db.add(new_assignment)
_record_assignment_history(
db,
unit_id=unit_id,
change_type="assignment_swapped" if (current and current.unit_id != unit_id) else "assignment_created",
new_value=f"{location.name} (project: {location.project_id})",
notes=notes,
)
# Update modem pairing on the seismograph if modem provided
if modem_id:
+34
View File
@@ -136,3 +136,37 @@ async def get_unit_events(
)
result["device_type"] = unit.device_type
return result
@router.get("/units/{unit_id}/deployment_timeline")
async def get_unit_deployment_timeline(
unit_id: str,
include_events: bool = Query(True),
db: Session = Depends(get_db),
):
"""
Return a chronological deployment timeline for a unit.
Merges three sources:
1. unit_assignments — authoritative project/location deployments
2. unit_history — state changes (calibration, retirement, etc.)
3. SFM events — per-assignment overlay (count + peak PVS + last event)
Replaces the legacy /api/deployments/{unit_id} (which read the
deprecated `deployment_records` table) and the
/api/roster/history/{unit_id} timeline endpoint, unifying them into
a single derived view.
Gaps >= 1 day between consecutive assignments are surfaced as
synthetic "gap" entries.
Pass include_events=false to skip the SFM event overlay (saves N
HTTP calls; useful for fast text-only history dumps).
"""
from backend.services.deployment_timeline import deployment_timeline_for_unit
return await deployment_timeline_for_unit(
db,
unit_id,
include_event_overlay=include_events,
)