v0.2.1. many features added and cleaned up.

This commit is contained in:
serversdwn
2025-12-03 21:23:18 +00:00
parent dc853806bb
commit 4cef580185
13 changed files with 1815 additions and 181 deletions

View File

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

View File

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

View File

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

241
backend/routers/settings.py Normal file
View File

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

View File

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