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