feat(portal): M1 scoping gate + scoped cache endpoints

resolve_client_location() enforces ownership (sound location in one of the
client's active projects) and 404s everything else — same response for missing
and not-yours, so location existence never leaks. active_unit_for_location()
resolves the currently-assigned SLM.

Scoped GET /portal/api/location/{id}/live and /history: gate -> resolve unit ->
read SLMM cache (never the device). /live returns a SCRUBBED projection (sound
metrics + run state only; no battery/SD/raw_payload). Both degrade gracefully
when there's no device or SLMM is down.

Verified: ownership gate (owns / other-client / vibration / deleted-project /
removed / nonexistent) + active-vs-completed unit resolution — 8/8 on a temp DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:38:16 +00:00
parent 6c048a9c30
commit 9f40210057
+88 -2
View File
@@ -6,14 +6,18 @@ 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. live data sourced from SLMM's cache. Every data route re-checks ownership.
""" """
import os
import logging import logging
from datetime import datetime
from fastapi import APIRouter, Request, Depends import httpx
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import Client from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates from backend.templates_config import templates
from backend.portal_auth import ( from backend.portal_auth import (
get_current_client, make_session_cookie, resolve_token, get_current_client, make_session_cookie, resolve_token,
@@ -23,6 +27,44 @@ from backend.portal_auth import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/portal", tags=["portal"]) 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
@router.get("/enter/{token}") @router.get("/enter/{token}")
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
@@ -65,3 +107,47 @@ def portal_home(request: Request, client: Client = Depends(get_current_client)):
"portal/overview.html", "portal/overview.html",
{"request": request, "client": client, "locations": []}, {"request": request, "client": client, "locations": []},
) )
# -- 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", [])}