diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 02c6813..6dff7a9 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -9,6 +9,7 @@ import logging import httpx from backend.database import get_db from backend.models import UnitHistory, Emitter, RosterUnit +from backend.services.unit_location import get_active_location log = logging.getLogger(__name__) @@ -140,6 +141,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends( days = int(hours_ago / 24) time_ago = f"{days}d ago" + loc = get_active_location(db, emitter.id) if roster_unit else None call_in = { "unit_id": emitter.id, "last_seen": emitter.last_seen.isoformat(), @@ -148,7 +150,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends( "device_type": roster_unit.device_type if roster_unit else "seismograph", "deployed": roster_unit.deployed if roster_unit else False, "note": roster_unit.note if roster_unit and roster_unit.note else "", - "location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "") + "location": (loc or {}).get("address") or (loc or {}).get("name") or "" } call_ins.append(call_in) diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py index 0a100d0..fe440ed 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)): # Last seen from emitter emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first() + from backend.services.unit_location import get_active_location + loc = get_active_location(db, u.id) return { "id": u.id, "unit_type": u.unit_type, "deployed": u.deployed, "out_for_calibration": u.out_for_calibration or False, "note": u.note or "", - "project_id": u.project_id or "", - "address": u.address or u.location or "", - "coordinates": u.coordinates or "", + "project_id": (loc or {}).get("project_id") or u.project_id or "", + "address": (loc or {}).get("address") or "", + "coordinates": (loc or {}).get("coordinates") or "", "deployed_with_modem_id": u.deployed_with_modem_id or "", "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, "next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None), diff --git a/backend/routers/modem_dashboard.py b/backend/routers/modem_dashboard.py index ed8b789..c7cb793 100644 --- a/backend/routers/modem_dashboard.py +++ b/backend/routers/modem_dashboard.py @@ -14,6 +14,7 @@ import logging from backend.database import get_db from backend.models import RosterUnit +from backend.services.unit_location import get_active_location from backend.templates_config import templates logger = logging.getLogger(__name__) @@ -85,8 +86,7 @@ async def get_modem_units( (RosterUnit.id.ilike(search_term)) | (RosterUnit.ip_address.ilike(search_term)) | (RosterUnit.hardware_model.ilike(search_term)) | - (RosterUnit.phone_number.ilike(search_term)) | - (RosterUnit.location.ilike(search_term)) + (RosterUnit.phone_number.ilike(search_term)) ) modems = query.order_by( @@ -128,6 +128,8 @@ async def get_modem_units( if filter_status and status != filter_status: continue + # Inherit location from the paired device's active assignment. + loc = get_active_location(db, modem.id) if paired else None modem_list.append({ "id": modem.id, "ip_address": modem.ip_address, @@ -135,8 +137,8 @@ async def get_modem_units( "hardware_model": modem.hardware_model, "deployed": modem.deployed, "retired": modem.retired, - "location": modem.location, - "project_id": modem.project_id, + "location": (loc or {}).get("address") or (loc or {}).get("name") or "", + "project_id": (loc or {}).get("project_id") or modem.project_id, "paired_device": paired, "status": status }) @@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)): ).first() if device: + loc = get_active_location(db, device.id) return { "paired": True, "device": { "id": device.id, "device_type": device.device_type, "deployed": device.deployed, - "project_id": device.project_id, - "location": device.location or device.address + "project_id": (loc or {}).get("project_id") or device.project_id, + "location": (loc or {}).get("address") or (loc or {}).get("name") or "" } } @@ -314,8 +317,6 @@ async def get_pairable_devices( query = query.filter( (RosterUnit.id.ilike(search_term)) | (RosterUnit.project_id.ilike(search_term)) | - (RosterUnit.location.ilike(search_term)) | - (RosterUnit.address.ilike(search_term)) | (RosterUnit.note.ilike(search_term)) ) @@ -338,12 +339,13 @@ async def get_pairable_devices( if hide_paired and is_paired_to_other: continue + loc = get_active_location(db, device.id) device_list.append({ "id": device.id, "device_type": device.device_type, "deployed": device.deployed, - "project_id": device.project_id, - "location": device.location or device.address, + "project_id": (loc or {}).get("project_id") or device.project_id, + "location": (loc or {}).get("address") or (loc or {}).get("name") or "", "note": device.note, "paired_modem_id": device.deployed_with_modem_id, "is_paired_to_this": is_paired_to_this, diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 8f81abb..0b17003 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -1483,11 +1483,13 @@ async def get_available_units( ).distinct().all() assigned_unit_ids = [uid[0] for uid in assigned_unit_ids] + # These units have no active assignment by definition, so there's no + # current location to show — leave the field empty. available_units = [ { "id": unit.id, "device_type": unit.device_type, - "location": unit.address or unit.location, + "location": "", "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type, "deployed": bool(unit.deployed), } diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index e343b83..045e98a 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -12,6 +12,7 @@ from backend.database import get_db from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord import uuid from backend.services.slmm_sync import sync_slm_to_slmm +from backend.services.unit_location import get_active_location router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) logger = logging.getLogger(__name__) @@ -182,9 +183,6 @@ async def add_roster_unit( out_for_calibration: str = Form(None), 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), @@ -249,9 +247,6 @@ async def add_roster_unit( out_for_calibration=out_for_calibration_bool, note=note, project_id=project_id, - location=location, - address=address, - coordinates=coordinates, last_updated=datetime.utcnow(), # Seismograph-specific fields last_calibrated=last_cal_date, @@ -273,19 +268,15 @@ async def add_roster_unit( slm_measurement_range=slm_measurement_range if slm_measurement_range else None, ) - # Auto-fill data from modem if pairing and fields are empty + # Auto-fill data from modem if pairing and fields are empty. + # Location/address/coordinates now come from MonitoringLocation via the + # active UnitAssignment, so there's nothing to copy from the modem row. if deployed_with_modem_id: modem = db.query(RosterUnit).filter( RosterUnit.id == deployed_with_modem_id, RosterUnit.device_type == "modem" ).first() if modem: - if not unit.location and modem.location: - unit.location = modem.location - if not unit.address and modem.address: - unit.address = modem.address - if not unit.coordinates and modem.coordinates: - unit.coordinates = modem.coordinates if not unit.project_id and modem.project_id: unit.project_id = modem.project_id if not unit.note and modem.note: @@ -493,6 +484,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): if not unit: raise HTTPException(status_code=404, detail="Unit not found") + active_loc = get_active_location(db, unit_id) + return { "id": unit.id, "device_type": unit.device_type or "seismograph", @@ -504,9 +497,11 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): "allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "", "note": unit.note or "", "project_id": unit.project_id or "", - "location": unit.location or "", - "address": unit.address or "", - "coordinates": unit.coordinates or "", + "active_location": active_loc, + # Convenience fields so the unit-detail page can read the same shape + # whether or not there's an active assignment. + "address": (active_loc or {}).get("address") or "", + "coordinates": (active_loc or {}).get("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 "", @@ -538,9 +533,6 @@ async def edit_roster_unit( allocated_to_project_id: str = Form(None), 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), @@ -565,8 +557,6 @@ async def edit_roster_unit( cascade_deployed: str = Form(None), cascade_retired: str = Form(None), cascade_project: str = Form(None), - cascade_location: str = Form(None), - cascade_coordinates: str = Form(None), cascade_note: str = Form(None), db: Session = Depends(get_db) ): @@ -620,9 +610,6 @@ async def edit_roster_unit( unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None 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 @@ -630,20 +617,15 @@ async def edit_roster_unit( unit.next_calibration_due = next_cal_date unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None - # Auto-fill data from modem if pairing and fields are empty + # Auto-fill data from modem if pairing and fields are empty. + # Location/address/coordinates live on MonitoringLocation now, nothing + # to copy across roster rows. if deployed_with_modem_id: modem = db.query(RosterUnit).filter( RosterUnit.id == deployed_with_modem_id, RosterUnit.device_type == "modem" ).first() if modem: - # Only fill if the device field is empty - if not unit.location and modem.location: - unit.location = modem.location - if not unit.address and modem.address: - unit.address = modem.address - if not unit.coordinates and modem.coordinates: - unit.coordinates = modem.coordinates if not unit.project_id and modem.project_id: unit.project_id = modem.project_id if not unit.note and modem.note: @@ -769,26 +751,6 @@ async def edit_roster_unit( record_history(db, paired_unit.id, "project_change", "project_id", old_paired_project or "", project_id or "", f"cascade from {unit_id}") - # Cascade address/location - if cascade_location in ['true', 'True', '1', 'yes']: - old_paired_address = paired_unit.address - old_paired_location = paired_unit.location - paired_unit.address = address - paired_unit.location = location - paired_unit.last_updated = datetime.utcnow() - if old_paired_address != address: - record_history(db, paired_unit.id, "address_change", "address", - old_paired_address or "", address or "", f"cascade from {unit_id}") - - # Cascade coordinates - if cascade_coordinates in ['true', 'True', '1', 'yes']: - old_paired_coords = paired_unit.coordinates - paired_unit.coordinates = coordinates - paired_unit.last_updated = datetime.utcnow() - if old_paired_coords != coordinates: - record_history(db, paired_unit.id, "coordinates_change", "coordinates", - old_paired_coords or "", coordinates or "", f"cascade from {unit_id}") - # Cascade note if cascade_note in ['true', 'True', '1', 'yes']: old_paired_note = paired_unit.note @@ -1011,9 +973,8 @@ async def import_csv( - retired: Boolean - note: Notes about the unit - project_id: Project identifier - - location: Location description - - address: Street address - - coordinates: GPS coordinates (lat;lon or lat,lon) + (Location / address / coordinates are not roster fields anymore — they + live on the MonitoringLocation a unit is assigned to.) Seismograph-specific: - last_calibrated: Date (YYYY-MM-DD) @@ -1126,9 +1087,6 @@ async def import_csv( existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired existing_unit.note = _get_csv_value(row, 'note', existing_unit.note) existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id) - existing_unit.location = _get_csv_value(row, 'location', existing_unit.location) - existing_unit.address = _get_csv_value(row, 'address', existing_unit.address) - existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates) existing_unit.last_updated = datetime.utcnow() # Seismograph-specific fields @@ -1194,9 +1152,6 @@ async def import_csv( retired=_parse_bool(row.get('retired', '')), note=_get_csv_value(row, 'note', ''), project_id=_get_csv_value(row, 'project_id'), - location=_get_csv_value(row, 'location'), - address=_get_csv_value(row, 'address'), - coordinates=_get_csv_value(row, 'coordinates'), last_updated=datetime.utcnow(), # Seismograph fields - auto-calc next_calibration_due from last_calibrated last_calibrated=last_cal, diff --git a/backend/routers/settings.py b/backend/routers/settings.py index ca95067..b9bb484 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -12,6 +12,7 @@ from pathlib import Path from backend.database import get_db from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences from backend.services.database_backup import DatabaseBackupService +from backend.services.unit_location import bulk_active_locations router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -21,11 +22,14 @@ def export_roster_csv(db: Session = Depends(get_db)): """Export all roster units to CSV""" units = db.query(RosterUnit).all() - # Create CSV in memory + # Create CSV in memory. Location lives on MonitoringLocation now, so + # we don't export legacy address/coordinates/location columns here — + # round-trip CSV editing would otherwise look like it edits unit + # location, when it can't. output = io.StringIO() fieldnames = [ 'unit_id', 'unit_type', 'device_type', 'deployed', 'retired', - 'note', 'project_id', 'location', 'address', 'coordinates', + 'note', 'project_id', 'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id', 'ip_address', 'phone_number', 'hardware_model' ] @@ -42,9 +46,6 @@ def export_roster_csv(db: Session = Depends(get_db)): '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 '', @@ -82,6 +83,7 @@ def get_table_stats(db: Session = Depends(get_db)): 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() + active_locs = bulk_active_locations(db, units) return [{ "id": unit.id, @@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)): "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 "", + "project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "", + "address": (active_locs.get(unit.id) or {}).get("address") or "", + "coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "", + "location_name": (active_locs.get(unit.id) or {}).get("name") 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 "", diff --git a/backend/routers/slm_ui.py b/backend/routers/slm_ui.py index b003771..3aa369b 100644 --- a/backend/routers/slm_ui.py +++ b/backend/routers/slm_ui.py @@ -14,6 +14,7 @@ import os from backend.database import get_db from backend.models import RosterUnit +from backend.services.unit_location import get_active_location from backend.templates_config import templates logger = logging.getLogger(__name__) @@ -58,13 +59,14 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)): except Exception as e: logger.warning(f"Failed to get SLM status for {unit_id}: {e}") + loc = get_active_location(db, unit_id) return { "unit_id": unit_id, "device_type": "slm", "deployed": unit.deployed, "model": unit.slm_model or "NL-43", - "location": unit.address or unit.location, - "coordinates": unit.coordinates, + "location": (loc or {}).get("address") or (loc or {}).get("name") or "", + "coordinates": (loc or {}).get("coordinates") or "", "note": unit.note, "status": status_data, "last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None, diff --git a/backend/routers/units.py b/backend/routers/units.py index 55fc75d..fdfdaba 100644 --- a/backend/routers/units.py +++ b/backend/routers/units.py @@ -5,6 +5,7 @@ from typing import Dict, Any, Optional from backend.database import get_db from backend.services.snapshot import emit_status_snapshot +from backend.services.unit_location import get_active_location from backend.models import RosterUnit router = APIRouter(prefix="/api", tags=["units"]) @@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"]) @router.get("/unit/{unit_id}") def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): """ - Returns detailed data for a single unit. + Returns detailed data for a single unit, including its active deployment + location (or None if benched / unassigned). """ snapshot = emit_status_snapshot() @@ -21,17 +23,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") unit_data = snapshot["units"][unit_id] - - # Mock coordinates for now (will be replaced with real data) - mock_coords = { - "BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"}, - "BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"}, - "BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"}, - "BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"}, - "BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"}, - } - - coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"}) + active_loc = get_active_location(db, unit_id) return { "id": unit_id, @@ -41,7 +33,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): "last_file": unit_data.get("fname", ""), "deployed": unit_data["deployed"], "note": unit_data.get("note", ""), - "coordinates": coords + "active_location": active_loc, } @@ -49,12 +41,16 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): """ Get unit data directly from the roster (for settings/configuration). + Address/coordinates come from the active MonitoringLocation, not the + roster row. """ unit = db.query(RosterUnit).filter_by(id=unit_id).first() if not unit: raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") + active_loc = get_active_location(db, unit_id) + return { "id": unit.id, "unit_type": unit.unit_type, @@ -62,9 +58,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): "deployed": unit.deployed, "retired": unit.retired, "note": unit.note, - "location": unit.location, - "address": unit.address, - "coordinates": unit.coordinates, + "active_location": active_loc, + "address": (active_loc or {}).get("address") or "", + "coordinates": (active_loc or {}).get("coordinates") or "", "slm_host": unit.slm_host, "slm_tcp_port": unit.slm_tcp_port, "slm_ftp_port": unit.slm_ftp_port, diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index f01cef6..d9659c3 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from backend.database import get_db_session from backend.models import Emitter, RosterUnit, IgnoredUnit +from backend.services.unit_location import bulk_active_locations log = logging.getLogger(__name__) @@ -137,6 +138,10 @@ def emit_status_snapshot(): emitters = {e.id: e for e in db.query(Emitter).all()} ignored = {i.id for i in db.query(IgnoredUnit).all()} + # Active-assignment location lookup for all roster units (direct only; + # modems inherit from their paired device below in the derive loop). + active_locs = bulk_active_locations(db, list(roster.values())) + # SFM event-forwards are now the primary "last seen" signal for # seismographs. Watcher heartbeats stay as a backup — if SFM is down # or hasn't seen a serial, we fall back to Emitter.last_seen. @@ -225,10 +230,13 @@ def emit_status_snapshot(): "ip_address": r.ip_address, "phone_number": r.phone_number, "hardware_model": r.hardware_model, - # Location for mapping - "location": r.location or "", - "address": r.address or "", - "coordinates": r.coordinates or "", + # Location for mapping — sourced from active UnitAssignment + # → MonitoringLocation. Empty for benched / unassigned. + "address": (active_locs.get(unit_id) or {}).get("address") or "", + "coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "", + "location_name": (active_locs.get(unit_id) or {}).get("name") or "", + "project_id": (active_locs.get(unit_id) or {}).get("project_id") or "", + "location_id": (active_locs.get(unit_id) or {}).get("location_id") or "", } # --- Add unexpected emitter-only units --- @@ -267,10 +275,12 @@ def emit_status_snapshot(): "ip_address": None, "phone_number": None, "hardware_model": None, - # Location fields - "location": "", + # Location fields — unknown units have no assignment "address": "", "coordinates": "", + "location_name": "", + "project_id": "", + "location_id": "", } # --- Derive modem status from paired devices --- @@ -301,6 +311,11 @@ def emit_status_snapshot(): unit_data["last"] = paired_unit.get("last") unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none") unit_data["derived_from"] = paired_unit_id + # Inherit deployment location too — modems don't carry + # their own UnitAssignment. + for k in ("address", "coordinates", "location_name", "project_id", "location_id"): + if not unit_data.get(k): + unit_data[k] = paired_unit.get(k, "") # Separate buckets for UI active_units = { diff --git a/backend/services/unit_location.py b/backend/services/unit_location.py new file mode 100644 index 0000000..87edaf3 --- /dev/null +++ b/backend/services/unit_location.py @@ -0,0 +1,125 @@ +""" +Active-assignment location resolution for roster units. + +`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields. +The current source of truth for "where is this unit deployed right now" is the +active `UnitAssignment` (assigned_until IS NULL) pointing at a +`MonitoringLocation`, which carries the canonical address/coordinates/name. + +Modems don't get their own `UnitAssignment` — they're paired with a +seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the +location of its paired device's active assignment. + +Returned dict shape (or None if no active assignment resolvable): + { + "location_id": "uuid", + "project_id": "uuid", + "name": "NRL-001", + "address": "123 Main St" | None, + "coordinates": "34.0522,-118.2437" | None, + "via_paired_unit_id": "BE1234" | None, # set only for modems + } +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from backend.models import MonitoringLocation, RosterUnit, UnitAssignment + + +def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict: + return { + "location_id": loc.id, + "project_id": loc.project_id, + "name": loc.name, + "address": loc.address or None, + "coordinates": loc.coordinates or None, + "via_paired_unit_id": via_paired_unit_id, + } + + +def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]: + """Return the MonitoringLocation tied to this unit's active assignment, if any.""" + row = ( + db.query(MonitoringLocation) + .join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id) + .filter( + UnitAssignment.unit_id == unit_id, + UnitAssignment.assigned_until == None, # noqa: E711 + ) + .order_by(UnitAssignment.assigned_at.desc()) + .first() + ) + return row + + +def get_active_location(db: Session, unit_id: str) -> Optional[dict]: + """ + Resolve the active deployment location for a unit. + + Seismographs / SLMs: their own active UnitAssignment. + Modems: follow `deployed_with_unit_id` to the paired device's active + assignment (modems don't carry their own assignment). + """ + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if unit is None: + return None + + if (unit.device_type or "seismograph") == "modem": + paired_id = unit.deployed_with_unit_id + if not paired_id: + return None + loc = _active_location_for_unit_id(db, paired_id) + return _serialize(loc, via_paired_unit_id=paired_id) if loc else None + + loc = _active_location_for_unit_id(db, unit_id) + return _serialize(loc) if loc else None + + +def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]: + """ + Resolve active locations for many units in two queries. Use this from + snapshot-style loops to avoid N+1 lookups. + + Returns {unit_id: } — only populated for units + that resolve to an active assignment. Modems are resolved by walking + `deployed_with_unit_id` to the paired device's entry in the same map. + """ + if not units: + return {} + + direct_unit_ids = [ + u.id for u in units + if (u.device_type or "seismograph") != "modem" + ] + + direct: dict[str, MonitoringLocation] = {} + if direct_unit_ids: + rows = ( + db.query(UnitAssignment.unit_id, MonitoringLocation) + .join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id) + .filter( + UnitAssignment.unit_id.in_(direct_unit_ids), + UnitAssignment.assigned_until == None, # noqa: E711 + ) + .order_by(UnitAssignment.assigned_at.desc()) + .all() + ) + # First row wins per unit_id (most recent assigned_at). + for unit_id, loc in rows: + direct.setdefault(unit_id, loc) + + out: dict[str, dict] = { + uid: _serialize(loc) for uid, loc in direct.items() + } + + # Modems inherit from paired device. + for u in units: + if (u.device_type or "seismograph") != "modem": + continue + paired_id = u.deployed_with_unit_id + if paired_id and paired_id in direct: + out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id) + + return out diff --git a/templates/dashboard.html b/templates/dashboard.html index cf42e61..379f1e0 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000); -
-
- Total Units - -- -
-
- Deployed - -- -
-
- Benched - -- -
-
- Allocated - -- -
-
-

By Device Type:

-
+
+ +
+
- -- + --
-
+
+
+ Deployed + -- +
+
+ Benched + -- +
+
+
+ + +
+ +
+
+ Deployed + -- +
+
+ Benched + --
- --
+
-

Deployed Status:

+

Call-in Status:

@@ -628,9 +637,14 @@ function updateFleetMapFiltered(allUnits) { fleetMarkers.forEach(marker => fleetMap.removeLayer(marker)); fleetMarkers = []; - // Get deployed units with coordinates that pass the filter + // Get deployed units with coordinates that pass the filter. + // Modems are not plotted — they inherit the paired device's location, + // which would just stack a duplicate marker on the same pin. const deployedUnits = Object.entries(allUnits || {}) - .filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u)); + .filter(([_, u]) => u.deployed + && u.coordinates + && (u.device_type || 'seismograph') !== 'modem' + && unitPassesFilter(u)); if (deployedUnits.length === 0) { return; @@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) { // Popup with device type const deviceLabel = getDeviceTypeLabel(deviceType); + const locName = unit.location_name || ''; marker.bindPopup(`

${id}

${deviceLabel}

+ ${locName ? `

📍 ${locName}

` : ''}

Status: ${unit.status}

${unit.note ? `

${unit.note}

` : ''} View Details @@ -783,32 +799,51 @@ function updateDashboard(event) { timeZoneName: 'short' }); - // ===== Fleet summary numbers (always unfiltered) ===== - document.getElementById('total-units').textContent = data.summary?.total ?? 0; - document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; - document.getElementById('benched-units').textContent = data.summary?.benched ?? 0; - document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0; - document.getElementById('status-ok').textContent = data.summary?.ok ?? 0; - document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; - document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; + // ===== Fleet Summary: per-device-type counts (always unfiltered) ===== + // Deployed = unit has an active UnitAssignment (location_id set by + // the snapshot helper). Benched = no active assignment. + // Retired, out-for-calibration, and roster-unknown units (emitters + // not in the roster) are excluded from totals. + const counts = { + seismograph: { total: 0, deployed: 0, benched: 0 }, + sound_level_meter: { total: 0, deployed: 0, benched: 0 }, + }; + let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0; + const unknownIds = new Set(Object.keys(data.unknown || {})); - // ===== Device type counts (always unfiltered) ===== - let seismoCount = 0; - let slmCount = 0; - let modemCount = 0; - Object.values(data.units || {}).forEach(unit => { - if (unit.retired) return; // Don't count retired units - const deviceType = unit.device_type || 'seismograph'; - if (deviceType === 'seismograph') { - seismoCount++; - } else if (deviceType === 'sound_level_meter') { - slmCount++; - } else if (deviceType === 'modem') { - modemCount++; + Object.entries(data.units || {}).forEach(([uid, unit]) => { + if (unit.retired || unit.out_for_calibration) return; + if (unknownIds.has(uid)) return; + const dt = unit.device_type || 'seismograph'; + const bucket = counts[dt]; + if (!bucket) return; // skip modems and anything else + + bucket.total++; + if (unit.location_id) { + bucket.deployed++; + } else { + bucket.benched++; + } + + // Status tally only for seismographs + SLMs that are actually + // deployed (assigned). Mirrors the per-device buckets so the + // sum matches. + if (unit.location_id) { + if (unit.status === 'OK') monitoredOk++; + else if (unit.status === 'Pending') monitoredPending++; + else if (unit.status === 'Missing') monitoredMissing++; } }); - document.getElementById('seismo-count').textContent = seismoCount; - document.getElementById('slm-count').textContent = slmCount; + + document.getElementById('seismo-count').textContent = counts.seismograph.total; + document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed; + document.getElementById('seismo-benched').textContent = counts.seismograph.benched; + document.getElementById('slm-count').textContent = counts.sound_level_meter.total; + document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed; + document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched; + document.getElementById('status-ok').textContent = monitoredOk; + document.getElementById('status-pending').textContent = monitoredPending; + document.getElementById('status-missing').textContent = monitoredMissing; // ===== Apply filters and render map + alerts ===== renderFilteredDashboard(data); diff --git a/templates/unit_detail.html b/templates/unit_detail.html index 905a6d3..1402292 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -129,6 +129,15 @@ Not assigned

+
+ +

+ + Not deployed +

+

--

@@ -639,18 +648,12 @@ {% include "partials/project_picker.html" with context %}
- -
- - -
- - -
- - + +
+ Address & coordinates are set on the deployment location. + Open the project to edit them.
@@ -848,16 +851,6 @@ class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> Project - -