From 4f56dea4f31451893de130180456eb6daa2a35bf Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 25 Mar 2026 17:36:51 +0000 Subject: [PATCH] feat: adds deployment records for seismographs. --- backend/main.py | 4 + backend/migrate_add_deployment_records.py | 79 ++++++++ backend/models.py | 35 ++++ backend/routers/deployments.py | 154 ++++++++++++++ backend/routers/roster_edit.py | 37 +++- backend/services/fleet_calendar_service.py | 65 +++++- templates/unit_detail.html | 224 +++++++++++++++++++++ 7 files changed, 594 insertions(+), 4 deletions(-) create mode 100644 backend/migrate_add_deployment_records.py create mode 100644 backend/routers/deployments.py diff --git a/backend/main.py b/backend/main.py index 5f5d7ea..5be71ee 100644 --- a/backend/main.py +++ b/backend/main.py @@ -126,6 +126,10 @@ app.include_router(recurring_schedules.router) from backend.routers import fleet_calendar app.include_router(fleet_calendar.router) +# Deployment Records router +from backend.routers import deployments +app.include_router(deployments.router) + # Start scheduler service and device status monitor on application startup from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor diff --git a/backend/migrate_add_deployment_records.py b/backend/migrate_add_deployment_records.py new file mode 100644 index 0000000..cc9069a --- /dev/null +++ b/backend/migrate_add_deployment_records.py @@ -0,0 +1,79 @@ +""" +Migration: Add deployment_records table. + +Tracks each time a unit is sent to the field and returned. +The active deployment is the row with actual_removal_date IS NULL. + +Run once per database: + python backend/migrate_add_deployment_records.py +""" + +import sqlite3 +import os + +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 + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + try: + # Check if table already exists + cursor.execute(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='deployment_records' + """) + if cursor.fetchone(): + print("✓ deployment_records table already exists, skipping") + return + + print("Creating deployment_records table...") + cursor.execute(""" + CREATE TABLE deployment_records ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL, + deployed_date DATE, + estimated_removal_date DATE, + actual_removal_date DATE, + project_ref TEXT, + project_id TEXT, + location_name TEXT, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute(""" + CREATE INDEX idx_deployment_records_unit_id + ON deployment_records(unit_id) + """) + cursor.execute(""" + CREATE INDEX idx_deployment_records_project_id + ON deployment_records(project_id) + """) + # Index for finding active deployments quickly + cursor.execute(""" + CREATE INDEX idx_deployment_records_active + ON deployment_records(unit_id, actual_removal_date) + """) + + conn.commit() + print("✓ deployment_records table created successfully") + print("✓ Indexes created") + + except Exception as e: + conn.rollback() + print(f"✗ Migration failed: {e}") + raise + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/backend/models.py b/backend/models.py index 30bec47..7359f21 100644 --- a/backend/models.py +++ b/backend/models.py @@ -448,6 +448,41 @@ class Alert(Base): expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time +# ============================================================================ +# Deployment Records +# ============================================================================ + +class DeploymentRecord(Base): + """ + Deployment records: tracks each time a unit is sent to the field and returned. + + Each row represents one deployment. The active deployment is the record + with actual_removal_date IS NULL. The fleet calendar uses this to show + units as "In Field" and surface their expected return date. + + project_ref is a freeform string for legacy/vibration jobs like "Fay I-80". + project_id will be populated once those jobs are migrated to proper Project records. + """ + __tablename__ = "deployment_records" + + id = Column(String, primary_key=True, index=True) # UUID + unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id + + deployed_date = Column(Date, nullable=True) # When unit left the yard + estimated_removal_date = Column(Date, nullable=True) # Expected return date + actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out + + # Project linkage: freeform for legacy jobs, FK for proper project records + project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs) + project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available) + + location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001" + notes = Column(Text, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # ============================================================================ # Fleet Calendar & Job Reservations # ============================================================================ diff --git a/backend/routers/deployments.py b/backend/routers/deployments.py new file mode 100644 index 0000000..acdcd93 --- /dev/null +++ b/backend/routers/deployments.py @@ -0,0 +1,154 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime, date +from typing import Optional +import uuid + +from backend.database import get_db +from backend.models import DeploymentRecord, RosterUnit + +router = APIRouter(prefix="/api", tags=["deployments"]) + + +def _serialize(record: DeploymentRecord) -> dict: + return { + "id": record.id, + "unit_id": record.unit_id, + "deployed_date": record.deployed_date.isoformat() if record.deployed_date else None, + "estimated_removal_date": record.estimated_removal_date.isoformat() if record.estimated_removal_date else None, + "actual_removal_date": record.actual_removal_date.isoformat() if record.actual_removal_date else None, + "project_ref": record.project_ref, + "project_id": record.project_id, + "location_name": record.location_name, + "notes": record.notes, + "created_at": record.created_at.isoformat() if record.created_at else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None, + "is_active": record.actual_removal_date is None, + } + + +@router.get("/deployments/{unit_id}") +def get_deployments(unit_id: str, db: Session = Depends(get_db)): + """Get all deployment records for a unit, newest first.""" + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") + + records = ( + db.query(DeploymentRecord) + .filter_by(unit_id=unit_id) + .order_by(DeploymentRecord.deployed_date.desc(), DeploymentRecord.created_at.desc()) + .all() + ) + return {"deployments": [_serialize(r) for r in records]} + + +@router.get("/deployments/{unit_id}/active") +def get_active_deployment(unit_id: str, db: Session = Depends(get_db)): + """Get the current active deployment (actual_removal_date is NULL), or null.""" + record = ( + db.query(DeploymentRecord) + .filter( + DeploymentRecord.unit_id == unit_id, + DeploymentRecord.actual_removal_date == None + ) + .order_by(DeploymentRecord.created_at.desc()) + .first() + ) + return {"deployment": _serialize(record) if record else None} + + +@router.post("/deployments/{unit_id}") +def create_deployment(unit_id: str, payload: dict, db: Session = Depends(get_db)): + """ + Create a new deployment record for a unit. + + Body fields (all optional): + deployed_date (YYYY-MM-DD) + estimated_removal_date (YYYY-MM-DD) + project_ref (freeform string) + project_id (UUID if linked to Project) + location_name + notes + """ + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") + + def parse_date(val) -> Optional[date]: + if not val: + return None + if isinstance(val, date): + return val + return date.fromisoformat(str(val)) + + record = DeploymentRecord( + id=str(uuid.uuid4()), + unit_id=unit_id, + deployed_date=parse_date(payload.get("deployed_date")), + estimated_removal_date=parse_date(payload.get("estimated_removal_date")), + actual_removal_date=None, + project_ref=payload.get("project_ref"), + project_id=payload.get("project_id"), + location_name=payload.get("location_name"), + notes=payload.get("notes"), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.add(record) + db.commit() + db.refresh(record) + return _serialize(record) + + +@router.put("/deployments/{unit_id}/{deployment_id}") +def update_deployment(unit_id: str, deployment_id: str, payload: dict, db: Session = Depends(get_db)): + """ + Update a deployment record. Used for: + - Setting/changing estimated_removal_date + - Closing a deployment (set actual_removal_date to mark unit returned) + - Editing project_ref, location_name, notes + """ + record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first() + if not record: + raise HTTPException(status_code=404, detail="Deployment record not found") + + def parse_date(val) -> Optional[date]: + if val is None: + return None + if val == "": + return None + if isinstance(val, date): + return val + return date.fromisoformat(str(val)) + + if "deployed_date" in payload: + record.deployed_date = parse_date(payload["deployed_date"]) + if "estimated_removal_date" in payload: + record.estimated_removal_date = parse_date(payload["estimated_removal_date"]) + if "actual_removal_date" in payload: + record.actual_removal_date = parse_date(payload["actual_removal_date"]) + if "project_ref" in payload: + record.project_ref = payload["project_ref"] + if "project_id" in payload: + record.project_id = payload["project_id"] + if "location_name" in payload: + record.location_name = payload["location_name"] + if "notes" in payload: + record.notes = payload["notes"] + + record.updated_at = datetime.utcnow() + db.commit() + db.refresh(record) + return _serialize(record) + + +@router.delete("/deployments/{unit_id}/{deployment_id}") +def delete_deployment(unit_id: str, deployment_id: str, db: Session = Depends(get_db)): + """Delete a deployment record.""" + record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first() + if not record: + raise HTTPException(status_code=404, detail="Deployment record not found") + db.delete(record) + db.commit() + return {"ok": True} diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index ea799ed..9972e50 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -9,7 +9,8 @@ import httpx import os from backend.database import get_db -from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences +from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord +import uuid from backend.services.slmm_sync import sync_slm_to_slmm router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) @@ -27,6 +28,38 @@ def get_calibration_interval(db: Session) -> int: SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") +def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool): + """ + Keep DeploymentRecord in sync with the deployed flag. + + deployed True → open a new DeploymentRecord if none is already open. + deployed False → close the active DeploymentRecord by setting actual_removal_date = today. + """ + if new_deployed: + existing = db.query(DeploymentRecord).filter( + DeploymentRecord.unit_id == unit.id, + DeploymentRecord.actual_removal_date == None + ).first() + if not existing: + record = DeploymentRecord( + id=str(uuid.uuid4()), + unit_id=unit.id, + project_ref=unit.project_id or None, + deployed_date=date.today(), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.add(record) + else: + active = db.query(DeploymentRecord).filter( + DeploymentRecord.unit_id == unit.id, + DeploymentRecord.actual_removal_date == None + ).first() + if active: + active.actual_removal_date = date.today() + active.updated_at = datetime.utcnow() + + def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None, old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None): """Helper function to record a change in unit history""" @@ -679,6 +712,7 @@ async def edit_roster_unit( status_text = "deployed" if deployed else "benched" old_status_text = "deployed" if old_deployed else "benched" record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual") + sync_deployment_record(db, unit, deployed_bool) if old_retired != retired: status_text = "retired" if retired else "active" @@ -795,6 +829,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D new_value=status_text, source="manual" ) + sync_deployment_record(db, unit, deployed) db.commit() diff --git a/backend/services/fleet_calendar_service.py b/backend/services/fleet_calendar_service.py index 4331cf5..fecc160 100644 --- a/backend/services/fleet_calendar_service.py +++ b/backend/services/fleet_calendar_service.py @@ -15,7 +15,7 @@ from sqlalchemy import and_, or_ from backend.models import ( RosterUnit, JobReservation, JobReservationUnit, - UserPreferences, Project + UserPreferences, Project, DeploymentRecord ) @@ -70,6 +70,19 @@ def get_unit_reservations_on_date( return reservations +def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]: + """Return the active (unreturned) deployment record for a unit, or None.""" + return ( + db.query(DeploymentRecord) + .filter( + DeploymentRecord.unit_id == unit_id, + DeploymentRecord.actual_removal_date == None + ) + .order_by(DeploymentRecord.created_at.desc()) + .first() + ) + + def is_unit_available_on_date( db: Session, unit: RosterUnit, @@ -82,8 +95,8 @@ def is_unit_available_on_date( Returns: (is_available, status, reservation_name) - is_available: True if unit can be assigned to new work - - status: "available", "reserved", "expired", "retired", "needs_calibration" - - reservation_name: Name of blocking reservation (if any) + - status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field" + - reservation_name: Name of blocking reservation or project ref (if any) """ # Check if retired if unit.retired: @@ -96,6 +109,12 @@ def is_unit_available_on_date( if cal_status == "needs_calibration": return False, "needs_calibration", None + # Check for an active deployment record (unit is physically in the field) + active_deployment = get_active_deployment(db, unit.id) + if active_deployment: + label = active_deployment.project_ref or "Field deployment" + return False, "in_field", label + # Check if already reserved reservations = get_unit_reservations_on_date(db, unit.id, check_date) if reservations: @@ -136,6 +155,7 @@ def get_day_summary( expired_units = [] expiring_soon_units = [] needs_calibration_units = [] + in_field_units = [] cal_expiring_today = [] # Units whose calibration expires ON this day for unit in units: @@ -167,6 +187,9 @@ def get_day_summary( available_units.append(unit_info) if cal_status == "expiring_soon": expiring_soon_units.append(unit_info) + elif status == "in_field": + unit_info["project_ref"] = reservation_name + in_field_units.append(unit_info) elif status == "reserved": unit_info["reservation_name"] = reservation_name reserved_units.append(unit_info) @@ -207,6 +230,7 @@ def get_day_summary( "date": check_date.isoformat(), "device_type": device_type, "available_units": available_units, + "in_field_units": in_field_units, "reserved_units": reserved_units, "expired_units": expired_units, "expiring_soon_units": expiring_soon_units, @@ -215,6 +239,7 @@ def get_day_summary( "reservations": reservation_list, "counts": { "available": len(available_units), + "in_field": len(in_field_units), "reserved": len(reserved_units), "expired": len(expired_units), "expiring_soon": len(expiring_soon_units), @@ -285,6 +310,14 @@ def get_calendar_year_data( unit_reservations[unit_id] = [] unit_reservations[unit_id].append((start_d, end_d, res.name)) + # Build set of unit IDs that have an active deployment record (still in the field) + unit_ids = [u.id for u in units] + active_deployments = db.query(DeploymentRecord.unit_id).filter( + DeploymentRecord.unit_id.in_(unit_ids), + DeploymentRecord.actual_removal_date == None + ).all() + unit_in_field = {row.unit_id for row in active_deployments} + # Generate data for each month months_data = {} @@ -301,6 +334,7 @@ def get_calendar_year_data( while current_day <= last_day: available = 0 + in_field = 0 reserved = 0 expired = 0 expiring_soon = 0 @@ -328,6 +362,11 @@ def get_calendar_year_data( needs_cal += 1 continue + # Check active deployment record (in field) + if unit.id in unit_in_field: + in_field += 1 + continue + # Check if reserved is_reserved = False if unit.id in unit_reservations: @@ -346,6 +385,7 @@ def get_calendar_year_data( days_data[current_day.day] = { "available": available, + "in_field": in_field, "reserved": reserved, "expired": expired, "expiring_soon": expiring_soon, @@ -462,6 +502,14 @@ def get_rolling_calendar_data( unit_reservations[unit_id] = [] unit_reservations[unit_id].append((start_d, end_d, res.name)) + # Build set of unit IDs that have an active deployment record (still in the field) + unit_ids = [u.id for u in units] + active_deployments = db.query(DeploymentRecord.unit_id).filter( + DeploymentRecord.unit_id.in_(unit_ids), + DeploymentRecord.actual_removal_date == None + ).all() + unit_in_field = {row.unit_id for row in active_deployments} + # Generate data for each of the 12 months months_data = [] current_year = start_year @@ -640,11 +688,22 @@ def get_available_units_for_period( for a in assigned: reserved_unit_ids.add(a.unit_id) + # Get units with active deployment records (still in the field) + unit_ids = [u.id for u in units] + active_deps = db.query(DeploymentRecord.unit_id).filter( + DeploymentRecord.unit_id.in_(unit_ids), + DeploymentRecord.actual_removal_date == None + ).all() + in_field_unit_ids = {row.unit_id for row in active_deps} + available_units = [] for unit in units: # Check if already reserved if unit.id in reserved_unit_ids: continue + # Check if currently in the field + if unit.id in in_field_unit_ids: + continue if unit.last_calibrated: expiry_date = unit.last_calibrated + timedelta(days=365) diff --git a/templates/unit_detail.html b/templates/unit_detail.html index ed5a52a..d17345b 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -278,6 +278,22 @@

--

+ +
+
+

Deployment History

+ +
+
+

Loading...

+
+
+

Timeline

@@ -320,6 +336,53 @@
+ + +