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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user