Status
+--
+--
+--
+--
+diff --git a/backend/main.py b/backend/main.py index 3e0003a..a61f2b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,6 +85,13 @@ async def roster_table_partial(request: Request): "last_seen": unit_data["last"], "deployed": unit_data["deployed"], "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "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"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), }) # Sort by status priority (Missing > Pending > OK) then by ID diff --git a/backend/migrate_add_device_types.py b/backend/migrate_add_device_types.py new file mode 100644 index 0000000..f923f34 --- /dev/null +++ b/backend/migrate_add_device_types.py @@ -0,0 +1,84 @@ +""" +Migration script to add device type support to the roster table. + +This adds columns for: +- device_type (seismograph/modem discriminator) +- Seismograph-specific fields (calibration dates, modem pairing) +- Modem-specific fields (IP address, phone number, hardware model) + +Run this script once to migrate an existing database. +""" + +import sqlite3 +import os + +# Database path +DB_PATH = "./data/seismo_fleet.db" + +def migrate_database(): + """Add new columns to the roster table""" + + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + print("The database will be created automatically when you run the application.") + return + + print(f"Migrating database: {DB_PATH}") + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if device_type column already exists + cursor.execute("PRAGMA table_info(roster)") + columns = [col[1] for col in cursor.fetchall()] + + if "device_type" in columns: + print("Migration already applied - device_type column exists") + conn.close() + return + + print("Adding new columns to roster table...") + + try: + # Add device type discriminator + cursor.execute("ALTER TABLE roster ADD COLUMN device_type TEXT DEFAULT 'seismograph'") + print(" ✓ Added device_type column") + + # Add seismograph-specific fields + cursor.execute("ALTER TABLE roster ADD COLUMN last_calibrated DATE") + print(" ✓ Added last_calibrated column") + + cursor.execute("ALTER TABLE roster ADD COLUMN next_calibration_due DATE") + print(" ✓ Added next_calibration_due column") + + cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_modem_id TEXT") + print(" ✓ Added deployed_with_modem_id column") + + # Add modem-specific fields + cursor.execute("ALTER TABLE roster ADD COLUMN ip_address TEXT") + print(" ✓ Added ip_address column") + + cursor.execute("ALTER TABLE roster ADD COLUMN phone_number TEXT") + print(" ✓ Added phone_number column") + + cursor.execute("ALTER TABLE roster ADD COLUMN hardware_model TEXT") + print(" ✓ Added hardware_model column") + + # Set all existing units to seismograph type + cursor.execute("UPDATE roster SET device_type = 'seismograph' WHERE device_type IS NULL") + print(" ✓ Set existing units to seismograph type") + + conn.commit() + print("\nMigration completed successfully!") + + except sqlite3.Error as e: + print(f"\nError during migration: {e}") + conn.rollback() + raise + + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/backend/models.py b/backend/models.py index 6f1f9d2..6474d80 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime, Boolean, Text +from sqlalchemy import Column, String, DateTime, Boolean, Text, Date from datetime import datetime from backend.database import Base @@ -18,11 +18,15 @@ class RosterUnit(Base): """ Roster table: represents our *intended assignment* of a unit. This is editable from the GUI. + + Supports multiple device types (seismograph, modem) with type-specific fields. """ __tablename__ = "roster" + # Core fields (all device types) id = Column(String, primary_key=True, index=True) - unit_type = Column(String, default="series3") + unit_type = Column(String, default="series3") # Backward compatibility + device_type = Column(String, default="seismograph") # "seismograph" | "modem" deployed = Column(Boolean, default=True) retired = Column(Boolean, default=False) note = Column(String, nullable=True) @@ -30,6 +34,16 @@ class RosterUnit(Base): location = Column(String, nullable=True) last_updated = Column(DateTime, default=datetime.utcnow) + # Seismograph-specific fields (nullable for modems) + last_calibrated = Column(Date, nullable=True) + next_calibration_due = Column(Date, nullable=True) + deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit + + # Modem-specific fields (nullable for seismographs) + ip_address = Column(String, nullable=True) + phone_number = Column(String, nullable=True) + hardware_model = Column(String, nullable=True) + class IgnoredUnit(Base): """ diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 1922942..a29ab89 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File from sqlalchemy.orm import Session -from datetime import datetime +from datetime import datetime, date import csv import io @@ -23,28 +23,149 @@ def get_or_create_roster_unit(db: Session, unit_id: str): @router.post("/add") def add_roster_unit( id: str = Form(...), + device_type: str = Form("seismograph"), unit_type: str = Form("series3"), deployed: bool = Form(False), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), + # Seismograph-specific fields + last_calibrated: str = Form(None), + next_calibration_due: str = Form(None), + deployed_with_modem_id: str = Form(None), + # Modem-specific fields + ip_address: str = Form(None), + phone_number: str = Form(None), + hardware_model: str = Form(None), db: Session = Depends(get_db) ): if db.query(RosterUnit).filter(RosterUnit.id == id).first(): raise HTTPException(status_code=400, detail="Unit already exists") + # Parse date fields if provided + last_cal_date = None + if last_calibrated: + try: + last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + + next_cal_date = None + if next_calibration_due: + try: + next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + unit = RosterUnit( id=id, + device_type=device_type, unit_type=unit_type, deployed=deployed, note=note, project_id=project_id, location=location, last_updated=datetime.utcnow(), + # Seismograph-specific fields + last_calibrated=last_cal_date, + next_calibration_due=next_cal_date, + deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None, + # Modem-specific fields + ip_address=ip_address if ip_address else None, + phone_number=phone_number if phone_number else None, + hardware_model=hardware_model if hardware_model else None, ) db.add(unit) db.commit() - return {"message": "Unit added", "id": id} + return {"message": "Unit added", "id": id, "device_type": device_type} + + +@router.get("/{unit_id}") +def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): + """Get a single roster unit by ID""" + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + return { + "id": unit.id, + "device_type": unit.device_type or "seismograph", + "unit_type": unit.unit_type, + "deployed": unit.deployed, + "retired": unit.retired, + "note": unit.note or "", + "project_id": unit.project_id or "", + "location": unit.location or "", + "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", + "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "", + "deployed_with_modem_id": unit.deployed_with_modem_id or "", + "ip_address": unit.ip_address or "", + "phone_number": unit.phone_number or "", + "hardware_model": unit.hardware_model or "", + } + + +@router.post("/edit/{unit_id}") +def edit_roster_unit( + unit_id: str, + device_type: str = Form("seismograph"), + unit_type: str = Form("series3"), + deployed: bool = Form(False), + retired: bool = Form(False), + note: str = Form(""), + project_id: str = Form(None), + location: str = Form(None), + # Seismograph-specific fields + last_calibrated: str = Form(None), + next_calibration_due: str = Form(None), + deployed_with_modem_id: str = Form(None), + # Modem-specific fields + ip_address: str = Form(None), + phone_number: str = Form(None), + hardware_model: str = Form(None), + 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") + + # Parse date fields if provided + last_cal_date = None + if last_calibrated: + try: + last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + + next_cal_date = None + if next_calibration_due: + try: + next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + + # Update all fields + unit.device_type = device_type + unit.unit_type = unit_type + unit.deployed = deployed + unit.retired = retired + unit.note = note + unit.project_id = project_id + unit.location = location + unit.last_updated = datetime.utcnow() + + # Seismograph-specific fields + unit.last_calibrated = last_cal_date + unit.next_calibration_due = next_cal_date + unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None + + # Modem-specific fields + unit.ip_address = ip_address if ip_address else None + unit.phone_number = phone_number if phone_number else None + unit.hardware_model = hardware_model if hardware_model else None + + db.commit() + return {"message": "Unit updated", "id": unit_id, "device_type": device_type} @router.post("/set-deployed/{unit_id}") @@ -65,6 +186,21 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g return {"message": "Updated", "id": unit_id, "retired": retired} +@router.delete("/{unit_id}") +def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): + """ + Permanently delete a unit from the roster database. + This is different from ignoring - the unit is completely removed. + """ + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + db.delete(unit) + db.commit() + return {"message": "Unit deleted", "id": unit_id} + + @router.post("/set-note/{unit_id}") def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)): unit = get_or_create_roster_unit(db, unit_id) diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index f71b2f5..fa10bdb 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -69,6 +69,16 @@ def emit_status_snapshot(): "deployed": r.deployed, "note": r.note or "", "retired": r.retired, + # 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, + "next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None, + "deployed_with_modem_id": r.deployed_with_modem_id, + "ip_address": r.ip_address, + "phone_number": r.phone_number, + "hardware_model": r.hardware_model, + # Location for mapping + "location": r.location or "", } # --- Add unexpected emitter-only units --- @@ -84,6 +94,14 @@ def emit_status_snapshot(): "deployed": False, # default "note": "", "retired": False, + # Device type and type-specific fields (defaults for unknown units) + "device_type": "seismograph", # default + "last_calibrated": None, + "next_calibration_due": None, + "deployed_with_modem_id": None, + "ip_address": None, + "phone_number": None, + "hardware_model": None, } # Separate buckets for UI diff --git a/create_test_db.py b/create_test_db.py new file mode 100644 index 0000000..11b44fc --- /dev/null +++ b/create_test_db.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Create a fresh test database with the new schema and some sample data. +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from datetime import datetime, date, timedelta +from backend.models import Base, RosterUnit, Emitter + +# Create a new test database +TEST_DB_PATH = "/tmp/sfm_test.db" +engine = create_engine(f"sqlite:///{TEST_DB_PATH}", connect_args={"check_same_thread": False}) + +# Drop all tables and recreate them with the new schema +Base.metadata.drop_all(bind=engine) +Base.metadata.create_all(bind=engine) + +# Create a session +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +db = SessionLocal() + +try: + # Add some test seismographs + seismo1 = RosterUnit( + id="BE9449", + device_type="seismograph", + unit_type="series3", + deployed=True, + note="Primary field unit", + project_id="PROJ-001", + location="Site A", + last_calibrated=date(2024, 1, 15), + next_calibration_due=date(2025, 1, 15), + deployed_with_modem_id="MDM001", + last_updated=datetime.utcnow(), + ) + + seismo2 = RosterUnit( + id="BE9450", + device_type="seismograph", + unit_type="series3", + deployed=False, + note="Benched for maintenance", + project_id="PROJ-001", + location="Warehouse", + last_calibrated=date(2023, 6, 20), + next_calibration_due=date(2024, 6, 20), # Past due + last_updated=datetime.utcnow(), + ) + + # Add some test modems + modem1 = RosterUnit( + id="MDM001", + device_type="modem", + unit_type="modem", + deployed=True, + note="Paired with BE9449", + project_id="PROJ-001", + location="Site A", + ip_address="192.168.1.100", + phone_number="+1-555-0123", + hardware_model="Raven XTV", + last_updated=datetime.utcnow(), + ) + + modem2 = RosterUnit( + id="MDM002", + device_type="modem", + unit_type="modem", + deployed=False, + note="Spare modem", + project_id="PROJ-001", + location="Warehouse", + ip_address="192.168.1.101", + phone_number="+1-555-0124", + hardware_model="Raven XT", + last_updated=datetime.utcnow(), + ) + + # Add test emitters (status reports) + emitter1 = Emitter( + id="BE9449", + unit_type="series3", + last_seen=datetime.utcnow() - timedelta(hours=2), + last_file="BE9449.2024.336.12.00.mseed", + status="OK", + notes="Running normally", + ) + + emitter2 = Emitter( + id="BE9450", + unit_type="series3", + last_seen=datetime.utcnow() - timedelta(days=30), + last_file="BE9450.2024.306.08.00.mseed", + status="Missing", + notes="No data received", + ) + + # Add all units + db.add_all([seismo1, seismo2, modem1, modem2, emitter1, emitter2]) + db.commit() + + print(f"✓ Test database created at {TEST_DB_PATH}") + print(f"✓ Added 2 seismographs (BE9449, BE9450)") + print(f"✓ Added 2 modems (MDM001, MDM002)") + print(f"✓ Added 2 emitter status reports") + print(f"\nDatabase is ready for testing!") + +except Exception as e: + print(f"Error creating test database: {e}") + db.rollback() + raise +finally: + db.close() diff --git a/templates/dashboard.html b/templates/dashboard.html index a52a101..4e3308a 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -98,6 +98,15 @@ + +
Loading fleet data...
Status: ${unit.status}
+Type: ${unit.device_type}
+ ${unit.note ? `${unit.note}
` : ''} + View Details → +| ID | -Status | -Age | -Last Seen | -File | -Note | -
|---|---|---|---|---|---|
| {{ uid }} | -{{ u.status }} | -{{ u.age }} | -{{ u.last }} | -{{ u.fname }} | -{{ u.note }} | -
{{ unit.note }}
+ {% endif %} +No active units
+{% endif %} diff --git a/templates/partials/benched_table.html b/templates/partials/benched_table.html index a680d88..b109259 100644 --- a/templates/partials/benched_table.html +++ b/templates/partials/benched_table.html @@ -1,25 +1,50 @@ -| ID | -Status | -Age | -Last Seen | -File | -Note | -
|---|---|---|---|---|---|
| {{ uid }} | -{{ u.status }} | -{{ u.age }} | -{{ u.last }} | -{{ u.fname }} | -{{ u.note }} | -
{{ unit.note }}
+ {% endif %} +No benched units
+{% endif %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 1b33fb3..f893aaf 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -8,6 +8,12 @@Seismograph Information
+Typically 1 year after last calibration
+Only needed when deployed
+Modem Information
+--
+--
+--
+--
+