diff --git a/backend/main.py b/backend/main.py index fe95f59..1be412b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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/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/templates/partials/devices_table.html b/templates/partials/devices_table.html index e0dafe5..c2b0127 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -51,7 +51,7 @@ {% for unit in units %}
Benched
{{ benched }}
+ {% if out_for_calibration > 0 %} +{{ out_for_calibration }} out for calibration
+ {% endif %}--