update to 0.16.0 #72
@@ -0,0 +1,25 @@
|
||||
# Terra-View deployment configuration — EXAMPLE / template.
|
||||
#
|
||||
# Copy this to `.env` in the same directory as docker-compose.yml and fill in
|
||||
# real values: cp .env.example .env
|
||||
# `.env` is gitignored — NEVER commit real secrets. Docker Compose auto-loads
|
||||
# `.env` and substitutes these into the ${VAR} placeholders in docker-compose.yml.
|
||||
|
||||
# Cookie-signing secret shared by the client portal AND the operator-auth
|
||||
# session cookie. MUST be a strong random value in production — the in-code
|
||||
# fallback ("dev-insecure-change-me") is public and forgeable.
|
||||
# Generate one (and keep it secret):
|
||||
# python3 -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
SECRET_KEY=change-me-generate-a-strong-random-value
|
||||
|
||||
# Set true ONLY when the app is served over HTTPS. On plain HTTP leave it false,
|
||||
# or the browser won't send the session cookie and login will look broken.
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Operator-auth login gate. Leave false to deploy "dark" (the app behaves exactly
|
||||
# as before — nothing gated, nothing can lock you out). Roll out by: deploy with
|
||||
# false -> seed a superadmin via `docker compose exec web-app python3
|
||||
# backend/operator_admin.py create-superadmin ...` -> confirm you can log in ->
|
||||
# set true and `docker compose up -d web-app` to enforce. Setting it back to
|
||||
# false is the instant escape hatch.
|
||||
OPERATOR_AUTH_ENABLED=false
|
||||
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.16.0] - 2026-06-23
|
||||
|
||||
**Modular projects & live Overview** — the project page becomes a module-aware workspace. Sound and vibration are now first-class *modules* with independent lifecycles (finish the sound study while vibration keeps running), the internal Overview gains the live-monitoring treatment the client portal already had, and vibration events become browsable in-app. Rounds out a batch of project-page UX work plus deployment-hopper and filter fixes carried since 0.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- **Per-module status (independent lifecycle).** Each project module (Sound / Vibration) now carries its own status — `active` / `on_hold` / `completed` — separate from the parent project. Mark the sound side "Completed" while vibration stays "Active" instead of archiving the whole project. Set from a status control in each module's tab; surfaced as a badge on the header module chips and on the project cards. New `PUT /api/projects/{id}/modules/{type}/status`; new `project_modules.status` column.
|
||||
- **Live monitoring on the internal project Overview.** The internal project page gets the live treatment the client portal already had: a live-monitoring section with per-NRL tiles (current level + status), live/offline counts, a "loudest now" readout, and auto-refresh — reading SLMM's shared cached feed, no extra device hits. Live status chips on the NRL list cards; tiles are clickable through to the NRL detail page. Shown only for projects with live-mode (connected) sound NRLs.
|
||||
- **Vibration events, browsable in-app.** A project-wide **Events** sub-tab on the Vibration tab lists SFM events across every vibration location, with a **Location** filter and **sortable columns** (timestamp, location, serial, Tran/Vert/Long/PVS/Mic). Each Vibration location card shows its **last event**. Rows open the shared event-detail modal.
|
||||
- **24-Hour session period type.** A full-day (day + night) period type for 24/7 jobs, alongside the existing weekday/weekend day/night types; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime.
|
||||
- **Per-module project cards + quick-open.** Redesigned project cards: a module-mix accent strip, per-module stat lines (e.g. "Vibration · 4 locations · 2 units" / "Sound · 2 NRLs · 2 units · 0 recording"), and **Sound / Vibration quick-open buttons** that deep-link straight into that module's tab.
|
||||
- **"Reforward info" button** on classified deployment-hopper cards — re-syncs the captured photo's GPS/metadata onto the location (recovery path for the coords-drop bug fixed below).
|
||||
- **`redeploy.sh`** helper — rebuild + redeploy a single compose service with `--no-deps` instead of rebuilding the whole stack.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Project page restructured around modules.** Sound-only actions (Generate Combined Report, Night Report, Report Settings) and the Manual/Remote chip moved out of the global project header into the **Sound tab's** toolbar; the header now carries project-level concerns only (status, modules, merge). The Overview's locations are split into **Vibration locations** and **NRLs** instead of one mixed list.
|
||||
- **Project cards: clearer stats.** The ambiguous single Locations / Units / **Active** row (where "Active" collided with the status badge and only counted sound recording sessions) is replaced by per-module stat lines; the misleading single-module subtitle ("Sound Monitoring") is replaced by the project's identity (number · client). Unit counts are derived by the assigned location's type, so each module's unit count reconciles with its location count.
|
||||
- **Overview live-monitoring scope.** The live section lists only connected / live-mode NRLs; offline / manual-upload NRLs are excluded, and the section is hidden entirely when no NRL is in a live mode.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **SLM "Start measurement" showed a false "Unknown error"** even though the unit was actually starting — the proxy timed out on a slow handler and surfaced an empty error. (Pairs with the SLMM-side `'Start'`-state fix in SLMM v0.4.0.)
|
||||
- **Empty project dropdown** in the pending-deployment classify modal — the projects endpoint returned HTML while the modal expected JSON; now backed by a JSON endpoint.
|
||||
- **Classify button stuck on "Classifying…"** after reopening the modal post-deploy.
|
||||
- **Deployment-capture GPS dropped** when assigning to an *existing* location — the captured coordinates now backfill onto a coordless existing location (and the new Reforward button re-syncs after the fact).
|
||||
- **Event date filters were unusable** on the unit / vibration-location detail pages (datetime-local inputs with no apply) — replaced with date inputs that apply on change.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- **Migration:** run `backend/migrate_add_module_status.py` once against the production DB (adds `project_modules.status`, default `active`) — e.g. `docker compose exec web-app python3 backend/migrate_add_module_status.py`. Without it, the per-module status endpoints/UI error.
|
||||
- **Ships with SLMM v0.4.0.** The Overview live monitoring reads SLMM's shared `/monitor` fan-out feed, and the SLM start fix pairs with SLMM's `'Start'`-state recognition — deploy the matching SLMM v0.4.0 build, not one without the other.
|
||||
|
||||
## [0.15.0] - 2026-06-18
|
||||
|
||||
**Operator authentication** — the internal app gets a login. The operator-facing surface had **zero auth**; this adds a deny-by-default login gate and roles, the prerequisite that makes the app safe to put behind a public URL (office-deployment sequencing: auth → expose). Built test-first (10 tasks, 90 passing tests — the project's first auth test suite alongside the portal's).
|
||||
|
||||
### Added
|
||||
|
||||
- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints `/emitters/report` `/api/series3/heartbeat` `/api/series4/heartbeat`). Two roles — `superadmin` (account management at `/admin/users`) and `admin` (full app); `operator` reserved/deferred. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration). Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` HMAC signer. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`, plan: `docs/superpowers/plans/2026-06-17-operator-auth.md`.
|
||||
- **`.env.example`** documenting `SECRET_KEY` / `COOKIE_SECURE` / `OPERATOR_AUTH_ENABLED` for deployment.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **SLMM proxy WebSocket endpoints bypass the gate.** `/api/slmm/{id}/stream|live|monitor` are WebSocket upgrades, which a Starlette HTTP middleware never sees — they stay unauthenticated even with the gate on. Pre-existing (not a regression); close it (in-handler `tv_session` check, as the portal WS already does) before true internet exposure.
|
||||
- **No TLS yet** — until served over HTTPS the login password crosses the wire in cleartext. Still a large improvement over the prior zero-auth exposure; real internet exposure needs the deployment-phase TLS.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- New `operator_users` table **auto-creates on startup — no migration**.
|
||||
- Set a real `SECRET_KEY` (in a gitignored `.env`; template at `.env.example`) before internet exposure; set `COOKIE_SECURE=true` once on HTTPS (leave `false` on plain HTTP or the browser won't send the cookie).
|
||||
- **Rollout (no self-lockout):** deploy with `OPERATOR_AUTH_ENABLED=false` (app behaves exactly as before) → seed a superadmin via `docker compose exec web-app python3 backend/operator_admin.py create-superadmin …` → confirm you can log in → set the flag `true` and `docker compose up -d web-app`. Flipping it back to `false` is the instant escape hatch.
|
||||
|
||||
## [0.14.0] - 2026-06-17
|
||||
|
||||
Rounds out **sound monitoring** and adds a **client-facing portal**, consolidating four threads since 0.13.x: SLM live monitoring (now on SLMM's shared, cached feed), an automated **FTP night-report pipeline**, a read-only **client portal**, and **per-project password auth** for it. Depends on the matching **SLMM `dev`** build — see Upgrade Notes at the end of each section.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.14.0
|
||||
# Terra-View v0.16.0
|
||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs, sound level meters, and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||
|
||||
## Features
|
||||
@@ -504,6 +504,16 @@ docker compose down -v
|
||||
|
||||
## Release Highlights
|
||||
|
||||
### v0.16.0 — 2026-06-23
|
||||
- **Per-Module Status**: Sound and Vibration are independent modules with their own lifecycle (`active` / `on_hold` / `completed`) — mark the sound study "Completed" while vibration keeps running, instead of archiving the whole project. Shown as badges on the header chips and project cards. (Migration: `backend/migrate_add_module_status.py`.)
|
||||
- **Live Monitoring on the Internal Overview**: The project Overview gains per-NRL live tiles (current level + status), live/offline counts, a "loudest now" readout, and live status chips on the NRL cards — reading SLMM's shared cached feed (no extra device hits). Tiles deep-link to the NRL detail page.
|
||||
- **Browsable Vibration Events**: A project-wide Events sub-tab on the Vibration tab with a Location filter and sortable columns (timestamp / location / serial / Tran / Vert / Long / PVS / Mic); each Vibration location card shows its last event.
|
||||
- **24-Hour Session Period Type**: Full-day (day + night) period for 24/7 jobs; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime.
|
||||
- **Redesigned Project Cards**: Module-mix accent strip, per-module stat lines (replacing the ambiguous Locations / Units / Active row), and Sound / Vibration quick-open buttons that jump straight into a module's tab.
|
||||
- **Project page restructured around modules**: sound-only actions (Combined Report, Night Report, Report Settings) moved from the global header into the Sound tab; Overview locations split into Vibration locations and NRLs.
|
||||
- **Fixes**: false "Unknown error" on SLM start, empty project dropdown + stuck button in the deployment classify modal, deployment GPS dropped when assigning to an existing location (+ Reforward button), unusable event date filters.
|
||||
- **Ships with SLMM v0.4.0** (shared `/monitor` fan-out feed + `'Start'`-state fix).
|
||||
|
||||
### v0.11.0 — 2026-05-15
|
||||
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
|
||||
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
|
||||
@@ -633,9 +643,13 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
||||
**Current: 0.16.0** — Modular projects & live Overview: per-module status (independent sound/vibration lifecycle), internal live-monitoring Overview, browsable vibration events, 24-Hour period type, redesigned project cards (2026-06-23)
|
||||
|
||||
Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||
Previous: 0.15.0 — Operator authentication: deny-by-default login gate + superadmin/admin roles, 30-day session cookie, `/admin/users` (2026-06-18)
|
||||
|
||||
0.11.0 — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
||||
|
||||
0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||
|
||||
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# backend/auth_cookies.py
|
||||
"""Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally
|
||||
later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read()
|
||||
verifies the signature in constant time and enforces a server-side iat expiry.
|
||||
|
||||
The signing secret is the same SECRET_KEY the portal already reads, so a single
|
||||
env var protects both cookies. Never store or log raw secrets."""
|
||||
import os
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Same env var the portal cookie uses — one secret protects both. The insecure
|
||||
# default only exists so dev/test boots without config; set a real SECRET_KEY in prod.
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
|
||||
# Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the
|
||||
# browser won't send the cookie.
|
||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _sign(body: str) -> str:
|
||||
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def sign(payload: dict) -> str:
|
||||
"""Serialize + sign a payload dict into a cookie-safe string."""
|
||||
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||||
return f"{body}.{_sign(body)}"
|
||||
|
||||
|
||||
def read(raw, max_age: int):
|
||||
"""Verify a signed value and return its payload dict, or None if missing,
|
||||
tampered, or older than max_age seconds (by its own `iat`)."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
if not hmac.compare_digest(sig, _sign(body)):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(body.encode()))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
iat = data.get("iat")
|
||||
if not isinstance(iat, (int, float)):
|
||||
return None
|
||||
now = time.time()
|
||||
# Reject implausibly future-dated tokens: the same server signs and verifies,
|
||||
# so there's no real clock skew — a far-future iat (e.g. to dodge max_age or
|
||||
# outlive a sessions_valid_from bump) is bogus. 60s of slack is generous.
|
||||
if iat - now > 60:
|
||||
return None
|
||||
if (now - iat) > max_age:
|
||||
return None
|
||||
return data
|
||||
+13
-1
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.14.0"
|
||||
VERSION = "0.16.0"
|
||||
if ENVIRONMENT == "development":
|
||||
_build = os.getenv("BUILD_NUMBER", "0")
|
||||
if _build and _build != "0":
|
||||
@@ -89,6 +89,18 @@ async def add_environment_to_context(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Operator auth — deny-by-default gate over the whole internal app. Governed by
|
||||
# OPERATOR_AUTH_ENABLED (default off → behaves exactly as today). See
|
||||
# docs/superpowers/specs/2026-06-17-operator-auth-design.md.
|
||||
from backend.operator_auth import operator_gate
|
||||
app.middleware("http")(operator_gate)
|
||||
|
||||
from backend.routers import operator_auth_routes
|
||||
app.include_router(operator_auth_routes.router)
|
||||
|
||||
from backend.routers import operator_users
|
||||
app.include_router(operator_users.router)
|
||||
|
||||
# Override TemplateResponse to include environment and version in context
|
||||
original_template_response = templates.TemplateResponse
|
||||
def custom_template_response(name, context=None, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Migration: add a per-module `status` column to `project_modules`.
|
||||
|
||||
A combined project (sound + vibration) often finishes one kind of work before
|
||||
the other. Rather than archiving the whole project, each module now carries its
|
||||
own lifecycle so e.g. the sound side can read "Completed" while vibration stays
|
||||
"Active".
|
||||
|
||||
Behavior:
|
||||
- status = 'active' → module is live (default for all existing rows)
|
||||
- status = 'on_hold' → paused; data/tabs stay visible
|
||||
- status = 'completed' → wrapped up; surfaced as a done badge
|
||||
|
||||
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||
|
||||
Run with:
|
||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_module_status.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def migrate_database() -> None:
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
if not _has_column(cur, "project_modules", "status"):
|
||||
cur.execute(
|
||||
"ALTER TABLE project_modules ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
|
||||
)
|
||||
conn.commit()
|
||||
print(" Added column project_modules.status (default 'active').")
|
||||
else:
|
||||
print(" project_modules already has status — nothing to do.")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running migration: add status to project_modules")
|
||||
migrate_database()
|
||||
print("Done.")
|
||||
@@ -3,6 +3,13 @@ from datetime import datetime
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
def _utcnow_seconds():
|
||||
"""utcnow truncated to whole seconds — used as the default for
|
||||
sessions_valid_from so a freshly-issued cookie (whose iat is a whole-second
|
||||
epoch) never falls a few microseconds before it and self-invalidates."""
|
||||
return datetime.utcnow().replace(microsecond=0)
|
||||
|
||||
|
||||
class Emitter(Base):
|
||||
__tablename__ = "emitters"
|
||||
|
||||
@@ -218,6 +225,10 @@ class ProjectModule(Base):
|
||||
project_id = Column(String, nullable=False, index=True) # FK to projects.id
|
||||
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
|
||||
enabled = Column(Boolean, default=True, nullable=False)
|
||||
# Per-module lifecycle, independent of the parent project's status. Lets one
|
||||
# part of a combined project wrap up (e.g. sound "completed") while another
|
||||
# keeps running ("active"). Values: active | on_hold | completed.
|
||||
status = Column(String, default="active", nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||
@@ -772,3 +783,27 @@ class ClientAccessToken(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OPERATOR AUTH — internal operator logins (see backend/operator_auth.py)
|
||||
# ============================================================================
|
||||
|
||||
class OperatorUser(Base):
|
||||
"""An internal operator login. Roles: 'superadmin' (Brian, + account mgmt) and
|
||||
'admin' (parents, full app). 'operator' is reserved (deferred). Brand-new table
|
||||
→ create_all builds it, no migration. Never store or log raw passwords."""
|
||||
__tablename__ = "operator_users"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
email = Column(String, nullable=False, unique=True, index=True) # login handle, lowercased
|
||||
display_name = Column(String, nullable=False) # "Brian", "Dad"
|
||||
password_hash = Column(String, nullable=False) # argon2id
|
||||
role = Column(String, nullable=False, default="admin") # superadmin | admin
|
||||
active = Column(Boolean, default=True) # False = login disabled
|
||||
must_change_password = Column(Boolean, default=False) # forces a change next login
|
||||
sessions_valid_from = Column(DateTime, default=_utcnow_seconds) # bump = log out everywhere
|
||||
failed_login_count = Column(Integer, default=0) # lockout counter
|
||||
locked_until = Column(DateTime, nullable=True) # set after too many bad tries
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Operator-account admin CLI — the bootstrap and the break-glass. Run inside the
|
||||
terra-view container against the live DB. Temp/raw passwords are printed ONCE; only
|
||||
hashes persist.
|
||||
|
||||
# first superadmin (before any UI is reachable) — prompts for a password, or --generate
|
||||
python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian"
|
||||
|
||||
# a parent's account — generates a temp password, must-change on first login
|
||||
python3 backend/operator_admin.py create-user --email dad@x.com --name "Dad" --role admin
|
||||
|
||||
python3 backend/operator_admin.py reset-password --email dad@x.com
|
||||
python3 backend/operator_admin.py list
|
||||
python3 backend/operator_admin.py disable --email dad@x.com
|
||||
python3 backend/operator_admin.py enable --email dad@x.com
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import getpass
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import OperatorUser
|
||||
from backend.operator_auth import (
|
||||
create_operator, reset_operator_password, set_operator_active, _norm_email,
|
||||
)
|
||||
|
||||
|
||||
def _get(db, email):
|
||||
u = db.query(OperatorUser).filter_by(email=_norm_email(email)).first()
|
||||
if not u:
|
||||
sys.exit(f"No operator with email '{email}'.")
|
||||
return u
|
||||
|
||||
|
||||
def cmd_create_superadmin(email, name, password=None, generate=False):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if password is None and not generate:
|
||||
password = getpass.getpass("Password for new superadmin: ")
|
||||
if not password or len(password) < 8:
|
||||
sys.exit("Password must be at least 8 characters.")
|
||||
user, raw = create_operator(db, email, name, "superadmin",
|
||||
password=None if generate else password)
|
||||
if generate:
|
||||
print(f"✓ Superadmin {user.email} created. Temp password (shown once): {raw}")
|
||||
else:
|
||||
print(f"✓ Superadmin {user.email} created.")
|
||||
except ValueError as e:
|
||||
sys.exit(str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_create_user(email, name, role="admin"):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user, raw = create_operator(db, email, name, role)
|
||||
print(f"✓ {role} {user.email} created. Temp password (shown once): {raw}")
|
||||
print(" They'll be required to change it on first login.")
|
||||
except ValueError as e:
|
||||
sys.exit(str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_reset_password(email):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = _get(db, email)
|
||||
raw = reset_operator_password(db, user)
|
||||
print(f"✓ Reset {user.email}. Temp password (shown once): {raw}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_set_active(email, active):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = _get(db, email)
|
||||
set_operator_active(db, user, active)
|
||||
print(f"✓ {user.email} {'enabled' if active else 'disabled'}.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_list():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
users = db.query(OperatorUser).order_by(OperatorUser.display_name).all()
|
||||
if not users:
|
||||
print("No operators yet. Run create-superadmin first.")
|
||||
return
|
||||
for u in users:
|
||||
locked = " [LOCKED]" if (u.locked_until and u.locked_until > datetime.utcnow()) else ""
|
||||
state = "active" if u.active else "DISABLED"
|
||||
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
|
||||
print(f" {u.display_name:<12} {u.email:<28} {u.role:<11} {state}{locked} last: {last}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Operator-account admin")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("create-superadmin")
|
||||
p.add_argument("--email", required=True); p.add_argument("--name", required=True)
|
||||
p.add_argument("--generate", action="store_true", help="generate a temp password instead of prompting")
|
||||
p.set_defaults(fn=lambda a: cmd_create_superadmin(a.email, a.name, generate=a.generate))
|
||||
|
||||
p = sub.add_parser("create-user")
|
||||
p.add_argument("--email", required=True); p.add_argument("--name", required=True)
|
||||
p.add_argument("--role", default="admin", choices=["admin", "superadmin"])
|
||||
p.set_defaults(fn=lambda a: cmd_create_user(a.email, a.name, a.role))
|
||||
|
||||
p = sub.add_parser("reset-password")
|
||||
p.add_argument("--email", required=True)
|
||||
p.set_defaults(fn=lambda a: cmd_reset_password(a.email))
|
||||
|
||||
p = sub.add_parser("disable"); p.add_argument("--email", required=True)
|
||||
p.set_defaults(fn=lambda a: cmd_set_active(a.email, False))
|
||||
|
||||
p = sub.add_parser("enable"); p.add_argument("--email", required=True)
|
||||
p.set_defaults(fn=lambda a: cmd_set_active(a.email, True))
|
||||
|
||||
p = sub.add_parser("list"); p.set_defaults(fn=lambda a: cmd_list())
|
||||
|
||||
args = ap.parse_args()
|
||||
args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,231 @@
|
||||
# backend/operator_auth.py
|
||||
"""Operator authentication: the deny-by-default gate, session cookie, login +
|
||||
lockout, and the small data helpers shared by the routes and the CLI. Reuses the
|
||||
argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies).
|
||||
|
||||
The flag and SessionLocal are read as module globals at call time so tests can
|
||||
monkeypatch them."""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from backend.models import OperatorUser
|
||||
from backend.auth_passwords import hash_password, verify_password, generate_password
|
||||
from backend.auth_cookies import sign, read, COOKIE_SECURE
|
||||
from backend.database import SessionLocal
|
||||
|
||||
# Feature flag — OFF by default. When off, the gate and require_role both pass
|
||||
# everything through and the app behaves exactly as it does today.
|
||||
OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
COOKIE_NAME = "tv_session"
|
||||
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days ("remember this device")
|
||||
MAX_LOGIN_FAILURES = 5
|
||||
LOCK_MINUTES = 15
|
||||
|
||||
# Role ladder — a rank map so checks read naturally and 'operator' slots in later.
|
||||
_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30}
|
||||
|
||||
# A throwaway hash used only to equalize verify time on the unknown-email path,
|
||||
# so a missing account can't be distinguished from a wrong password by timing
|
||||
# (no user-enumeration). The value never authenticates anything.
|
||||
_DUMMY_PASSWORD_HASH = hash_password("operator-auth-timing-equalizer")
|
||||
|
||||
|
||||
def role_at_least(role: str, minimum: str) -> bool:
|
||||
"""True iff `role` ranks at or above `minimum`. Unknown roles rank as 0."""
|
||||
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum]
|
||||
|
||||
|
||||
def _norm_email(email: str) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
def make_operator_cookie(uid: str, iat: int = None) -> str:
|
||||
"""Sign a tv_session value for a user id. iat defaults to now; pass an explicit
|
||||
iat when you bump sessions_valid_from to that same instant (change-password)."""
|
||||
return sign({"uid": uid, "iat": int(iat if iat is not None else time.time())})
|
||||
|
||||
|
||||
def current_operator(request, db):
|
||||
"""Resolve the OperatorUser for a request's tv_session cookie, or None.
|
||||
Re-validated against the DB every call: a disabled / locked / password-changed
|
||||
user drops on the next request. Used by the gate middleware (with its own
|
||||
session) — does not raise."""
|
||||
data = read(request.cookies.get(COOKIE_NAME), COOKIE_MAX_AGE)
|
||||
if not data:
|
||||
return None
|
||||
uid, iat = data.get("uid"), data.get("iat")
|
||||
if not uid or not isinstance(iat, (int, float)):
|
||||
return None
|
||||
user = db.query(OperatorUser).filter_by(id=uid).first()
|
||||
if not user or not user.active:
|
||||
return None
|
||||
if user.locked_until and user.locked_until > datetime.utcnow():
|
||||
return None
|
||||
if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def register_login_failure(db, user) -> None:
|
||||
"""Increment a user's failure counter and lock them out past the threshold."""
|
||||
user.failed_login_count = (user.failed_login_count or 0) + 1
|
||||
if user.failed_login_count >= MAX_LOGIN_FAILURES:
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCK_MINUTES)
|
||||
db.commit()
|
||||
|
||||
|
||||
def authenticate(db, email, password):
|
||||
"""Return (user, "ok") on success, (None, "locked") if locked out, else
|
||||
(None, "bad"). Never reveals whether the email exists: an unknown email runs
|
||||
the same argon2 verify (against a dummy hash) as a wrong password, so neither
|
||||
the response text nor its timing distinguishes the two."""
|
||||
user = db.query(OperatorUser).filter_by(email=_norm_email(email)).first()
|
||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
||||
return None, "locked"
|
||||
password_ok = verify_password(password, user.password_hash if user else _DUMMY_PASSWORD_HASH)
|
||||
if not user or not user.active or not password_ok:
|
||||
if user:
|
||||
register_login_failure(db, user)
|
||||
return None, "bad"
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return user, "ok"
|
||||
|
||||
|
||||
def create_operator(db, email, name, role, password=None, must_change=None):
|
||||
"""Create an operator. With no password, generate a temp one and force a change
|
||||
(must_change defaults True). With a password, must_change defaults False.
|
||||
Returns (user, raw_password_to_show_once). Raises ValueError on dup/bad role."""
|
||||
email = _norm_email(email)
|
||||
if role not in _ROLE_RANK:
|
||||
raise ValueError(f"unknown role {role!r}")
|
||||
if db.query(OperatorUser).filter_by(email=email).first():
|
||||
raise ValueError(f"operator {email} already exists")
|
||||
if password is None:
|
||||
password = generate_password()
|
||||
if must_change is None:
|
||||
must_change = True
|
||||
elif must_change is None:
|
||||
must_change = False
|
||||
user = OperatorUser(id=str(uuid.uuid4()), email=email, display_name=name,
|
||||
password_hash=hash_password(password), role=role,
|
||||
active=True, must_change_password=must_change)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user, password
|
||||
|
||||
|
||||
def reset_operator_password(db, user) -> str:
|
||||
"""Generate a fresh temp password, force a change, log the user out everywhere.
|
||||
Returns the raw password to show once."""
|
||||
raw = generate_password()
|
||||
user.password_hash = hash_password(raw)
|
||||
user.must_change_password = True
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.sessions_valid_from = datetime.utcnow().replace(microsecond=0)
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def change_own_password(db, user, new_password) -> int:
|
||||
"""Set a user's own new password, clear the forced-change flag, and bump
|
||||
sessions_valid_from to the returned iat — the caller mints the replacement
|
||||
cookie with that exact iat so it stays valid while older cookies die."""
|
||||
new_iat = int(time.time())
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.sessions_valid_from = datetime.utcfromtimestamp(new_iat)
|
||||
db.commit()
|
||||
return new_iat
|
||||
|
||||
|
||||
def set_operator_active(db, user, active: bool):
|
||||
user.active = bool(active)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def set_operator_role(db, user, role: str):
|
||||
if role not in _ROLE_RANK:
|
||||
raise ValueError(f"unknown role {role!r}")
|
||||
user.role = role
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
# Routes reachable with no login. A new route added next year is gated by default.
|
||||
_EXEMPT_EXACT = {
|
||||
"/login", "/logout", "/health",
|
||||
"/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js",
|
||||
"/portal", # portal home (its own auth)
|
||||
# machine endpoints — LAN-only, automated, no human (watchers/heartbeats):
|
||||
"/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat",
|
||||
}
|
||||
_EXEMPT_PREFIX = ("/static/", "/portal/")
|
||||
|
||||
|
||||
def _is_exempt(path: str) -> bool:
|
||||
return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX)
|
||||
|
||||
|
||||
async def operator_gate(request: Request, call_next):
|
||||
"""Deny-by-default gate. Flag off → pass through (app as today). Flag on →
|
||||
exempt paths pass; otherwise require a valid operator session, stash it on
|
||||
request.state.operator, and force a password change when pending."""
|
||||
if not OPERATOR_AUTH_ENABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# CORS preflight carries no auth and must reach CORSMiddleware, not the gate.
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if _is_exempt(path):
|
||||
return await call_next(request)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = current_operator(request, db)
|
||||
if user is not None:
|
||||
db.expunge(user) # detach a fully-loaded row so we can close now
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if user is None:
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
|
||||
return RedirectResponse(f"/login?next={quote(path)}", status_code=303)
|
||||
|
||||
if user.must_change_password and path not in ("/change-password", "/logout"):
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse({"detail": "Password change required"}, status_code=403)
|
||||
return RedirectResponse("/change-password", status_code=303)
|
||||
|
||||
request.state.operator = user
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def require_role(minimum: str):
|
||||
"""Dependency factory: require a logged-in operator ranked >= `minimum`.
|
||||
Respects the flag (off → pass through). When on, the middleware has already
|
||||
set request.state.operator before this runs."""
|
||||
def _dep(request: Request):
|
||||
if not OPERATOR_AUTH_ENABLED:
|
||||
return None
|
||||
user = getattr(request.state, "operator", None)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
if not role_at_least(user.role, minimum):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user
|
||||
return _dep
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Operator login / logout / change-password. These routes intentionally work
|
||||
regardless of OPERATOR_AUTH_ENABLED (you log in while the flag is still off during
|
||||
rollout). /login and /logout are on the gate's exempt list; /change-password
|
||||
requires a session (the gate sets request.state.operator)."""
|
||||
from fastapi import APIRouter, Request, Depends, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import OperatorUser
|
||||
from backend.templates_config import templates
|
||||
from backend.operator_auth import (
|
||||
authenticate, current_operator, change_own_password, make_operator_cookie,
|
||||
COOKIE_NAME, COOKIE_MAX_AGE,
|
||||
)
|
||||
from backend.auth_cookies import COOKIE_SECURE
|
||||
from backend.auth_passwords import verify_password
|
||||
|
||||
router = APIRouter(tags=["operator-auth"])
|
||||
|
||||
|
||||
def _safe_next(next_url: str) -> str:
|
||||
"""Only allow same-site relative redirects (an open-redirect guard). Rejects
|
||||
`//host` and `/\\host` — browsers treat a backslash as `/` in the authority
|
||||
position, so both escape to an external site."""
|
||||
if next_url and next_url.startswith("/") and not next_url.startswith(("//", "/\\")):
|
||||
return next_url
|
||||
return "/"
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_page(request: Request, next: str = "", error: str = ""):
|
||||
return templates.TemplateResponse("login.html",
|
||||
{"request": request, "next": next, "error": error})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request, next: str = "",
|
||||
email: str = Form(...), password: str = Form(...),
|
||||
db: Session = Depends(get_db)):
|
||||
user, status = authenticate(db, email, password)
|
||||
if status == "locked":
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "next": next,
|
||||
"error": "Too many attempts — try again in 15 minutes."},
|
||||
status_code=200)
|
||||
if user is None:
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "next": next, "error": "Invalid email or password."},
|
||||
status_code=200)
|
||||
dest = "/change-password" if user.must_change_password else _safe_next(next)
|
||||
resp = RedirectResponse(url=dest, status_code=303)
|
||||
resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id),
|
||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
resp = RedirectResponse(url="/login", status_code=303)
|
||||
resp.delete_cookie(COOKIE_NAME)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/change-password")
|
||||
async def change_password_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = getattr(request.state, "operator", None) or current_operator(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"change_password.html",
|
||||
{"request": request, "must_change": user.must_change_password, "error": ""})
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password_submit(request: Request,
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
confirm_password: str = Form(...),
|
||||
db: Session = Depends(get_db)):
|
||||
_user_ref = getattr(request.state, "operator", None) or current_operator(request, db)
|
||||
if _user_ref is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
# Re-fetch a session-bound copy so mutations via `db` will be committed.
|
||||
# request.state.operator may be expunged (detached) from the gate's own
|
||||
# SessionLocal; operating on a detached object against a different session
|
||||
# would silently drop the UPDATE.
|
||||
user = db.query(OperatorUser).filter_by(id=_user_ref.id).first()
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
def _err(msg):
|
||||
return templates.TemplateResponse(
|
||||
"change_password.html",
|
||||
{"request": request, "must_change": user.must_change_password, "error": msg},
|
||||
status_code=200)
|
||||
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return _err("Current password is incorrect.")
|
||||
if len(new_password) < 8:
|
||||
return _err("New password must be at least 8 characters.")
|
||||
if new_password != confirm_password:
|
||||
return _err("New passwords do not match.")
|
||||
|
||||
new_iat = change_own_password(db, user, new_password)
|
||||
resp = RedirectResponse(url="/", status_code=303)
|
||||
resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id, iat=new_iat),
|
||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
|
||||
return resp
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Operator account management — superadmin only. Temp passwords are returned in
|
||||
the JSON response once (shown to the superadmin to hand off); only hashes persist."""
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.templates_config import templates
|
||||
from backend.models import OperatorUser
|
||||
from backend.operator_auth import (
|
||||
require_role, create_operator, reset_operator_password,
|
||||
set_operator_active, set_operator_role,
|
||||
)
|
||||
import backend.operator_auth as operator_auth
|
||||
from backend.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
def _require_auth_enabled():
|
||||
"""The operator-management surface does not exist while operator auth is
|
||||
disabled — otherwise these net-new endpoints would be world-open with the
|
||||
flag off (the default), letting anyone pre-seed a superadmin. Read the flag
|
||||
as a live module attribute so the test monkeypatch and a runtime flip both
|
||||
take effect."""
|
||||
if not operator_auth.OPERATOR_AUTH_ENABLED:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
router = APIRouter(tags=["operator-users"], dependencies=[Depends(_require_auth_enabled)])
|
||||
_superadmin = require_role("superadmin")
|
||||
|
||||
|
||||
class NewUser(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class RoleChange(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
def _serialize(u: OperatorUser) -> dict:
|
||||
from datetime import datetime
|
||||
return {
|
||||
"id": u.id, "email": u.email, "display_name": u.display_name, "role": u.role,
|
||||
"active": bool(u.active), "must_change_password": bool(u.must_change_password),
|
||||
"locked": bool(u.locked_until and u.locked_until > datetime.utcnow()),
|
||||
"last_login_at": format_local_datetime(u.last_login_at, "%Y-%m-%d %H:%M") if u.last_login_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def users_page(request: Request, _=Depends(_superadmin)):
|
||||
return templates.TemplateResponse("admin/users.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def list_users(_=Depends(_superadmin), db: Session = Depends(get_db)):
|
||||
users = db.query(OperatorUser).order_by(OperatorUser.display_name).all()
|
||||
return {"users": [_serialize(u) for u in users]}
|
||||
|
||||
|
||||
@router.post("/api/admin/users")
|
||||
async def add_user(body: NewUser, _=Depends(_superadmin), db: Session = Depends(get_db)):
|
||||
if body.role not in ("admin", "superadmin"):
|
||||
return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"})
|
||||
try:
|
||||
user, raw = create_operator(db, body.email, body.name, body.role)
|
||||
except ValueError as e:
|
||||
return JSONResponse(status_code=400, content={"detail": str(e)})
|
||||
return {"user": _serialize(user), "password": raw}
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/reset-password")
|
||||
async def reset_user_password(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)):
|
||||
user = db.query(OperatorUser).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raw = reset_operator_password(db, user)
|
||||
return {"password": raw}
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/disable")
|
||||
async def disable_user(user_id: str, acting=Depends(_superadmin), db: Session = Depends(get_db)):
|
||||
if acting and acting.id == user_id:
|
||||
return JSONResponse(status_code=400, content={"detail": "Cannot disable your own account"})
|
||||
user = db.query(OperatorUser).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
set_operator_active(db, user, False)
|
||||
return {"active": False}
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/enable")
|
||||
async def enable_user(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)):
|
||||
user = db.query(OperatorUser).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
set_operator_active(db, user, True)
|
||||
return {"active": True}
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/role")
|
||||
async def change_user_role(user_id: str, body: RoleChange,
|
||||
acting=Depends(_superadmin), db: Session = Depends(get_db)):
|
||||
if acting and acting.id == user_id:
|
||||
return JSONResponse(status_code=400, content={"detail": "Cannot change your own role"})
|
||||
if body.role not in ("admin", "superadmin"):
|
||||
return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"})
|
||||
user = db.query(OperatorUser).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
set_operator_role(db, user, body.role)
|
||||
return {"role": user.role}
|
||||
@@ -296,6 +296,12 @@ async def promote_pending(
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.")
|
||||
project_id = location.project_id
|
||||
# Backfill the captured GPS onto the existing location if it doesn't
|
||||
# have coordinates yet. (Previously the captured coords were dropped on
|
||||
# the assign-to-existing path, so only create-new locations got a pin.)
|
||||
# Don't clobber coordinates an operator already set.
|
||||
if pd.coordinates and not (location.coordinates or "").strip():
|
||||
location.coordinates = pd.coordinates
|
||||
else:
|
||||
# Create-new path. Need a project (existing or new).
|
||||
project_id = payload.get("project_id")
|
||||
@@ -456,6 +462,50 @@ async def cancel_pending(
|
||||
return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()}
|
||||
|
||||
|
||||
@router.post("/pending/{pending_id}/resync-location")
|
||||
async def resync_location(pending_id: str, db: Session = Depends(get_db)):
|
||||
"""Re-push a promoted capture's GPS onto its assigned location.
|
||||
|
||||
Use when a capture's coordinates didn't land on the location (e.g. it was
|
||||
assigned to a pre-existing location that had none). Unlike the auto-backfill
|
||||
on classify, this is an explicit operator action and OVERWRITES the
|
||||
location's coordinates with the captured GPS.
|
||||
"""
|
||||
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||
if not pd:
|
||||
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||
if pd.status != "assigned" or not pd.resulting_assignment_id:
|
||||
raise HTTPException(status_code=400, detail="Only a promoted (assigned) capture can be re-forwarded.")
|
||||
if not (pd.coordinates or "").strip():
|
||||
raise HTTPException(status_code=400, detail="This capture has no GPS coordinates to forward.")
|
||||
|
||||
asg = db.query(UnitAssignment).filter_by(id=pd.resulting_assignment_id).first()
|
||||
if not asg:
|
||||
raise HTTPException(status_code=404, detail="Resulting assignment not found.")
|
||||
location = db.query(MonitoringLocation).filter_by(id=asg.location_id).first()
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Assigned location not found.")
|
||||
|
||||
old = location.coordinates
|
||||
location.coordinates = pd.coordinates.strip()
|
||||
|
||||
_record_history(
|
||||
db, unit_id=pd.unit_id,
|
||||
change_type="deployment_coords_reforwarded",
|
||||
old_value=old,
|
||||
new_value=location.coordinates,
|
||||
notes=f"Re-forwarded capture GPS to location '{location.name}'",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"location_id": location.id,
|
||||
"location_name": location.name,
|
||||
"coordinates": location.coordinates,
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict:
|
||||
|
||||
@@ -160,6 +160,7 @@ async def get_project_locations(
|
||||
# (sessions don't really exist for the watcher-forward pipeline).
|
||||
# Sound locations skip this and keep showing session counts.
|
||||
event_counts: dict[str, int] = {}
|
||||
last_events: dict[str, str] = {}
|
||||
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
||||
if vibration_locations:
|
||||
import asyncio
|
||||
@@ -171,7 +172,10 @@ async def get_project_locations(
|
||||
for loc, res in zip(vibration_locations, results):
|
||||
if isinstance(res, Exception):
|
||||
continue # leave event_counts[loc.id] unset → template falls back
|
||||
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
|
||||
stats = res.get("stats") or {}
|
||||
event_counts[loc.id] = stats.get("event_count", 0) or 0
|
||||
if stats.get("last_event"):
|
||||
last_events[loc.id] = stats["last_event"]
|
||||
|
||||
# Enrich with assignment info, splitting active vs removed.
|
||||
active_data: list = []
|
||||
@@ -205,6 +209,8 @@ async def get_project_locations(
|
||||
}
|
||||
if location.id in event_counts:
|
||||
item["event_count"] = event_counts[location.id]
|
||||
if location.id in last_events:
|
||||
item["last_event"] = last_events[location.id]
|
||||
if location.removed_at is None:
|
||||
active_data.append(item)
|
||||
else:
|
||||
@@ -1579,6 +1585,70 @@ async def get_project_vibration_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vibration-events", response_class=JSONResponse)
|
||||
async def get_project_vibration_events(
|
||||
project_id: str,
|
||||
from_dt: Optional[datetime] = Query(None),
|
||||
to_dt: Optional[datetime] = Query(None),
|
||||
false_trigger: Optional[bool] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Project-wide SFM events across every active vibration location.
|
||||
|
||||
Fans out events_for_location per location (each of which unions that
|
||||
location's assignment windows), tags each event with its location, then
|
||||
merges newest-first. Powers the Vibration tab's Events sub-tab.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found.")
|
||||
|
||||
locations = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(
|
||||
MonitoringLocation.project_id == project_id,
|
||||
MonitoringLocation.location_type == "vibration",
|
||||
MonitoringLocation.removed_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not locations:
|
||||
return {"events": [], "count": 0, "location_count": 0}
|
||||
|
||||
import asyncio
|
||||
from backend.services.sfm_events import events_for_location
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
events_for_location(
|
||||
db, loc.id, from_dt=from_dt, to_dt=to_dt,
|
||||
false_trigger=false_trigger, limit=limit,
|
||||
)
|
||||
for loc in locations
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
merged = []
|
||||
for loc, res in zip(locations, results):
|
||||
if isinstance(res, Exception):
|
||||
continue
|
||||
for ev in res.get("events", []):
|
||||
ev = dict(ev)
|
||||
ev["location_id"] = loc.id
|
||||
ev["location_name"] = loc.name
|
||||
merged.append(ev)
|
||||
|
||||
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||
total = len(merged)
|
||||
return {
|
||||
"events": merged[:limit],
|
||||
"count": total,
|
||||
"location_count": len(locations),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
||||
async def get_location_events(
|
||||
project_id: str,
|
||||
|
||||
+249
-14
@@ -58,12 +58,25 @@ MODULES = {
|
||||
}
|
||||
|
||||
|
||||
MODULE_STATUSES = {"active", "on_hold", "completed"}
|
||||
|
||||
|
||||
def _get_project_modules(project_id: str, db: Session) -> list[str]:
|
||||
"""Return list of enabled module_type strings for a project."""
|
||||
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
|
||||
return [r.module_type for r in rows]
|
||||
|
||||
|
||||
def _get_module_statuses(project_id: str, db: Session) -> dict[str, str]:
|
||||
"""Return {module_type: status} for a project's enabled modules.
|
||||
|
||||
Per-module lifecycle is independent of the parent project's status — lets
|
||||
one half of a combined project be "completed" while the other stays "active".
|
||||
"""
|
||||
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
|
||||
return {r.module_type: (r.status or "active") for r in rows}
|
||||
|
||||
|
||||
def _require_module(project: Project, module_type: str, db: Session) -> None:
|
||||
"""Raise 400 if the project does not have the given module enabled."""
|
||||
if not project:
|
||||
@@ -404,6 +417,26 @@ def _build_combined_location_data(
|
||||
# Project List & Overview
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/list-json")
|
||||
async def get_projects_list_json(db: Session = Depends(get_db)):
|
||||
"""JSON list of assignable projects (id, name, status) for pickers such as
|
||||
the pending-deployment classify modal. Excludes deleted/archived/completed,
|
||||
matching the default /list view. (The /list endpoint returns HTML cards, so
|
||||
JSON consumers must use this one.)"""
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.status.notin_(["deleted", "archived", "completed"]))
|
||||
.order_by(Project.name)
|
||||
.all()
|
||||
)
|
||||
return JSONResponse({
|
||||
"projects": [
|
||||
{"id": p.id, "name": p.name, "status": p.status}
|
||||
for p in projects
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@router.get("/list", response_class=HTMLResponse)
|
||||
async def get_projects_list(
|
||||
request: Request,
|
||||
@@ -437,8 +470,10 @@ async def get_projects_list(
|
||||
for project in projects:
|
||||
# Get project type
|
||||
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
|
||||
mods = _get_project_modules(project.id, db)
|
||||
|
||||
# Count locations
|
||||
# Count locations (project-wide, includes removed — kept for back-compat
|
||||
# with the compact list view).
|
||||
location_count = db.query(func.count(MonitoringLocation.id)).filter_by(
|
||||
project_id=project.id
|
||||
).scalar()
|
||||
@@ -451,7 +486,7 @@ async def get_projects_list(
|
||||
)
|
||||
).scalar()
|
||||
|
||||
# Count active sessions
|
||||
# Count active (recording) sessions — a sound-monitoring concept
|
||||
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||
and_(
|
||||
MonitoringSession.project_id == project.id,
|
||||
@@ -459,9 +494,46 @@ async def get_projects_list(
|
||||
)
|
||||
).scalar()
|
||||
|
||||
# Per-module stats — each module shows only its own, relevant counts.
|
||||
# Locations: active only (removed_at IS NULL). Units: active assignments
|
||||
# counted by the TYPE OF LOCATION they sit on (join), so a module's unit
|
||||
# count always lines up with its own location count — independent of the
|
||||
# assignment's denormalized device_type.
|
||||
def _module_loc_count(loc_type):
|
||||
return db.query(func.count(MonitoringLocation.id)).filter(
|
||||
MonitoringLocation.project_id == project.id,
|
||||
MonitoringLocation.location_type == loc_type,
|
||||
MonitoringLocation.removed_at.is_(None),
|
||||
).scalar()
|
||||
|
||||
def _module_unit_count(loc_type):
|
||||
return db.query(func.count(UnitAssignment.id)).join(
|
||||
MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id
|
||||
).filter(
|
||||
UnitAssignment.project_id == project.id,
|
||||
UnitAssignment.assigned_until.is_(None),
|
||||
MonitoringLocation.location_type == loc_type,
|
||||
).scalar()
|
||||
|
||||
module_stats = {}
|
||||
if "vibration_monitoring" in mods:
|
||||
module_stats["vibration"] = {
|
||||
"locations": _module_loc_count("vibration"),
|
||||
"units": _module_unit_count("vibration"),
|
||||
}
|
||||
if "sound_monitoring" in mods:
|
||||
module_stats["sound"] = {
|
||||
"locations": _module_loc_count("sound"),
|
||||
"units": _module_unit_count("sound"),
|
||||
"recording": active_session_count,
|
||||
}
|
||||
|
||||
projects_data.append({
|
||||
"project": project,
|
||||
"project_type": project_type,
|
||||
"modules": mods,
|
||||
"module_status": _get_module_statuses(project.id, db),
|
||||
"module_stats": module_stats,
|
||||
"location_count": location_count,
|
||||
"unit_count": unit_count,
|
||||
"active_session_count": active_session_count,
|
||||
@@ -818,6 +890,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
"project_type_id": project.project_type_id,
|
||||
"project_type_name": project_type.name if project_type else None,
|
||||
"modules": modules,
|
||||
"module_status": _get_module_statuses(project.id, db),
|
||||
"status": project.status,
|
||||
"client_name": project.client_name,
|
||||
"site_address": project.site_address,
|
||||
@@ -882,6 +955,33 @@ async def remove_project_module(project_id: str, module_type: str, db: Session =
|
||||
return {"ok": True, "modules": _get_project_modules(project_id, db)}
|
||||
|
||||
|
||||
@router.put("/{project_id}/modules/{module_type}/status")
|
||||
async def set_project_module_status(
|
||||
project_id: str, module_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Set a module's lifecycle status. Body: {status: active|on_hold|completed}.
|
||||
|
||||
Independent of the parent project's status — used to wrap up one half of a
|
||||
combined project (e.g. sound "completed") while the other stays "active".
|
||||
"""
|
||||
data = await request.json()
|
||||
status = (data.get("status") or "").strip()
|
||||
if status not in MODULE_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status '{status}'. Expected one of: {', '.join(sorted(MODULE_STATUSES))}.",
|
||||
)
|
||||
row = db.query(ProjectModule).filter_by(
|
||||
project_id=project_id, module_type=module_type, enabled=True
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Module not enabled on this project.")
|
||||
row.status = status
|
||||
db.commit()
|
||||
return {"ok": True, "module_type": module_type, "status": status,
|
||||
"module_status": _get_module_statuses(project_id, db)}
|
||||
|
||||
|
||||
@router.put("/{project_id}")
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
@@ -1103,6 +1203,113 @@ async def get_project_dashboard(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/live-stats")
|
||||
async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)):
|
||||
"""Live SLM readings for each sound NRL in the project.
|
||||
|
||||
Reads SLMM's cached per-unit status snapshots (the same source the client
|
||||
portal uses) and returns one entry per active sound location. Powers the
|
||||
Overview tab's live monitoring section. Internal-only, so it includes
|
||||
device-health fields (battery, power source, reachability) the portal hides.
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import httpx
|
||||
import json as _json
|
||||
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
now = datetime.utcnow()
|
||||
locations = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(
|
||||
MonitoringLocation.project_id == project_id,
|
||||
MonitoringLocation.location_type == "sound",
|
||||
MonitoringLocation.removed_at.is_(None),
|
||||
)
|
||||
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Only connected/live-mode NRLs belong in live monitoring. connection_mode
|
||||
# lives in location_metadata JSON (default "connected"); offline/manual NRLs
|
||||
# are excluded. With none connected, the caller gets [] and hides the section.
|
||||
def _is_connected(loc) -> bool:
|
||||
try:
|
||||
meta = _json.loads(loc.location_metadata or "{}")
|
||||
return meta.get("connection_mode", "connected") != "offline"
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
locations = [loc for loc in locations if _is_connected(loc)]
|
||||
|
||||
# Active SLM unit per location (mirrors portal.active_unit_for_location).
|
||||
def _active_unit(loc_id: str):
|
||||
asg = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(
|
||||
UnitAssignment.location_id == loc_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
|
||||
|
||||
loc_units = [(loc, _active_unit(loc.id)) for loc in locations]
|
||||
|
||||
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
async def _fetch(unit_id):
|
||||
if not unit_id:
|
||||
return None, "no_device"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as hc:
|
||||
r = await hc.get(f"{slmm_base}/api/nl43/{unit_id}/status")
|
||||
except Exception:
|
||||
return None, "unreachable"
|
||||
if r.status_code != 200:
|
||||
return None, "no_data"
|
||||
return (r.json() or {}).get("data") or {}, None
|
||||
|
||||
results = await asyncio.gather(*[_fetch(u) for (_, u) in loc_units])
|
||||
|
||||
out = []
|
||||
for (loc, unit_id), (data, reason) in zip(loc_units, results):
|
||||
entry = {
|
||||
"id": loc.id,
|
||||
"name": loc.name,
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
if data is None:
|
||||
entry["reason"] = reason
|
||||
entry["measurement_state"] = None
|
||||
else:
|
||||
entry.update(
|
||||
{
|
||||
"measurement_state": data.get("measurement_state"),
|
||||
"leq": data.get("leq"),
|
||||
"lp": data.get("lp"),
|
||||
"lmax": data.get("lmax"),
|
||||
"last_seen": data.get("last_seen"),
|
||||
"battery_level": data.get("battery_level"),
|
||||
"power_source": data.get("power_source"),
|
||||
"is_reachable": data.get("is_reachable"),
|
||||
"connection_state": data.get("connection_state"),
|
||||
}
|
||||
)
|
||||
out.append(entry)
|
||||
|
||||
return {"status": "ok", "locations": out}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Project Types
|
||||
# ============================================================================
|
||||
@@ -1128,6 +1335,7 @@ async def get_project_header(
|
||||
"project": project,
|
||||
"project_type": project_type,
|
||||
"modules": _get_project_modules(project_id, db),
|
||||
"module_status": _get_module_statuses(project_id, db),
|
||||
})
|
||||
|
||||
|
||||
@@ -2147,7 +2355,10 @@ async def delete_session(
|
||||
})
|
||||
|
||||
|
||||
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
|
||||
# full_24h = a single continuous 24-hour period (day + night). Operator-set
|
||||
# only; never auto-derived. In reports its rows are split across
|
||||
# Daytime/Evening/Nighttime by hour rather than filtered to one window.
|
||||
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night", "full_24h"}
|
||||
|
||||
|
||||
def _derive_period_type(dt: datetime) -> str:
|
||||
@@ -2161,7 +2372,7 @@ def _derive_period_type(dt: datetime) -> str:
|
||||
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
|
||||
day_abbr = dt.strftime("%a")
|
||||
date_str = f"{dt.month}/{dt.day}"
|
||||
period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
|
||||
period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night", "full_24h": "24-Hour"}.get(period_type, "")
|
||||
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
|
||||
return " — ".join(parts)
|
||||
|
||||
@@ -3991,7 +4202,15 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
|
||||
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
|
||||
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
|
||||
if sh is None or eh is None:
|
||||
|
||||
target_date = None
|
||||
if period_type == 'full_24h':
|
||||
# 24-hour continuous: keep every row (rows get split across
|
||||
# Daytime/Evening/Nighttime by hour in the sheet builder). No
|
||||
# hour-window filtering and no single target date.
|
||||
is_day_session = False
|
||||
filtered = [(dt, row) for dt, row in parsed if dt]
|
||||
elif sh is None or eh is None:
|
||||
# Legacy defaults based on period_type
|
||||
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||
sh = 7 if is_day_session else 19
|
||||
@@ -3999,8 +4218,9 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
else:
|
||||
is_day_session = eh > sh # crosses midnight when end < start
|
||||
|
||||
target_date = None
|
||||
if is_day_session:
|
||||
if period_type == 'full_24h':
|
||||
pass # filtered already set above
|
||||
elif is_day_session:
|
||||
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
|
||||
in_window = lambda h: sh <= h < eh
|
||||
if entry.get("report_date"):
|
||||
@@ -4058,7 +4278,8 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
# Rebuild session label using the correct label date
|
||||
if label_dt and entry["loc_name"]:
|
||||
period_str = {"weekday_day": "Day", "weekday_night": "Night",
|
||||
"weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
|
||||
"weekend_day": "Day", "weekend_night": "Night",
|
||||
"full_24h": "24-Hour"}.get(period_type, "")
|
||||
day_abbr = label_dt.strftime("%a")
|
||||
date_label = f"{label_dt.month}/{label_dt.day}"
|
||||
session_label = " — ".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p)
|
||||
@@ -4317,21 +4538,35 @@ async def generate_combined_from_preview(
|
||||
evening_rows_data = []
|
||||
night_rows_data = []
|
||||
|
||||
def _row_hour(time_v):
|
||||
if time_v and ':' in str(time_v):
|
||||
try:
|
||||
return int(str(time_v).split(':')[0])
|
||||
except ValueError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
for pt, time_v, lmx, l1, l2 in parsed_rows:
|
||||
if pt in PERIOD_TYPE_IS_DAY:
|
||||
day_rows_data.append((lmx, l1, l2))
|
||||
elif pt in PERIOD_TYPE_IS_NIGHT:
|
||||
# Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59
|
||||
hour = 0
|
||||
if time_v and ':' in str(time_v):
|
||||
try:
|
||||
hour = int(str(time_v).split(':')[0])
|
||||
except ValueError:
|
||||
pass
|
||||
hour = _row_hour(time_v)
|
||||
if 19 <= hour <= 21:
|
||||
evening_rows_data.append((lmx, l1, l2))
|
||||
else:
|
||||
night_rows_data.append((lmx, l1, l2))
|
||||
elif pt == 'full_24h':
|
||||
# 24-hour continuous: split each row by hour into the three
|
||||
# non-overlapping buckets (Daytime 7–18:59, Evening 19–21:59,
|
||||
# Nighttime 22:00–06:59). Empty buckets are dropped downstream.
|
||||
hour = _row_hour(time_v)
|
||||
if 7 <= hour < 19:
|
||||
day_rows_data.append((lmx, l1, l2))
|
||||
elif 19 <= hour <= 21:
|
||||
evening_rows_data.append((lmx, l1, l2))
|
||||
else:
|
||||
night_rows_data.append((lmx, l1, l2))
|
||||
else:
|
||||
day_rows_data.append((lmx, l1, l2))
|
||||
|
||||
|
||||
@@ -207,24 +207,39 @@ async def control_slm(unit_id: str, action: str):
|
||||
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 30s: a real device start confirms over cellular in a few seconds, but
|
||||
# leave headroom so a healthy start is never cut off mid-flight (which
|
||||
# surfaced to users as a misleading "Unknown error").
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
|
||||
# Surface SLMM's own error detail when it provides one.
|
||||
detail = f"SLMM returned status {response.status_code}"
|
||||
try:
|
||||
body = response.json()
|
||||
if isinstance(body, dict) and body.get("detail"):
|
||||
detail = body["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "error", "detail": detail}
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout controlling {unit_id} (action={action}) via SLMM")
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
"detail": (
|
||||
f"Timed out waiting for the device to {action}. "
|
||||
f"The command may still have been applied — refresh to confirm."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to control {unit_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": str(e)
|
||||
}
|
||||
# Never return an empty detail — it renders to users as "Unknown error".
|
||||
return {"status": "error", "detail": str(e) or f"{type(e).__name__}"}
|
||||
|
||||
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
||||
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// PWA users actually receive the new bundles instead of being stuck on
|
||||
// the pre-bump version. Convention: keep it in sync with the Terra-View
|
||||
// version string in backend/main.py.
|
||||
const CACHE_VERSION = 'v0.13.2';
|
||||
const CACHE_VERSION = 'v0.16.0';
|
||||
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||
|
||||
@@ -18,6 +18,10 @@ services:
|
||||
# browser won't send the cookie and the portal breaks).
|
||||
- SECRET_KEY=${SECRET_KEY:-dev-insecure-change-me}
|
||||
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
||||
# Operator login gate. Leave false to ship dark; seed a superadmin via
|
||||
# backend/operator_admin.py, confirm you can log in, THEN set true to enforce.
|
||||
# Instant escape hatch: set back to false. See docs/superpowers/specs/2026-06-17-operator-auth-design.md
|
||||
- OPERATOR_AUTH_ENABLED=${OPERATOR_AUTH_ENABLED:-false}
|
||||
# Display timezone for server logs + any text-rendered timestamps.
|
||||
# DB columns are stored UTC regardless; this only affects what
|
||||
# operators see. Override here for non-US-East deployments.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Living document — captures known deferred work, in-flight initiatives, and lon
|
||||
Bump items up/down or strike them through as priorities shift. Source of truth for "what's next"
|
||||
should be this file plus the `## Current Development Focus` block in `CLAUDE.md`.
|
||||
|
||||
Last updated: 2026-06-05 (Terra-View v0.13.3)
|
||||
Last updated: 2026-06-23 (Terra-View v0.16.0)
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
# Operator Authentication — Design & Build Plan
|
||||
|
||||
**Status:** in development (`feat/operator-auth`) · **Targets:** 0.15.x · **Date:** 2026-06-17
|
||||
|
||||
Adds a login + roles to the **internal** Terra-View app — the operator-facing
|
||||
surface that today has **zero auth**. This is the prerequisite that makes the app
|
||||
safe to expose to the internet (the office-deployment sequencing: operator auth →
|
||||
expose). Expands the "Deferred A" section of
|
||||
[2026-06-15-portal-auth-design.md](2026-06-15-portal-auth-design.md) into a
|
||||
standalone spec.
|
||||
|
||||
## Goal
|
||||
|
||||
Anyone reaching the internal app must log in. Three known users to start (you +
|
||||
two parents), two effective roles, and a **dead-simple password-reset story** for a
|
||||
family-run shop. Reuses the building blocks the client portal already shipped: the
|
||||
argon2 hasher (`backend/auth_passwords.py`) and the HMAC signed-cookie pattern
|
||||
(`backend/portal_auth.py`).
|
||||
|
||||
## Scope
|
||||
|
||||
**v1 (this spec):** email + password login (argon2) · long-lived "remember this
|
||||
device" session · brute-force lockout · a **deny-by-default gate** over the whole
|
||||
internal app · `superadmin`/`admin` roles · **superadmin-only user management** ·
|
||||
**password reset** (superadmin-resets-anyone + self-service change + forced change)
|
||||
· a **seed CLI** to bootstrap · the `OPERATOR_AUTH_ENABLED` **feature-flag rollout**.
|
||||
|
||||
**Deferred (designed-not-built):** TOTP 2FA (near-term follow-up, `superadmin`
|
||||
account first) · the `operator` restricted role · email-based self-service
|
||||
password reset (needs the email infra coming with the report work).
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Deny by default.** Every route requires a login *except* an explicit allow-list.
|
||||
A route added next year is protected automatically — you can't forget to gate it.
|
||||
2. **Can't lock yourself out.** Ship dark behind a feature flag; seed + verify before
|
||||
enforcing; the flag is an instant escape hatch; a CLI is the break-glass.
|
||||
3. **Reuse, don't reinvent.** argon2 + the signed-cookie HMAC already exist and are
|
||||
tested. Operator auth is a thin new layer, not a parallel crypto stack.
|
||||
4. **Easy recovery.** For a 3-person shop, "forgot my password" must be a 10-second
|
||||
fix — the superadmin resets it, no email round-trip required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
OPERATOR_AUTH_ENABLED=false ──▶ pass everything (app as today)
|
||||
request ──▶ gate middleware ─┤
|
||||
└ enabled ─▶ path exempt? ──yes──▶ serve (no login)
|
||||
│ exempt: /login /logout /health
|
||||
│ /static/* /portal/* + 3 machine endpoints
|
||||
└no─▶ valid operator session?
|
||||
├ no ─▶ HTML: 303 → /login?next=…
|
||||
│ /api/*: 401 JSON
|
||||
├ must_change_password ─▶ 303 → /change-password
|
||||
└ yes ─▶ request.state.operator = user
|
||||
─▶ route runs; require_role() may 403
|
||||
```
|
||||
|
||||
One **Starlette HTTP middleware** is the gate (not per-route dependencies — a
|
||||
middleware can't miss a route). It resolves the operator from the cookie using its
|
||||
own `SessionLocal()` (same pattern the portal WS handler uses), stashes the user on
|
||||
`request.state.operator`, and a `require_role(...)` dependency reads it for the few
|
||||
routes that need more than "logged in."
|
||||
|
||||
## Data model
|
||||
|
||||
New table **`operator_users`** (brand-new → `create_all` builds it on startup, **no
|
||||
migration needed**, same as the portal's `clients` table):
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | str UUID | caller-supplied `str(uuid.uuid4())` (codebase convention) |
|
||||
| `email` | str, unique, indexed | login handle, stored lowercased |
|
||||
| `display_name` | str | "Brian", "Dad" — shown in UI + history |
|
||||
| `password_hash` | str | argon2id via `auth_passwords.hash_password` |
|
||||
| `role` | str | `"superadmin"` \| `"admin"` (`"operator"` reserved, deferred) |
|
||||
| `active` | bool, default True | disable a login without deleting |
|
||||
| `must_change_password` | bool, default False | set on create/reset → forces a change on next login |
|
||||
| `sessions_valid_from` | datetime, default `utcnow` | bump to invalidate ALL of a user's sessions |
|
||||
| `failed_login_count` | int, default 0 | lockout counter |
|
||||
| `locked_until` | datetime, nullable | set after too many bad tries |
|
||||
| `created_at` | datetime, default `utcnow` | |
|
||||
| `last_login_at` | datetime, nullable | |
|
||||
|
||||
(Deferred columns, not in v1: `totp_secret`, `totp_enabled`.)
|
||||
|
||||
**Role ladder** — a rank map so checks read naturally and `operator` slots in later:
|
||||
```python
|
||||
_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30}
|
||||
```
|
||||
`require_role("admin")` = admin or above; `require_role("superadmin")` for account mgmt.
|
||||
|
||||
## Sessions
|
||||
|
||||
**New shared module `backend/auth_cookies.py`** — lift the generic signer out so both
|
||||
auth systems share one implementation:
|
||||
```python
|
||||
def sign(payload: dict) -> str # f"{b64url(json)}.{hmac_sha256(b64, SECRET_KEY)}"
|
||||
def read(raw: str, max_age: int) -> dict | None # verify sig (compare_digest) + iat expiry; None on tamper/expiry
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") # same env the portal reads
|
||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false") in truthy
|
||||
```
|
||||
Operator auth uses it now. (Portal's existing cookie helpers keep working untouched;
|
||||
migrating them onto `auth_cookies` is an optional later dedupe, gated on the portal
|
||||
tests staying green — don't destabilize the shipped portal for it.)
|
||||
|
||||
**Operator session cookie:** name **`tv_session`** (distinct from the portal's
|
||||
`portal_session`), payload `{"uid": <id>, "iat": <epoch>}`, `max_age` 30 days
|
||||
(= the "remember this device" — a small trusted set re-logs in rarely), `httponly`,
|
||||
`samesite=lax`, `secure=COOKIE_SECURE`.
|
||||
|
||||
**Validation each request** (`current_operator(request, db)`): read+verify cookie →
|
||||
load `OperatorUser` by `uid` → require `active`, `iat >= sessions_valid_from`
|
||||
(epoch), and not `locked_until > now`. Any failure → no session. Bumping
|
||||
`sessions_valid_from` (on password change / "log out everywhere") instantly kills all
|
||||
live cookies with no session table.
|
||||
|
||||
## Authorization
|
||||
|
||||
**The gate (middleware) exempt list:**
|
||||
- `/login`, `/logout`, `/health`, `/static/*`, plus PWA assets
|
||||
(`/manifest.json`, `/sw.js`, `/favicon.ico`)
|
||||
- `/portal/*` — the client portal keeps its own (separate) auth
|
||||
- **machine endpoints (LAN-only, automated, no human):** `/emitters/report`,
|
||||
`/api/series3/heartbeat`, `/api/series4/heartbeat`
|
||||
|
||||
`/change-password` is **not** exempt — it requires a logged-in session (you change
|
||||
*your own* password). It's only *excluded from the `must_change_password` redirect*,
|
||||
so a forced-change user can actually reach it (no redirect loop).
|
||||
|
||||
**Permission split — minimal by design.** Because the `operator` role is deferred,
|
||||
every real v1 user is `admin` or `superadmin`, so "logged in" already means "full
|
||||
app." The *only* thing gated above plain-admin is **account management** →
|
||||
`require_role("superadmin")` on the user-management routes. Everything else just
|
||||
requires a valid session (the middleware). One extra guard, not a sprawling matrix.
|
||||
|
||||
**The flag governs everything.** Both the middleware *and* `require_role` respect
|
||||
`OPERATOR_AUTH_ENABLED`: when it's off, neither enforces anything (no session is set,
|
||||
and `require_role` passes through) — the app behaves exactly as it does today. When
|
||||
it's on, the middleware guarantees `request.state.operator` is set before any
|
||||
`require_role` check runs.
|
||||
|
||||
## Password management & reset *(the emphasized requirement)*
|
||||
|
||||
Three paths, no email infra required:
|
||||
|
||||
1. **Superadmin resets anyone** — from the user-management UI, "Reset password" →
|
||||
generates a strong password (`auth_passwords.generate_password`), stores its hash,
|
||||
sets `must_change_password=True`, **shows the temp password once** for you to hand
|
||||
off. Covers "easy for *me* to reset *their* password."
|
||||
2. **Self-service change** — `/change-password` (any logged-in user): current + new.
|
||||
Used for routine changes **and** the forced post-reset change. On success, bump
|
||||
`sessions_valid_from` (logs out other devices) and clear `must_change_password`.
|
||||
3. **Forced change** — after a reset/first login, `must_change_password=True` → the
|
||||
gate routes them to `/change-password` until they set their own.
|
||||
|
||||
**Forgot it entirely (can't log in):** v1 has **no email reset** — `/login` shows
|
||||
"Forgot your password? Contact your administrator," and you (superadmin) reset it via
|
||||
the UI or CLI. For a 3-person shop that's a text message, not a feature. (Email-based
|
||||
self-service is the deferred follow-up once email infra lands.)
|
||||
|
||||
## Bootstrapping — seed CLI
|
||||
|
||||
`backend/operator_admin.py` (modeled on the existing `portal_admin.py`), run inside
|
||||
the container against the live DB:
|
||||
```
|
||||
create-superadmin --email you@x.com --name "Brian" # prompts for a password (or --generate)
|
||||
create-user --email dad@x.com --name "Dad" --role admin # generates a temp password, must_change=True
|
||||
reset-password --email dad@x.com # generates a temp, must_change=True
|
||||
list # users + roles + active/locked state
|
||||
disable --email dad@x.com / enable --email dad@x.com
|
||||
```
|
||||
The CLI is the bootstrap (first superadmin, before any UI is reachable) **and** the
|
||||
break-glass (locked out / forgot everything).
|
||||
|
||||
## Account-management UI (superadmin-only)
|
||||
|
||||
`GET /admin/users` (page, `require_role("superadmin")`) + JSON endpoints:
|
||||
- list operators (name, email, role, active, locked, last login)
|
||||
- add operator (email, name, role) → temp password shown once
|
||||
- reset password → temp shown once
|
||||
- enable / disable, change role
|
||||
Template `templates/admin/users.html`. Admins (parents) don't see this; superadmin only.
|
||||
|
||||
## Login / logout / change-password
|
||||
|
||||
- `GET /login` → `templates/login.html` (email + password, optional `?next=`).
|
||||
- `POST /login` → lowercase email, lockout check, argon2 verify; on success set
|
||||
`tv_session`, stamp `last_login_at`, clear `failed_login_count`, redirect to `next`
|
||||
or `/`; on `must_change_password` → `/change-password`; on fail → increment +
|
||||
generic "invalid email or password" (no user-enumeration), lock after 5 → 15 min.
|
||||
- `GET /logout` → clear cookie → `/login`.
|
||||
- `GET/POST /change-password` → `templates/change_password.html`.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Wrong email/password → generic message, increment fail count.
|
||||
- ≥5 fails → "too many attempts, try again in 15 minutes" (`locked_until`).
|
||||
- No/expired/forged cookie → HTML routes 303→`/login?next=…`; `/api/*` → 401 JSON.
|
||||
- Disabled / role-changed / password-changed-elsewhere → bounced on next request
|
||||
(re-validated against the DB every request).
|
||||
- Superadmin-only route hit by an admin → 403.
|
||||
|
||||
## Rollout — the no-self-lockout sequence
|
||||
|
||||
1. Ship with `OPERATOR_AUTH_ENABLED=false` (default) → the middleware short-circuits,
|
||||
app behaves **exactly as today**. Deploying can't break or lock anything.
|
||||
2. Seed your `superadmin` via `operator_admin.py`.
|
||||
3. Hit `/login` and confirm you get a session **while the flag is still off** (the
|
||||
login routes work regardless of the flag).
|
||||
4. Flip `OPERATOR_AUTH_ENABLED=true` → the gate enforces. Your cookie is valid → you're
|
||||
in. Anything wrong → flip it back off (instant escape hatch).
|
||||
5. Create your parents' accounts from `/admin/users` (temp passwords, they change on
|
||||
first login).
|
||||
- **Break-glass:** `operator_admin.py reset-password` / `create-superadmin` in the
|
||||
container; or flag off.
|
||||
|
||||
## Testing
|
||||
|
||||
Reuses the pytest harness from the portal work (`docker exec … python -m pytest`).
|
||||
- **Middleware:** flag off → every path passes; flag on → exempt paths + the 3 machine
|
||||
endpoints pass with no cookie, a gated HTML path 303s to `/login`, a gated `/api/*`
|
||||
path 401s, `must_change_password` user is routed to `/change-password`.
|
||||
- **Login:** success sets `tv_session`; wrong password rejected + counts; 5 wrong →
|
||||
locked (even correct password refused).
|
||||
- **Roles:** `require_role("superadmin")` route → admin gets 403, superadmin 200.
|
||||
- **Sessions:** bumping `sessions_valid_from` invalidates an existing cookie.
|
||||
- **Password:** self-change works + clears `must_change_password`; superadmin reset
|
||||
sets a new hash + `must_change_password` + returns the raw once.
|
||||
- **Machine endpoints:** `/api/series3/heartbeat` etc. still 200 with the gate ON and
|
||||
no cookie (regression guard so we never silently break the watchers).
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `backend/auth_cookies.py` *(new)* | generic `sign`/`read` + `SECRET_KEY`/`COOKIE_SECURE` |
|
||||
| `backend/models.py` | add `OperatorUser` |
|
||||
| `backend/operator_auth.py` *(new)* | `current_operator`, `require_role`, the gate middleware, login/lockout helpers |
|
||||
| `backend/routers/operator_auth_routes.py` *(new)* | `/login`, `/logout`, `/change-password` |
|
||||
| `backend/routers/operator_users.py` *(new)* | `/admin/users` page + CRUD (superadmin) |
|
||||
| `backend/operator_admin.py` *(new)* | seed/break-glass CLI |
|
||||
| `backend/main.py` | register the gate middleware + routers; `OPERATOR_AUTH_ENABLED` |
|
||||
| `templates/login.html`, `templates/change_password.html`, `templates/admin/users.html` *(new)* | UI |
|
||||
|
||||
## Going to prod
|
||||
|
||||
- New table auto-creates; **no migration**. Just code + seeding.
|
||||
- Set a real `SECRET_KEY` (shared with the portal cookie) and `COOKIE_SECURE=true`
|
||||
once on HTTPS — same env knobs already wired in `docker-compose.yml`.
|
||||
- Operator auth is what makes internet-exposing the internal app safe; pair with the
|
||||
(deferred) office deployment + reverse-proxy/TLS work.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Deny-by-default; client-supplied ids never trusted; every request re-validates the
|
||||
session against the DB (instant revoke via `active` / `sessions_valid_from`).
|
||||
- Passwords argon2-hashed; generic login errors (no user-enumeration); lockout on
|
||||
brute force; raw temp passwords shown once, never stored or logged.
|
||||
- Cookies `HttpOnly` + `SameSite=Lax` + `Secure` (on TLS), HMAC-signed with server-side
|
||||
`iat` expiry.
|
||||
- **Known residual until deploy:** without TLS the password crosses the wire in
|
||||
cleartext — fix is the deployment-phase TLS (Synology Let's Encrypt / Cloudflare
|
||||
Tunnel). The login is still a massive improvement over today's zero-auth exposure.
|
||||
- TOTP 2FA is the near-term follow-up (superadmin first), especially without the UniFi
|
||||
edge in front on the home network.
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Rebuild + redeploy a SINGLE compose service without touching the rest of the
|
||||
# stack. The whole-stack rebuild you keep hitting happens because `web-app`
|
||||
# depends_on slmm + sfm, so `compose up --build web-app` rebuilds the entire
|
||||
# dependency tree. `--no-deps` is the fix: build + recreate ONLY this service.
|
||||
#
|
||||
# Usage:
|
||||
# ./redeploy.sh # rebuild + redeploy web-app (prod-style, :8001)
|
||||
# ./redeploy.sh terra-view # the dev container (:1001, bind-mounted source)
|
||||
# ./redeploy.sh sfm # any single service
|
||||
#
|
||||
# Tip: the dev `terra-view` service bind-mounts the source, so for plain
|
||||
# code/template edits you usually only need: docker compose restart terra-view
|
||||
# Rebuild it only when requirements.txt or the Dockerfile changed.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SVC="${1:-web-app}"
|
||||
|
||||
if ! docker compose config --services | grep -qx "$SVC"; then
|
||||
echo "✗ '$SVC' is not a service in this compose project."
|
||||
echo " Available: $(docker compose config --services | paste -sd' ')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ Rebuilding '$SVC' (dependencies untouched)…"
|
||||
docker compose build "$SVC"
|
||||
|
||||
echo "▶ Recreating ONLY '$SVC'…"
|
||||
docker compose up -d --no-deps "$SVC"
|
||||
|
||||
echo
|
||||
echo "✓ '$SVC' redeployed. Current state:"
|
||||
docker compose ps "$SVC"
|
||||
@@ -207,9 +207,19 @@ function _renderPdCard(pd) {
|
||||
</button>
|
||||
</div>`;
|
||||
} else if (pd.status === 'assigned') {
|
||||
const reforward = pd.coordinates
|
||||
? `<button onclick="reforwardInfo('${_esc(pd.id)}')"
|
||||
class="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
|
||||
title="Re-push this capture's GPS coordinates onto its assigned location">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Reforward info
|
||||
</button>`
|
||||
: '';
|
||||
footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400">
|
||||
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span>
|
||||
</div>`;
|
||||
</div>${reforward}`;
|
||||
} else if (pd.status === 'cancelled') {
|
||||
footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''}
|
||||
@@ -266,6 +276,12 @@ async function openClassifyModal(pendingId) {
|
||||
document.getElementById('new-project-name').value = '';
|
||||
document.getElementById('new-location-name').value = '';
|
||||
|
||||
// Reset the submit button — the success path closes the modal without
|
||||
// clearing it, so without this it stays stuck on "Classifying…" on reopen.
|
||||
const submitBtn = document.getElementById('classify-submit');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Classify';
|
||||
|
||||
// Coords hint for "use captured coords" checkbox.
|
||||
const hint = document.getElementById('captured-coords-hint');
|
||||
if (pd.coordinates) {
|
||||
@@ -295,9 +311,10 @@ function closeClassifyModal() {
|
||||
|
||||
async function _loadProjects() {
|
||||
try {
|
||||
const r = await fetch('/api/projects/list');
|
||||
// Must be the JSON endpoint — /api/projects/list returns HTML cards.
|
||||
const r = await fetch('/api/projects/list-json');
|
||||
const data = r.ok ? await r.json() : { projects: [] };
|
||||
// Endpoint shape varies; tolerate either { projects: [...] } or a flat array.
|
||||
// Tolerate either { projects: [...] } or a flat array.
|
||||
_pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []);
|
||||
} catch (e) {
|
||||
_pdState.projectsCache = [];
|
||||
@@ -452,6 +469,22 @@ async function cancelPending(pendingId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-push a promoted capture's GPS coordinates onto its assigned location.
|
||||
async function reforwardInfo(pendingId) {
|
||||
try {
|
||||
const r = await fetch(`/api/deployments/pending/${pendingId}/resync-location`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.detail || 'HTTP ' + r.status);
|
||||
const msg = `Coordinates synced to "${j.location_name}": ${j.coordinates}`;
|
||||
if (window.showToast) showToast(msg, 'success'); else alert(msg);
|
||||
} catch (e) {
|
||||
const msg = 'Reforward failed: ' + e.message;
|
||||
if (window.showToast) showToast(msg, 'error'); else alert(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off the initial load.
|
||||
loadPdList();
|
||||
// Refresh awaiting count every 30s for the badge.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Operator Accounts{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-semibold">Operator Accounts</h1>
|
||||
<button id="add-user-btn" class="px-3 py-2 rounded bg-orange-500 hover:bg-orange-600 text-white text-sm">+ Add operator</button>
|
||||
</div>
|
||||
<div id="temp-pw-banner" class="hidden mb-4 px-3 py-2 rounded bg-emerald-900/60 text-emerald-100 text-sm"></div>
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left border-b border-slate-600">
|
||||
<th class="py-2">Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="user-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
function showTemp(email, pw) {
|
||||
const b = $("#temp-pw-banner");
|
||||
b.textContent = `Temporary password for ${email}: ${pw} — copy it now, it won't be shown again.`;
|
||||
b.classList.remove("hidden");
|
||||
}
|
||||
async function load() {
|
||||
const r = await fetch("/api/admin/users");
|
||||
const { users } = await r.json();
|
||||
$("#user-rows").innerHTML = users.map(u => `
|
||||
<tr class="border-b border-slate-700">
|
||||
<td class="py-2">${u.display_name}</td>
|
||||
<td>${u.email}</td>
|
||||
<td>
|
||||
<select data-role="${u.id}" class="bg-slate-700 rounded px-1 py-0.5">
|
||||
<option value="admin"${u.role==='admin'?' selected':''}>admin</option>
|
||||
<option value="superadmin"${u.role==='superadmin'?' selected':''}>superadmin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>${u.active ? 'active' : 'disabled'}${u.locked ? ' (locked)' : ''}</td>
|
||||
<td>${u.last_login_at || '—'}</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button data-reset="${u.id}" data-email="${u.email}" class="text-orange-400 hover:underline">Reset pw</button>
|
||||
<button data-toggle="${u.id}" data-active="${u.active}" class="text-slate-300 hover:underline">${u.active ? 'Disable' : 'Enable'}</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
document.addEventListener("click", async (e) => {
|
||||
if (e.target.dataset.reset) {
|
||||
const r = await fetch(`/api/admin/users/${e.target.dataset.reset}/reset-password`, {method:"POST"});
|
||||
const d = await r.json(); showTemp(e.target.dataset.email, d.password); load();
|
||||
} else if (e.target.dataset.toggle) {
|
||||
const action = e.target.dataset.active === "true" ? "disable" : "enable";
|
||||
await fetch(`/api/admin/users/${e.target.dataset.toggle}/${action}`, {method:"POST"}); load();
|
||||
} else if (e.target.id === "add-user-btn") {
|
||||
const email = prompt("Email?"); if (!email) return;
|
||||
const name = prompt("Display name?") || email;
|
||||
const role = prompt("Role (admin / superadmin)?", "admin") || "admin";
|
||||
const r = await fetch("/api/admin/users", {method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({email, name, role})});
|
||||
if (r.ok) { const d = await r.json(); showTemp(email, d.password); load(); }
|
||||
else { alert((await r.json()).detail || "Failed"); }
|
||||
}
|
||||
});
|
||||
document.addEventListener("change", async (e) => {
|
||||
if (e.target.dataset.role) {
|
||||
await fetch(`/api/admin/users/${e.target.dataset.role}/role`, {method:"POST",
|
||||
headers:{"Content-Type":"application/json"}, body: JSON.stringify({role: e.target.value})});
|
||||
load();
|
||||
}
|
||||
});
|
||||
load();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Change password · Terra-View</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-slate-900 text-slate-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-sm p-8 bg-slate-800 rounded-xl shadow-lg">
|
||||
<h1 class="text-xl font-semibold mb-2 text-center">Change your password</h1>
|
||||
{% if must_change %}
|
||||
<p class="mb-4 text-sm text-amber-300 text-center">Please set a new password to continue.</p>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="mb-4 px-3 py-2 rounded bg-red-900/60 text-red-200 text-sm">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/change-password" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm mb-1" for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password" required
|
||||
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" for="new_password">New password</label>
|
||||
<input id="new_password" name="new_password" type="password" minlength="8" required
|
||||
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" for="confirm_password">Confirm new password</label>
|
||||
<input id="confirm_password" name="confirm_password" type="password" minlength="8" required
|
||||
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full py-2 rounded bg-orange-500 hover:bg-orange-600 font-medium">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · Terra-View</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-slate-900 text-slate-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-sm p-8 bg-slate-800 rounded-xl shadow-lg">
|
||||
<h1 class="text-xl font-semibold mb-6 text-center">Terra-View</h1>
|
||||
{% if error %}
|
||||
<div class="mb-4 px-3 py-2 rounded bg-red-900/60 text-red-200 text-sm">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login{% if next %}?next={{ next }}{% endif %}" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm mb-1" for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autofocus required
|
||||
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required
|
||||
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full py-2 rounded bg-orange-500 hover:bg-orange-600 font-medium">Sign in</button>
|
||||
</form>
|
||||
<p class="mt-4 text-xs text-slate-400 text-center">Forgot your password? Contact your administrator.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -74,12 +74,19 @@
|
||||
{% else %}
|
||||
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
||||
{% endif %}
|
||||
{% if item.last_event %}
|
||||
<span>Last event: <span class="text-gray-700 dark:text-gray-300">{{ item.last_event[:16] }}</span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||
<!-- Right column: live status chip + small assign/unassign pill + 3-dot menu -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Live status chip — painted by the Overview live poller
|
||||
(paintInlineLive) keyed on data-loc-live. Hidden until
|
||||
there's live data for this location. -->
|
||||
<span class="nrl-live-chip hidden" data-loc-live="{{ item.location.id }}"></span>
|
||||
{% if not item.assignment %}
|
||||
<!-- Primary action: visible because the unassigned card
|
||||
is most likely getting clicked on right after creation -->
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{% if project_type %}
|
||||
{{ project_type.name }}
|
||||
{% else %}
|
||||
Project
|
||||
{# Identity line — project number / client, not a module name. The
|
||||
enabled modules are already shown as chips in the page header. #}
|
||||
{% set _idbits = [] %}
|
||||
{% if project.project_number %}{% set _ = _idbits.append(project.project_number) %}{% endif %}
|
||||
{% if project.client_name %}{% set _ = _idbits.append(project.client_name) %}{% endif %}
|
||||
{% if _idbits %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _idbits | join(' · ') }}</p>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if project.status == 'upcoming' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||
@@ -48,35 +49,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Separate location lists per module type so vibration points and sound NRLs
|
||||
don't get mixed in one list. Build the section set from the enabled modules. #}
|
||||
{% set loc_sections = [] %}
|
||||
{% if 'vibration_monitoring' in modules %}{% set _ = loc_sections.append(('vibration', 'Vibration Locations', 'Add Location')) %}{% endif %}
|
||||
{% if 'sound_monitoring' in modules %}{% set _ = loc_sections.append(('sound', 'NRLs', 'Add NRL')) %}{% endif %}
|
||||
{% if not loc_sections %}{% set _ = loc_sections.append(('', 'Locations', 'Add Location')) %}{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-6">
|
||||
{% for ltype, title, add_label in loc_sections %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||
NRLs
|
||||
{% else %}
|
||||
Locations
|
||||
{% endif %}
|
||||
</h3>
|
||||
<button onclick="openLocationModal('{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}sound{% elif 'vibration_monitoring' in modules and 'sound_monitoring' not in modules %}vibration{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||||
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||
Add NRL
|
||||
{% else %}
|
||||
Add Location
|
||||
{% endif %}
|
||||
</button>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
||||
<button onclick="openLocationModal('{{ ltype }}')" class="text-sm text-seismo-orange hover:text-seismo-navy">{{ add_label }}</button>
|
||||
</div>
|
||||
<div id="project-locations"
|
||||
hx-get="/api/projects/{{ project.id }}/locations{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}"
|
||||
<div id="project-locations{{ '-' + ltype if ltype else '' }}"
|
||||
hx-get="/api/projects/{{ project.id }}/locations{{ '?location_type=' + ltype if ltype else '' }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse space-y-3">
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Location map — uses the reusable partial that fetches from
|
||||
/api/projects/{p}/locations-json. Same render is reused on the
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Vibration Monitoring
|
||||
{% else %}{{ m }}{% endif %}
|
||||
{% set mstatus = (module_status or {}).get(m, 'active') %}
|
||||
{% if mstatus == 'completed' %}
|
||||
<span class="ml-0.5 inline-flex items-center px-1.5 py-0 rounded-full text-[10px] font-semibold bg-blue-200 text-blue-900 dark:bg-blue-800/70 dark:text-blue-100" title="This module is marked completed">✓ Done</span>
|
||||
{% elif mstatus == 'on_hold' %}
|
||||
<span class="ml-0.5 inline-flex items-center px-1.5 py-0 rounded-full text-[10px] font-semibold bg-amber-200 text-amber-900 dark:bg-amber-800/70 dark:text-amber-100" title="This module is on hold">On hold</span>
|
||||
{% endif %}
|
||||
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
|
||||
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
@@ -47,50 +53,11 @@
|
||||
Add Module
|
||||
</button>
|
||||
</div>
|
||||
{% if project.data_collection_mode == 'remote' %}
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Remote
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||
</svg>
|
||||
Manual
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Project Actions -->
|
||||
<!-- Project Actions — project-level only. Sound-specific actions
|
||||
(Combined Report, Night Report, Report Settings) live in the Sound tab. -->
|
||||
<div class="flex items-center gap-3">
|
||||
{% if 'sound_monitoring' in modules %}
|
||||
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Generate Combined Report
|
||||
</a>
|
||||
<button onclick="openNightReportModal()"
|
||||
title="Last night's noise vs baseline, per location (FTP report pipeline)"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
Night Report
|
||||
</button>
|
||||
<button onclick="openReportSettings('{{ project.id }}')"
|
||||
title="Nightly report settings — schedule, baseline range, recipients"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="openMergeModal()"
|
||||
title="Merge this project into another (consolidates duplicates)"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
|
||||
|
||||
@@ -1,92 +1,119 @@
|
||||
<!-- Project List Grid -->
|
||||
{% if projects %}
|
||||
{% for item in projects %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<a href="/projects/{{ item.project.id }}" class="block p-6">
|
||||
<!-- Project Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{{ item.project.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 flex items-center">
|
||||
{% if item.project_type %}
|
||||
{% if item.project_type.id == 'sound_monitoring' %}
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
{% elif item.project_type.id == 'vibration_monitoring' %}
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
{% set p = item.project %}
|
||||
{% set mods = item.modules or [] %}
|
||||
{% set mstatus = item.module_status or {} %}
|
||||
{% set has_sound = 'sound_monitoring' in mods %}
|
||||
{% set has_vib = 'vibration_monitoring' in mods %}
|
||||
<div class="group relative flex flex-col bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 overflow-hidden">
|
||||
<!-- Accent strip — reflects the project's module mix -->
|
||||
<div class="absolute inset-x-0 top-0 h-1
|
||||
{% if has_sound and has_vib %}bg-gradient-to-r from-seismo-orange to-blue-500
|
||||
{% elif has_sound %}bg-seismo-orange
|
||||
{% elif has_vib %}bg-blue-500
|
||||
{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></div>
|
||||
|
||||
<a href="/projects/{{ p.id }}" class="block flex-1 p-6 pt-7">
|
||||
<!-- Header: name + identity + status -->
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ p.name }}</h3>
|
||||
{% set idbits = [] %}
|
||||
{% if p.project_number %}{% set _ = idbits.append(p.project_number) %}{% endif %}
|
||||
{% if p.client_name %}{% set _ = idbits.append(p.client_name) %}{% endif %}
|
||||
{% if idbits %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">{{ idbits | join(' · ') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if p.status == 'active' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">Active</span>
|
||||
{% elif p.status == 'upcoming' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||
{% elif p.status == 'on_hold' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||
{% elif p.status == 'completed' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">Completed</span>
|
||||
{% elif p.status == 'archived' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full">Archived</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{% if p.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">{{ p.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Per-module stats — each module shows only its own, relevant counts.
|
||||
These lines double as the module legend (identity + status), so a
|
||||
separate chip row would be redundant. -->
|
||||
{% set ms = item.module_stats %}
|
||||
{% if ms %}
|
||||
<div class="space-y-2.5 pt-4 border-t border-gray-100 dark:border-gray-700/60">
|
||||
{% if 'vibration' in ms %}
|
||||
{% set vst = mstatus.get('vibration_monitoring', 'active') %}
|
||||
<div class="flex items-baseline gap-2.5 text-sm flex-wrap">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Vibration
|
||||
{% if vst == 'completed' %}<span title="Completed">✓</span>{% elif vst == 'on_hold' %}<span class="opacity-80" title="On hold">⏸</span>{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
<b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.vibration.locations }}</b> location{{ '' if ms.vibration.locations == 1 else 's' }}
|
||||
· <b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.vibration.units }}</b> unit{{ '' if ms.vibration.units == 1 else 's' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'sound' in ms %}
|
||||
{% set sst = mstatus.get('sound_monitoring', 'active') %}
|
||||
<div class="flex items-baseline gap-2.5 text-sm flex-wrap">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
||||
Sound
|
||||
{% if sst == 'completed' %}<span title="Completed">✓</span>{% elif sst == 'on_hold' %}<span class="opacity-80" title="On hold">⏸</span>{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
<b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.sound.locations }}</b> NRL{{ '' if ms.sound.locations == 1 else 's' }}
|
||||
· <b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.sound.units }}</b> unit{{ '' if ms.sound.units == 1 else 's' }}
|
||||
· {% if ms.sound.recording > 0 %}<b class="font-semibold text-green-600 dark:text-green-400 tabular-nums">{{ ms.sound.recording }}</b> recording{% else %}<span class="text-gray-400 dark:text-gray-500">0 recording</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ item.project_type.name }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
{% if item.project.status == 'active' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
{% elif item.project.status == 'on_hold' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
||||
On Hold
|
||||
</span>
|
||||
{% elif item.project.status == 'completed' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
{% elif item.project.status == 'archived' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full">
|
||||
Archived
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Project Description -->
|
||||
{% if item.project.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{{ item.project.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project Stats -->
|
||||
<div class="grid grid-cols-3 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Fallback: project with no modules enabled yet -->
|
||||
<div class="grid grid-cols-2 gap-3 pt-4 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Locations</p>
|
||||
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Locations</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Units</p>
|
||||
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Units</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Active</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if item.active_session_count > 0 %}
|
||||
<span class="text-green-600 dark:text-green-400">{{ item.active_session_count }}</span>
|
||||
{% else %}
|
||||
{{ item.active_session_count }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Info -->
|
||||
{% if item.project.client_name %}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Client: <span class="font-medium text-gray-700 dark:text-gray-300">{{ item.project.client_name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<!-- Per-module quick-open footer — jumps straight into that module's tab -->
|
||||
{% if has_sound or has_vib %}
|
||||
<div class="flex items-stretch border-t border-gray-100 dark:border-gray-700/60 divide-x divide-gray-100 dark:divide-gray-700/60">
|
||||
{% if has_sound %}
|
||||
<a href="/projects/{{ p.id }}#sound"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-semibold text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
||||
Sound
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_vib %}
|
||||
<a href="/projects/{{ p.id }}#vibration"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Vibration
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
'weekday_night': 'Weekday Night',
|
||||
'weekend_day': 'Weekend Day',
|
||||
'weekend_night': 'Weekend Night',
|
||||
'full_24h': '24-Hour',
|
||||
} %}
|
||||
{% set period_colors = {
|
||||
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
'full_24h': 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300',
|
||||
} %}
|
||||
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
@@ -54,6 +56,7 @@
|
||||
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||
<button onclick="openPeriodEditor('{{ s.id }}')"
|
||||
id="period-badge-{{ s.id }}"
|
||||
data-current-period="{{ s.period_type or '' }}"
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||
title="Click to edit period type and hours">
|
||||
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||
@@ -78,6 +81,12 @@
|
||||
{{ pt_label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<button onclick="selectPeriodType('{{ s.id }}', 'full_24h')"
|
||||
id="pt-btn-{{ s.id }}-full_24h"
|
||||
class="period-type-btn col-span-2 text-xs py-1 px-2 rounded border transition-colors
|
||||
{% if s.period_type == 'full_24h' %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
|
||||
24-Hour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,14 +238,17 @@ const PERIOD_COLORS = {
|
||||
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
full_24h: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300',
|
||||
};
|
||||
const PERIOD_LABELS = {
|
||||
weekday_day: 'Weekday Day',
|
||||
weekday_night: 'Weekday Night',
|
||||
weekend_day: 'Weekend Day',
|
||||
weekend_night: 'Weekend Night',
|
||||
full_24h: '24-Hour',
|
||||
};
|
||||
// Default hours for each period type
|
||||
// Default hours for each period type. full_24h has no window (the report splits
|
||||
// its rows by hour), so its hour inputs are cleared + disabled in the editor.
|
||||
const PERIOD_DEFAULT_HOURS = {
|
||||
weekday_day: {start: 7, end: 19},
|
||||
weekday_night: {start: 19, end: 7},
|
||||
@@ -260,6 +272,20 @@ function openPeriodEditor(sessionId) {
|
||||
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
|
||||
|
||||
// Reflect the current period type's hour-input state on open (24-Hour
|
||||
// has no window, so its hour inputs open disabled).
|
||||
const cur = document.getElementById('period-badge-' + sessionId)?.dataset?.currentPeriod;
|
||||
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||
const eh = document.getElementById('period-end-hr-' + sessionId);
|
||||
const disable = cur === 'full_24h';
|
||||
[sh, eh].forEach(el => {
|
||||
if (!el) return;
|
||||
el.disabled = disable;
|
||||
el.classList.toggle('opacity-50', disable);
|
||||
el.classList.toggle('cursor-not-allowed', disable);
|
||||
if (disable) el.placeholder = 'n/a';
|
||||
});
|
||||
}
|
||||
|
||||
function closePeriodEditor(sessionId) {
|
||||
@@ -281,14 +307,32 @@ function selectPeriodType(sessionId, pt) {
|
||||
btn.classList.toggle('text-gray-600', !isSelected);
|
||||
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
||||
});
|
||||
// Fill default hours
|
||||
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||
if (defaults) {
|
||||
// Hour inputs: full_24h has no window — clear + disable them. Other types
|
||||
// re-enable and fill their default window if empty.
|
||||
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||
const eh = document.getElementById('period-end-hr-' + sessionId);
|
||||
if (pt === 'full_24h') {
|
||||
[sh, eh].forEach(el => {
|
||||
if (!el) return;
|
||||
el.value = '';
|
||||
el.disabled = true;
|
||||
el.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
el.placeholder = 'n/a';
|
||||
});
|
||||
} else {
|
||||
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||
[sh, eh].forEach(el => {
|
||||
if (!el) return;
|
||||
el.disabled = false;
|
||||
el.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
});
|
||||
if (sh) sh.placeholder = 'e.g. 19';
|
||||
if (eh) eh.placeholder = 'e.g. 7';
|
||||
if (defaults) {
|
||||
if (sh && !sh.value) sh.value = defaults.start;
|
||||
if (eh && !eh.value) eh.value = defaults.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function savePeriodEditor(sessionId) {
|
||||
@@ -315,6 +359,7 @@ async function savePeriodEditor(sessionId) {
|
||||
const badge = document.getElementById('period-badge-' + sessionId);
|
||||
const label = document.getElementById('period-label-' + sessionId);
|
||||
const newPt = result.period_type;
|
||||
badge.dataset.currentPeriod = newPt || '';
|
||||
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||
if (newPt && PERIOD_COLORS[newPt]) {
|
||||
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
||||
|
||||
@@ -85,6 +85,36 @@
|
||||
<div id="tab-content">
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview-tab" class="tab-panel">
|
||||
<!-- Live monitoring (sound) — self-contained, refreshes itself every 15s.
|
||||
Kept OUTSIDE #project-dashboard so the 30s htmx swap below never
|
||||
clobbers its DOM/timer. Shown only for projects with sound NRLs. -->
|
||||
<div id="live-stats-section" class="hidden mb-6">
|
||||
<div class="flex flex-wrap items-center gap-2.5 mb-4">
|
||||
<span class="text-[10px] uppercase tracking-[0.18em] text-seismo-orange/90 font-semibold">Live monitoring</span>
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-seismo-orange"></span>
|
||||
</span>
|
||||
<b id="ls-live" class="text-lg font-semibold text-seismo-orange tabular-nums">–</b>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">live</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
|
||||
<b id="ls-offline" class="text-lg font-semibold text-gray-700 dark:text-gray-200 tabular-nums">–</b>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">offline</span>
|
||||
</div>
|
||||
<div id="ls-loudest-wrap" class="hidden inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
|
||||
<span class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Loudest now</span>
|
||||
<b id="ls-loudest" class="text-lg font-semibold text-seismo-orange tabular-nums">–</b>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">dB</span>
|
||||
<span id="ls-loudest-loc" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500 ml-auto">auto-refresh 15s</span>
|
||||
</div>
|
||||
<div id="ls-tiles" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="project-dashboard"
|
||||
hx-get="/api/projects/{{ project_id }}/dashboard"
|
||||
hx-trigger="load, every 30s"
|
||||
@@ -101,13 +131,29 @@
|
||||
|
||||
<!-- Vibration Tab -->
|
||||
<div id="vibration-tab" class="tab-panel hidden">
|
||||
<!-- Vibration module toolbar — per-module status, scoped to this module -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Module status</span>
|
||||
<select id="vibration-module-status" onchange="setModuleStatus('vibration_monitoring', this.value, this)"
|
||||
class="text-sm font-medium bg-transparent border-0 focus:ring-0 text-gray-900 dark:text-white cursor-pointer py-0 pr-6">
|
||||
<option value="active">Active</option>
|
||||
<option value="on_hold">On hold</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vibration sub-nav -->
|
||||
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<button id="vib-sub-locations-btn" onclick="switchVibSubTab('locations')"
|
||||
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
|
||||
Locations
|
||||
</button>
|
||||
<!-- Future sub-tabs: Sessions, Data Files -->
|
||||
<button id="vib-sub-events-btn" onclick="switchVibSubTab('events')"
|
||||
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
|
||||
Events
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vibration Locations sub-panel -->
|
||||
@@ -153,10 +199,101 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vibration Events sub-panel — project-wide events across all locations -->
|
||||
<div id="vib-sub-events" class="vib-sub-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mr-auto">Project Events</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||
<input type="date" id="pve-from" onchange="loadProjectVibrationEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||
<input type="date" id="pve-to" onchange="loadProjectVibrationEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Location</label>
|
||||
<select id="pve-loc" onchange="_pveApplyAndRender()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All locations</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Events</label>
|
||||
<select id="pve-ft" onchange="loadProjectVibrationEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All</option>
|
||||
<option value="false">Real Only</option>
|
||||
<option value="true">FT Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
|
||||
<select id="pve-limit" onchange="loadProjectVibrationEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="250">250</option>
|
||||
<option value="500" selected>500</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="clearProjectEventFilters()"
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div id="pve-container" class="overflow-x-auto">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Tab -->
|
||||
<div id="sound-tab" class="tab-panel hidden">
|
||||
<!-- Sound module toolbar — per-module status + sound-only actions
|
||||
(relocated here from the global project header). -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Module status</span>
|
||||
<select id="sound-module-status" onchange="setModuleStatus('sound_monitoring', this.value, this)"
|
||||
class="text-sm font-medium bg-transparent border-0 focus:ring-0 text-gray-900 dark:text-white cursor-pointer py-0 pr-6">
|
||||
<option value="active">Active</option>
|
||||
<option value="on_hold">On hold</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<span id="sound-mode-chip" class="hidden items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium"></span>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
||||
class="px-3.5 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Generate Combined Report
|
||||
</a>
|
||||
<button onclick="openNightReportModal()"
|
||||
title="Last night's noise vs baseline, per location (FTP report pipeline)"
|
||||
class="px-3.5 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
Night Report
|
||||
</button>
|
||||
<button onclick="openReportSettings('{{ project_id }}')"
|
||||
title="Nightly report settings — schedule, baseline range, recipients"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound sub-nav -->
|
||||
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<button id="sound-sub-locations-btn" onclick="switchSoundSubTab('locations')"
|
||||
@@ -955,6 +1092,175 @@ function switchVibSubTab(name) {
|
||||
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
// Lazy-load the Events table on first open.
|
||||
if (name === 'events' && !_projectEventsLoaded) {
|
||||
_projectEventsLoaded = true;
|
||||
loadProjectVibrationEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vibration Events sub-tab ─────────────────────────────────────────────
|
||||
let _projectEventsLoaded = false;
|
||||
let _pveAllEvents = []; // full fetched set (before client-side location filter)
|
||||
let _pveTotal = 0; // project-wide count reported by the API
|
||||
let _pveSort = { key: 'timestamp', dir: 'desc' };
|
||||
|
||||
function clearProjectEventFilters() {
|
||||
document.getElementById('pve-from').value = '';
|
||||
document.getElementById('pve-to').value = '';
|
||||
document.getElementById('pve-ft').value = '';
|
||||
const loc = document.getElementById('pve-loc'); if (loc) loc.value = '';
|
||||
loadProjectVibrationEvents();
|
||||
}
|
||||
|
||||
function _pveFmtPPV(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
|
||||
function _pvePPVClass(v) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
if (v >= 0.5) return 'text-red-500';
|
||||
if (v >= 0.2) return 'text-amber-500';
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
}
|
||||
|
||||
// Date range / FT / limit are server-side filters → re-fetch. Location and
|
||||
// column sorting are applied client-side over the cached set so they're instant.
|
||||
async function loadProjectVibrationEvents() {
|
||||
const container = document.getElementById('pve-container');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const from = document.getElementById('pve-from').value;
|
||||
const to = document.getElementById('pve-to').value;
|
||||
const ft = document.getElementById('pve-ft').value;
|
||||
const limit = document.getElementById('pve-limit').value;
|
||||
if (from) params.set('from_dt', from + ' 00:00:00');
|
||||
if (to) params.set('to_dt', to + ' 23:59:59');
|
||||
if (ft) params.set('false_trigger', ft);
|
||||
params.set('limit', limit);
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${projectId}/vibration-events?${params.toString()}`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
_pveAllEvents = d.events || [];
|
||||
_pveTotal = d.count || 0;
|
||||
_pvePopulateLocations();
|
||||
_pveApplyAndRender();
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${lsEsc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the Location dropdown from whatever locations actually have events in
|
||||
// the current fetch, preserving the operator's current selection if still valid.
|
||||
function _pvePopulateLocations() {
|
||||
const sel = document.getElementById('pve-loc');
|
||||
if (!sel) return;
|
||||
const prev = sel.value;
|
||||
const seen = new Map();
|
||||
_pveAllEvents.forEach(ev => {
|
||||
if (ev.location_id && !seen.has(ev.location_id)) seen.set(ev.location_id, ev.location_name || ev.location_id);
|
||||
});
|
||||
const opts = ['<option value="">All locations</option>'];
|
||||
[...seen.entries()]
|
||||
.sort((a, b) => String(a[1]).localeCompare(String(b[1])))
|
||||
.forEach(([id, name]) => opts.push(`<option value="${lsEsc(id)}">${lsEsc(name)}</option>`));
|
||||
sel.innerHTML = opts.join('');
|
||||
if (prev && seen.has(prev)) sel.value = prev;
|
||||
}
|
||||
|
||||
function _pveSortBy(key) {
|
||||
if (_pveSort.key === key) {
|
||||
_pveSort.dir = (_pveSort.dir === 'asc') ? 'desc' : 'asc';
|
||||
} else {
|
||||
_pveSort.key = key;
|
||||
_pveSort.dir = 'desc'; // numbers + dates most useful high→low first
|
||||
}
|
||||
_pveApplyAndRender();
|
||||
}
|
||||
|
||||
const _PVE_NUM_KEYS = new Set(['tran_ppv', 'vert_ppv', 'long_ppv', 'peak_vector_sum', 'mic_ppv']);
|
||||
const _PVE_STR_KEYS = new Set(['location_name', 'serial']);
|
||||
|
||||
function _pveApplyAndRender() {
|
||||
const container = document.getElementById('pve-container');
|
||||
if (!container) return;
|
||||
|
||||
const locId = document.getElementById('pve-loc')?.value || '';
|
||||
let rows = locId ? _pveAllEvents.filter(ev => ev.location_id === locId) : _pveAllEvents.slice();
|
||||
|
||||
const { key, dir } = _pveSort;
|
||||
const mul = dir === 'asc' ? 1 : -1;
|
||||
rows.sort((a, b) => {
|
||||
if (_PVE_NUM_KEYS.has(key)) {
|
||||
const av = (a[key] == null) ? -Infinity : Number(a[key]);
|
||||
const bv = (b[key] == null) ? -Infinity : Number(b[key]);
|
||||
return (av - bv) * mul;
|
||||
}
|
||||
if (_PVE_STR_KEYS.has(key)) {
|
||||
return String(a[key] || '').toLowerCase().localeCompare(String(b[key] || '').toLowerCase()) * mul;
|
||||
}
|
||||
// timestamp — ISO strings sort lexicographically
|
||||
return String(a.timestamp || '').localeCompare(String(b.timestamp || '')) * mul;
|
||||
});
|
||||
|
||||
_renderProjectEvents(rows, container, locId);
|
||||
}
|
||||
|
||||
function _pveTh(label, key, align) {
|
||||
const active = _pveSort.key === key;
|
||||
const arrow = active ? (_pveSort.dir === 'asc' ? '▲' : '▼') : '<span class="opacity-0 group-hover:opacity-40">▼</span>';
|
||||
const alignCls = align === 'right' ? 'text-right' : 'text-left';
|
||||
return `<th onclick="_pveSortBy('${key}')"
|
||||
class="group px-4 py-3 text-xs font-medium uppercase tracking-wider cursor-pointer select-none ${alignCls} ${active ? 'text-seismo-orange' : 'text-gray-700 dark:text-gray-300 hover:text-seismo-orange'}">
|
||||
${label}<span class="ml-1 inline-block text-[10px] text-seismo-orange">${arrow}</span></th>`;
|
||||
}
|
||||
|
||||
function _renderProjectEvents(events, container, locId) {
|
||||
if (!events.length) {
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No events for the current filter.</div>';
|
||||
return;
|
||||
}
|
||||
const rows = events.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const mic = ev.mic_ppv != null ? Number(ev.mic_ppv).toFixed(3) : '—';
|
||||
const ft = ev.false_trigger
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>' : '';
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${lsEsc(ev.id)}')">
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${lsEsc(ev.location_name || '—')}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono text-seismo-orange">
|
||||
<a href="/unit/${lsEsc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${lsEsc(ev.serial)}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.tran_ppv)}">${_pveFmtPPV(ev.tran_ppv)}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.vert_ppv)}">${_pveFmtPPV(ev.vert_ppv)}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.long_ppv)}">${_pveFmtPPV(ev.long_ppv)}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_pvePPVClass(ev.peak_vector_sum)}">${_pveFmtPPV(ev.peak_vector_sum)}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
|
||||
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const scope = locId
|
||||
? `Showing ${events.length.toLocaleString()} event${events.length === 1 ? '' : 's'} at this location`
|
||||
: `Showing ${events.length.toLocaleString()} of ${_pveTotal.toLocaleString()} events`;
|
||||
container.innerHTML = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 px-1 pb-2">${scope}</div>
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
${_pveTh('Timestamp', 'timestamp')}
|
||||
${_pveTh('Location', 'location_name')}
|
||||
${_pveTh('Serial', 'serial')}
|
||||
${_pveTh('Tran', 'tran_ppv')}
|
||||
${_pveTh('Vert', 'vert_ppv')}
|
||||
${_pveTh('Long', 'long_ppv')}
|
||||
${_pveTh('PVS', 'peak_vector_sum')}
|
||||
${_pveTh('Mic', 'mic_ppv')}
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function switchSoundSubTab(name) {
|
||||
@@ -971,6 +1277,41 @@ function switchSoundSubTab(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-module status (active / on_hold / completed) ─────────────────────
|
||||
// Each module has its own lifecycle independent of the parent project, so the
|
||||
// sound side can be "completed" while vibration keeps running.
|
||||
const _MODULE_STATUS_LABEL = { active: 'Active', on_hold: 'On hold', completed: 'Completed' };
|
||||
async function setModuleStatus(moduleType, status, selectEl) {
|
||||
const prev = selectEl ? selectEl.getAttribute('data-prev') : null;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${projectId}/modules/${moduleType}/status`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
if (selectEl) selectEl.setAttribute('data-prev', status);
|
||||
// Refresh the header so the module chip's status badge updates.
|
||||
if (window.htmx) htmx.ajax('GET', `/api/projects/${projectId}/header`, { target: '#project-header', swap: 'innerHTML' });
|
||||
const name = (moduleType === 'sound_monitoring') ? 'Sound' : 'Vibration';
|
||||
if (window.showToast) showToast(`${name} module marked ${_MODULE_STATUS_LABEL[status] || status}.`, 'success');
|
||||
} catch (e) {
|
||||
if (selectEl && prev) selectEl.value = prev; // revert the dropdown on failure
|
||||
if (window.showToast) showToast('Could not update module status.', 'error');
|
||||
else alert('Could not update module status.');
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSoundModeChip(mode) {
|
||||
const chip = document.getElementById('sound-mode-chip');
|
||||
if (!chip) return;
|
||||
const remote = mode === 'remote';
|
||||
chip.classList.remove('hidden');
|
||||
chip.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ' +
|
||||
(remote ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300');
|
||||
chip.textContent = remote ? 'Remote — data via FTP' : 'Manual — SD card upload';
|
||||
}
|
||||
|
||||
// Load project details
|
||||
async function loadProjectDetails() {
|
||||
try {
|
||||
@@ -1000,6 +1341,14 @@ async function loadProjectDetails() {
|
||||
if (modeRadio) modeRadio.checked = true;
|
||||
settingsUpdateModeStyles();
|
||||
|
||||
// Per-module status selects + the (sound-scoped) data-collection chip.
|
||||
const ms = data.module_status || {};
|
||||
const ssel = document.getElementById('sound-module-status');
|
||||
if (ssel) { ssel.value = ms.sound_monitoring || 'active'; ssel.setAttribute('data-prev', ssel.value); }
|
||||
const vsel = document.getElementById('vibration-module-status');
|
||||
if (vsel) { vsel.value = ms.vibration_monitoring || 'active'; vsel.setAttribute('data-prev', vsel.value); }
|
||||
_renderSoundModeChip(mode);
|
||||
|
||||
// Show/hide module tabs based on active modules
|
||||
const hasSoundModule = projectModules.includes('sound_monitoring');
|
||||
const hasVibrationModule = projectModules.includes('vibration_monitoring');
|
||||
@@ -1009,6 +1358,10 @@ async function loadProjectDetails() {
|
||||
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
||||
document.getElementById('sound-settings-section')?.classList.toggle('hidden', !hasSoundModule);
|
||||
|
||||
// Live monitoring section: only for sound projects (idempotent).
|
||||
if (hasSoundModule) startLiveStats();
|
||||
else document.getElementById('live-stats-section')?.classList.add('hidden');
|
||||
|
||||
// Within Sound: show Assigned Units + Schedules sub-tabs only for remote projects
|
||||
document.getElementById('sound-sub-units-btn')?.classList.toggle('hidden', !isRemote);
|
||||
document.getElementById('sound-sub-schedules-btn')?.classList.toggle('hidden', !isRemote);
|
||||
@@ -2075,6 +2428,196 @@ function submitUploadAll() {
|
||||
}
|
||||
|
||||
// Load project details on page load and restore active tab from URL hash
|
||||
// ── Live monitoring section (sound) ──────────────────────────────────────
|
||||
// Self-contained: fetches /live-stats every 15s and renders a rollup + a
|
||||
// live tile per NRL. Started from loadProjectDetails() once we know the
|
||||
// project has the sound module, so vibration-only projects don't poll.
|
||||
let liveStatsTimer = null;
|
||||
const LS_LEVEL_AMBER = 55, LS_LEVEL_RED = 70;
|
||||
|
||||
function lsEsc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
||||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function lsNum(v) { const f = parseFloat(v); return isNaN(f) ? null : f; }
|
||||
function lsFmtAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
||||
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.round(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.round(s / 3600) + 'h ago';
|
||||
return Math.round(s / 86400) + 'd ago';
|
||||
}
|
||||
// Headline Leq color, matched to the portal thresholds.
|
||||
function lsLeqColor(leq, measuring) {
|
||||
if (!measuring || leq == null) return 'text-gray-400 dark:text-gray-500';
|
||||
if (leq >= LS_LEVEL_RED) return 'text-red-500';
|
||||
if (leq >= LS_LEVEL_AMBER) return 'text-amber-500';
|
||||
return 'text-green-500';
|
||||
}
|
||||
// Friendly labels for NL-43 battery / power-source codes (fall back to raw).
|
||||
function lsBattery(code) {
|
||||
return ({F:'Full', M:'Mid', L:'Low', D:'Dead', E:'Empty'})[code] || (code || '');
|
||||
}
|
||||
function lsPower(code) {
|
||||
return ({I:'Battery', E:'External', U:'USB'})[code] || (code || '');
|
||||
}
|
||||
|
||||
function lsRenderTile(loc) {
|
||||
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
|
||||
const wedged = loc.connection_state === 'wedged';
|
||||
const reachable = loc.is_reachable !== false; // null/absent → assume ok
|
||||
const hasData = loc.measurement_state != null || loc.leq != null;
|
||||
|
||||
// Status badge
|
||||
let badge;
|
||||
if (!loc.unit_id) {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">No unit</span>';
|
||||
} else if (wedged) {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>';
|
||||
} else if (!reachable || !hasData) {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>';
|
||||
} else if (measuring) {
|
||||
badge = '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] bg-orange-100 dark:bg-orange-900/30 text-seismo-orange"><span class="relative flex h-1.5 w-1.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span><span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-seismo-orange"></span></span>Live</span>';
|
||||
} else {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>';
|
||||
}
|
||||
|
||||
const leqNum = lsNum(loc.leq);
|
||||
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
|
||||
const leqColor = lsLeqColor(leqNum, measuring);
|
||||
|
||||
// Health line: unit · last-seen · battery/power
|
||||
const bits = [];
|
||||
if (loc.unit_id) bits.push(`<span class="font-mono text-seismo-orange">${lsEsc(loc.unit_id)}</span>`);
|
||||
if (hasData && loc.last_seen) bits.push(lsEsc(lsFmtAgo(loc.last_seen)));
|
||||
if (hasData && (loc.battery_level || loc.power_source)) {
|
||||
const b = lsBattery(loc.battery_level), p = lsPower(loc.power_source);
|
||||
const low = loc.battery_level === 'L' || loc.battery_level === 'D' || loc.battery_level === 'E';
|
||||
bits.push(`<span class="${low ? 'text-red-500' : ''}">${lsEsc([p, b].filter(Boolean).join(' · '))}</span>`);
|
||||
}
|
||||
|
||||
const levels = (hasData)
|
||||
? `<div class="mt-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
Lp ${lsEsc(loc.lp ?? '--')} Lmax ${lsEsc(loc.lmax ?? '--')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<a href="/projects/${projectId}/nrl/${encodeURIComponent(loc.id)}"
|
||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 border border-transparent hover:border-seismo-orange hover:shadow-xl transition-all">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="font-semibold text-gray-900 dark:text-white truncate">${lsEsc(loc.name)}</div>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="mt-3 flex items-baseline gap-1.5">
|
||||
<span class="text-4xl leading-none font-semibold tabular-nums ${leqColor}">${lsEsc(leqStr)}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">dB Leq</span>
|
||||
</div>
|
||||
${levels}
|
||||
<div class="mt-2 text-[11px] text-gray-400 dark:text-gray-500 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
${bits.join('<span class="opacity-40">·</span>')}
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function lsRender(locations) {
|
||||
const section = document.getElementById('live-stats-section');
|
||||
if (!section) return;
|
||||
if (!locations.length) { section.classList.add('hidden'); return; }
|
||||
section.classList.remove('hidden');
|
||||
|
||||
// Rollup
|
||||
let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null;
|
||||
for (const l of locations) {
|
||||
const measuring = l.measurement_state === 'Start' || l.measurement_state === 'Measure';
|
||||
const hasData = l.measurement_state != null || l.leq != null;
|
||||
if (measuring) {
|
||||
live++;
|
||||
const n = lsNum(l.leq);
|
||||
if (n != null && (peak == null || n > peak)) { peak = n; peakStr = l.leq; peakLoc = l.name; }
|
||||
} else if (!l.unit_id || !hasData || l.is_reachable === false) {
|
||||
off++;
|
||||
}
|
||||
}
|
||||
document.getElementById('ls-live').textContent = live;
|
||||
document.getElementById('ls-offline').textContent = off;
|
||||
const pw = document.getElementById('ls-loudest-wrap');
|
||||
if (peak != null) {
|
||||
pw.classList.remove('hidden');
|
||||
document.getElementById('ls-loudest').textContent = peakStr;
|
||||
document.getElementById('ls-loudest-loc').textContent = peakLoc;
|
||||
} else { pw.classList.add('hidden'); }
|
||||
|
||||
document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join('');
|
||||
}
|
||||
|
||||
// Compact level-tinted pill classes for the inline NRL-card chips.
|
||||
function lsInlineLevelPill(leq) {
|
||||
if (leq == null) return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
|
||||
if (leq >= LS_LEVEL_RED) return 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300';
|
||||
if (leq >= LS_LEVEL_AMBER) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300';
|
||||
}
|
||||
function lsInlineChipHtml(loc) {
|
||||
if (!loc.unit_id) return ''; // no unit assigned → no chip
|
||||
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ';
|
||||
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
|
||||
const hasData = loc.measurement_state != null || loc.leq != null;
|
||||
const reachable = loc.is_reachable !== false;
|
||||
if (loc.connection_state === 'wedged')
|
||||
return `<span class="${base}bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>`;
|
||||
if (!reachable || !hasData)
|
||||
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>`;
|
||||
if (measuring) {
|
||||
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
|
||||
return `<span class="${base}${lsInlineLevelPill(lsNum(loc.leq))}">`
|
||||
+ `<span class="w-1.5 h-1.5 rounded-full bg-current"></span>${lsEsc(leqStr)} dB</span>`;
|
||||
}
|
||||
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>`;
|
||||
}
|
||||
// Paint the inline chips on the NRL list cards (Overview + Sound tab).
|
||||
function lsPaintInline(locations) {
|
||||
const byId = {};
|
||||
for (const l of locations) byId[l.id] = l;
|
||||
document.querySelectorAll('[data-loc-live]').forEach(el => {
|
||||
const loc = byId[el.getAttribute('data-loc-live')];
|
||||
const html = loc ? lsInlineChipHtml(loc) : '';
|
||||
el.innerHTML = html;
|
||||
el.classList.toggle('hidden', !html);
|
||||
});
|
||||
}
|
||||
|
||||
let lsLastData = [];
|
||||
async function loadLiveStats() {
|
||||
// Skip work while the tab is hidden in the background.
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${projectId}/live-stats`);
|
||||
if (!r.ok) return;
|
||||
const j = await r.json();
|
||||
lsLastData = j.locations || [];
|
||||
lsRender(lsLastData);
|
||||
lsPaintInline(lsLastData);
|
||||
} catch (e) { /* keep last render */ }
|
||||
}
|
||||
|
||||
// The NRL list partial reloads via htmx (e.g. the 30s dashboard swap), which
|
||||
// wipes the painted chips — repaint from the last poll as soon as it settles.
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
const id = (e.target && e.target.id) || '';
|
||||
// Overview lists are now per-type (project-locations-sound/-vibration); the
|
||||
// Sound tab uses sound-locations. Repaint chips whenever any of them swap.
|
||||
if (id.startsWith('project-locations') || id === 'sound-locations') lsPaintInline(lsLastData);
|
||||
});
|
||||
|
||||
function startLiveStats() {
|
||||
if (liveStatsTimer) return; // already running
|
||||
loadLiveStats();
|
||||
liveStatsTimer = setInterval(loadLiveStats, 15000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjectDetails();
|
||||
|
||||
@@ -2189,4 +2732,15 @@ async function regeneratePassword() {
|
||||
} catch (e) { paToast('Could not generate a password.'); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Shared SFM event-detail modal, for the Vibration Events sub-tab rows. -->
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
<script>
|
||||
// When an event's review (FT flag / notes) is saved in the modal, refresh
|
||||
// the project events table so the FT badge updates without a full reload.
|
||||
window.addEventListener('sfm-event-review-saved', () => {
|
||||
if (_projectEventsLoaded) loadProjectVibrationEvents();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -473,12 +473,12 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||
<input type="datetime-local" id="ue-filter-from" onchange="loadUnitEvents()"
|
||||
<input type="date" id="ue-filter-from" onchange="loadUnitEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||
<input type="datetime-local" id="ue-filter-to" onchange="loadUnitEvents()"
|
||||
<input type="date" id="ue-filter-to" onchange="loadUnitEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -2882,8 +2882,9 @@ async function loadUnitEvents() {
|
||||
const ft = document.getElementById('ue-filter-ft').value;
|
||||
const limit = document.getElementById('ue-filter-limit').value;
|
||||
params.set('bucket', bucket);
|
||||
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||
// Date inputs: From = start of that day, To = inclusive end of that day.
|
||||
if (from) params.set('from_dt', from + ' 00:00:00');
|
||||
if (to) params.set('to_dt', to + ' 23:59:59');
|
||||
if (ft) params.set('false_trigger', ft);
|
||||
params.set('limit', limit);
|
||||
|
||||
|
||||
@@ -287,12 +287,12 @@
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()"
|
||||
<input type="date" id="ev-filter-from" onchange="loadLocationEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||
<input type="datetime-local" id="ev-filter-to" onchange="loadLocationEvents()"
|
||||
<input type="date" id="ev-filter-to" onchange="loadLocationEvents()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -501,8 +501,9 @@ async function loadLocationEvents() {
|
||||
const to = document.getElementById('ev-filter-to').value;
|
||||
const ft = document.getElementById('ev-filter-ft').value;
|
||||
const limit = document.getElementById('ev-filter-limit').value;
|
||||
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||
// Date inputs: From = start of that day, To = inclusive end of that day.
|
||||
if (from) params.set('from_dt', from + ' 00:00:00');
|
||||
if (to) params.set('to_dt', to + ' 23:59:59');
|
||||
if (ft) params.set('false_trigger', ft);
|
||||
params.set('limit', limit);
|
||||
|
||||
|
||||
@@ -50,6 +50,20 @@ def _reset_portal_lockout():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _operator_auth_off_by_default(monkeypatch):
|
||||
"""Pin the operator-auth gate OFF for every test, so the suite is deterministic
|
||||
regardless of the container's OPERATOR_AUTH_ENABLED env (the dev container may
|
||||
have it ON for manual testing). Tests that exercise the gate opt in via
|
||||
wire_operator_auth(enabled=True), which overrides this within the test body."""
|
||||
try:
|
||||
import backend.operator_auth as _oa
|
||||
monkeypatch.setattr(_oa, "OPERATOR_AUTH_ENABLED", False, raising=False)
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
|
||||
|
||||
def make_project(db_session, name=None, **kwargs):
|
||||
"""Insert and return a Project with a unique name."""
|
||||
p = models.Project(
|
||||
@@ -62,3 +76,15 @@ def make_project(db_session, name=None, **kwargs):
|
||||
db_session.add(p)
|
||||
db_session.commit()
|
||||
return p
|
||||
|
||||
|
||||
def wire_operator_auth(monkeypatch, db_session, enabled=True):
|
||||
"""Point the gate middleware's SessionLocal at the test engine and flip the
|
||||
flag. The middleware opens its OWN session (it can't use the get_db override),
|
||||
so it must read the same engine the test writes to."""
|
||||
import backend.operator_auth as oa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
maker = sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False)
|
||||
monkeypatch.setattr(oa, "SessionLocal", maker, raising=False)
|
||||
monkeypatch.setattr(oa, "OPERATOR_AUTH_ENABLED", enabled, raising=False)
|
||||
return oa
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# tests/test_operator_admin_cli.py
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import OperatorUser
|
||||
from backend.auth_passwords import verify_password
|
||||
import backend.operator_admin as cli
|
||||
|
||||
|
||||
def _maker(db_session):
|
||||
return sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
def test_seed_superadmin(db_session, monkeypatch):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_superadmin(email="brian@x.com", name="Brian", password="chosen-pw-1")
|
||||
u = db_session.query(OperatorUser).filter_by(email="brian@x.com").first()
|
||||
assert u.role == "superadmin"
|
||||
assert u.must_change_password is False
|
||||
assert verify_password("chosen-pw-1", u.password_hash)
|
||||
|
||||
|
||||
def test_create_user_generates_temp(db_session, monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_user(email="dad@x.com", name="Dad", role="admin")
|
||||
u = db_session.query(OperatorUser).filter_by(email="dad@x.com").first()
|
||||
assert u.role == "admin" and u.must_change_password is True
|
||||
assert "dad@x.com" in capsys.readouterr().out # prints the temp once
|
||||
|
||||
|
||||
def test_reset_password_cli(db_session, monkeypatch):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_user(email="r@x.com", name="R", role="admin")
|
||||
before = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash
|
||||
cli.cmd_reset_password(email="r@x.com")
|
||||
after = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_disable_enable_cli(db_session, monkeypatch):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_user(email="d@x.com", name="D", role="admin")
|
||||
cli.cmd_set_active(email="d@x.com", active=False)
|
||||
assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is False
|
||||
cli.cmd_set_active(email="d@x.com", active=True)
|
||||
assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is True
|
||||
@@ -0,0 +1,89 @@
|
||||
# tests/test_operator_authenticate.py
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
|
||||
from backend.operator_auth import (
|
||||
authenticate, create_operator, reset_operator_password,
|
||||
set_operator_active, set_operator_role, change_own_password, MAX_LOGIN_FAILURES,
|
||||
)
|
||||
from backend.auth_passwords import verify_password
|
||||
from backend.models import OperatorUser
|
||||
|
||||
|
||||
def test_create_operator_generates_temp_and_forces_change(db_session):
|
||||
user, raw = create_operator(db_session, "Dad@X.com", "Dad", "admin")
|
||||
assert user.email == "dad@x.com" # lowercased
|
||||
assert user.must_change_password is True
|
||||
assert verify_password(raw, user.password_hash)
|
||||
|
||||
|
||||
def test_create_operator_with_explicit_password_no_forced_change(db_session):
|
||||
user, raw = create_operator(db_session, "brian@x.com", "Brian", "superadmin", password="chosen-pw-123")
|
||||
assert raw == "chosen-pw-123"
|
||||
assert user.must_change_password is False
|
||||
|
||||
|
||||
def test_create_operator_rejects_duplicate_and_bad_role(db_session):
|
||||
create_operator(db_session, "a@x.com", "A", "admin")
|
||||
with pytest.raises(ValueError):
|
||||
create_operator(db_session, "A@x.com", "A2", "admin") # dup (case-insensitive)
|
||||
with pytest.raises(ValueError):
|
||||
create_operator(db_session, "b@x.com", "B", "wizard") # bad role
|
||||
|
||||
|
||||
def test_authenticate_success(db_session):
|
||||
user, raw = create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
|
||||
got, status = authenticate(db_session, "OK@x.com", "rightpw-9")
|
||||
assert status == "ok" and got.id == user.id
|
||||
assert got.last_login_at is not None
|
||||
assert got.failed_login_count == 0
|
||||
|
||||
|
||||
def test_authenticate_wrong_password_counts(db_session):
|
||||
create_operator(db_session, "wp@x.com", "Wp", "admin", password="rightpw-9")
|
||||
got, status = authenticate(db_session, "wp@x.com", "nope")
|
||||
assert got is None and status == "bad"
|
||||
assert db_session.query(OperatorUser).filter_by(email="wp@x.com").first().failed_login_count == 1
|
||||
|
||||
|
||||
def test_lockout_after_five_then_correct_password_refused(db_session):
|
||||
create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9")
|
||||
for _ in range(MAX_LOGIN_FAILURES):
|
||||
authenticate(db_session, "lk@x.com", "nope")
|
||||
got, status = authenticate(db_session, "lk@x.com", "rightpw-9") # correct, but locked
|
||||
assert got is None and status == "locked"
|
||||
|
||||
|
||||
def test_authenticate_unknown_email_is_bad_not_error(db_session):
|
||||
got, status = authenticate(db_session, "ghost@x.com", "whatever")
|
||||
assert got is None and status == "bad"
|
||||
|
||||
|
||||
def test_reset_password_sets_new_hash_forces_change_and_bumps_sessions(db_session):
|
||||
user, _ = create_operator(db_session, "r@x.com", "R", "admin", password="orig-pw-1")
|
||||
before = user.sessions_valid_from
|
||||
raw = reset_operator_password(db_session, user)
|
||||
assert verify_password(raw, user.password_hash)
|
||||
assert user.must_change_password is True
|
||||
assert user.sessions_valid_from >= before
|
||||
|
||||
|
||||
def test_change_own_password_clears_flag_and_bumps(db_session):
|
||||
user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1")
|
||||
user.must_change_password = True
|
||||
db_session.commit()
|
||||
new_iat = change_own_password(db_session, user, "brand-new-pw-2")
|
||||
assert verify_password("brand-new-pw-2", user.password_hash)
|
||||
assert user.must_change_password is False
|
||||
assert user.sessions_valid_from == datetime.utcfromtimestamp(new_iat)
|
||||
|
||||
|
||||
def test_set_active_and_role(db_session):
|
||||
user, _ = create_operator(db_session, "s@x.com", "S", "admin", password="orig-pw-1")
|
||||
set_operator_active(db_session, user, False)
|
||||
assert user.active is False
|
||||
set_operator_role(db_session, user, "superadmin")
|
||||
assert user.role == "superadmin"
|
||||
with pytest.raises(ValueError):
|
||||
set_operator_role(db_session, user, "wizard")
|
||||
@@ -0,0 +1,49 @@
|
||||
# tests/test_operator_cookies.py
|
||||
import time
|
||||
import base64
|
||||
import json
|
||||
from backend.auth_cookies import sign, read
|
||||
|
||||
|
||||
def test_sign_then_read_round_trips():
|
||||
now = int(time.time())
|
||||
raw = sign({"uid": "abc", "iat": now})
|
||||
data = read(raw, max_age=3600)
|
||||
assert data == {"uid": "abc", "iat": now}
|
||||
|
||||
|
||||
def test_tampered_signature_is_rejected():
|
||||
raw = sign({"uid": "abc", "iat": int(time.time())})
|
||||
body, _sig = raw.rsplit(".", 1)
|
||||
assert read(body + ".deadbeef", max_age=3600) is None
|
||||
|
||||
|
||||
def test_tampered_body_is_rejected():
|
||||
raw = sign({"uid": "abc", "iat": int(time.time())})
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
forged = base64.urlsafe_b64encode(json.dumps({"uid": "evil", "iat": int(time.time())}).encode()).decode()
|
||||
assert read(forged + "." + sig, max_age=3600) is None
|
||||
|
||||
|
||||
def test_expired_by_iat_is_rejected():
|
||||
raw = sign({"uid": "abc", "iat": int(time.time()) - 10_000})
|
||||
assert read(raw, max_age=3600) is None
|
||||
|
||||
|
||||
def test_garbage_input_is_none_not_raise():
|
||||
assert read("not-a-cookie", max_age=3600) is None
|
||||
assert read("", max_age=3600) is None
|
||||
assert read(None, max_age=3600) is None
|
||||
|
||||
|
||||
def test_wrong_secret_is_rejected(monkeypatch):
|
||||
import backend.auth_cookies as ac
|
||||
monkeypatch.setattr(ac, "SECRET_KEY", "secret-A")
|
||||
raw = ac.sign({"uid": "x", "iat": int(time.time())})
|
||||
monkeypatch.setattr(ac, "SECRET_KEY", "secret-B")
|
||||
assert ac.read(raw, max_age=3600) is None
|
||||
|
||||
|
||||
def test_future_dated_iat_is_rejected():
|
||||
raw = sign({"uid": "x", "iat": int(time.time()) + 10_000})
|
||||
assert read(raw, max_age=3600) is None
|
||||
@@ -0,0 +1,76 @@
|
||||
# tests/test_operator_gate.py
|
||||
import uuid
|
||||
from tests.conftest import wire_operator_auth
|
||||
from backend.models import OperatorUser
|
||||
from backend.operator_auth import make_operator_cookie, COOKIE_NAME
|
||||
from backend.auth_passwords import hash_password
|
||||
|
||||
|
||||
def _make_user(db, role="admin", **kw):
|
||||
u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"),
|
||||
display_name="U", password_hash=hash_password("pw"), role=role, **kw)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
return u
|
||||
|
||||
|
||||
def test_flag_off_passes_everything(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=False)
|
||||
assert client.get("/", follow_redirects=False).status_code == 200
|
||||
|
||||
|
||||
def test_gated_html_redirects_to_login_when_unauth(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login?next=")
|
||||
|
||||
|
||||
def test_gated_api_returns_401_json_when_unauth(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.get("/api/status-snapshot", follow_redirects=False)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_valid_session_passes(client, db_session, monkeypatch):
|
||||
u = _make_user(db_session)
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id))
|
||||
assert client.get("/", follow_redirects=False).status_code == 200
|
||||
|
||||
|
||||
def test_must_change_password_user_routed_to_change_password(client, db_session, monkeypatch):
|
||||
u = _make_user(db_session, must_change_password=True)
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id))
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/change-password"
|
||||
|
||||
|
||||
def test_exempt_paths_pass_without_cookie(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
assert client.get("/health", follow_redirects=False).status_code == 200
|
||||
|
||||
|
||||
def test_portal_paths_are_exempt(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
# /portal/p/<bad> hits the portal's own gate (403/404), never the operator login.
|
||||
r = client.get("/portal/p/nope", follow_redirects=False)
|
||||
assert r.status_code in (403, 404)
|
||||
|
||||
|
||||
def test_must_change_user_on_api_gets_403_json_not_redirect(client, db_session, monkeypatch):
|
||||
u = _make_user(db_session, must_change_password=True)
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id))
|
||||
r = client.get("/api/status-snapshot", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "Password change required"
|
||||
|
||||
|
||||
def test_options_preflight_passes_through_gate(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
# CORS preflight has no cookie; the gate must not 303/401 it.
|
||||
r = client.options("/api/status-snapshot", follow_redirects=False)
|
||||
assert r.status_code not in (303, 401)
|
||||
@@ -0,0 +1,98 @@
|
||||
# tests/test_operator_login.py
|
||||
import uuid
|
||||
from tests.conftest import wire_operator_auth
|
||||
from backend.operator_auth import (
|
||||
create_operator, make_operator_cookie, COOKIE_NAME, MAX_LOGIN_FAILURES,
|
||||
)
|
||||
|
||||
|
||||
def test_login_page_renders(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.get("/login")
|
||||
assert r.status_code == 200
|
||||
assert "password" in r.text.lower()
|
||||
|
||||
|
||||
def test_login_success_sets_cookie_and_redirects(client, db_session, monkeypatch):
|
||||
create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.post("/login", data={"email": "ok@x.com", "password": "rightpw-9"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/"
|
||||
assert f"{COOKIE_NAME}=" in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_login_honors_next(client, db_session, monkeypatch):
|
||||
create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.post("/login?next=/settings", data={"email": "ok@x.com", "password": "rightpw-9"},
|
||||
follow_redirects=False)
|
||||
assert r.headers["location"] == "/settings"
|
||||
|
||||
|
||||
def test_login_wrong_password_no_cookie_generic_error(client, db_session, monkeypatch):
|
||||
create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.post("/login", data={"email": "ok@x.com", "password": "nope"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert f"{COOKIE_NAME}=" not in r.headers.get("set-cookie", "")
|
||||
assert "invalid" in r.text.lower()
|
||||
|
||||
|
||||
def test_login_must_change_redirects_to_change_password(client, db_session, monkeypatch):
|
||||
create_operator(db_session, "new@x.com", "New", "admin") # generated temp → must_change
|
||||
from backend.models import OperatorUser
|
||||
user = db_session.query(OperatorUser).filter_by(email="new@x.com").first()
|
||||
from backend.auth_passwords import hash_password
|
||||
user.password_hash = hash_password("temp-pw-1")
|
||||
db_session.commit()
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.post("/login", data={"email": "new@x.com", "password": "temp-pw-1"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/change-password"
|
||||
|
||||
|
||||
def test_login_lockout_message_after_five(client, db_session, monkeypatch):
|
||||
create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
for _ in range(MAX_LOGIN_FAILURES):
|
||||
client.post("/login", data={"email": "lk@x.com", "password": "nope"}, follow_redirects=False)
|
||||
r = client.post("/login", data={"email": "lk@x.com", "password": "rightpw-9"}, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "too many" in r.text.lower()
|
||||
|
||||
|
||||
def test_logout_clears_cookie(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
r = client.get("/logout", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/login"
|
||||
set_cookie = r.headers.get("set-cookie", "").lower()
|
||||
assert COOKIE_NAME.lower() in set_cookie
|
||||
assert 'max-age=0' in set_cookie or 'expires=thu, 01 jan 1970' in set_cookie
|
||||
|
||||
|
||||
def test_change_password_self_service(client, db_session, monkeypatch):
|
||||
user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id))
|
||||
r = client.post("/change-password",
|
||||
data={"current_password": "orig-pw-1", "new_password": "brand-new-2",
|
||||
"confirm_password": "brand-new-2"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
from backend.auth_passwords import verify_password
|
||||
db_session.refresh(user)
|
||||
assert verify_password("brand-new-2", user.password_hash)
|
||||
assert user.must_change_password is False
|
||||
|
||||
|
||||
def test_safe_next_blocks_open_redirect():
|
||||
from backend.routers.operator_auth_routes import _safe_next
|
||||
assert _safe_next("//evil.com") == "/"
|
||||
assert _safe_next("/\\evil.com") == "/" # backslash authority bypass
|
||||
assert _safe_next("https://evil.com") == "/"
|
||||
assert _safe_next("") == "/"
|
||||
assert _safe_next("/settings") == "/settings"
|
||||
@@ -0,0 +1,42 @@
|
||||
# tests/test_operator_machine_endpoints.py
|
||||
from tests.conftest import wire_operator_auth
|
||||
|
||||
|
||||
def test_machine_endpoints_not_blocked_by_gate(client, db_session, monkeypatch):
|
||||
"""With the gate ON and no cookie, the LAN-only watcher endpoints must reach
|
||||
their handlers (the gate must never silently break heartbeats). A handler may
|
||||
return 422 for an empty body — that still proves the gate let it through.
|
||||
|
||||
Note: /emitters/report uses a minimal valid body to avoid triggering the
|
||||
app's validation_exception_handler (which calls await request.body() — a
|
||||
known deadlock in Starlette 0.27 TestClient when the body is already
|
||||
consumed). The gate behaviour is identical regardless of body validity.
|
||||
"""
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
|
||||
r = client.post("/api/series3/heartbeat", json={}, follow_redirects=False)
|
||||
assert r.status_code != 401 # gate would 401 an unauth /api/* route
|
||||
assert r.status_code != 303
|
||||
|
||||
r = client.post("/api/series4/heartbeat", json={}, follow_redirects=False)
|
||||
assert r.status_code not in (401, 303)
|
||||
|
||||
# /emitters/report is a sync endpoint with required Pydantic fields; supply a
|
||||
# valid body so the validation_exception_handler (which awaits request.body())
|
||||
# is never triggered — that handler deadlocks the Starlette 0.27 TestClient.
|
||||
valid_report = {
|
||||
"unit": "TEST001",
|
||||
"unit_type": "series3",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"file": "test.evt",
|
||||
"status": "OK",
|
||||
}
|
||||
r = client.post("/emitters/report", json=valid_report, follow_redirects=False)
|
||||
assert r.status_code != 303 # gate would 303 an unauth HTML route
|
||||
|
||||
|
||||
def test_static_assets_exempt(client, db_session, monkeypatch):
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
# /sw.js and /manifest.json are PWA assets clients fetch pre-login.
|
||||
assert client.get("/sw.js", follow_redirects=False).status_code in (200, 404)
|
||||
assert client.get("/sw.js", follow_redirects=False).status_code != 303
|
||||
@@ -0,0 +1,36 @@
|
||||
# tests/test_operator_model.py
|
||||
import uuid
|
||||
from backend.models import OperatorUser
|
||||
from backend.operator_auth import role_at_least, _ROLE_RANK
|
||||
|
||||
|
||||
def test_operator_user_defaults(db_session):
|
||||
u = OperatorUser(id=str(uuid.uuid4()), email="a@x.com", display_name="A",
|
||||
password_hash="h", role="admin")
|
||||
db_session.add(u)
|
||||
db_session.commit()
|
||||
got = db_session.query(OperatorUser).filter_by(email="a@x.com").first()
|
||||
assert got.active is True
|
||||
assert got.must_change_password is False
|
||||
assert got.failed_login_count == 0
|
||||
assert got.locked_until is None
|
||||
assert got.sessions_valid_from is not None
|
||||
assert got.sessions_valid_from.microsecond == 0 # truncated to whole seconds
|
||||
|
||||
|
||||
def test_email_is_unique(db_session):
|
||||
for i in range(2):
|
||||
db_session.add(OperatorUser(id=str(uuid.uuid4()), email="dup@x.com",
|
||||
display_name="d", password_hash="h", role="admin"))
|
||||
import pytest
|
||||
with pytest.raises(Exception):
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def test_role_ladder():
|
||||
assert _ROLE_RANK == {"operator": 10, "admin": 20, "superadmin": 30}
|
||||
assert role_at_least("superadmin", "admin") is True
|
||||
assert role_at_least("admin", "admin") is True
|
||||
assert role_at_least("admin", "superadmin") is False
|
||||
assert role_at_least("operator", "admin") is False
|
||||
assert role_at_least("nonsense", "admin") is False
|
||||
@@ -0,0 +1,63 @@
|
||||
# tests/test_operator_session.py
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from backend.models import OperatorUser
|
||||
from backend.operator_auth import (
|
||||
make_operator_cookie, current_operator, COOKIE_NAME,
|
||||
)
|
||||
|
||||
|
||||
def _make_user(db, **kw):
|
||||
u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"),
|
||||
display_name="U", password_hash="h", role=kw.pop("role", "admin"), **kw)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
return u
|
||||
|
||||
|
||||
def _req(cookie_value):
|
||||
# current_operator only reads request.cookies — a stub is enough.
|
||||
return SimpleNamespace(cookies={COOKIE_NAME: cookie_value} if cookie_value else {})
|
||||
|
||||
|
||||
def test_valid_cookie_resolves_user(db_session):
|
||||
u = _make_user(db_session)
|
||||
cookie = make_operator_cookie(u.id)
|
||||
assert current_operator(_req(cookie), db_session).id == u.id
|
||||
|
||||
|
||||
def test_no_or_garbage_cookie_is_none(db_session):
|
||||
assert current_operator(_req(None), db_session) is None
|
||||
assert current_operator(_req("garbage"), db_session) is None
|
||||
|
||||
|
||||
def test_inactive_user_is_none(db_session):
|
||||
u = _make_user(db_session, active=False)
|
||||
assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None
|
||||
|
||||
|
||||
def test_locked_user_is_none(db_session):
|
||||
u = _make_user(db_session, locked_until=datetime.utcnow() + timedelta(minutes=5))
|
||||
assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None
|
||||
|
||||
|
||||
def test_cookie_older_than_sessions_valid_from_is_none(db_session):
|
||||
u = _make_user(db_session)
|
||||
old_iat = int(time.time()) - 1000
|
||||
cookie = make_operator_cookie(u.id, iat=old_iat)
|
||||
u.sessions_valid_from = datetime.utcnow()
|
||||
db_session.commit()
|
||||
assert current_operator(_req(cookie), db_session) is None
|
||||
|
||||
|
||||
def test_cookie_minted_with_matching_iat_after_bump_still_valid(db_session):
|
||||
# Guards the change-password race: bump sessions_valid_from to the new cookie's
|
||||
# exact iat → that fresh cookie must remain valid.
|
||||
u = _make_user(db_session)
|
||||
new_iat = int(time.time())
|
||||
u.sessions_valid_from = datetime.utcfromtimestamp(new_iat)
|
||||
db_session.commit()
|
||||
assert current_operator(_req(make_operator_cookie(u.id, iat=new_iat)), db_session).id == u.id
|
||||
@@ -0,0 +1,132 @@
|
||||
# tests/test_operator_users.py
|
||||
import uuid
|
||||
from tests.conftest import wire_operator_auth
|
||||
from backend.operator_auth import create_operator, make_operator_cookie, COOKIE_NAME
|
||||
from backend.models import OperatorUser
|
||||
|
||||
|
||||
def _login_as(client, user):
|
||||
client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id))
|
||||
|
||||
|
||||
def test_admin_cannot_reach_user_management(client, db_session, monkeypatch):
|
||||
admin, _ = create_operator(db_session, "admin@x.com", "Admin", "admin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, admin)
|
||||
assert client.get("/admin/users", follow_redirects=False).status_code == 403
|
||||
|
||||
|
||||
def test_superadmin_sees_user_management(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
assert client.get("/admin/users", follow_redirects=False).status_code == 200
|
||||
|
||||
|
||||
def test_superadmin_lists_users_json(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
emails = [u["email"] for u in r.json()["users"]]
|
||||
assert "su@x.com" in emails
|
||||
assert all("password_hash" not in u for u in r.json()["users"]) # never leak hashes
|
||||
|
||||
|
||||
def test_create_user_returns_temp_once(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
r = client.post("/api/admin/users",
|
||||
json={"email": "dad@x.com", "name": "Dad", "role": "admin"})
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["password"]) >= 12
|
||||
made = db_session.query(OperatorUser).filter_by(email="dad@x.com").first()
|
||||
assert made.must_change_password is True
|
||||
|
||||
|
||||
def test_reset_password_returns_temp_once(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
r = client.post(f"/api/admin/users/{target.id}/reset-password")
|
||||
assert r.status_code == 200 and len(r.json()["password"]) >= 12
|
||||
db_session.refresh(target)
|
||||
assert target.must_change_password is True
|
||||
|
||||
|
||||
def test_disable_and_enable(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 200
|
||||
db_session.refresh(target); assert target.active is False
|
||||
assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 200
|
||||
db_session.refresh(target); assert target.active is True
|
||||
|
||||
|
||||
def test_change_role(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
r = client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"})
|
||||
assert r.status_code == 200
|
||||
db_session.refresh(target); assert target.role == "superadmin"
|
||||
|
||||
|
||||
def test_admin_cannot_reach_json_endpoints(client, db_session, monkeypatch):
|
||||
admin, _ = create_operator(db_session, "a@x.com", "A", "admin", password="pw-123456")
|
||||
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, admin)
|
||||
assert client.get("/api/admin/users").status_code == 403
|
||||
assert client.post("/api/admin/users", json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 403
|
||||
assert client.post(f"/api/admin/users/{target.id}/reset-password").status_code == 403
|
||||
assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 403
|
||||
assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 403
|
||||
assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"}).status_code == 403
|
||||
|
||||
|
||||
def test_cannot_disable_own_account(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
r = client.post(f"/api/admin/users/{su.id}/disable")
|
||||
assert r.status_code == 400
|
||||
db_session.refresh(su)
|
||||
assert su.active is True
|
||||
|
||||
|
||||
def test_cannot_change_own_role(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
r = client.post(f"/api/admin/users/{su.id}/role", json={"role": "admin"})
|
||||
assert r.status_code == 400
|
||||
db_session.refresh(su)
|
||||
assert su.role == "superadmin"
|
||||
|
||||
|
||||
def test_deferred_operator_role_rejected_by_api(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=True)
|
||||
_login_as(client, su)
|
||||
assert client.post("/api/admin/users", json={"email": "op@x.com", "name": "Op", "role": "operator"}).status_code == 400
|
||||
assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "operator"}).status_code == 400
|
||||
|
||||
|
||||
def test_admin_surface_404s_when_flag_off(client, db_session, monkeypatch):
|
||||
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
|
||||
wire_operator_auth(monkeypatch, db_session, enabled=False)
|
||||
_login_as(client, su)
|
||||
# With operator auth OFF, the management surface must not exist (404), even
|
||||
# though require_role passes through — otherwise it'd be world-open.
|
||||
assert client.get("/admin/users").status_code == 404
|
||||
assert client.get("/api/admin/users").status_code == 404
|
||||
assert client.post("/api/admin/users",
|
||||
json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 404
|
||||
Reference in New Issue
Block a user