v0.2 fleet overhaul

This commit is contained in:
serversdwn
2025-12-03 07:57:25 +00:00
parent 802601ae8d
commit dc853806bb
12 changed files with 1400 additions and 282 deletions

View File

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

View File

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

View File

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

View File

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

View File

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