diff --git a/CHANGELOG.md b/CHANGELOG.md index d559ead..afc34f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,31 @@ All notable changes to Terra-View will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.9.1] - 2026-03-23 +## [0.9.2] - 2026-03-27 + +### Added +- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations. +- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators. +- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster. +- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page. +- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing. +- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page. + +### Migration Notes +Run on each database before deploying: +```bash +docker compose exec terra-view python3 backend/migrate_add_allocated.py +docker compose exec terra-view python3 backend/migrate_add_deployment_records.py +``` + +--- + +## [0.9.1] - 2026-03-20 ### Fixed - **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots. - **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance. +- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility. ### Migration Notes Run on each database before deploying: diff --git a/README.md b/README.md index 64f10cb..93865ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.9.1 +# Terra-View v0.9.2 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features diff --git a/backend/main.py b/backend/main.py index 5f5d7ea..4d9cc1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.9.1" +VERSION = "0.9.2" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": @@ -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 @@ -703,6 +707,33 @@ async def devices_all_partial(request: Request): "hardware_model": unit_data.get("hardware_model"), }) + # Add allocated units + for unit_id, unit_data in snapshot.get("allocated", {}).items(): + units_list.append({ + "id": unit_id, + "status": "Allocated", + "age": "N/A", + "last_seen": "N/A", + "deployed": False, + "retired": False, + "out_for_calibration": False, + "allocated": True, + "allocated_to_project_id": unit_data.get("allocated_to_project_id", ""), + "ignored": False, + "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), + "last_calibrated": unit_data.get("last_calibrated"), + "next_calibration_due": unit_data.get("next_calibration_due"), + "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), + "deployed_with_unit_id": unit_data.get("deployed_with_unit_id"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), + }) + # Add out-for-calibration units for unit_id, unit_data in snapshot["out_for_calibration"].items(): units_list.append({ @@ -780,17 +811,19 @@ async def devices_all_partial(request: Request): # Sort by status category, then by ID def sort_key(unit): - # Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored + # Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored if unit["deployed"]: return (0, unit["id"]) - elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]: + elif unit.get("allocated"): return (1, unit["id"]) - elif unit["out_for_calibration"]: + elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]: return (2, unit["id"]) - elif unit["retired"]: + elif unit["out_for_calibration"]: return (3, unit["id"]) - else: + elif unit["retired"]: return (4, unit["id"]) + else: + return (5, unit["id"]) units_list.sort(key=sort_key) diff --git a/backend/migrate_add_allocated.py b/backend/migrate_add_allocated.py new file mode 100644 index 0000000..ac1900d --- /dev/null +++ b/backend/migrate_add_allocated.py @@ -0,0 +1,35 @@ +""" +Migration: Add allocated and allocated_to_project_id columns to roster table. +Run once: python backend/migrate_add_allocated.py +""" +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db') + +def run(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + # Check existing columns + cur.execute("PRAGMA table_info(roster)") + cols = {row[1] for row in cur.fetchall()} + + if 'allocated' not in cols: + cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL") + print("Added column: allocated") + else: + print("Column already exists: allocated") + + if 'allocated_to_project_id' not in cols: + cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR") + print("Added column: allocated_to_project_id") + else: + print("Column already exists: allocated_to_project_id") + + conn.commit() + conn.close() + print("Migration complete.") + +if __name__ == '__main__': + run() 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..bb0ca10 100644 --- a/backend/models.py +++ b/backend/models.py @@ -33,6 +33,8 @@ class RosterUnit(Base): deployed = Column(Boolean, default=True) retired = Column(Boolean, default=False) out_for_calibration = Column(Boolean, default=False) + allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed + allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to note = Column(String, nullable=True) project_id = Column(String, nullable=True) location = Column(String, nullable=True) # Legacy field - use address/coordinates instead @@ -448,6 +450,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/fleet_calendar.py b/backend/routers/fleet_calendar.py index eafcff9..0a100d0 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -701,6 +701,8 @@ async def get_planner_availability( "calibration_status": "needs_calibration" if not u.last_calibrated else "valid", "deployed": u.deployed, "out_for_calibration": u.out_for_calibration or False, + "allocated": getattr(u, 'allocated', False) or False, + "allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "", "note": u.note or "", "reservations": unit_reservations.get(u.id, []) }) @@ -716,6 +718,56 @@ async def get_planner_availability( } +@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse) +async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)): + """Return at-a-glance info for the planner quick-view modal.""" + from backend.models import Emitter + u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not u: + raise HTTPException(status_code=404, detail="Unit not found") + + today = date.today() + expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None + + # Active/upcoming reservations + assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all() + reservations = [] + for a in assignments: + res = db.query(JobReservation).filter( + JobReservation.id == a.reservation_id, + JobReservation.end_date >= today + ).first() + if res: + reservations.append({ + "name": res.name, + "start_date": res.start_date.isoformat() if res.start_date else None, + "end_date": res.end_date.isoformat() if res.end_date else None, + "end_date_tbd": res.end_date_tbd, + "color": res.color or "#3B82F6", + "location_name": a.location_name, + }) + + # Last seen from emitter + emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first() + + return { + "id": u.id, + "unit_type": u.unit_type, + "deployed": u.deployed, + "out_for_calibration": u.out_for_calibration or False, + "note": u.note or "", + "project_id": u.project_id or "", + "address": u.address or u.location or "", + "coordinates": u.coordinates or "", + "deployed_with_modem_id": u.deployed_with_modem_id or "", + "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, + "next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None), + "cal_expired": not u.last_calibrated or (expiry and expiry < today), + "last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None, + "reservations": reservations, + } + + @router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse) async def get_available_units_partial( request: Request, diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index ea799ed..e343b83 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""" @@ -467,6 +500,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): "deployed": unit.deployed, "retired": unit.retired, "out_for_calibration": unit.out_for_calibration or False, + "allocated": getattr(unit, 'allocated', False) or False, + "allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "", "note": unit.note or "", "project_id": unit.project_id or "", "location": unit.location or "", @@ -499,6 +534,8 @@ async def edit_roster_unit( deployed: str = Form(None), retired: str = Form(None), out_for_calibration: str = Form(None), + allocated: str = Form(None), + allocated_to_project_id: str = Form(None), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), @@ -541,6 +578,7 @@ async def edit_roster_unit( deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False + allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False # Convert port strings to integers slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None @@ -578,6 +616,8 @@ async def edit_roster_unit( unit.deployed = deployed_bool unit.retired = retired_bool unit.out_for_calibration = out_for_calibration_bool + unit.allocated = allocated_bool + unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None unit.note = note unit.project_id = project_id unit.location = location @@ -679,6 +719,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 +836,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/routers/seismo_dashboard.py b/backend/routers/seismo_dashboard.py index fde33b9..72cf7f9 100644 --- a/backend/routers/seismo_dashboard.py +++ b/backend/routers/seismo_dashboard.py @@ -3,13 +3,13 @@ Seismograph Dashboard API Router Provides endpoints for the seismograph-specific dashboard """ -from datetime import date +from datetime import date, datetime, timedelta -from fastapi import APIRouter, Request, Depends, Query +from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from backend.database import get_db -from backend.models import RosterUnit +from backend.models import RosterUnit, UnitHistory, UserPreferences from backend.templates_config import templates router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) @@ -120,3 +120,109 @@ async def get_seismo_units( "today": date.today() } ) + + +def _get_calibration_interval(db: Session) -> int: + prefs = db.query(UserPreferences).first() + if prefs and prefs.calibration_interval_days: + return prefs.calibration_interval_days + return 365 + + +def _row_context(request: Request, unit: RosterUnit) -> dict: + return {"request": request, "unit": unit, "today": date.today()} + + +@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse) +async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)): + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit)) + + +@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse) +async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)): + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit)) + + +@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse) +async def quick_update_seismo_unit( + unit_id: str, + request: Request, + db: Session = Depends(get_db), + status: str = Form(...), + last_calibrated: str = Form(""), + note: str = Form(""), +): + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + # --- Status --- + old_deployed = unit.deployed + old_out_for_cal = unit.out_for_calibration + if status == "deployed": + unit.deployed = True + unit.out_for_calibration = False + elif status == "out_for_calibration": + unit.deployed = False + unit.out_for_calibration = True + else: + unit.deployed = False + unit.out_for_calibration = False + + if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal: + old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched") + db.add(UnitHistory( + unit_id=unit_id, + change_type="deployed_change", + field_name="status", + old_value=old_status, + new_value=status, + source="manual", + )) + + # --- Last calibrated --- + old_cal = unit.last_calibrated + if last_calibrated: + try: + new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + unit.last_calibrated = new_cal + unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db)) + else: + unit.last_calibrated = None + unit.next_calibration_due = None + + if unit.last_calibrated != old_cal: + db.add(UnitHistory( + unit_id=unit_id, + change_type="calibration_status_change", + field_name="last_calibrated", + old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None, + new_value=last_calibrated or None, + source="manual", + )) + + # --- Note --- + old_note = unit.note + unit.note = note or None + if unit.note != old_note: + db.add(UnitHistory( + unit_id=unit_id, + change_type="note_change", + field_name="note", + old_value=old_note, + new_value=unit.note, + source="manual", + )) + + db.commit() + db.refresh(unit) + + return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit)) 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/backend/services/snapshot.py b/backend/services/snapshot.py index e4340f2..da54f65 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -86,6 +86,12 @@ def emit_status_snapshot(): age = "N/A" last_seen = None fname = "" + elif getattr(r, 'allocated', False) and not r.deployed: + # Allocated: staged for an upcoming job, not yet physically deployed + status = "Allocated" + age = "N/A" + last_seen = None + fname = "" else: if e: last_seen = ensure_utc(e.last_seen) @@ -110,6 +116,8 @@ def emit_status_snapshot(): "note": r.note or "", "retired": r.retired, "out_for_calibration": r.out_for_calibration or False, + "allocated": getattr(r, 'allocated', False) or False, + "allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "", # Device type and type-specific fields "device_type": r.device_type or "seismograph", "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None, @@ -141,6 +149,8 @@ def emit_status_snapshot(): "note": "", "retired": False, "out_for_calibration": False, + "allocated": False, + "allocated_to_project_id": "", # Device type and type-specific fields (defaults for unknown units) "device_type": "seismograph", # default "last_calibrated": None, @@ -192,7 +202,12 @@ def emit_status_snapshot(): benched_units = { uid: u for uid, u in units.items() - if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored + if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored + } + + allocated_units = { + uid: u for uid, u in units.items() + if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored } retired_units = { @@ -216,13 +231,15 @@ def emit_status_snapshot(): "units": units, "active": active_units, "benched": benched_units, + "allocated": allocated_units, "retired": retired_units, "out_for_calibration": out_for_calibration_units, "unknown": unknown_units, "summary": { - "total": len(active_units) + len(benched_units), + "total": len(active_units) + len(benched_units) + len(allocated_units), "active": len(active_units), "benched": len(benched_units), + "allocated": len(allocated_units), "retired": len(retired_units), "out_for_calibration": len(out_for_calibration_units), "unknown": len(unknown_units), diff --git a/templates/dashboard.html b/templates/dashboard.html index 3ffa65b..d1ec900 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -57,6 +57,10 @@ Benched -- +
By Device Type: