diff --git a/backend/main.py b/backend/main.py
index a61f2b5..e9264e8 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,12 +1,14 @@
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
-from backend.database import engine, Base
+from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
from backend.services.snapshot import emit_status_snapshot
+from backend.models import IgnoredUnit
# Create database tables
Base.metadata.create_all(bind=engine)
@@ -41,6 +43,9 @@ app.include_router(roster_edit.router)
app.include_router(dashboard.router)
app.include_router(dashboard_tabs.router)
+from backend.routers import settings
+app.include_router(settings.router)
+
# Legacy routes from the original backend
@@ -70,14 +75,20 @@ async def unit_detail_page(request: Request, unit_id: str):
})
-@app.get("/partials/roster-table", response_class=HTMLResponse)
-async def roster_table_partial(request: Request):
- """Partial template for roster table (HTMX)"""
+@app.get("/settings", response_class=HTMLResponse)
+async def settings_page(request: Request):
+ """Settings page for roster management"""
+ return templates.TemplateResponse("settings.html", {"request": request})
+
+
+@app.get("/partials/roster-deployed", response_class=HTMLResponse)
+async def roster_deployed_partial(request: Request):
+ """Partial template for deployed units tab"""
from datetime import datetime
snapshot = emit_status_snapshot()
units_list = []
- for unit_id, unit_data in snapshot["units"].items():
+ for unit_id, unit_data in snapshot["active"].items():
units_list.append({
"id": unit_id,
"status": unit_data["status"],
@@ -105,6 +116,98 @@ async def roster_table_partial(request: Request):
})
+@app.get("/partials/roster-benched", response_class=HTMLResponse)
+async def roster_benched_partial(request: Request):
+ """Partial template for benched units tab"""
+ from datetime import datetime
+ snapshot = emit_status_snapshot()
+
+ units_list = []
+ for unit_id, unit_data in snapshot["benched"].items():
+ units_list.append({
+ "id": unit_id,
+ "status": unit_data["status"],
+ "age": unit_data["age"],
+ "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 ID
+ units_list.sort(key=lambda x: x["id"])
+
+ return templates.TemplateResponse("partials/roster_table.html", {
+ "request": request,
+ "units": units_list,
+ "timestamp": datetime.now().strftime("%H:%M:%S")
+ })
+
+
+@app.get("/partials/roster-retired", response_class=HTMLResponse)
+async def roster_retired_partial(request: Request):
+ """Partial template for retired units tab"""
+ from datetime import datetime
+ snapshot = emit_status_snapshot()
+
+ units_list = []
+ for unit_id, unit_data in snapshot["retired"].items():
+ units_list.append({
+ "id": unit_id,
+ "status": unit_data["status"],
+ "age": unit_data["age"],
+ "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 ID
+ units_list.sort(key=lambda x: x["id"])
+
+ return templates.TemplateResponse("partials/retired_table.html", {
+ "request": request,
+ "units": units_list,
+ "timestamp": datetime.now().strftime("%H:%M:%S")
+ })
+
+
+@app.get("/partials/roster-ignored", response_class=HTMLResponse)
+async def roster_ignored_partial(request: Request, db: Session = Depends(get_db)):
+ """Partial template for ignored units tab"""
+ from datetime import datetime
+
+ ignored = db.query(IgnoredUnit).all()
+ ignored_list = []
+ for unit in ignored:
+ ignored_list.append({
+ "id": unit.id,
+ "reason": unit.reason or "",
+ "ignored_at": unit.ignored_at.strftime("%Y-%m-%d %H:%M:%S") if unit.ignored_at else "Unknown"
+ })
+
+ # Sort by ID
+ ignored_list.sort(key=lambda x: x["id"])
+
+ return templates.TemplateResponse("partials/ignored_table.html", {
+ "request": request,
+ "ignored_units": ignored_list,
+ "timestamp": datetime.now().strftime("%H:%M:%S")
+ })
+
+
@app.get("/partials/unknown-emitters", response_class=HTMLResponse)
async def unknown_emitters_partial(request: Request):
"""Partial template for unknown emitters (HTMX)"""
diff --git a/backend/models.py b/backend/models.py
index 6474d80..d941264 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -31,7 +31,9 @@ class RosterUnit(Base):
retired = Column(Boolean, default=False)
note = Column(String, nullable=True)
project_id = Column(String, nullable=True)
- location = Column(String, nullable=True)
+ location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
+ address = Column(String, nullable=True) # Human-readable address
+ coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
last_updated = Column(DateTime, default=datetime.utcnow)
# Seismograph-specific fields (nullable for modems)
diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py
index a29ab89..82f2061 100644
--- a/backend/routers/roster_edit.py
+++ b/backend/routers/roster_edit.py
@@ -5,7 +5,7 @@ import csv
import io
from backend.database import get_db
-from backend.models import RosterUnit, IgnoredUnit
+from backend.models import RosterUnit, IgnoredUnit, Emitter
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
@@ -26,9 +26,12 @@ def add_roster_unit(
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),
+ address: str = Form(None),
+ coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
@@ -62,9 +65,12 @@ def add_roster_unit(
device_type=device_type,
unit_type=unit_type,
deployed=deployed,
+ retired=retired,
note=note,
project_id=project_id,
location=location,
+ address=address,
+ coordinates=coordinates,
last_updated=datetime.utcnow(),
# Seismograph-specific fields
last_calibrated=last_cal_date,
@@ -96,6 +102,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
+ "address": unit.address or "",
+ "coordinates": unit.coordinates 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 "",
@@ -115,6 +123,8 @@ def edit_roster_unit(
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
+ address: str = Form(None),
+ coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
@@ -152,6 +162,8 @@ def edit_roster_unit(
unit.note = note
unit.project_id = project_id
unit.location = location
+ unit.address = address
+ unit.coordinates = coordinates
unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
@@ -189,14 +201,27 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
@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.
+ Permanently delete a unit from the database.
+ Checks both roster and emitters tables and deletes from any table where the unit exists.
"""
- unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
- if not unit:
+ deleted = False
+
+ # Try to delete from roster table
+ roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
+ if roster_unit:
+ db.delete(roster_unit)
+ deleted = True
+
+ # Try to delete from emitters table
+ emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
+ if emitter:
+ db.delete(emitter)
+ deleted = True
+
+ # If not found in either table, return error
+ if not deleted:
raise HTTPException(status_code=404, detail="Unit not found")
- db.delete(unit)
db.commit()
return {"message": "Unit deleted", "id": unit_id}
@@ -274,6 +299,8 @@ async def import_csv(
existing_unit.note = row.get('note', existing_unit.note or '')
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
existing_unit.location = row.get('location', existing_unit.location)
+ existing_unit.address = row.get('address', existing_unit.address)
+ existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow()
results["updated"].append(unit_id)
@@ -287,6 +314,8 @@ async def import_csv(
note=row.get('note', ''),
project_id=row.get('project_id'),
location=row.get('location'),
+ address=row.get('address'),
+ coordinates=row.get('coordinates'),
last_updated=datetime.utcnow()
)
db.add(new_unit)
diff --git a/backend/routers/settings.py b/backend/routers/settings.py
new file mode 100644
index 0000000..7063209
--- /dev/null
+++ b/backend/routers/settings.py
@@ -0,0 +1,241 @@
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+from datetime import datetime, date
+import csv
+import io
+
+from backend.database import get_db
+from backend.models import RosterUnit, Emitter, IgnoredUnit
+
+router = APIRouter(prefix="/api/settings", tags=["settings"])
+
+
+@router.get("/export-csv")
+def export_roster_csv(db: Session = Depends(get_db)):
+ """Export all roster units to CSV"""
+ units = db.query(RosterUnit).all()
+
+ # Create CSV in memory
+ output = io.StringIO()
+ fieldnames = [
+ 'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
+ 'note', 'project_id', 'location', 'address', 'coordinates',
+ 'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
+ 'ip_address', 'phone_number', 'hardware_model'
+ ]
+
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
+ writer.writeheader()
+
+ for unit in units:
+ writer.writerow({
+ 'unit_id': unit.id,
+ 'unit_type': unit.unit_type or '',
+ 'device_type': unit.device_type or 'seismograph',
+ 'deployed': 'true' if unit.deployed else 'false',
+ 'retired': 'true' if unit.retired else 'false',
+ 'note': unit.note or '',
+ 'project_id': unit.project_id or '',
+ 'location': unit.location or '',
+ 'address': unit.address or '',
+ 'coordinates': unit.coordinates or '',
+ 'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
+ 'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') 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 ''
+ })
+
+ output.seek(0)
+ filename = f"roster_export_{date.today().isoformat()}.csv"
+
+ return StreamingResponse(
+ io.BytesIO(output.getvalue().encode('utf-8')),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+
+@router.get("/stats")
+def get_table_stats(db: Session = Depends(get_db)):
+ """Get counts for all tables"""
+ roster_count = db.query(RosterUnit).count()
+ emitters_count = db.query(Emitter).count()
+ ignored_count = db.query(IgnoredUnit).count()
+
+ return {
+ "roster": roster_count,
+ "emitters": emitters_count,
+ "ignored": ignored_count,
+ "total": roster_count + emitters_count + ignored_count
+ }
+
+
+@router.get("/roster-units")
+def get_all_roster_units(db: Session = Depends(get_db)):
+ """Get all roster units for management table"""
+ units = db.query(RosterUnit).order_by(RosterUnit.id).all()
+
+ return [{
+ "id": unit.id,
+ "device_type": unit.device_type or "seismograph",
+ "unit_type": unit.unit_type or "series3",
+ "deployed": unit.deployed,
+ "retired": unit.retired,
+ "note": unit.note or "",
+ "project_id": unit.project_id or "",
+ "location": unit.location or "",
+ "address": unit.address or "",
+ "coordinates": unit.coordinates or "",
+ "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
+ "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
+ "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 "",
+ "last_updated": unit.last_updated.isoformat() if unit.last_updated else None
+ } for unit in units]
+
+
+def parse_date(date_str):
+ """Helper function to parse date strings"""
+ if not date_str or not date_str.strip():
+ return None
+ try:
+ return datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
+ except ValueError:
+ return None
+
+
+@router.post("/import-csv-replace")
+async def import_csv_replace(
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db)
+):
+ """
+ Replace all roster data with CSV import (atomic transaction).
+ Clears roster table first, then imports all rows from CSV.
+ """
+
+ if not file.filename.endswith('.csv'):
+ raise HTTPException(status_code=400, detail="File must be a CSV")
+
+ # Read and parse CSV
+ contents = await file.read()
+ csv_text = contents.decode('utf-8')
+ csv_reader = csv.DictReader(io.StringIO(csv_text))
+
+ # Parse all rows FIRST (fail fast before deletion)
+ parsed_units = []
+ for row_num, row in enumerate(csv_reader, start=2):
+ unit_id = row.get('unit_id', '').strip()
+ if not unit_id:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Row {row_num}: Missing required field unit_id"
+ )
+
+ # Parse and validate dates
+ last_cal_date = parse_date(row.get('last_calibrated'))
+ next_cal_date = parse_date(row.get('next_calibration_due'))
+
+ parsed_units.append({
+ 'id': unit_id,
+ 'unit_type': row.get('unit_type', 'series3'),
+ 'device_type': row.get('device_type', 'seismograph'),
+ 'deployed': row.get('deployed', '').lower() in ('true', '1', 'yes'),
+ 'retired': row.get('retired', '').lower() in ('true', '1', 'yes'),
+ 'note': row.get('note', ''),
+ 'project_id': row.get('project_id') or None,
+ 'location': row.get('location') or None,
+ 'address': row.get('address') or None,
+ 'coordinates': row.get('coordinates') or None,
+ 'last_calibrated': last_cal_date,
+ 'next_calibration_due': next_cal_date,
+ 'deployed_with_modem_id': row.get('deployed_with_modem_id') or None,
+ 'ip_address': row.get('ip_address') or None,
+ 'phone_number': row.get('phone_number') or None,
+ 'hardware_model': row.get('hardware_model') or None,
+ })
+
+ # Atomic transaction: delete all, then insert all
+ try:
+ deleted_count = db.query(RosterUnit).delete()
+
+ for unit_data in parsed_units:
+ new_unit = RosterUnit(**unit_data, last_updated=datetime.utcnow())
+ db.add(new_unit)
+
+ db.commit()
+
+ return {
+ "message": "Roster replaced successfully",
+ "deleted": deleted_count,
+ "added": len(parsed_units)
+ }
+
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
+
+
+@router.post("/clear-all")
+def clear_all_data(db: Session = Depends(get_db)):
+ """Clear all tables (roster, emitters, ignored)"""
+ try:
+ roster_count = db.query(RosterUnit).delete()
+ emitters_count = db.query(Emitter).delete()
+ ignored_count = db.query(IgnoredUnit).delete()
+
+ db.commit()
+
+ return {
+ "message": "All data cleared",
+ "deleted": {
+ "roster": roster_count,
+ "emitters": emitters_count,
+ "ignored": ignored_count,
+ "total": roster_count + emitters_count + ignored_count
+ }
+ }
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
+
+
+@router.post("/clear-roster")
+def clear_roster(db: Session = Depends(get_db)):
+ """Clear roster table only"""
+ try:
+ count = db.query(RosterUnit).delete()
+ db.commit()
+ return {"message": "Roster cleared", "deleted": count}
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
+
+
+@router.post("/clear-emitters")
+def clear_emitters(db: Session = Depends(get_db)):
+ """Clear emitters table only"""
+ try:
+ count = db.query(Emitter).delete()
+ db.commit()
+ return {"message": "Emitters cleared", "deleted": count}
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
+
+
+@router.post("/clear-ignored")
+def clear_ignored(db: Session = Depends(get_db)):
+ """Clear ignored units table only"""
+ try:
+ count = db.query(IgnoredUnit).delete()
+ db.commit()
+ return {"message": "Ignored units cleared", "deleted": count}
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py
index fa10bdb..478d987 100644
--- a/backend/services/snapshot.py
+++ b/backend/services/snapshot.py
@@ -79,6 +79,8 @@ def emit_status_snapshot():
"hardware_model": r.hardware_model,
# Location for mapping
"location": r.location or "",
+ "address": r.address or "",
+ "coordinates": r.coordinates or "",
}
# --- Add unexpected emitter-only units ---
@@ -102,6 +104,10 @@ def emit_status_snapshot():
"ip_address": None,
"phone_number": None,
"hardware_model": None,
+ # Location fields
+ "location": "",
+ "address": "",
+ "coordinates": "",
}
# Separate buckets for UI
@@ -139,9 +145,10 @@ def emit_status_snapshot():
"benched": len(benched_units),
"retired": len(retired_units),
"unknown": len(unknown_units),
- "ok": sum(1 for u in units.values() if u["status"] == "OK"),
- "pending": sum(1 for u in units.values() if u["status"] == "Pending"),
- "missing": sum(1 for u in units.values() if u["status"] == "Missing"),
+ # Status counts only for deployed units (active_units)
+ "ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
+ "pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
+ "missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
}
}
finally:
diff --git a/templates/base.html b/templates/base.html
index eb72c35..e2d496c 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -68,7 +68,7 @@
Seismo
Fleet Manager
-
v0.1.1
+v 0.2.1
@@ -94,7 +94,7 @@ Projects - +