feat(dashboard): clarify the fleet status card and swap map locations to project monitoring location coords.
feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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: <serialized location dict>} — 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
|
||||
Reference in New Issue
Block a user