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