feat: add new "out for cal" status for units currently being calibrated.

-retire unit button changed to be more dramatic... lol
This commit is contained in:
2026-03-12 17:59:42 +00:00
parent e89a04f58c
commit c138e8c6a0
11 changed files with 209 additions and 29 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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():

View File

@@ -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":

View File

@@ -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"),