feat(portal): M1 pages — locations overview + read-only live location view

/portal overview: client's active sound locations as live tiles (current Lp +
Live/Stopped badge + "updated Xm ago", polled from the scoped cache every 15s)
plus a Leaflet map of locations with coordinates. /portal/location/{id}: 404-gated
read-only live panel — Lp/Leq/Lmax/L1/L10 cards + a 4-line Chart.js trace
(backfilled from /history) + measuring/freshness badge. Cache-only, 15s poll, no
device controls, no refresh-from-device. _client_locations() feeds the overview.

Verified: portal.py compiles; both inline scripts balance; all four portal
templates parse in Jinja2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:41:13 +00:00
parent 9f40210057
commit d3e221b6b1
3 changed files with 257 additions and 8 deletions
+37 -3
View File
@@ -66,6 +66,26 @@ def active_unit_for_location(location_id: str, db: Session):
return asg.unit_id if asg else None
def _client_locations(client: Client, db: Session) -> list:
"""The client's active sound locations (for the overview tiles + map)."""
pids = _client_project_ids(client, db)
if not pids:
return []
projs = {p.id: p.name for p in
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
locs = (db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id.in_(pids),
MonitoringLocation.location_type == "sound",
MonitoringLocation.removed_at.is_(None))
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
return [{
"id": loc.id, "name": loc.name,
"address": loc.address, "coordinates": loc.coordinates,
"project_name": projs.get(loc.project_id),
"has_device": active_unit_for_location(loc.id, db) is not None,
} for loc in locs]
@router.get("/enter/{token}")
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
@@ -101,14 +121,28 @@ def portal_access(request: Request):
@router.get("")
def portal_home(request: Request, client: Client = Depends(get_current_client)):
"""Client overview. (M1 task 4 fills in the scoped location list + map.)"""
def portal_home(request: Request, client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Client overview — their active sound locations with live tiles + a map."""
return templates.TemplateResponse(
"portal/overview.html",
{"request": request, "client": client, "locations": []},
{"request": request, "client": client,
"locations": _client_locations(client, db)},
)
@router.get("/location/{location_id}")
def portal_location(location_id: str, request: Request,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Read-only live view for one of the client's locations (404 if not owned)."""
loc = resolve_client_location(client, location_id, db)
return templates.TemplateResponse("portal/location.html", {
"request": request, "client": client, "location": loc,
"has_device": active_unit_for_location(location_id, db) is not None,
})
# -- scoped data (cache reads only — never hits the device) ------------------
@router.get("/api/location/{location_id}/live")