""" 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 @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)): """Client overview. (M1 task 4 fills in the scoped location list + map.)""" return templates.TemplateResponse( "portal/overview.html", {"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", [])}