""" Migration: deprecate the `deployment_records` table. Why: The deployment-history view on the unit detail page used to render from `deployment_records` — a manually-maintained table that drifted out of sync with `unit_assignments` (the auto-written project/location assignment table). That caused the "wonky timeline" symptom: missing entries, duplicate / contradictory rows, and a UI that couldn't tell the operator what the unit was actually doing during each window. Phase 4 of the SFM integration replaces the deployment-history view with a derived timeline computed from `unit_assignments` + `unit_history` + SFM event overlay. This migration is the cleanup: 1. Adds a `deprecated_at` timestamp column to `deployment_records` so we can mark rows that have been migrated. 2. For every `deployment_records` row that does NOT have a matching `unit_assignments` row (matched by unit_id + overlapping date range), synthesizes a best-effort UnitAssignment row. The free-text `location_name` from the legacy table is preserved on the new row's `notes` field (we do NOT try to fuzzy-match it to a MonitoringLocation id; too error-prone — operators will need to reattach those manually if they want). 3. Marks every migrated deployment_records row with `deprecated_at`. This migration is non-destructive: deployment_records rows stay in the DB. The actual `DROP TABLE` happens in a follow-up release after one operator cycle confirms nothing relies on the legacy data. Idempotent: re-running the script is a no-op if the column already exists and all migratable rows have already been processed. Run with: docker exec terra-view-terra-view-1 python3 /app/backend/migrate_deprecate_deployment_records.py """ import os import sqlite3 import uuid from datetime import datetime DB_PATH = "./data/seismo_fleet.db" def migrate_database(): if not os.path.exists(DB_PATH): print(f"Database not found at {DB_PATH}") return print(f"Migrating database: {DB_PATH}") conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.cursor() # 1. Add deprecated_at column if not present. cur.execute("PRAGMA table_info(deployment_records)") cols = {row["name"] for row in cur.fetchall()} if "deprecated_at" not in cols: print("Adding deployment_records.deprecated_at column ...") cur.execute("ALTER TABLE deployment_records ADD COLUMN deprecated_at TEXT") conn.commit() else: print("deployment_records.deprecated_at column already exists — skipping ADD COLUMN") # 2. Find candidate rows: not-yet-deprecated deployment_records that # have no matching unit_assignments row. cur.execute(""" SELECT id, unit_id, deployed_date, estimated_removal_date, actual_removal_date, project_id, project_ref, location_name, notes FROM deployment_records WHERE deprecated_at IS NULL """) rows = cur.fetchall() print(f"\nFound {len(rows)} deployment_records rows not yet deprecated.") backfilled = 0 skipped_no_match_attempted = 0 skipped_already_in_assignments = 0 skipped_missing_unit = 0 for row in rows: unit_id = row["unit_id"] if not unit_id: print(f" ⚠ row {row['id']!r}: no unit_id, marking deprecated without backfill") cur.execute( "UPDATE deployment_records SET deprecated_at=? WHERE id=?", (datetime.utcnow().isoformat(), row["id"]), ) skipped_missing_unit += 1 continue # Does the unit still exist? If not, skip — we don't synthesize # assignments for ghost units. cur.execute("SELECT id, device_type FROM roster WHERE id=?", (unit_id,)) roster = cur.fetchone() if not roster: print(f" ⚠ row {row['id']!r}: unit_id {unit_id!r} not in roster, marking deprecated without backfill") cur.execute( "UPDATE deployment_records SET deprecated_at=? WHERE id=?", (datetime.utcnow().isoformat(), row["id"]), ) skipped_missing_unit += 1 continue # Check if a UnitAssignment already covers this window (any overlap). # We don't try to be clever — just see if a row exists for this unit # whose [assigned_at, assigned_until] overlaps the deployment window. cur.execute(""" SELECT id FROM unit_assignments WHERE unit_id=? AND (assigned_at <= COALESCE(?, '9999') AND COALESCE(assigned_until, '9999') >= COALESCE(?, '0000')) LIMIT 1 """, ( unit_id, row["actual_removal_date"] or row["estimated_removal_date"] or row["deployed_date"], row["deployed_date"], )) if cur.fetchone(): cur.execute( "UPDATE deployment_records SET deprecated_at=? WHERE id=?", (datetime.utcnow().isoformat(), row["id"]), ) skipped_already_in_assignments += 1 continue # No matching UnitAssignment — synthesize one. We can't FK to a # MonitoringLocation because the legacy `location_name` is free # text. Backfilled rows go in with location_id = "" (empty) and # the original location_name dropped into notes for operator # context. if not row["project_id"]: print(f" ⚠ row {row['id']!r}: no project_id, can't synthesize unit_assignment, marking deprecated") cur.execute( "UPDATE deployment_records SET deprecated_at=? WHERE id=?", (datetime.utcnow().isoformat(), row["id"]), ) skipped_no_match_attempted += 1 continue synthesized_id = str(uuid.uuid4()) synth_notes_parts = [] if row["location_name"]: synth_notes_parts.append(f"Legacy location: {row['location_name']}") if row["project_ref"]: synth_notes_parts.append(f"Legacy project_ref: {row['project_ref']}") if row["notes"]: synth_notes_parts.append(f"Original notes: {row['notes']}") synth_notes_parts.append(f"(Synthesized from deployment_records row {row['id']})") synth_notes = " | ".join(synth_notes_parts) assigned_until = row["actual_removal_date"] # Don't auto-close active deployments based on estimated_removal_date. status = "completed" if assigned_until else "active" # Need a location_id to satisfy NOT NULL constraint. Use a # placeholder UUID so the FK can be cleaned up later if the # operator decides to retarget the assignment to a real location. # We tag this with the synthesized notes so it's discoverable. placeholder_loc_id = "" try: cur.execute(""" INSERT INTO unit_assignments ( id, unit_id, location_id, project_id, device_type, assigned_at, assigned_until, status, notes, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( synthesized_id, unit_id, placeholder_loc_id, row["project_id"], roster["device_type"] or "seismograph", row["deployed_date"] or datetime.utcnow().isoformat(), assigned_until, status, synth_notes, datetime.utcnow().isoformat(), )) cur.execute( "UPDATE deployment_records SET deprecated_at=? WHERE id=?", (datetime.utcnow().isoformat(), row["id"]), ) backfilled += 1 print( f" ✓ row {row['id']!r}: synthesized unit_assignment {synthesized_id} " f"for unit={unit_id} project={row['project_id'][:8]}… " f"({row['deployed_date']} → {assigned_until or 'present'})" ) except Exception as e: print(f" ✗ row {row['id']!r}: failed to synthesize — {e}") conn.commit() conn.close() print("\n────────────────────────────────────────────────────────") print(f"Backfilled new unit_assignments: {backfilled}") print(f"Already covered (deprecated only): {skipped_already_in_assignments}") print(f"No project_id (deprecated only): {skipped_no_match_attempted}") print(f"Missing/orphaned unit (deprecated): {skipped_missing_unit}") print(f"\nNOTE: synthesized rows have an empty location_id and the legacy") print(f" free-text location is preserved in notes. An operator should") print(f" retarget them to real MonitoringLocation rows if they want") print(f" events to show up on a location detail page.") if __name__ == "__main__": migrate_database()