d3e221b6b1
/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>
188 lines
7.8 KiB
Python
188 lines
7.8 KiB
Python
"""
|
|
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
|
|
|
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
|
|
session cookie, then sees their locations (overview) and per-location read-only
|
|
live data sourced from SLMM's cache. Every data route re-checks ownership.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import get_db
|
|
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
|
|
from backend.templates_config import templates
|
|
from backend.portal_auth import (
|
|
get_current_client, make_session_cookie, resolve_token,
|
|
COOKIE_NAME, COOKIE_MAX_AGE,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/portal", tags=["portal"])
|
|
|
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
|
|
# Whitelist of fields the portal exposes to a client — sound metrics + run state
|
|
# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed.
|
|
_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time",
|
|
"lp", "leq", "lmax", "lpeak", "ln1", "ln2")
|
|
|
|
|
|
# -- scoping (every data route gates through these) --------------------------
|
|
|
|
def _client_project_ids(client: Client, db: Session) -> list:
|
|
return [r[0] for r in db.query(Project.id).filter(
|
|
Project.client_id == client.id, Project.status != "deleted").all()]
|
|
|
|
|
|
def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation:
|
|
"""Ownership gate: location must be a sound location in one of the client's
|
|
active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so
|
|
we never leak whether a location exists."""
|
|
loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first()
|
|
if (not loc or loc.location_type != "sound"
|
|
or loc.project_id not in _client_project_ids(client, db)):
|
|
raise HTTPException(status_code=404, detail="Location not found")
|
|
return loc
|
|
|
|
|
|
def active_unit_for_location(location_id: str, db: Session):
|
|
"""The SLM unit currently assigned to this location, or None."""
|
|
now = datetime.utcnow()
|
|
asg = (db.query(UnitAssignment)
|
|
.filter(UnitAssignment.location_id == location_id,
|
|
UnitAssignment.status == "active",
|
|
UnitAssignment.device_type == "slm",
|
|
or_(UnitAssignment.assigned_until.is_(None),
|
|
UnitAssignment.assigned_until > now))
|
|
.order_by(UnitAssignment.assigned_at.desc()).first())
|
|
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."""
|
|
tok, client = resolve_token(token, db)
|
|
if not client:
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html",
|
|
{"request": request, "reason": "invalid"},
|
|
status_code=403,
|
|
)
|
|
resp = RedirectResponse(url="/portal", status_code=303)
|
|
resp.set_cookie(
|
|
COOKIE_NAME, make_session_cookie(tok.id),
|
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
|
|
)
|
|
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
|
|
return resp
|
|
|
|
|
|
@router.get("/logout")
|
|
def portal_logout():
|
|
resp = RedirectResponse(url="/portal/access", status_code=303)
|
|
resp.delete_cookie(COOKIE_NAME)
|
|
return resp
|
|
|
|
|
|
@router.get("/access")
|
|
def portal_access(request: Request):
|
|
"""Landing for an unauthenticated visitor (no valid link)."""
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html", {"request": request, "reason": "required"}
|
|
)
|
|
|
|
|
|
@router.get("")
|
|
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": _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")
|
|
async def portal_location_live(location_id: str,
|
|
client: Client = Depends(get_current_client),
|
|
db: Session = Depends(get_db)):
|
|
"""Scrubbed cached live reading for a location the client owns."""
|
|
resolve_client_location(client, location_id, db)
|
|
unit_id = active_unit_for_location(location_id, db)
|
|
if not unit_id:
|
|
return {"status": "ok", "data": None, "reason": "no_device"}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
|
except Exception:
|
|
return {"status": "ok", "data": None, "reason": "unreachable"}
|
|
if r.status_code != 200:
|
|
return {"status": "ok", "data": None, "reason": "no_data"}
|
|
full = (r.json() or {}).get("data", {}) or {}
|
|
return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}}
|
|
|
|
|
|
@router.get("/api/location/{location_id}/history")
|
|
async def portal_location_history(location_id: str, hours: float = 2.0,
|
|
client: Client = Depends(get_current_client),
|
|
db: Session = Depends(get_db)):
|
|
"""Cached chart trail for a location the client owns. (Trail rows are already
|
|
just timestamp + lp/leq/lmax/ln1/ln2 — safe to pass through.)"""
|
|
resolve_client_location(client, location_id, db)
|
|
unit_id = active_unit_for_location(location_id, db)
|
|
if not unit_id:
|
|
return {"status": "ok", "readings": []}
|
|
hours = max(0.1, min(hours, 48.0))
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history",
|
|
params={"hours": hours})
|
|
except Exception:
|
|
return {"status": "ok", "readings": []}
|
|
if r.status_code != 200:
|
|
return {"status": "ok", "readings": []}
|
|
return {"status": "ok", "readings": (r.json() or {}).get("readings", [])}
|