diff --git a/.gitignore b/.gitignore index 466d45f..8458942 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Terra-View Specifics # Dev build counter (local only, never commit) build_number.txt +docker-compose.override.yml # SQLite database files *.db @@ -9,7 +10,7 @@ data/ data-dev/ .aider* .aider* -docker-compose.override.yml + # Byte-compiled / optimized / DLL files __pycache__/ @@ -219,3 +220,14 @@ marimo/_static/ marimo/_lsp/ __marimo__/ +<<<<<<< HEAD +# Seismo Fleet Manager +# SQLite database files +*.db +*.db-journal +/data/ +/data-dev/ +.aider* +.aider* +======= +>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027 diff --git a/CHANGELOG.md b/CHANGELOG.md index 756074f..0801df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.7.1] - 2026-03-12 + +### Added +- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel +- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations + +### Changed +- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks + +### Fixed +- **Migration Scripts**: Fixed database path references in several migration scripts +- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate + +### Migration Notes +Run the following migration script once per database before deploying: +```bash +python backend/migrate_add_out_for_calibration.py +``` + +--- + ## [0.7.0] - 2026-03-07 ### Added diff --git a/backend/main.py b/backend/main.py index fe95f59..ee55012 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.7.0" +VERSION = "0.7.1" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": @@ -659,6 +659,7 @@ async def devices_all_partial(request: Request): "last_seen": unit_data.get("last", "Never"), "deployed": True, "retired": False, + "out_for_calibration": False, "ignored": False, "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), @@ -683,6 +684,32 @@ async def devices_all_partial(request: Request): "last_seen": unit_data.get("last", "Never"), "deployed": False, "retired": False, + "out_for_calibration": False, + "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({ + "id": unit_id, + "status": "Out for Calibration", + "age": "N/A", + "last_seen": "N/A", + "deployed": False, + "retired": False, + "out_for_calibration": True, "ignored": False, "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), @@ -707,6 +734,7 @@ async def devices_all_partial(request: Request): "last_seen": "N/A", "deployed": False, "retired": True, + "out_for_calibration": False, "ignored": False, "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), @@ -731,6 +759,7 @@ async def devices_all_partial(request: Request): "last_seen": "N/A", "deployed": False, "retired": False, + "out_for_calibration": False, "ignored": True, "note": unit_data.get("note", unit_data.get("reason", "")), "device_type": unit_data.get("device_type", "unknown"), @@ -748,15 +777,17 @@ async def devices_all_partial(request: Request): # Sort by status category, then by ID def sort_key(unit): - # Priority: deployed (active) -> benched -> retired -> ignored + # Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored if unit["deployed"]: return (0, unit["id"]) - elif not unit["retired"] and not unit["ignored"]: + elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]: return (1, unit["id"]) - elif unit["retired"]: + elif unit["out_for_calibration"]: return (2, unit["id"]) - else: + elif unit["retired"]: return (3, unit["id"]) + else: + return (4, unit["id"]) units_list.sort(key=sort_key) diff --git a/backend/migrate_add_out_for_calibration.py b/backend/migrate_add_out_for_calibration.py new file mode 100644 index 0000000..e373cd0 --- /dev/null +++ b/backend/migrate_add_out_for_calibration.py @@ -0,0 +1,54 @@ +""" +Database Migration: Add out_for_calibration field to roster table + +Changes: +- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table +- Safe to run multiple times (idempotent) +- No data loss + +Usage: + python backend/migrate_add_out_for_calibration.py +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def migrate(): + db = SessionLocal() + try: + print("=" * 60) + print("Migration: Add out_for_calibration to roster") + print("=" * 60) + + # Check if column already exists + result = db.execute(text("PRAGMA table_info(roster)")).fetchall() + columns = [row[1] for row in result] + + if "out_for_calibration" in columns: + print("Column out_for_calibration already exists. Skipping.") + else: + db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE")) + db.commit() + print("Added out_for_calibration column to roster table.") + + print("Migration complete.") + except Exception as e: + db.rollback() + print(f"Error: {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + migrate() diff --git a/backend/migrate_add_project_deleted_at.py b/backend/migrate_add_project_deleted_at.py index d15ed34..1f07431 100644 --- a/backend/migrate_add_project_deleted_at.py +++ b/backend/migrate_add_project_deleted_at.py @@ -44,7 +44,7 @@ def migrate(db_path: str): if __name__ == "__main__": - db_path = "./data/terra-view.db" + db_path = "./data/seismo_fleet.db" if len(sys.argv) > 1: db_path = sys.argv[1] diff --git a/backend/migrate_rename_recording_to_monitoring_sessions.py b/backend/migrate_rename_recording_to_monitoring_sessions.py index 475ed67..c62f416 100644 --- a/backend/migrate_rename_recording_to_monitoring_sessions.py +++ b/backend/migrate_rename_recording_to_monitoring_sessions.py @@ -42,7 +42,7 @@ def migrate(db_path: str): if __name__ == "__main__": - db_path = "./data/terra-view.db" + db_path = "./data/seismo_fleet.db" if len(sys.argv) > 1: db_path = sys.argv[1] diff --git a/backend/models.py b/backend/models.py index 1c0a39d..9abee13 100644 --- a/backend/models.py +++ b/backend/models.py @@ -32,6 +32,7 @@ class RosterUnit(Base): device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm" deployed = Column(Boolean, default=True) retired = Column(Boolean, default=False) + out_for_calibration = Column(Boolean, default=False) note = Column(String, nullable=True) project_id = Column(String, nullable=True) location = Column(String, nullable=True) # Legacy field - use address/coordinates instead diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index e4083f7..ea799ed 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -146,6 +146,7 @@ async def add_roster_unit( unit_type: str = Form("series3"), deployed: str = Form(None), retired: str = Form(None), + out_for_calibration: str = Form(None), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), @@ -177,6 +178,7 @@ async def add_roster_unit( # Convert boolean strings to actual booleans 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 # Convert port strings to integers slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None @@ -211,6 +213,7 @@ async def add_roster_unit( unit_type=unit_type, deployed=deployed_bool, retired=retired_bool, + out_for_calibration=out_for_calibration_bool, note=note, project_id=project_id, location=location, @@ -463,6 +466,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): "unit_type": unit.unit_type, "deployed": unit.deployed, "retired": unit.retired, + "out_for_calibration": unit.out_for_calibration or False, "note": unit.note or "", "project_id": unit.project_id or "", "location": unit.location or "", @@ -494,6 +498,7 @@ async def edit_roster_unit( unit_type: str = Form("series3"), deployed: str = Form(None), retired: str = Form(None), + out_for_calibration: str = Form(None), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), @@ -535,6 +540,7 @@ async def edit_roster_unit( # Convert boolean strings to actual booleans 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 # Convert port strings to integers slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None @@ -564,12 +570,14 @@ async def edit_roster_unit( old_note = unit.note old_deployed = unit.deployed old_retired = unit.retired + old_out_for_calibration = unit.out_for_calibration # Update all fields unit.device_type = device_type unit.unit_type = unit_type unit.deployed = deployed_bool unit.retired = retired_bool + unit.out_for_calibration = out_for_calibration_bool unit.note = note unit.project_id = project_id unit.location = location @@ -677,6 +685,11 @@ async def edit_roster_unit( old_status_text = "retired" if old_retired else "active" record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual") + if old_out_for_calibration != out_for_calibration_bool: + status_text = "out_for_calibration" if out_for_calibration_bool else "available" + old_status_text = "out_for_calibration" if old_out_for_calibration else "available" + record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual") + # Handle cascade to paired device cascaded_unit_id = None if cascade_to_unit_id and cascade_to_unit_id.strip(): diff --git a/backend/routers/seismo_dashboard.py b/backend/routers/seismo_dashboard.py index 7e48f83..fde33b9 100644 --- a/backend/routers/seismo_dashboard.py +++ b/backend/routers/seismo_dashboard.py @@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)): total = len(seismos) deployed = sum(1 for s in seismos if s.deployed) - benched = sum(1 for s in seismos if not s.deployed) + benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration) + out_for_calibration = sum(1 for s in seismos if s.out_for_calibration) # Count modems assigned to deployed seismographs with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id) @@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)): "total": total, "deployed": deployed, "benched": benched, + "out_for_calibration": out_for_calibration, "with_modem": with_modem, "without_modem": without_modem } @@ -77,7 +79,9 @@ async def get_seismo_units( if status == "deployed": query = query.filter(RosterUnit.deployed == True) elif status == "benched": - query = query.filter(RosterUnit.deployed == False) + query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False) + elif status == "out_for_calibration": + query = query.filter(RosterUnit.out_for_calibration == True) # Apply modem filter if modem == "with": diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index ec0dc2c..e4340f2 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -80,6 +80,12 @@ def emit_status_snapshot(): age = "N/A" last_seen = None fname = "" + elif r.out_for_calibration: + # Out for calibration units get separated later + status = "Out for Calibration" + age = "N/A" + last_seen = None + fname = "" else: if e: last_seen = ensure_utc(e.last_seen) @@ -103,6 +109,7 @@ def emit_status_snapshot(): "deployed": r.deployed, "note": r.note or "", "retired": r.retired, + "out_for_calibration": r.out_for_calibration or False, # 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, @@ -133,6 +140,7 @@ def emit_status_snapshot(): "deployed": False, # default "note": "", "retired": False, + "out_for_calibration": False, # Device type and type-specific fields (defaults for unknown units) "device_type": "seismograph", # default "last_calibrated": None, @@ -179,12 +187,12 @@ def emit_status_snapshot(): # Separate buckets for UI active_units = { uid: u for uid, u in units.items() - if not u["retired"] and u["deployed"] and uid not in ignored + if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored } benched_units = { uid: u for uid, u in units.items() - if not u["retired"] and not u["deployed"] and uid not in ignored + if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored } retired_units = { @@ -192,6 +200,11 @@ def emit_status_snapshot(): if u["retired"] } + out_for_calibration_units = { + uid: u for uid, u in units.items() + if u["out_for_calibration"] + } + # Unknown units - emitters that aren't in the roster and aren't ignored unknown_units = { uid: u for uid, u in units.items() @@ -204,12 +217,14 @@ def emit_status_snapshot(): "active": active_units, "benched": benched_units, "retired": retired_units, + "out_for_calibration": out_for_calibration_units, "unknown": unknown_units, "summary": { "total": len(active_units) + len(benched_units), "active": len(active_units), "benched": len(benched_units), "retired": len(retired_units), + "out_for_calibration": len(out_for_calibration_units), "unknown": len(unknown_units), # Status counts only for deployed units (active_units) "ok": sum(1 for u in active_units.values() if u["status"] == "OK"), diff --git a/docker-compose.yml b/docker-compose.yml index 3a8964c..5ea8549 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,7 @@ services: - # --- TERRA-VIEW PRODUCTION --- terra-view: build: . - container_name: terra-view ports: - "8001:8001" volumes: @@ -29,7 +27,6 @@ services: build: context: ../slmm dockerfile: Dockerfile - container_name: slmm network_mode: host volumes: - ../slmm/data:/app/data diff --git a/scripts/rename_unit.py b/scripts/rename_unit.py index 68d4dc6..d181b3e 100644 --- a/scripts/rename_unit.py +++ b/scripts/rename_unit.py @@ -8,7 +8,7 @@ import sys from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker -DATABASE_URL = "sqlite:///data/sfm.db" +DATABASE_URL = "sqlite:///data/seismo_fleet.db" def rename_unit(old_id: str, new_id: str): """ diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html index 09879cf..d67e695 100644 --- a/templates/fleet_calendar.html +++ b/templates/fleet_calendar.html @@ -397,13 +397,14 @@