1 Commits

Author SHA1 Message Date
claude 62fd963c07 Add: pair_devices.html template for device pairing interface
Fix:
- Polling intervals for SLMM.
-modem view now list
- device pairing much improved.
-various other tweaks through out UI.
2026-01-29 06:08:40 +00:00
203 changed files with 2315 additions and 47079 deletions
-3
View File
@@ -1,5 +1,3 @@
docker-compose.override.yml
# Python cache / compiled # Python cache / compiled
__pycache__ __pycache__
*.pyc *.pyc
@@ -30,7 +28,6 @@ ENV/
# Runtime data (mounted volumes) # Runtime data (mounted volumes)
data/ data/
data-dev/
# Editors / OS junk # Editors / OS junk
.vscode/ .vscode/
-25
View File
@@ -1,25 +0,0 @@
# 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
+2 -16
View File
@@ -1,17 +1,3 @@
# Terra-View Specifics
# Dev build counter (local only, never commit)
build_number.txt
docker-compose.override.yml
# SQLite database files
*.db
*.db-journal
data/
data-dev/
.aider*
.aider*
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[codz]
@@ -224,6 +210,6 @@ __marimo__/
# SQLite database files # SQLite database files
*.db *.db
*.db-journal *.db-journal
/data/ data/
/data-dev/ .aider*
.aider* .aider*
-817
View File
@@ -5,821 +5,6 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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.
### SLM live monitoring — fan-out feed + cache-first
SLM live monitoring — fan-out feed + cache-first reads. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
#### Added
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
#### Changed
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
#### Fixed
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
#### Upgrade Notes
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
```bash
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
```
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
---
### FTP night-report pipeline *(new)*
Automated daily morning report of last night's noise (7 PM7 AM) vs a baseline,
per location, for 24/7 remote sound jobs. The meter records to its SD card
regardless of TCP state, so the report pulls the meter's own stored 15-minute
Leq intervals over FTP (via the SLMM proxy) — accurate, and resilient to a
control-path wedge. **Field-tested on a real NL-43.**
#### Added
- **Report engine.** Per-location LAmax / LA01 / LA10 / LA90 / LAeq over Evening
(710 PM) + Nighttime (10 PM7 AM); Leq energy-averaged, percentiles/Lmax
arithmetic; the LN→percentile map is read from the device's own `.rnh`. Two
baseline modes: *captured* (weekly average) and *reference* (typed per-location
limits).
- **Renderers.** HTML email body + Excel attachment (per-NRL interval table +
line chart + Last/Base/Δ summary).
- **Capture cycle.** The daily scheduled "24/7 Continuous" cycle stops →
downloads → ingests → re-indexes → restarts the meter, verifies it resumed
measuring via a fresh DOD read, and retries the restart once before alerting.
- **Standardized ingest.** Manual SD upload, manual FTP "Download & Save", and
the scheduled cycle all funnel through one ingest core: keeps the `.rnh` +
15-minute Leq, drops the 1-second `_Lp_` files, parses the header, dedupes, and
derives the session's real recording window from the Leq rows.
- **UI.** Night Report button/modal (view / run-and-email / recent reports) and a
per-project Settings panel (enable, time, baseline, recipients, test-email); the
per-NRL Data Files tab now matches the project-wide tab.
- **Config-driven SMTP** sender (`REPORT_SMTP_*`), dry-run when unconfigured.
#### Fixed
- **NL-43 sessions stamped `now` / zero-duration.** The NL-43 `.rnh` carries no
measurement timestamps, so the session window is now derived from the Leq rows.
Also fixes NL-43 dedupe (it had keyed on an always-empty start time).
- **"Browse Files" did nothing on the NRL Data Files tab** — the FTP-browser
script's global functions collided with the SLM live-view's (both loaded on that
page); it's now namespaced behind `window.FtpBrowser`.
#### Upgrade Notes
- **No DB migration** — the `sound_report_configs` table auto-creates on startup.
- Set `REPORT_SMTP_HOST/PORT/SECURITY/USER/PASSWORD/FROM/RECIPIENTS` to send email
(reports build to `data/reports/…` in dry-run until then).
- To automate a job: a **"24/7 Continuous"** recurring schedule (~7:15 AM) + enable
the report (~8:00 AM) + set a baseline.
---
### Client portal *(new — read-only client-facing view)*
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
locations, live. Built inside Terra-View (no new service), reusing the cached
SLMM feed; every route resolves the client through one swappable
`get_current_client` gate, so the interim magic/open-link auth can be replaced
(M4) without touching routes or templates. Strictly read-only — no device control.
#### Added
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
id, re-validated against the DB each request (revoke kills live sessions, with
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
headline metric.
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
round-the-clock.
- **Operator sharing tools.** A **"View client portal"** preview button and a
**"Copy client link"** modal (mint / list / revoke magic links) on the project
page, plus a `backend/portal_admin.py` CLI.
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
**light/dark toggle** (light default, persisted, no-flash).
#### Security
- All scoping enforced server-side (404-not-403, no existence leak); client
endpoints return **scrubbed** projections (no device-health/internal ids); WS
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
Pre-merge code review hardened cookie expiry, open-links default, and the slug
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
#### Upgrade Notes
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
auto-create).
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
and keep **`PORTAL_OPEN_LINKS=false`** there.
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
cooldown + keepalive coupling) — same build pairing as above.
---
### Portal authentication (Phase 1)
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
- **Upgrade:** new `argon2-cffi` dependency → **rebuild the image**, then run `python3 backend/migrate_add_project_portal_auth.py` per DB (adds the `projects.portal_*` columns). `SECRET_KEY` and `COOKIE_SECURE` are now passed through in `docker-compose.yml` (settable via a `.env` file) — set a real `SECRET_KEY` (and `COOKIE_SECURE=true` once on HTTPS) before the portal faces the internet.
---
## [0.13.3] - 2026-06-05
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
### Added
- **Calibration sync service** (`backend/services/calibration_sync.py`). Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`. Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
- **Conflict rule: events-as-truth, manual wins when newer.** Three outcomes per unit:
- `already_in_sync` — stored date already matches the event's calibration date.
- `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved. Only a future event can supersede it.
- `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`). Started in `main.py`'s startup hook, stopped on shutdown. Does not run on boot — first sync after a server start fires at the next 03:15.
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`. Powers the Settings button.
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows. Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab). Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist. Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.
### Fixed
- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**. The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it. Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.
### Upgrade Notes
No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist. Rebuild only:
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```
After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.
---
## [0.13.2] - 2026-05-30
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
### Fixed
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
### What mobile users will see after deploy
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
---
## [0.13.1] - 2026-05-29
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
### Fixed
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
### Migration Notes
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
```
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
---
## [0.13.0] - 2026-05-29
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
### Added — Event-detail modal: Chart.js waveform/histogram panels
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
### Added — Event-detail modal: PDF preview + downloads + review form
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
### Added — `/admin/events` row click opens the modal
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
### Added — `mic_unit_pref` user setting (Settings → General)
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
### Fixed — Docker Compose: SFM container can finally read the DB
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
### Migration Notes
```bash
cd /home/serversdown/terra-view
# Apply the new column to the database — required. Idempotent.
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
docker compose build terra-view && docker compose up -d
```
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
### What's NOT in this release
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
---
## [0.12.1] - 2026-05-20
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
### Added — Unit Swap wizard (`/tools/unit-swap`)
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
- **`POST /locations/{loc}/swap` enhancements**:
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
- Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
### Added — Editable deployment timeline on `/unit/{id}`
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.
### Fixed
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit. Unassign and swap also clear the modem's back-reference. Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
- **Deployment timeline now respects user timezone for display *and* edits.** Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Two-sided fix:
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
### Migration Notes
No schema changes. Static code-only release — pull and restart:
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```
---
## [0.12.0] - 2026-05-17
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
### Added — field-deployment workflow
- **`/deploy` — mobile-first 3-step capture wizard**: pick unit → take photo (opens phone camera via `<input capture="environment">`) → optional note → submit. Designed for under-90-seconds-on-site. Success page shows captured coords and links back to "Deploy another" or the pending hopper.
- **`/tools/pending-deployments` — the hopper**: filter pills Awaiting / Assigned / Cancelled. Each card has photo thumbnail, unit link, coords, operator note, status-appropriate actions.
- **Classify modal**: two modes — assign to existing project+location, OR create new location with new-or-existing project + a "use captured coords" checkbox that writes the pending row's coords onto the new location record.
- **`PendingDeployment` data model** (`pending_deployments` table): lifecycle `awaiting → assigned | cancelled`. Photo file lives under `data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>`. Migration: `backend/migrate_add_pending_deployments.py` (idempotent).
- **Backend endpoints**:
- `POST /api/deployments/capture` — multipart upload (unit_id, photo, optional note), EXIF GPS extraction, seismograph-only (rejects others with 400)
- `GET /api/deployments/pending` — list by status
- `GET /api/deployments/pending/{id}` — single row detail
- `POST /api/deployments/pending/{id}/promote` — classify and create `UnitAssignment`; events in the assignment window get retroactively attributed via the existing metadata-backfill mechanism
- `POST /api/deployments/pending/{id}/cancel` — abandon with optional reason
- `GET /api/deployments/seismograph-picker` — JSON list for the `/deploy` picker, annotated with `has_pending`
- **Discovery surfaces**: orange "Field Deploy" button on the desktop dashboard header (md+), bottom-nav slot 3 on mobile (Menu / Dashboard / Deploy / Events; Devices moved into the Menu drawer), `/tools` cards for both Field Deploy and Pending Deployments, dashboard banner that auto-shows when awaiting captures exist (polled every 30s, hides at 0).
- **Full audit trail**: every capture / promote / cancel writes a `UnitHistory` row (`pending_deployment_captured` / `_promoted` / `_cancelled`).
### Added — fleet-wide deployment history
- **`/tools/deployment-history` — fleet-wide 12-month calendar** (Phase 2 of the per-unit Gantt from v0.11.0). 4-month-per-row grid styled like the Job Planner, responsive to single column on mobile. Each day cell shows up to 4 deterministically-colored mini-bars (one per active project that day), with "+N" overflow. KPI strip across the top: project count, distinct unit count, total assignment count in the window. Collapsible project legend ordered by first-active date.
- **Click-a-day side panel**: slide-over from the right, groups by project, lists every (unit, location) active that day with auto-backfilled tags, sourced from new `GET /api/admin/deployment-history/day`.
- **Prev / Next / Recent month navigation**: shifts the 12-month window by 1 month. Default window is 11 months back from current → operator sees recent past on first load, not future emptiness.
- **Gantt by Project tab**: horizontal time-axis bars per project, hover for tooltip with unit + location + window. Reduced opacity for closed assignments, blue outline for metadata-backfilled, today dashed-orange line.
- **Gantt by Unit tab**: same idea inverted — one row per seismograph, bars colored by project. Natural for "where has BE11529 been across all my jobs?" Service layer returns a `units` array with bars carrying baked-in `project_color`.
- **Tab switcher with hash sync**: `#gantt` / `#byunit` preserved across month-paging. Tab registry (`_DH_TABS`) makes adding future views a one-line addition.
### Added — SFM event DB management
- **`/admin/events` — SFM Event DB Manager** under Developer Tools. Cross-unit event browser with filters (serial, from/to, false_trigger, limit), checkbox selection with select-all, and bulk actions:
- **Delete selected** — hard-delete chosen rows from SFM's `events` table
- **Delete ALL matching current filter** — dry-run first to show match count + sample serials in confirm dialog; only proceeds on explicit confirmation
- Same Flag-as-FT / Clear-FT bulk actions for convenience
- **Destructive operations also clean up on-disk files**: associated `.AB0*` blastware binary, `.a5.pkl`, `.sfm.json` sidecar, and `.h5` files are unlinked alongside the DB row. Cannot be undone — the manager has a prominent red warning banner and a `max_rows` safety cap (10,000) that refuses oversized deletes without explicit acknowledgment.
- **Designed for cleaning bogus events from a misbehaving unit** — a stuck-triggered seismograph can dump hundreds of junk events into SFM before it's recovered; this is the operator's broom.
- **`unit_detail.html` also gains bulk false-trigger flagging**: same checkbox UX as the DB manager, but with **🚩 Flag as false trigger** / **✓ Clear false trigger** instead of delete (delete is admin-only via `/admin/events`). Concurrent fan-out (8 in flight) for fast bulk PATCH.
### Added — maps, navigation, polish
- **Reusable location-map partial** (`templates/partials/projects/location_map.html`): self-contained map div + self-fetch script. Accepts `project_id`, `map_height`, `location_type` filter. Project overview's inline map (~150 lines of JS) replaced with a 1-line include; Vibration tab on the project detail page now uses the same partial with `location_type='vibration'` at 450px height.
- **Hover location card → highlight matching map pin** on the project overview map. Enlarges + reddens the pin, opens its tooltip. Bidirectional with the existing pin → card flash. Event delegation on `document` so cards from htmx swaps keep the behavior without rewiring.
- **Mobile bottom-nav swap Settings → Events**: Settings (rarely needed in the field) replaced by Events (the daily mobile destination since the SFM integration). Settings/Projects/Tools/admin pages still in the Menu drawer.
### Fixed
- **`/deploy` photo input now allows gallery picks**: `capture="environment"` was forcing mobile browsers to open the camera and skip "Photo Library" / "Choose File". Useful at the install site, problematic when uploading a photo taken earlier. Attribute removed; chooser now offers both options. EXIF extraction works identically.
### Migration Notes
One new migration this release. Idempotent and non-destructive.
```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
```
Or sweep all migrations at once (safe — already-applied ones no-op):
```bash
for f in backend/migrate_*.py; do
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```
New table: `pending_deployments` — capture rows for the field-deployment workflow. Empty after migration; populated as field techs use `/deploy`.
**Deploy order matters**: run the migration BEFORE the new code is up, or the running app will 500 on the missing table. Same gotcha that bit the v0.10.0 → v0.11.0 deploy.
**SFM bump pairing**: this release pairs with seismo-relay v0.17.0, which adds the `DELETE /db/events/{id}` and `POST /db/events/delete_bulk` endpoints that `/admin/events` consumes. An older SFM will return 405/404 for those routes; the manager will surface the error in its result alert.
---
## [0.11.0] - 2026-05-15
Operator-facing polish release. All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.
### Added
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events. Cascade-closes active unit assignments and cancels pending scheduled actions at the location. Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming). Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page. Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups. Click a bar to scroll the matching list row into view with a flash highlight.
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record). Now auto-detected and surfaceable in the timeline header — one click combines them into a single record. Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment. Trash icon in each row of the location's Deployment History panel. Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history. Writes an `assignment_deleted` UnitHistory row.
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left. Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`. Implementation uses native HTML5 drag-and-drop (no library). New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu. Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations still show session counts.
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map. One pin per active monitoring location (parsed from the `coordinates` field). Click pin → scrolls + flashes the matching card. Tooltip on hover. Locations without coordinates surface as an inline hint below the map. If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.
### Changed
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores. Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio. Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens. Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90. Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).
### Fixed
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler. Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
- **Location Remove button** on the project page
- **Metadata-backfill typeahead dropdown** (existing project + location pickers)
- **Project-merge typeahead dropdown** (in the per-project header)
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`. Fixed by adding `isolation: isolate` to the map container.
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`. Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.
### Migration Notes
Run on each database before deploying. Both migrations are idempotent and non-destructive.
```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
```
Or sweep all migrations at once (safe — already-applied ones no-op):
```bash
for f in backend/migrate_*.py; do
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```
New columns added this release:
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
- `monitoring_locations.removal_reason` (TEXT, nullable)
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration
**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns. Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.
---
## [0.10.0] - 2026-05-14
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
### Added
- **SFM Integration**: New fleet-wide events page at `/sfm` listing every event ingested by SFM, with filters for serial, date range, false-trigger flag, and limit. Unit detail pages and project-location pages show their own attributed subsets of the same event stream.
- **Event Detail Modal**: Shared across `/sfm`, unit detail, and project-location pages — clicking any event opens a rich modal showing peaks per channel (PVS color-coded by magnitude), microphone dB(L) + ZC frequency + time of peak, sensor self-check table with pass/fail per channel, device/recording metadata (firmware, battery, calibration date, geo range), and download buttons for the original Blastware binary and the sidecar JSON. Includes an inline pretty-printed JSON viewer with copy-to-clipboard.
- **Events Attribution Engine** (`backend/services/sfm_events.py`): Per-event attribution against `UnitAssignment` time windows. Events outside any assignment window surface in an "Unattributed" bucket with the nearest-assignment diagnostic (which location, signed delta in days).
- **Metadata Backfill Tool** (`/tools` → Backfill from event metadata): Scans operator-typed `project` and `sensor_location` strings in event sidecars, fuzzy-clusters them via `rapidfuzz.WRatio`, and proposes retroactive `UnitAssignment` records to attribute orphan events. Tracks operator decisions per cluster across re-scans.
- **Project Tidy Tool** (`/tools` → Project Tidy): Fuzzy-detect duplicate projects and bulk-merge them with a single click. Source projects soft-deleted with full audit trail.
- **Vibration Summary on Project Pages**: New roll-up card on vibration project detail pages showing per-location event counts, the project's "Overall Peak" PVS (false triggers excluded), last event timestamp, and a Top Locations by Activity list.
- **SFM-Primary Seismograph Status**: `emit_status_snapshot()` now consults SFM's `/db/units` (cached 15s) before falling back to `Emitter.last_seen` for each seismograph. The fresher signal wins; the choice is recorded in a new per-unit `last_seen_source` field. A small `SFM` (orange) or `HB` (gray) badge on each unit's active-table row shows which path is currently driving the status.
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moved to a horizontal collapsible card below the Fleet Map, auto-expanding only when pending actions exist. Recent Call-Ins now sources from a new `/api/recent-event-callins` endpoint backed by SFM event forwards instead of the watcher-heartbeat endpoint.
- **Sortable Events Tables**: `/sfm` and unit-detail SFM Events tables now have clickable column headers with ↕/↓/↑ indicators. Default sort is Timestamp DESC. Click same column to toggle direction; click different column to switch and reset to DESC. Pure client-side over cached rows — no re-fetches.
- **Developer → SFM Admin** (`/admin/sfm`): Health banner with reachability indicator, terra-view↔SFM connection panel, 4 KPI tiles (known units, total events, stale `monitor_log` rows, stale `ach_sessions` rows), per-unit roll-up table, recent-events table with color-coded forwarding latency (so stale watcher forwards stand out), and a raw API tester for any `/api/sfm/*` path.
- **Developer → SLMM Admin** (`/admin/slmm`): Stripped-down companion page — health, connection info, raw API tester.
- **Tools Workflow Hub** (`/tools`): New top-level sidebar entry consolidating Pair Devices, Project Tidy, Metadata Backfill, Reports (info card), and Swap Detection (placeholder).
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices) replacing five separate sidebar items.
- **Synology Deployment Doc** (`docs/SYNOLOGY_DEPLOYMENT.md`): End-to-end playbook for migrating the stack to an always-on office NAS — phased rollout (pre-stage, data rsync, watcher repoint, external access, decommission), Tailscale vs reverse-proxy options, rollback plan, and gotchas.
### Changed
- **Overall Peak excludes false triggers**: The project-level "Overall Peak" KPI tile (and the underlying `_compute_stats()` function in `sfm_events.py`) now skip events flagged as false triggers when computing the highest PVS, so operators see the highest real event rather than the biggest sensor glitch. `false_trigger_count` still includes flagged events so operators can see how many were filtered out.
- **`RosterUnit.note` Editing**: Inline edit on seismograph cards is more forgiving and now auto-saves on blur.
- **Sidebar Nav Renamed**: Old "Fleet" sidebar entry → "Devices" (renamed because it always meant the device list, not the broader fleet view).
### Fixed
- **Status drift between watcher heartbeat and actual event arrivals**: Seismographs are now reported with whichever signal is more recent — eliminates the case where a unit had recent SFM events but a stale heartbeat (or vice-versa) showed the wrong status.
- **Event modal: Record Type always showed "Waveform"**: Workaround client-side — Record Type now derived from the Blastware filename's last-char code (`H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo). The proper fix lives in SFM's sidecar parser; tracked separately.
- **Event modal: Mic PSI tile removed**: Operators only care about dB(L); the redundant PSI tile was dropped.
### Migration Notes
Run on each database before deploying. Every migration is idempotent.
```bash
# Cleanest: re-run all migrations in chronological order.
# Already-applied migrations no-op safely.
for f in backend/migrate_*.py; do
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```
Migrations new in this release:
- `migrate_add_metadata_backfill.py` — adds `unit_assignments.source` column and `metadata_backfill_decisions` table for the Metadata Backfill tool
### Deployment Notes
- **`SFM_BASE_URL`**: Confirm prod's `docker-compose.yml` sets this for the terra-view service (typically `http://sfm:8200` for the in-stack SFM container, or an external URL if SFM lives elsewhere).
- **Watcher repoint**: series3-watcher's `sfm_forward_url` should point at `https://<your-terra-view-host>/api/sfm` (proxy-based — no second port forward needed). Watcher composes the full path `/db/import/blastware_file` itself.
---
## [0.9.4] - 2026-04-06
### Added
- **Modular Project Types**: Projects now support optional modules (Sound Monitoring, Vibration Monitoring) selectable at creation time. The project header and dashboard dynamically show/hide tabs and actions based on which modules are enabled, and modules can be added or removed after creation.
- **Deleted Project Management**: Settings page now includes a section for soft-deleted projects with options to restore or permanently delete each one. Deleted projects load automatically when the Data tab is opened.
### Changed
- **Swap Modal Search**: The unit/modem swap modal on vibration location detail pages now includes live search filtering for both seismographs and modems, making it easier to find the right unit in large fleets.
### Fixed
- **Roster Auto-Refresh No Longer Disrupts Scroll/Sort**: The roster page's 30-second background refresh now updates status, age, and last-seen values in-place via a lightweight JSON poll instead of replacing the entire table HTML. Sort order, scroll position, and active filters are all preserved across refreshes.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_project_modules.py
```
---
## [0.9.3] - 2026-03-28
### Added
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
### Fixed
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
### Changed
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
```
---
## [0.9.2] - 2026-03-27
### Added
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_allocated.py
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
```
---
## [0.9.1] - 2026-03-20
### Fixed
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
```
---
## [0.9.0] - 2026-03-19
### Added
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
- **Available Units panel**: Shows units available for the job's date range when assigning
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
### Changed
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 -c "
import sqlite3
conn = sqlite3.connect('/app/data/seismo_fleet.db')
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
conn.commit()
conn.close()
"
```
---
## [0.8.0] - 2026-03-18
### Added
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
- Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
- Trigger Update button to queue a self-update on the agent's next heartbeat
- Expand/collapse log tail with full-log expand mode
- Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open
### Changed
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing
### Fixed
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`
---
## [0.7.1] - 2026-03-12
### Added
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
### Changed
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
### Fixed
- **Migration Scripts**: Fixed database path references in several migration scripts
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
### Migration Notes
Run the following migration script once per database before deploying:
```bash
python backend/migrate_add_out_for_calibration.py
```
---
## [0.7.0] - 2026-03-07
### Added
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
- Wizard UI grouped by location with period type badges (day/night)
- Each selected session produces one `.xlsx` in a ZIP archive
- Period type filtering: day sessions keep last calendar date (7AM6:59PM); night sessions span both days (7PM6:59AM)
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
- **Chart Preview**: Live chart preview in the report generator matching final report styling
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
### Changed
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
### Fixed
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
### Technical Details
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
### Migration Notes
Run the following migration scripts once per database before deploying:
```bash
python backend/migrate_rename_recording_to_monitoring_sessions.py
python backend/migrate_add_session_period_type.py
python backend/migrate_add_session_device_model.py
```
---
## [0.6.1] - 2026-02-16
### Added
- **One-Off Recording Schedules**: Support for scheduling single recordings with specific start and end datetimes
- **Bidirectional Pairing Sync**: Pairing a device with a modem now automatically updates both sides, clearing stale pairings when reassigned
- **Auto-Fill Notes from Modem**: Notes are now copied from modem to paired device when fields are empty
- **SLMM Download Requests**: New `_download_request` method in SLMM client for binary file downloads with local save
### Fixed
- **Scheduler Timezone**: One-off scheduler times now use local time instead of UTC
- **Pairing Consistency**: Old device references are properly cleared when a modem is re-paired to a new device
## [0.6.0] - 2026-02-06
### Added
- **Calendar & Reservation Mode**: Fleet calendar view with reservation system for scheduling device deployments
- **Device Pairing Interface**: New two-column pairing page (`/pair-devices`) for linking recorders (seismographs/SLMs) with modems
- Visual pairing interface with drag-and-drop style interactions
- Fuzzy-search modem pairing for SLMs
- Pairing options now accessible from modem page
- Improved pair status sharing across views
- **Modem Dashboard Enhancements**:
- Modem model number now a dedicated configuration field with per-model options
- Direct link to modem login page from unit detail view
- Modem view converted to list format
- **Seismograph List Improvements**:
- Enhanced visibility with better filtering and sorting
- Calibration dates now color-coded for quick status assessment
- User sets date of previous calibration (not expiry) for clearer workflow
- **SLMM Device Control Lock**: Prevents command flooding to NL-43 devices
### Changed
- **Calibration Date UX**: Users now set the date of the previous calibration rather than upcoming expiry dates - more intuitive workflow
- **Settings Persistence**: Settings save no longer reloads the page
- **Tab State**: Tab state now persists in URL hash for better navigation
- **Scheduler Management**: Schedule changes now cascade to individual events
- **Dashboard Filtering**: Enhanced dashboard with additional filtering options and SLM status sync
- **SLMM Polling Intervals**: Fixed and improved polling intervals for better responsiveness
- **24-Hour Scheduler Cycle**: Improved cycle handling to prevent issues with scheduled downloads
### Fixed
- **SLM Modal Fields**: Modal now only contains correct device-specific fields
- **IP Address Handling**: IP address correctly passed via modem pairing
- **Mobile Type Display**: Fixed incorrect device type display in roster and device tables
- **SLMM Scheduled Downloads**: Fixed issues with scheduled download operations
## [0.5.1] - 2026-01-27 ## [0.5.1] - 2026-01-27
### Added ### Added
@@ -1214,8 +399,6 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit - Photo management per unit
- Automated status categorization (OK/Pending/Missing) - Automated status categorization (OK/Pending/Missing)
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1 [0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0 [0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4 [0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
-4
View File
@@ -1,9 +1,5 @@
FROM python:3.11-slim FROM python:3.11-slim
# Build number for dev builds (injected via --build-arg)
ARG BUILD_NUMBER=0
ENV BUILD_NUMBER=${BUILD_NUMBER}
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
+6 -106
View File
@@ -1,5 +1,5 @@
# Terra-View v0.16.0 # Terra-View v0.5.1
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. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs 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 ## Features
@@ -9,23 +9,15 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar - **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals - **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
- **Background Sync**: Queue edits while offline and automatically sync when connection returns - **Background Sync**: Queue edits while offline and automatically sync when connection returns
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map - **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage - **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables - **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools - **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit - **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry - **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
- **Sound Level Meter Monitoring**: Live per-device monitoring through SLMM's shared, cached feed — multiple viewers without contending for the NL-43's single connection — with L1/L10 percentile lines, a measuring/freshness indicator, and on-demand refresh - **Data Ingestion**: Accept reports from emitter scripts via REST API
- **Automated Night Reports**: Daily per-location noise report (last night vs a baseline) for 24/7 remote sound jobs — pulls the meter's 15-minute Leq over FTP and emails an HTML summary + Excel; the meter is auto-cycled (stop → download → ingest → restart, with restart verification) each morning
- **Client Portal** (`/portal/*`): scoped, read-only, client-facing live view of *their* locations only, gated by a per-project link + shared password (argon2-hashed)
- **SFM Event DB Manager** (`/admin/events`): cross-unit event browser with bulk false-trigger flagging and admin-only hard-delete (cleans on-disk binaries + sidecars too) for purging bogus events from misbehaving units
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
- **Photo Management**: Upload and view photos for each unit - **Photo Management**: Upload and view photos for each unit
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab) - **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
- **SQLite Storage**: Lightweight, file-based database for easy deployment - **SQLite Storage**: Lightweight, file-based database for easy deployment
- **Database Management**: Comprehensive backup and restore system - **Database Management**: Comprehensive backup and restore system
- **Manual Snapshots**: Create on-demand backups with descriptions - **Manual Snapshots**: Create on-demand backups with descriptions
@@ -504,70 +496,6 @@ docker compose down -v
## Release Highlights ## 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.
- **Merge Consecutive Deployments**: Auto-detects runs of same-location assignments within a 7-day gap and offers a one-click "Merge into one" button. Preserves notes, ingest source, and writes an `assignment_merged` audit entry.
- **Delete Assignment for Mis-Clicks**: Trash icon on each row of the location's Deployment History panel. Hard-deletes the assignment with a safety check that refuses if real MonitoringSessions sit inside the window (those should go through Unassign instead).
- **Drag-to-Reorder Location Cards**: Six-dot drag handle on each card; drop order persists via a new `/locations/reorder` endpoint. Removed locations stay sorted by removal date (their order is historical).
- **Three-Dot Kebab Menu**: Replaces the inline Unassign / Edit / Remove / Delete pill row with a single ⋮ menu. Much cleaner card layout, especially for projects with many locations.
- **Event Count on Vibration Cards**: Vibration locations now show "{N} events" instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations are unchanged.
- **Project Location Map**: Right column of the project overview is now a Leaflet map with a pin per location. Click pin → scrolls + flashes the matching card. Replaces the lightly-used Upcoming Actions panel (still discoverable via a link to the Schedules tab when actions exist).
- **Stricter Location Fuzzy Matching**: Metadata-backfill no longer suggests obviously-wrong matches. WRatio was over-confident on location names ("Area 2 - Brookville Dam - Loc 2" vs "Area 1 - Loc 1 - 87 Jenks" used to score 86%); now uses `token_set_ratio` + a multi-digit penalty so disjoint address numbers correctly demote the score.
- **Fixed: Multiple typeahead dropdowns weren't clickable**: Same JSON.stringify quote-collision bug surfaced in three places (location Remove button, backfill typeahead, project-merge dropdown). All three fixed by switching to `data-*` attributes + trampoline functions.
- **Fixed: Merge-project modal had to be scrolled to see options**: Modal body's `flex-1 overflow-y-auto` collapsed too tight; added `min-height` so the dropdown has room to render below the input.
### v0.10.0 — 2026-05-14
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moves to a horizontal collapsible card below the Fleet Map, auto-expanding only when there's a pending action. Recent Call-Ins now sources from SFM event forwards instead of the legacy watcher-heartbeat endpoint.
- **Event Detail Modal**: Click any event anywhere in the app to open a rich detail modal showing peak particle velocity per channel, microphone dB(L), sensor self-check results, device/recording metadata, and download buttons for the original Blastware binary and sidecar JSON. Includes an inline JSON viewer with one-click copy.
- **Sortable Events Tables**: Every events table (project events, unit-detail events, fleet-wide /sfm) now supports clickable column-header sorting with directional indicators. Defaults to newest-first.
- **Events Attribution & Backfill**: Each SFM event is automatically attributed to a project/location based on `UnitAssignment` time windows. Unattributed events get a diagnostic showing the nearest assignment and a delta-days gap. The metadata-backfill tool in `/tools` scans operator-typed project/sensor-location strings in event sidecars and clusters them via fuzzy matching to propose new assignment retroactives.
- **Projects Tools**: New `/tools` workflow hub consolidates Pair Devices, Project Tidy (fuzzy-detect + merge duplicate projects), Metadata Backfill, Reports, and Swap Detection (placeholder).
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices).
- **Developer → SFM Admin**: New `/admin/sfm` page surfacing SFM health, per-unit roll-up from `/db/units`, recent-events table with forwarding latency (so operators can spot stale watcher forwards), stale-table counts, and a raw API tester. Companion `/admin/slmm` page covers SLMM health + raw API.
- **"Overall Peak" excludes False Triggers**: The project-level Overall Peak KPI tile now excludes events flagged as false triggers — operators see the highest real event, not the biggest sensor glitch.
- **Synology Deployment Doc**: New `docs/SYNOLOGY_DEPLOYMENT.md` covers migrating the stack to an always-on office NAS, including phased rollout, data rsync, watcher repoint, external-access (Tailscale or reverse-proxy), and rollback plan.
### v0.8.0 — 2026-03-18
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
- **Live Refresh**: Watcher Manager surgically patches status, last-seen, and pending indicators every 30s without a full page reload
### v0.7.0 — 2026-03-07
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
- **Chart Preview**: Live chart preview in the report generator matching final output styling
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
### v0.6.1 — 2026-02-16
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
- **Scheduler Timezone Fix**: One-off schedule times use local time instead of UTC
### v0.6.0 — 2026-02-06
- **Calendar & Reservation Mode**: Fleet calendar view with device deployment scheduling and reservation system
- **Device Pairing Interface**: New `/pair-devices` page with two-column layout for linking recorders with modems, fuzzy-search, and visual pairing workflow
- **Calibration UX Overhaul**: Users now set date of previous calibration (not expiry); seismograph list enhanced with color-coded calibration status, filtering, and sorting
- **Modem Dashboard**: Model number as dedicated config, modem login links, list view format, and pairing options accessible from modem page
- **SLMM Improvements**: Device control lock prevents command flooding, fixed polling intervals and scheduled downloads
- **UI Polish**: Tab state persists in URL hash, settings save without reload, scheduler changes cascade to events, fixed mobile type display
### v0.4.3 — 2026-01-14 ### v0.4.3 — 2026-01-14
- **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning. - **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning.
- **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units. - **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units.
@@ -643,37 +571,9 @@ MIT
## Version ## Version
**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) **Current: 0.5.1**Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
Previous: 0.15.0 — Operator authentication: deny-by-default login gate + superadmin/admin roles, 30-day session cookie, `/admin/users` (2026-06-18) Previous: 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
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.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
0.9.2 — Deployment records, allocated status, quick-info unit modal, inline seismograph editing (2026-03-27)
0.9.1 — Fix location slots not persisting on save/reload (2026-03-20)
0.9.0 — Job Planner redesign, monitoring locations, estimated units, smart color picker, calendar bar tooltips, toast notifications (2026-03-19)
0.8.0 — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14) 0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
-59
View File
@@ -1,59 +0,0 @@
# FTP Report Pipeline — session brief
**Branch:** `feat/ftp-report-pipeline` (off `dev`), worktree `/home/serversdown/terra-view-reports`.
**Scope:** Terra-View only. Do NOT touch SLMM — the SLMM alert/monitor work is live in a
parallel session on `slmm` branch `feat/drd-fix`. Pull device data through the **existing**
SLMM FTP proxy endpoints; add no SLMM code (for v1).
See memory note `client_sound_monitoring_job_2026-07` for the client requirements + timeline.
## Goal
Automated **daily morning report** for the John Myler 3-location sound job: each AM, last
night's noise levels vs the **baseline week**, per location. Data pulled from the meters via
FTP (the meter records 24/7 to SD regardless of TCP wedges). Alerts are a *separate* workstream
(SLMM, real-time DOD) — not in scope here.
## The big realization (why this is small)
The hard parts already exist:
- **SLMM (use as-is, via the `/api/slmm/...` proxy):**
- `GET /api/slmm/{unit}/ftp/files?path=/NL-43` → list files/folders
- `POST /api/slmm/{unit}/ftp/download-folder` → returns the `Auto_####` folder as a **ZIP**
- **Terra-View ingest (reuse):** `backend/routers/project_locations.py:1743` `upload_nrl_data`
already accepts a **ZIP**, extracts, keeps `.rnh` + `_Leq_ .rnd` (drops `_Lp_`/junk via
`_is_wanted`), runs `_parse_rnh` (line 1687) → creates `MonitoringSession` + `DataFile`.
- **Report generator (reuse, source-agnostic):** `backend/routers/projects.py`. The `.rnd`
file reads funnel through 3 helpers — `_peek_rnd_headers` (~135), `_is_leq_file` (~147),
`_read_rnd_file_rows` (~256). `.rnd` files live on disk under `data/{file_path}` (DataFile
holds the path, not a BLOB). The stats/Excel/formatting logic doesn't care where bytes come from.
## Build (Terra-View)
1. **Refactor** `upload_nrl_data`'s core into a callable `ingest_nrl_zip(location_id, zip_bytes, db)`
so it can be invoked programmatically (not only via HTTP UploadFile).
2. **Scheduled pull job** (reuse the existing scheduler): per project location/unit →
`GET /ftp/files` to find new `Auto_####` folders → `POST /ftp/download-folder` (zip) →
`ingest_nrl_zip(...)`. **Dedup** so repeated pulls don't duplicate sessions/files
(track ingested folder names per location).
3. **Baseline aggregation:** aggregate the baseline-week `_Leq_` intervals per location →
reference values (nighttime Leq, L90 floor, typical Lmax).
4. **Nightly report + email:** compute last night's metrics per location, compare to baseline
(deltas), render (reuse the Excel/report machinery), email each morning.
## Data-location decision (light version, agreed)
Keep `MonitoringSession`/`DataFile` **metadata in TV** for now; reuse the existing on-disk file
store. Optional refinement (later): have SLMM keep the pulled files and TV read them through a
SLMM file-serve endpoint (avoids the copy-into-TV step). Don't do that refinement under the
deadline unless trivial — the report logic is identical either way.
## Open questions to resolve early
1. **What's actually in a `_Leq_ .rnd`** — Leq only, or Leq + Lmax + Ln per 15-min interval?
Decides whether the night-vs-baseline report can show L90/Lmax or just Leq. Inspect a real file.
2. **Session rollover / dedup** — does a 2-week run write one growing `Auto_####` folder or new
folders? Drives the "what's new" logic.
3. **`download-folder` over a multi-day run** — confirm it zips cleanly (size/time).
## Client params (confirm with Dave before locking)
Threshold/metric + their "night" window; report recipients + format (email body vs PDF/Excel).
## Timeline
Setup ~7/17/2 (baseline week), shutdown week through ~7/17. Reports needed by ~7/8 (before
shutdown). Today is ~3 weeks out — reliability > features.
-64
View File
@@ -1,64 +0,0 @@
# 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
-26
View File
@@ -1,26 +0,0 @@
"""Password hashing for the client portal — argon2id via argon2-cffi.
Kept separate from portal_auth (cookie signing) so the future operator auth can
reuse the same hasher. Never store or log raw passwords."""
import secrets
from argon2 import PasswordHasher
_ph = PasswordHasher()
def hash_password(raw: str) -> str:
"""Return an argon2id hash string for a raw password."""
return _ph.hash(raw)
def verify_password(raw: str, hashed: str) -> bool:
"""True iff raw matches the stored hash. Never raises."""
try:
return _ph.verify(hashed, raw)
except Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
return False
def generate_password(n_bytes: int = 12) -> str:
"""A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
return secrets.token_urlsafe(n_bytes)
+1 -1
View File
@@ -18,7 +18,7 @@ from backend.models import (
MonitoringLocation, MonitoringLocation,
UnitAssignment, UnitAssignment,
ScheduledAction, ScheduledAction,
MonitoringSession, RecordingSession,
DataFile, DataFile,
) )
from datetime import datetime from datetime import datetime
+17 -293
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -18,7 +18,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from backend.database import engine, Base, get_db from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, sfm, projects, project_locations, scheduler, modem_dashboard from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit from backend.models import IgnoredUnit
from backend.utils.timezone import get_user_timezone from backend.utils.timezone import get_user_timezone
@@ -30,11 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.16.0" VERSION = "0.5.1"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
VERSION = f"{VERSION}-{_build}"
app = FastAPI( app = FastAPI(
title="Seismo Fleet Manager", title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status", description="Backend API for managing seismograph fleet status",
@@ -66,21 +62,6 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Use shared templates configuration with timezone filters # Use shared templates configuration with timezone filters
from backend.templates_config import templates from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client).
from backend.portal_auth import PortalAuthError
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
if request.url.path.startswith("/portal/api"):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "required"},
status_code=401,
)
# Add custom context processor to inject environment variable into all templates # Add custom context processor to inject environment variable into all templates
@app.middleware("http") @app.middleware("http")
async def add_environment_to_context(request: Request, call_next): async def add_environment_to_context(request: Request, call_next):
@@ -89,18 +70,6 @@ async def add_environment_to_context(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
return response 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 # Override TemplateResponse to include environment and version in context
original_template_response = templates.TemplateResponse original_template_response = templates.TemplateResponse
def custom_template_response(name, context=None, *args, **kwargs): def custom_template_response(name, context=None, *args, **kwargs):
@@ -124,28 +93,11 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router) app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router) app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router) app.include_router(seismo_dashboard.router)
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
from backend.routers import portal
app.include_router(portal.router)
app.include_router(sfm.router)
app.include_router(modem_dashboard.router) app.include_router(modem_dashboard.router)
from backend.routers import settings from backend.routers import settings
app.include_router(settings.router) app.include_router(settings.router)
from backend.routers import watcher_manager
app.include_router(watcher_manager.router)
from backend.routers import admin_modules
app.include_router(admin_modules.router)
from backend.routers import deployment_history
app.include_router(deployment_history.router)
from backend.routers import pending_deployments
app.include_router(pending_deployments.router)
# Projects system routers # Projects system routers
app.include_router(projects.router) app.include_router(projects.router)
app.include_router(project_locations.router) app.include_router(project_locations.router)
@@ -155,10 +107,6 @@ app.include_router(scheduler.router)
from backend.routers import report_templates from backend.routers import report_templates
app.include_router(report_templates.router) app.include_router(report_templates.router)
# Metadata-backfill admin router (Phase 5a)
from backend.routers import metadata_backfill
app.include_router(metadata_backfill.router)
# Alerts router # Alerts router
from backend.routers import alerts from backend.routers import alerts
app.include_router(alerts.router) app.include_router(alerts.router)
@@ -167,26 +115,9 @@ app.include_router(alerts.router)
from backend.routers import recurring_schedules from backend.routers import recurring_schedules
app.include_router(recurring_schedules.router) app.include_router(recurring_schedules.router)
# Fleet Calendar router
from backend.routers import fleet_calendar
app.include_router(fleet_calendar.router)
# Deployment Records router
from backend.routers import deployments
app.include_router(deployments.router)
# Calibration sync router (SFM-driven cal date updates)
from backend.routers import calibration
app.include_router(calibration.router)
# Nightly sound-report pipeline (manual triggers; scheduled tick reuses run_nightly_report)
from backend.routers import reports
app.include_router(reports.router)
# Start scheduler service and device status monitor on application startup # Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
from backend.services.calibration_sync import get_calibration_sync_scheduler
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
@@ -199,10 +130,6 @@ async def startup_event():
await start_device_status_monitor() await start_device_status_monitor()
logger.info("Device status monitor started") logger.info("Device status monitor started")
logger.info("Starting calibration sync scheduler...")
get_calibration_sync_scheduler().start()
logger.info("Calibration sync scheduler started")
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown_event(): def shutdown_event():
"""Clean up services on app shutdown""" """Clean up services on app shutdown"""
@@ -214,10 +141,6 @@ def shutdown_event():
stop_scheduler() stop_scheduler()
logger.info("Scheduler service stopped") logger.info("Scheduler service stopped")
logger.info("Stopping calibration sync scheduler...")
get_calibration_sync_scheduler().stop()
logger.info("Calibration sync scheduler stopped")
# Legacy routes from the original backend # Legacy routes from the original backend
from backend import routes as legacy_routes from backend import routes as legacy_routes
@@ -295,58 +218,6 @@ async def seismographs_page(request: Request):
return templates.TemplateResponse("seismographs.html", {"request": request}) return templates.TemplateResponse("seismographs.html", {"request": request})
@app.get("/sfm", response_class=HTMLResponse)
async def sfm_page(request: Request):
"""SFM live event data and device control dashboard"""
return templates.TemplateResponse("sfm.html", {"request": request})
@app.get("/settings/developer/metadata-backfill", response_class=HTMLResponse)
async def metadata_backfill_wizard_page(request: Request):
"""Wizard for auto-creating projects/locations/assignments from
operator-typed BW event metadata (Phase 5a)."""
return templates.TemplateResponse("admin/metadata_backfill.html", {"request": request})
@app.get("/settings/developer/project-tidy", response_class=HTMLResponse)
async def project_tidy_page(request: Request):
"""Tidy duplicate-looking projects: detect by fuzzy name match, merge
by clicking through pairs (Phase 5b)."""
return templates.TemplateResponse("admin/project_tidy.html", {"request": request})
@app.get("/tools", response_class=HTMLResponse)
async def tools_page(request: Request):
"""Tools / workflow hub. Active operator workflows (device pairing,
project tidy, metadata backfill, future swap detection, report
generators) all live here in card form."""
return templates.TemplateResponse("tools.html", {"request": request})
@app.get("/deploy", response_class=HTMLResponse)
async def deploy_page(request: Request):
"""Mobile-first field-capture wizard. Pick a seismograph, snap a
photo of the install, optionally add a memo — drop into the pending
hopper for later classification."""
return templates.TemplateResponse("deploy.html", {"request": request})
@app.get("/tools/pending-deployments", response_class=HTMLResponse)
async def pending_deployments_page(request: Request):
"""List of field captures awaiting classification, plus filters for
historical assigned / cancelled rows. Operators promote a capture
into a real UnitAssignment from here."""
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
@app.get("/tools/unit-swap", response_class=HTMLResponse)
async def unit_swap_page(request: Request):
"""Mobile-first wizard for swapping a vibration unit (and optionally its
modem) at a monitoring location. Pick project → location → incoming
unit → modem decision → confirm → optional photo of the new install."""
return templates.TemplateResponse("admin/unit_swap.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse) @app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request): async def modems_page(request: Request):
"""Field modems management dashboard""" """Field modems management dashboard"""
@@ -425,82 +296,10 @@ async def project_detail_page(request: Request, project_id: str):
"""Project detail dashboard""" """Project detail dashboard"""
return templates.TemplateResponse("projects/detail.html", { return templates.TemplateResponse("projects/detail.html", {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id
}) })
@app.get("/projects/{project_id}/portal-preview")
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
"""Operator testing shortcut: open this project's client portal (no CLI)."""
from backend.models import Project
from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
token_id = mint_portal_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
return resp
@app.get("/projects/{project_id}/portal-access")
async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Current portal-access state for the operator panel."""
from backend.models import Project
p = db.query(Project).filter_by(id=project_id).first()
if not p:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \
if (p.portal_enabled and p.portal_link_token) else None
return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash),
"link_url": link_url}
@app.post("/projects/{project_id}/portal-access/enable")
async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Turn the portal on; mint a link token if one doesn't exist yet."""
import secrets
from backend.models import Project
p = db.query(Project).filter_by(id=project_id).first()
if not p:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
if not p.portal_link_token:
p.portal_link_token = secrets.token_urlsafe(24)
p.portal_enabled = True
db.commit()
link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}"
return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url}
@app.post("/projects/{project_id}/portal-access/password")
async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)):
"""Generate a fresh strong password, store its hash, return the raw once."""
from backend.models import Project
from backend.auth_passwords import hash_password, generate_password
p = db.query(Project).filter_by(id=project_id).first()
if not p:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
raw = generate_password()
p.portal_password_hash = hash_password(raw)
db.commit()
return {"password": raw}
@app.post("/projects/{project_id}/portal-access/disable")
async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)):
"""Turn the portal off and rotate the link token (kills the old link)."""
import secrets
from backend.models import Project
p = db.query(Project).filter_by(id=project_id).first()
if not p:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
p.portal_enabled = False
p.portal_link_token = secrets.token_urlsafe(24) # rotate so the old link 404s
db.commit()
return {"enabled": False}
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
async def nrl_detail_page( async def nrl_detail_page(
request: Request, request: Request,
@@ -509,7 +308,7 @@ async def nrl_detail_page(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""NRL (Noise Recording Location) detail page with tabs""" """NRL (Noise Recording Location) detail page with tabs"""
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
from sqlalchemy import and_ from sqlalchemy import and_
# Get project # Get project
@@ -541,40 +340,27 @@ async def nrl_detail_page(
).first() ).first()
assigned_unit = None assigned_unit = None
assigned_modem = None
if assignment: if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if assigned_unit and assigned_unit.deployed_with_modem_id:
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
# Get session count # Get session count
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count() session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
# Get file count (DataFile links to session, not directly to location) # Get file count (DataFile links to session, not directly to location)
file_count = db.query(DataFile).join( file_count = db.query(DataFile).join(
MonitoringSession, RecordingSession,
DataFile.session_id == MonitoringSession.id DataFile.session_id == RecordingSession.id
).filter(MonitoringSession.location_id == location_id).count() ).filter(RecordingSession.location_id == location_id).count()
# Check for active session # Check for active session
active_session = db.query(MonitoringSession).filter( active_session = db.query(RecordingSession).filter(
and_( and_(
MonitoringSession.location_id == location_id, RecordingSession.location_id == location_id,
MonitoringSession.status == "recording" RecordingSession.status == "recording"
) )
).first() ).first()
# Parse connection_mode from location_metadata JSON return templates.TemplateResponse("nrl_detail.html", {
import json as _json
connection_mode = "connected"
try:
meta = _json.loads(location.location_metadata or "{}")
connection_mode = meta.get("connection_mode", "connected")
except Exception:
pass
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
return templates.TemplateResponse(template, {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id,
"location_id": location_id, "location_id": location_id,
@@ -582,11 +368,9 @@ async def nrl_detail_page(
"location": location, "location": location,
"assignment": assignment, "assignment": assignment,
"assigned_unit": assigned_unit, "assigned_unit": assigned_unit,
"assigned_modem": assigned_modem,
"session_count": session_count, "session_count": session_count,
"file_count": file_count, "file_count": file_count,
"active_session": active_session, "active_session": active_session,
"connection_mode": connection_mode,
}) })
@@ -856,7 +640,6 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"), "last_seen": unit_data.get("last", "Never"),
"deployed": True, "deployed": True,
"retired": False, "retired": False,
"out_for_calibration": False,
"ignored": False, "ignored": False,
"note": unit_data.get("note", ""), "note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"), "device_type": unit_data.get("device_type", "seismograph"),
@@ -881,59 +664,6 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"), "last_seen": unit_data.get("last", "Never"),
"deployed": False, "deployed": False,
"retired": False, "retired": False,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
"address": unit_data.get("address", ""),
"coordinates": unit_data.get("coordinates", ""),
"project_id": unit_data.get("project_id", ""),
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
})
# Add allocated units
for unit_id, unit_data in snapshot.get("allocated", {}).items():
units_list.append({
"id": unit_id,
"status": "Allocated",
"age": "N/A",
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": False,
"allocated": True,
"allocated_to_project_id": unit_data.get("allocated_to_project_id", ""),
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
"address": unit_data.get("address", ""),
"coordinates": unit_data.get("coordinates", ""),
"project_id": unit_data.get("project_id", ""),
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
})
# Add out-for-calibration units
for unit_id, unit_data in snapshot["out_for_calibration"].items():
units_list.append({
"id": unit_id,
"status": "Out for Calibration",
"age": "N/A",
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": True,
"ignored": False, "ignored": False,
"note": unit_data.get("note", ""), "note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"), "device_type": unit_data.get("device_type", "seismograph"),
@@ -958,7 +688,6 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A", "last_seen": "N/A",
"deployed": False, "deployed": False,
"retired": True, "retired": True,
"out_for_calibration": False,
"ignored": False, "ignored": False,
"note": unit_data.get("note", ""), "note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"), "device_type": unit_data.get("device_type", "seismograph"),
@@ -983,7 +712,6 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A", "last_seen": "N/A",
"deployed": False, "deployed": False,
"retired": False, "retired": False,
"out_for_calibration": False,
"ignored": True, "ignored": True,
"note": unit_data.get("note", unit_data.get("reason", "")), "note": unit_data.get("note", unit_data.get("reason", "")),
"device_type": unit_data.get("device_type", "unknown"), "device_type": unit_data.get("device_type", "unknown"),
@@ -1001,19 +729,15 @@ async def devices_all_partial(request: Request):
# Sort by status category, then by ID # Sort by status category, then by ID
def sort_key(unit): def sort_key(unit):
# Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored # Priority: deployed (active) -> benched -> retired -> ignored
if unit["deployed"]: if unit["deployed"]:
return (0, unit["id"]) return (0, unit["id"])
elif unit.get("allocated"): elif not unit["retired"] and not unit["ignored"]:
return (1, unit["id"]) return (1, unit["id"])
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
return (2, unit["id"])
elif unit["out_for_calibration"]:
return (3, unit["id"])
elif unit["retired"]: elif unit["retired"]:
return (4, unit["id"]) return (2, unit["id"])
else: else:
return (5, unit["id"]) return (3, unit["id"])
units_list.sort(key=sort_key) units_list.sort(key=sort_key)
-35
View File
@@ -1,35 +0,0 @@
"""
Migration: Add allocated and allocated_to_project_id columns to roster table.
Run once: python backend/migrate_add_allocated.py
"""
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db')
def run():
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# Check existing columns
cur.execute("PRAGMA table_info(roster)")
cols = {row[1] for row in cur.fetchall()}
if 'allocated' not in cols:
cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL")
print("Added column: allocated")
else:
print("Column already exists: allocated")
if 'allocated_to_project_id' not in cols:
cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR")
print("Added column: allocated_to_project_id")
else:
print("Column already exists: allocated_to_project_id")
conn.commit()
conn.close()
print("Migration complete.")
if __name__ == '__main__':
run()
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""
Database migration: Client Portal (M1).
Adds the authoritative client link to projects:
- projects.client_id (TEXT, nullable) -> clients.id
The `clients` and `client_access_tokens` tables are created automatically by
SQLAlchemy `create_all` at app startup (they're brand-new tables), so this
migration only handles the column that create_all won't add to an existing
`projects` table.
Run once per database:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("A fresh DB created via models.py will include projects.client_id automatically.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(projects)")
existing = {row[1] for row in cursor.fetchall()}
if "client_id" not in existing:
try:
cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT")
print("✓ Added column: projects.client_id (TEXT)")
except sqlite3.OperationalError as e:
print(f"✗ Failed to add projects.client_id: {e}")
else:
print("○ Column already exists: projects.client_id")
conn.commit()
conn.close()
print("\n✓ Client-portal migration complete.")
print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.")
if __name__ == "__main__":
migrate()
-79
View File
@@ -1,79 +0,0 @@
"""
Migration: Add deployment_records table.
Tracks each time a unit is sent to the field and returned.
The active deployment is the row with actual_removal_date IS NULL.
Run once per database:
python backend/migrate_add_deployment_records.py
"""
import sqlite3
import os
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Check if table already exists
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='deployment_records'
""")
if cursor.fetchone():
print("✓ deployment_records table already exists, skipping")
return
print("Creating deployment_records table...")
cursor.execute("""
CREATE TABLE deployment_records (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL,
deployed_date DATE,
estimated_removal_date DATE,
actual_removal_date DATE,
project_ref TEXT,
project_id TEXT,
location_name TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX idx_deployment_records_unit_id
ON deployment_records(unit_id)
""")
cursor.execute("""
CREATE INDEX idx_deployment_records_project_id
ON deployment_records(project_id)
""")
# Index for finding active deployments quickly
cursor.execute("""
CREATE INDEX idx_deployment_records_active
ON deployment_records(unit_id, actual_removal_date)
""")
conn.commit()
print("✓ deployment_records table created successfully")
print("✓ Indexes created")
except Exception as e:
conn.rollback()
print(f"✗ Migration failed: {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()
-62
View File
@@ -1,62 +0,0 @@
"""
Migration: Add estimated_units to job_reservations
Adds column:
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
"""
import sqlite3
import sys
from pathlib import Path
# Default database path (matches production pattern)
DB_PATH = "./data/seismo_fleet.db"
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if job_reservations table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if not cursor.fetchone():
print("job_reservations table does not exist. Skipping migration.")
return
# Get existing columns in job_reservations
cursor.execute("PRAGMA table_info(job_reservations)")
existing_cols = {row[1] for row in cursor.fetchall()}
# Add estimated_units column if it doesn't exist
if 'estimated_units' not in existing_cols:
print("Adding estimated_units column to job_reservations...")
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
else:
print("estimated_units column already exists. Skipping.")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
db_path = DB_PATH
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)
-103
View File
@@ -1,103 +0,0 @@
"""
Migration script to add job reservations for the Fleet Calendar feature.
This creates two tables:
- job_reservations: Track future unit assignments for jobs/projects
- job_reservation_units: Link specific units to reservations
Run this script once to migrate an existing database.
"""
import sqlite3
import os
# Database path
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
"""Create the job_reservations and job_reservation_units tables"""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
print("The database will be created automatically when you run the application.")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if job_reservations table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if cursor.fetchone():
print("Migration already applied - job_reservations table exists")
conn.close()
return
print("Creating job_reservations table...")
try:
# Create job_reservations table
cursor.execute("""
CREATE TABLE job_reservations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
assignment_type TEXT NOT NULL DEFAULT 'quantity',
device_type TEXT DEFAULT 'seismograph',
quantity_needed INTEGER,
notes TEXT,
color TEXT DEFAULT '#3B82F6',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print(" Created job_reservations table")
# Create indexes for job_reservations
cursor.execute("CREATE INDEX idx_job_reservations_project_id ON job_reservations(project_id)")
print(" Created index on project_id")
cursor.execute("CREATE INDEX idx_job_reservations_dates ON job_reservations(start_date, end_date)")
print(" Created index on dates")
# Create job_reservation_units table
print("Creating job_reservation_units table...")
cursor.execute("""
CREATE TABLE job_reservation_units (
id TEXT PRIMARY KEY,
reservation_id TEXT NOT NULL,
unit_id TEXT NOT NULL,
assignment_source TEXT DEFAULT 'specific',
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (reservation_id) REFERENCES job_reservations(id),
FOREIGN KEY (unit_id) REFERENCES roster(id)
)
""")
print(" Created job_reservation_units table")
# Create indexes for job_reservation_units
cursor.execute("CREATE INDEX idx_job_reservation_units_reservation_id ON job_reservation_units(reservation_id)")
print(" Created index on reservation_id")
cursor.execute("CREATE INDEX idx_job_reservation_units_unit_id ON job_reservation_units(unit_id)")
print(" Created index on unit_id")
conn.commit()
print("\nMigration completed successfully!")
print("You can now use the Fleet Calendar to manage unit reservations.")
except sqlite3.Error as e:
print(f"\nError during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()
-63
View File
@@ -1,63 +0,0 @@
"""
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
Lets operators mark a location as no longer actively monitored without
deleting it (so historical events stay attributed correctly). Mirrors
the timestamp-based "closed state" pattern already used by
`unit_assignments.assigned_until`.
Behavior:
- `removed_at IS NULL` → location is active (default for all existing
rows after this migration)
- `removed_at` set → location is removed; historical events still
attribute to it but it's hidden from active
surfaces (assign dropdowns, calendar, etc.)
- `removal_reason` → optional operator note (e.g. "client dropped
from scope")
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.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()
added = []
if not _has_column(cur, "monitoring_locations", "removed_at"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
added.append("removed_at")
if not _has_column(cur, "monitoring_locations", "removal_reason"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
added.append("removal_reason")
conn.commit()
conn.close()
if added:
print(f" Added columns to monitoring_locations: {', '.join(added)}")
else:
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
if __name__ == "__main__":
print("Running migration: add removed_at + removal_reason to monitoring_locations")
migrate_database()
print("Done.")
-24
View File
@@ -1,24 +0,0 @@
"""
Migration: Add location_slots column to job_reservations table.
Stores the full ordered slot list (including empty/unassigned slots) as JSON.
Run once per database.
"""
import sqlite3
import os
DB_PATH = os.environ.get("DB_PATH", "/app/data/seismo_fleet.db")
def run():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
existing = [r[1] for r in cursor.execute("PRAGMA table_info(job_reservations)").fetchall()]
if "location_slots" not in existing:
cursor.execute("ALTER TABLE job_reservations ADD COLUMN location_slots TEXT")
conn.commit()
print("Added location_slots column to job_reservations.")
else:
print("location_slots column already exists, skipping.")
conn.close()
if __name__ == "__main__":
run()
@@ -1,76 +0,0 @@
"""
Migration: add `sort_order` column to `monitoring_locations` and seed
existing rows.
Lets operators reorder location cards via drag-and-drop on the project
detail page. Lower sort_order renders first; ties fall back to name.
Seed strategy: for each existing project, assign sort_order = 0, 1, 2, …
to its locations in their current alphabetical-by-name order. After
this migration, the visible card order on every existing project will
be unchanged.
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.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()
added_column = False
if not _has_column(cur, "monitoring_locations", "sort_order"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0")
added_column = True
print(" Added column: monitoring_locations.sort_order")
# Seed: for each project, set sort_order to its alphabetical index.
# Re-runs are harmless — operator-edited orderings can be re-seeded by
# passing FORCE_RESEED=1, but the default behavior leaves existing
# nonzero sort_order values alone so we don't clobber user choices.
force_reseed = os.environ.get("FORCE_RESEED") == "1"
if added_column or force_reseed:
cur.execute("SELECT DISTINCT project_id FROM monitoring_locations")
projects = [r[0] for r in cur.fetchall()]
seeded = 0
for project_id in projects:
cur.execute(
"SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name",
(project_id,),
)
for idx, (loc_id,) in enumerate(cur.fetchall()):
cur.execute(
"UPDATE monitoring_locations SET sort_order = ? WHERE id = ?",
(idx, loc_id),
)
seeded += 1
print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).")
else:
print(" monitoring_locations.sort_order already present — leaving existing values alone.")
print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)")
conn.commit()
conn.close()
if __name__ == "__main__":
print("Running migration: add sort_order to monitoring_locations")
migrate_database()
print("Done.")
-94
View File
@@ -1,94 +0,0 @@
"""
Migration: add metadata-backfill support.
Adds:
1. `unit_assignments.source` column (TEXT, default 'manual').
Lets us audit which assignments were created by the metadata-backfill
parser vs by a human, and bulk-undo parser actions if needed.
2. `metadata_backfill_decisions` table. Tracks operator decisions per
cluster_id so the wizard remembers what's been skipped, what's
been applied, and what's pending across re-scans.
Idempotent — safe to re-run.
Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_metadata_backfill.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# ── 1. unit_assignments.source column ──────────────────────────────────
cur.execute("PRAGMA table_info(unit_assignments)")
cols = {row[1] for row in cur.fetchall()}
if "source" not in cols:
print("Adding unit_assignments.source column (default 'manual') ...")
cur.execute(
"ALTER TABLE unit_assignments ADD COLUMN source TEXT DEFAULT 'manual'"
)
# Backfill: any existing row gets source='manual'
cur.execute("UPDATE unit_assignments SET source='manual' WHERE source IS NULL")
conn.commit()
print(" Done.")
else:
print("unit_assignments.source already exists — skipping")
# ── 2. metadata_backfill_decisions table ──────────────────────────────
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_backfill_decisions'"
)
if cur.fetchone() is None:
print("Creating metadata_backfill_decisions table ...")
cur.execute("""
CREATE TABLE metadata_backfill_decisions (
cluster_id TEXT PRIMARY KEY, -- deterministic hash
status TEXT NOT NULL, -- pending | applied | skipped | conflict
confidence TEXT NOT NULL, -- high | medium | low (at time of decision)
decided_at TEXT, -- when applied/skipped
decided_by TEXT, -- 'background' | 'operator' | 'auto-high'
applied_assignment_id TEXT, -- FK to unit_assignments (if applied)
notes TEXT,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
serial TEXT NOT NULL,
project_raw TEXT,
location_raw TEXT,
first_event_ts TEXT,
last_event_ts TEXT,
event_count INTEGER NOT NULL DEFAULT 0
)
""")
cur.execute(
"CREATE INDEX idx_mbd_status ON metadata_backfill_decisions(status)"
)
cur.execute(
"CREATE INDEX idx_mbd_last_seen ON metadata_backfill_decisions(last_seen_at)"
)
cur.execute(
"CREATE INDEX idx_mbd_serial ON metadata_backfill_decisions(serial)"
)
conn.commit()
print(" Done.")
else:
print("metadata_backfill_decisions table already exists — skipping")
conn.close()
print("\nMigration complete.")
if __name__ == "__main__":
migrate_database()
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Database migration: Add mic_unit_pref column to user_preferences.
Adds a single field controlling the mic channel's unit on the event-
report waveform chart in the SFM event detail modal. "psi" (default —
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
elsewhere are always dBL regardless.
History: v0.13.0 originally shipped this with default "dBL", which
made the website chart inconsistent with the PDF. v0.13.1 flips the
default to "psi" so they match. This migration is idempotent and
covers three cases:
1. Fresh DB without the column — adds it with default 'psi'.
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
'psi' on the assumption no operator deliberately picked 'dBL' yet.
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("Will be created with the new column when models.py initialises.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA table_info(user_preferences)")
existing = {row[1] for row in cur.fetchall()}
if "mic_unit_pref" not in existing:
cur.execute(
"ALTER TABLE user_preferences "
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
)
# Backfill any rows where the column ended up NULL.
cur.execute(
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
"WHERE mic_unit_pref IS NULL"
)
print("Added mic_unit_pref column (default 'psi').")
else:
print("mic_unit_pref column already exists.")
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
# 'dBL' default get bumped to 'psi'. If any operator deliberately
# chose 'dBL' through Settings before this migration runs they'd
# get reset — acceptable trade-off given the small user base and
# the fact the setting is one click to restore.
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
"WHERE mic_unit_pref = 'dBL'")
flipped = cur.rowcount
if flipped:
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
conn.commit()
conn.close()
if __name__ == "__main__":
migrate()
-54
View File
@@ -1,54 +0,0 @@
"""
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.")
@@ -1,73 +0,0 @@
"""
Migration: Add one-off schedule fields to recurring_schedules table
Adds start_datetime and end_datetime columns for one-off recording schedules.
Run this script once to update existing databases:
python -m backend.migrate_add_oneoff_schedule_fields
"""
import sqlite3
import os
DB_PATH = "data/seismo_fleet.db"
def migrate():
"""Add one-off schedule columns to recurring_schedules table."""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return False
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='recurring_schedules'
""")
if not cursor.fetchone():
print("recurring_schedules table does not exist yet. Will be created on app startup.")
conn.close()
return True
cursor.execute("PRAGMA table_info(recurring_schedules)")
columns = [row[1] for row in cursor.fetchall()]
added = False
if "start_datetime" not in columns:
print("Adding start_datetime column to recurring_schedules table...")
cursor.execute("""
ALTER TABLE recurring_schedules
ADD COLUMN start_datetime DATETIME NULL
""")
added = True
if "end_datetime" not in columns:
print("Adding end_datetime column to recurring_schedules table...")
cursor.execute("""
ALTER TABLE recurring_schedules
ADD COLUMN end_datetime DATETIME NULL
""")
added = True
if added:
conn.commit()
print("Successfully added one-off schedule columns.")
else:
print("One-off schedule columns already exist.")
conn.close()
return True
except Exception as e:
print(f"Migration failed: {e}")
conn.close()
return False
if __name__ == "__main__":
success = migrate()
exit(0 if success else 1)
@@ -1,54 +0,0 @@
"""
Database Migration: Add out_for_calibration field to roster table
Changes:
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
- Safe to run multiple times (idempotent)
- No data loss
Usage:
python backend/migrate_add_out_for_calibration.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def migrate():
db = SessionLocal()
try:
print("=" * 60)
print("Migration: Add out_for_calibration to roster")
print("=" * 60)
# Check if column already exists
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
columns = [row[1] for row in result]
if "out_for_calibration" in columns:
print("Column out_for_calibration already exists. Skipping.")
else:
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
db.commit()
print("Added out_for_calibration column to roster table.")
print("Migration complete.")
except Exception as e:
db.rollback()
print(f"Error: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
migrate()
@@ -1,75 +0,0 @@
"""
Migration: add `pending_deployments` table.
Stores "I just installed this seismograph" captures from the field.
A pending deployment is the prospective form of a UnitAssignment —
captured at install time (photo + coords + maybe a free-text note),
classified later (project + location chosen at a desk).
Once classified, a real UnitAssignment is created, the pending row's
status flips to "assigned", and resulting_assignment_id points at the
new assignment for audit.
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
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()
cur.execute("""
CREATE TABLE IF NOT EXISTS pending_deployments (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL,
captured_at DATETIME NOT NULL,
coordinates TEXT,
operator_note TEXT,
photo_filename TEXT,
status TEXT NOT NULL DEFAULT 'awaiting',
promoted_at DATETIME,
resulting_assignment_id TEXT,
cancelled_at DATETIME,
cancelled_reason TEXT,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
)
""")
print(" Table 'pending_deployments' ready.")
# Indexes — operators will query by status (hopper list) and by
# unit_id (per-unit detail page → "is there a pending capture?").
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_pending_deployments_status
ON pending_deployments (status)
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_pending_deployments_unit_id
ON pending_deployments (unit_id)
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_pending_deployments_captured_at
ON pending_deployments (captured_at)
""")
print(" Indexes ready.")
conn.commit()
conn.close()
if __name__ == "__main__":
print("Running migration: add pending_deployments table")
migrate_database()
print("Done.")
@@ -1,53 +0,0 @@
#!/usr/bin/env python3
"""
Migration: Add data_collection_mode column to projects table.
Values:
"remote" — units have modems; data pulled via FTP/scheduler automatically
"manual" — no modem; SD cards retrieved daily and uploaded by hand
All existing projects are backfilled to "manual" (safe conservative default).
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_project_data_collection_mode.py
"""
from pathlib import Path
DB_PATH = Path("data/seismo_fleet.db")
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Add column (idempotent) ───────────────────────────────────────────
cur.execute("PRAGMA table_info(projects)")
existing_cols = {row["name"] for row in cur.fetchall()}
if "data_collection_mode" not in existing_cols:
cur.execute("ALTER TABLE projects ADD COLUMN data_collection_mode TEXT DEFAULT 'manual'")
conn.commit()
print("✓ Added column data_collection_mode to projects")
else:
print("○ Column data_collection_mode already exists — skipping ALTER TABLE")
# ── 2. Backfill NULLs to 'manual' ────────────────────────────────────────
cur.execute("UPDATE projects SET data_collection_mode = 'manual' WHERE data_collection_mode IS NULL")
updated = cur.rowcount
conn.commit()
conn.close()
if updated:
print(f"✓ Backfilled {updated} project(s) to data_collection_mode='manual'.")
print("Migration complete.")
if __name__ == "__main__":
migrate()
-56
View File
@@ -1,56 +0,0 @@
"""
Migration: Add deleted_at column to projects table
Adds columns:
- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("projects table does not exist. Skipping migration.")
return
cursor.execute("PRAGMA table_info(projects)")
existing_cols = {row[1] for row in cursor.fetchall()}
if 'deleted_at' not in existing_cols:
print("Adding deleted_at column to projects...")
cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME")
else:
print("deleted_at column already exists. Skipping.")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)
-71
View File
@@ -1,71 +0,0 @@
"""
Migration: Add project_modules table and seed from existing project_type_id values.
Safe to run multiple times — idempotent.
"""
import sqlite3
import uuid
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "seismo_fleet.db")
DB_PATH = os.path.abspath(DB_PATH)
def run():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 1. Create project_modules table if not exists
cur.execute("""
CREATE TABLE IF NOT EXISTS project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
module_type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, module_type)
)
""")
print(" Table 'project_modules' ready.")
# 2. Seed modules from existing project_type_id values
cur.execute("SELECT id, project_type_id FROM projects WHERE project_type_id IS NOT NULL")
projects = cur.fetchall()
seeded = 0
for p in projects:
pid = p["id"]
ptype = p["project_type_id"]
modules_to_add = []
if ptype == "sound_monitoring":
modules_to_add = ["sound_monitoring"]
elif ptype == "vibration_monitoring":
modules_to_add = ["vibration_monitoring"]
elif ptype == "combined":
modules_to_add = ["sound_monitoring", "vibration_monitoring"]
for module_type in modules_to_add:
# INSERT OR IGNORE — skip if already exists
cur.execute("""
INSERT OR IGNORE INTO project_modules (id, project_id, module_type, enabled)
VALUES (?, ?, ?, 1)
""", (str(uuid.uuid4()), pid, module_type))
if cur.rowcount > 0:
seeded += 1
conn.commit()
print(f" Seeded {seeded} module record(s) from existing project_type_id values.")
# 3. Make project_type_id nullable (SQLite doesn't support ALTER COLUMN,
# but since we're just loosening a constraint this is a no-op in SQLite —
# the column already accepts NULL in practice. Nothing to do.)
print(" project_type_id column is now treated as nullable (legacy field).")
conn.close()
print("Migration complete.")
if __name__ == "__main__":
run()
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""
Database migration: Project portal auth (Phase 1).
Adds the per-project portal gate columns to `projects`:
- portal_enabled (BOOLEAN, default 0)
- portal_password_hash (TEXT, nullable)
- portal_link_token (TEXT, nullable) [+ unique index]
Idempotent. Run once per existing DB:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py
"""
import sqlite3
from pathlib import Path
_COLUMNS = {
"portal_enabled": "BOOLEAN DEFAULT 0",
"portal_password_hash": "TEXT",
"portal_link_token": "TEXT",
}
def migrate():
possible_paths = [Path("data/seismo_fleet.db"), Path("data/sfm.db"), Path("data/seismo.db")]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("A fresh DB created via models.py will include these columns automatically.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(projects)")
existing = {row[1] for row in cursor.fetchall()}
for col, ddl in _COLUMNS.items():
if col in existing:
print(f"○ Column already exists: projects.{col}")
continue
try:
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}")
print(f"✓ Added column: projects.{col} ({ddl})")
except sqlite3.OperationalError as e:
print(f"✗ Failed to add projects.{col}: {e}")
# Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS).
try:
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token "
"ON projects (portal_link_token)")
print("✓ Ensured unique index: ix_projects_portal_link_token")
except sqlite3.OperationalError as e:
print(f"✗ Failed to create index: {e}")
conn.commit()
conn.close()
print("\n✓ Project portal-auth migration complete.")
if __name__ == "__main__":
migrate()
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env python3
"""
Migration: Add device_model column to monitoring_sessions table.
Records which physical SLM model produced each session's data (e.g. "NL-43",
"NL-53", "NL-32"). Used by report generation to apply the correct parsing
logic without re-opening files to detect format.
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_session_device_model.py
Backfill strategy for existing rows:
1. If session.unit_id is set, use roster.slm_model for that unit.
2. Else, peek at the first .rnd file in the session: presence of the 'LAeq'
column header identifies AU2 / NL-32 format.
Sessions where neither hint is available remain NULL — the file-content
fallback in report code handles them transparently.
"""
import csv
import io
from pathlib import Path
DB_PATH = Path("data/seismo_fleet.db")
def _peek_first_row(abs_path: Path) -> dict:
"""Read only the header + first data row of an RND file. Very cheap."""
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
return next(reader, None) or {}
except Exception:
return {}
def _detect_model_from_rnd(abs_path: Path) -> str | None:
"""Return 'NL-32' if file uses AU2 column format, else None."""
row = _peek_first_row(abs_path)
if "LAeq" in row:
return "NL-32"
return None
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Add column (idempotent) ───────────────────────────────────────────
cur.execute("PRAGMA table_info(monitoring_sessions)")
existing_cols = {row["name"] for row in cur.fetchall()}
if "device_model" not in existing_cols:
cur.execute("ALTER TABLE monitoring_sessions ADD COLUMN device_model TEXT")
conn.commit()
print("✓ Added column device_model to monitoring_sessions")
else:
print("○ Column device_model already exists — skipping ALTER TABLE")
# ── 2. Backfill existing NULL rows ───────────────────────────────────────
cur.execute(
"SELECT id, unit_id FROM monitoring_sessions WHERE device_model IS NULL"
)
sessions = cur.fetchall()
print(f"Backfilling {len(sessions)} session(s) with device_model=NULL...")
updated = skipped = 0
for row in sessions:
session_id = row["id"]
unit_id = row["unit_id"]
device_model = None
# Strategy A: look up unit's slm_model from the roster
if unit_id:
cur.execute(
"SELECT slm_model FROM roster WHERE id = ?", (unit_id,)
)
unit_row = cur.fetchone()
if unit_row and unit_row["slm_model"]:
device_model = unit_row["slm_model"]
# Strategy B: detect from first .rnd file in the session
if device_model is None:
cur.execute(
"""SELECT file_path FROM data_files
WHERE session_id = ?
AND lower(file_path) LIKE '%.rnd'
LIMIT 1""",
(session_id,),
)
file_row = cur.fetchone()
if file_row:
abs_path = Path("data") / file_row["file_path"]
device_model = _detect_model_from_rnd(abs_path)
# None here means NL-43/NL-53 format (or unreadable file) —
# leave as NULL so the existing fallback applies.
if device_model:
cur.execute(
"UPDATE monitoring_sessions SET device_model = ? WHERE id = ?",
(device_model, session_id),
)
updated += 1
else:
skipped += 1
conn.commit()
conn.close()
print(f"✓ Backfilled {updated} session(s) with a device_model.")
if skipped:
print(
f" {skipped} session(s) left as NULL "
"(no unit link and no AU2 file hint — NL-43/NL-53 or unknown; "
"file-content detection applies at report time)."
)
print("Migration complete.")
if __name__ == "__main__":
migrate()
@@ -1,42 +0,0 @@
"""
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
Run once:
python backend/migrate_add_session_period_hours.py
Or inside the container:
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import engine
from sqlalchemy import text
def run():
with engine.connect() as conn:
# Check which columns already exist
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
existing = {row[1] for row in result}
added = []
for col, definition in [
("period_start_hour", "INTEGER"),
("period_end_hour", "INTEGER"),
]:
if col not in existing:
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
added.append(col)
else:
print(f" Column '{col}' already exists — skipping.")
conn.commit()
if added:
print(f" Added columns: {', '.join(added)}")
print("Migration complete.")
if __name__ == "__main__":
run()
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""
Migration: Add session_label and period_type columns to monitoring_sessions.
session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night"
period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night
Auto-derived from started_at when NULL.
Period definitions (used in report stats table):
weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM)
weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM)
weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM)
weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM)
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_session_period_type.py
"""
from pathlib import Path
from datetime import datetime
DB_PATH = Path("data/seismo_fleet.db")
def _derive_period_type(started_at_str: str) -> str | None:
"""Derive period_type from a started_at ISO datetime string."""
if not started_at_str:
return None
try:
dt = datetime.fromisoformat(started_at_str)
except ValueError:
return None
is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun
is_night = dt.hour >= 22 or dt.hour < 7
if is_weekend:
return "weekend_night" if is_night else "weekend_day"
else:
return "weekday_night" if is_night else "weekday_day"
def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None:
"""Build a human-readable session label."""
if not started_at_str:
return None
try:
dt = datetime.fromisoformat(started_at_str)
except ValueError:
return None
day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc.
date_str = dt.strftime("%-m/%-d") # 2/23
period_labels = {
"weekday_day": "Day",
"weekday_night": "Night",
"weekend_day": "Day",
"weekend_night": "Night",
}
period_str = period_labels.get(period_type or "", "")
parts = []
if location_name:
parts.append(location_name)
parts.append(f"{day_abbr} {date_str}")
if period_str:
parts.append(period_str)
return "".join(parts)
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 1. Add columns (idempotent)
cur.execute("PRAGMA table_info(monitoring_sessions)")
existing_cols = {row["name"] for row in cur.fetchall()}
for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]:
if col not in existing_cols:
cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}")
conn.commit()
print(f"✓ Added column {col} to monitoring_sessions")
else:
print(f"○ Column {col} already exists — skipping ALTER TABLE")
# 2. Backfill existing rows
cur.execute(
"""SELECT ms.id, ms.started_at, ms.location_id
FROM monitoring_sessions ms
WHERE ms.period_type IS NULL OR ms.session_label IS NULL"""
)
sessions = cur.fetchall()
print(f"Backfilling {len(sessions)} session(s)...")
updated = 0
for row in sessions:
session_id = row["id"]
started_at = row["started_at"]
location_id = row["location_id"]
# Look up location name
location_name = None
if location_id:
cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,))
loc_row = cur.fetchone()
if loc_row:
location_name = loc_row["name"]
period_type = _derive_period_type(started_at)
label = _build_label(started_at, location_name, period_type)
cur.execute(
"UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?",
(period_type, label, session_id),
)
updated += 1
conn.commit()
conn.close()
print(f"✓ Backfilled {updated} session(s).")
print("Migration complete.")
if __name__ == "__main__":
migrate()
@@ -1,41 +0,0 @@
"""
Migration: add report_date to monitoring_sessions.
Run once:
python backend/migrate_add_session_report_date.py
Or inside the container:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import engine
from sqlalchemy import text
def run():
with engine.connect() as conn:
# Check which columns already exist
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
existing = {row[1] for row in result}
added = []
for col, definition in [
("report_date", "DATE"),
]:
if col not in existing:
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
added.append(col)
else:
print(f" Column '{col}' already exists — skipping.")
conn.commit()
if added:
print(f" Added columns: {', '.join(added)}")
print("Migration complete.")
if __name__ == "__main__":
run()
-89
View File
@@ -1,89 +0,0 @@
"""
Migration: Add TBD date support to job reservations
Adds columns:
- job_reservations.estimated_end_date: For planning when end is TBD
- job_reservations.end_date_tbd: Boolean flag for TBD end dates
- job_reservation_units.unit_start_date: Unit-specific start (for swaps)
- job_reservation_units.unit_end_date: Unit-specific end (for swaps)
- job_reservation_units.unit_end_tbd: Unit-specific TBD flag
- job_reservation_units.notes: Notes for the assignment
Also makes job_reservations.end_date nullable.
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if job_reservations table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if not cursor.fetchone():
print("job_reservations table does not exist. Skipping migration.")
return
# Get existing columns in job_reservations
cursor.execute("PRAGMA table_info(job_reservations)")
existing_cols = {row[1] for row in cursor.fetchall()}
# Add new columns to job_reservations if they don't exist
if 'estimated_end_date' not in existing_cols:
print("Adding estimated_end_date column to job_reservations...")
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_end_date DATE")
if 'end_date_tbd' not in existing_cols:
print("Adding end_date_tbd column to job_reservations...")
cursor.execute("ALTER TABLE job_reservations ADD COLUMN end_date_tbd BOOLEAN DEFAULT 0")
# Get existing columns in job_reservation_units
cursor.execute("PRAGMA table_info(job_reservation_units)")
unit_cols = {row[1] for row in cursor.fetchall()}
# Add new columns to job_reservation_units if they don't exist
if 'unit_start_date' not in unit_cols:
print("Adding unit_start_date column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_start_date DATE")
if 'unit_end_date' not in unit_cols:
print("Adding unit_end_date column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_date DATE")
if 'unit_end_tbd' not in unit_cols:
print("Adding unit_end_tbd column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_tbd BOOLEAN DEFAULT 0")
if 'notes' not in unit_cols:
print("Adding notes column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN notes TEXT")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
# Default to dev database
db_path = "./data-dev/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)
@@ -1,209 +0,0 @@
"""
Migration: deprecate the `deployment_records` table.
Why:
The deployment-history view on the unit detail page used to render
from `deployment_records` — a manually-maintained table that drifted
out of sync with `unit_assignments` (the auto-written project/location
assignment table). That caused the "wonky timeline" symptom: missing
entries, duplicate / contradictory rows, and a UI that couldn't tell
the operator what the unit was actually doing during each window.
Phase 4 of the SFM integration replaces the deployment-history view
with a derived timeline computed from `unit_assignments` +
`unit_history` + SFM event overlay. This migration is the cleanup:
1. Adds a `deprecated_at` timestamp column to `deployment_records` so
we can mark rows that have been migrated.
2. For every `deployment_records` row that does NOT have a matching
`unit_assignments` row (matched by unit_id + overlapping date
range), synthesizes a best-effort UnitAssignment row. The
free-text `location_name` from the legacy table is preserved on
the new row's `notes` field (we do NOT try to fuzzy-match it to a
MonitoringLocation id; too error-prone — operators will need to
reattach those manually if they want).
3. Marks every migrated deployment_records row with `deprecated_at`.
This migration is non-destructive: deployment_records rows stay in
the DB. The actual `DROP TABLE` happens in a follow-up release after
one operator cycle confirms nothing relies on the legacy data.
Idempotent: re-running the script is a no-op if the column already
exists and all migratable rows have already been processed.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_deprecate_deployment_records.py
"""
import os
import sqlite3
import uuid
from datetime import datetime
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 1. Add deprecated_at column if not present.
cur.execute("PRAGMA table_info(deployment_records)")
cols = {row["name"] for row in cur.fetchall()}
if "deprecated_at" not in cols:
print("Adding deployment_records.deprecated_at column ...")
cur.execute("ALTER TABLE deployment_records ADD COLUMN deprecated_at TEXT")
conn.commit()
else:
print("deployment_records.deprecated_at column already exists — skipping ADD COLUMN")
# 2. Find candidate rows: not-yet-deprecated deployment_records that
# have no matching unit_assignments row.
cur.execute("""
SELECT id, unit_id, deployed_date, estimated_removal_date,
actual_removal_date, project_id, project_ref, location_name, notes
FROM deployment_records
WHERE deprecated_at IS NULL
""")
rows = cur.fetchall()
print(f"\nFound {len(rows)} deployment_records rows not yet deprecated.")
backfilled = 0
skipped_no_match_attempted = 0
skipped_already_in_assignments = 0
skipped_missing_unit = 0
for row in rows:
unit_id = row["unit_id"]
if not unit_id:
print(f" ⚠ row {row['id']!r}: no unit_id, marking deprecated without backfill")
cur.execute(
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
(datetime.utcnow().isoformat(), row["id"]),
)
skipped_missing_unit += 1
continue
# Does the unit still exist? If not, skip — we don't synthesize
# assignments for ghost units.
cur.execute("SELECT id, device_type FROM roster WHERE id=?", (unit_id,))
roster = cur.fetchone()
if not roster:
print(f" ⚠ row {row['id']!r}: unit_id {unit_id!r} not in roster, marking deprecated without backfill")
cur.execute(
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
(datetime.utcnow().isoformat(), row["id"]),
)
skipped_missing_unit += 1
continue
# Check if a UnitAssignment already covers this window (any overlap).
# We don't try to be clever — just see if a row exists for this unit
# whose [assigned_at, assigned_until] overlaps the deployment window.
cur.execute("""
SELECT id FROM unit_assignments
WHERE unit_id=?
AND (assigned_at <= COALESCE(?, '9999')
AND COALESCE(assigned_until, '9999') >= COALESCE(?, '0000'))
LIMIT 1
""", (
unit_id,
row["actual_removal_date"] or row["estimated_removal_date"] or row["deployed_date"],
row["deployed_date"],
))
if cur.fetchone():
cur.execute(
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
(datetime.utcnow().isoformat(), row["id"]),
)
skipped_already_in_assignments += 1
continue
# No matching UnitAssignment — synthesize one. We can't FK to a
# MonitoringLocation because the legacy `location_name` is free
# text. Backfilled rows go in with location_id = "" (empty) and
# the original location_name dropped into notes for operator
# context.
if not row["project_id"]:
print(f" ⚠ row {row['id']!r}: no project_id, can't synthesize unit_assignment, marking deprecated")
cur.execute(
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
(datetime.utcnow().isoformat(), row["id"]),
)
skipped_no_match_attempted += 1
continue
synthesized_id = str(uuid.uuid4())
synth_notes_parts = []
if row["location_name"]:
synth_notes_parts.append(f"Legacy location: {row['location_name']}")
if row["project_ref"]:
synth_notes_parts.append(f"Legacy project_ref: {row['project_ref']}")
if row["notes"]:
synth_notes_parts.append(f"Original notes: {row['notes']}")
synth_notes_parts.append(f"(Synthesized from deployment_records row {row['id']})")
synth_notes = " | ".join(synth_notes_parts)
assigned_until = row["actual_removal_date"]
# Don't auto-close active deployments based on estimated_removal_date.
status = "completed" if assigned_until else "active"
# Need a location_id to satisfy NOT NULL constraint. Use a
# placeholder UUID so the FK can be cleaned up later if the
# operator decides to retarget the assignment to a real location.
# We tag this with the synthesized notes so it's discoverable.
placeholder_loc_id = ""
try:
cur.execute("""
INSERT INTO unit_assignments (
id, unit_id, location_id, project_id, device_type,
assigned_at, assigned_until, status, notes, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
synthesized_id,
unit_id,
placeholder_loc_id,
row["project_id"],
roster["device_type"] or "seismograph",
row["deployed_date"] or datetime.utcnow().isoformat(),
assigned_until,
status,
synth_notes,
datetime.utcnow().isoformat(),
))
cur.execute(
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
(datetime.utcnow().isoformat(), row["id"]),
)
backfilled += 1
print(
f" ✓ row {row['id']!r}: synthesized unit_assignment {synthesized_id} "
f"for unit={unit_id} project={row['project_id'][:8]}"
f"({row['deployed_date']}{assigned_until or 'present'})"
)
except Exception as e:
print(f" ✗ row {row['id']!r}: failed to synthesize — {e}")
conn.commit()
conn.close()
print("\n────────────────────────────────────────────────────────")
print(f"Backfilled new unit_assignments: {backfilled}")
print(f"Already covered (deprecated only): {skipped_already_in_assignments}")
print(f"No project_id (deprecated only): {skipped_no_match_attempted}")
print(f"Missing/orphaned unit (deprecated): {skipped_missing_unit}")
print(f"\nNOTE: synthesized rows have an empty location_id and the legacy")
print(f" free-text location is preserved in notes. An operator should")
print(f" retarget them to real MonitoringLocation rows if they want")
print(f" events to show up on a location detail page.")
if __name__ == "__main__":
migrate_database()
-105
View File
@@ -1,105 +0,0 @@
"""
Migration: Make job_reservations.end_date nullable for TBD support
SQLite doesn't support ALTER COLUMN, so we need to:
1. Create a new table with the correct schema
2. Copy data
3. Drop old table
4. Rename new table
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if job_reservations table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if not cursor.fetchone():
print("job_reservations table does not exist. Skipping migration.")
return
# Check current schema
cursor.execute("PRAGMA table_info(job_reservations)")
columns = cursor.fetchall()
col_info = {row[1]: row for row in columns}
# Check if end_date is already nullable (notnull=0)
if 'end_date' in col_info and col_info['end_date'][3] == 0:
print("end_date is already nullable. Skipping table recreation.")
return
print("Recreating job_reservations table with nullable end_date...")
# Create new table with correct schema
cursor.execute("""
CREATE TABLE job_reservations_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
start_date DATE NOT NULL,
end_date DATE,
estimated_end_date DATE,
end_date_tbd BOOLEAN DEFAULT 0,
assignment_type TEXT NOT NULL DEFAULT 'quantity',
device_type TEXT DEFAULT 'seismograph',
quantity_needed INTEGER,
notes TEXT,
color TEXT DEFAULT '#3B82F6',
created_at DATETIME,
updated_at DATETIME
)
""")
# Copy existing data
cursor.execute("""
INSERT INTO job_reservations_new
SELECT
id, name, project_id, start_date, end_date,
COALESCE(estimated_end_date, NULL) as estimated_end_date,
COALESCE(end_date_tbd, 0) as end_date_tbd,
assignment_type, device_type, quantity_needed, notes, color,
created_at, updated_at
FROM job_reservations
""")
# Drop old table
cursor.execute("DROP TABLE job_reservations")
# Rename new table
cursor.execute("ALTER TABLE job_reservations_new RENAME TO job_reservations")
# Recreate index
cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_id ON job_reservations (id)")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_project_id ON job_reservations (project_id)")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
# Default to dev database
db_path = "./data-dev/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)
@@ -1,54 +0,0 @@
"""
Migration: Rename recording_sessions table to monitoring_sessions
Renames the table and updates the model name from RecordingSession to MonitoringSession.
Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'")
if not cursor.fetchone():
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'")
if cursor.fetchone():
print("monitoring_sessions table already exists. Skipping migration.")
else:
print("recording_sessions table does not exist. Skipping migration.")
return
print("Renaming recording_sessions -> monitoring_sessions...")
cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)
+13 -418
View File
@@ -1,15 +1,8 @@
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer, UniqueConstraint from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
from datetime import datetime from datetime import datetime
from backend.database import Base 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): class Emitter(Base):
__tablename__ = "emitters" __tablename__ = "emitters"
@@ -39,9 +32,6 @@ class RosterUnit(Base):
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm" device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
deployed = Column(Boolean, default=True) deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False) retired = Column(Boolean, default=False)
out_for_calibration = Column(Boolean, default=False)
allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed
allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to
note = Column(String, nullable=True) note = Column(String, nullable=True)
project_id = Column(String, nullable=True) project_id = Column(String, nullable=True)
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
@@ -75,26 +65,6 @@ class RosterUnit(Base):
slm_last_check = Column(DateTime, nullable=True) # Last communication check slm_last_check = Column(DateTime, nullable=True) # Last communication check
class WatcherAgent(Base):
"""
Watcher agents: tracks the watcher processes (series3-watcher, thor-watcher)
that run on field machines and report unit heartbeats.
Updated on every heartbeat received from each source_id.
"""
__tablename__ = "watcher_agents"
id = Column(String, primary_key=True, index=True) # source_id (hostname)
source_type = Column(String, nullable=False) # series3_watcher | series4_watcher
version = Column(String, nullable=True) # e.g. "1.4.0"
last_seen = Column(DateTime, default=datetime.utcnow)
status = Column(String, nullable=False, default="unknown") # ok | pending | missing | error | unknown
ip_address = Column(String, nullable=True)
log_tail = Column(Text, nullable=True) # last N log lines (JSON array of strings)
update_pending = Column(Boolean, default=False) # set True to trigger remote update
update_version = Column(String, nullable=True) # target version to update to
class IgnoredUnit(Base): class IgnoredUnit(Base):
""" """
Ignored units: units that report but should be filtered out from unknown emitters. Ignored units: units that report but should be filtered out from unknown emitters.
@@ -142,11 +112,6 @@ class UserPreferences(Base):
calibration_warning_days = Column(Integer, default=30) calibration_warning_days = Column(Integer, default=30)
status_ok_threshold_hours = Column(Integer, default=12) status_ok_threshold_hours = Column(Integer, default=12)
status_pending_threshold_hours = Column(Integer, default=24) status_pending_threshold_hours = Column(Integer, default=24)
# Mic display units on the event-report waveform chart only — peaks
# and KPI tiles elsewhere are always dBL. "psi" (default — matches
# the PDF report) or "dBL". Default flipped in v0.13.1 after
# operator feedback that the chart should mirror the PDF.
mic_unit_pref = Column(String, default="psi")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -189,21 +154,11 @@ class Project(Base):
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23") project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall") name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=True) # Legacy FK to ProjectType.id; use ProjectModule for feature flags project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, on_hold, completed, archived, deleted status = Column(String, default="active") # active, completed, archived
# Data collection mode: how field data reaches Terra-View.
# "remote" — units have modems; data pulled via FTP/scheduler automatically
# "manual" — no modem; SD cards retrieved daily and uploaded by hand
data_collection_mode = Column(String, default="manual") # remote | manual
# Project metadata # Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
# --- Client portal (Phase 1: per-project link + password gate) ---
portal_enabled = Column(Boolean, default=False) # is the portal open for this project
portal_password_hash = Column(String, nullable=True) # argon2 hash of the shared password
portal_link_token = Column(String, nullable=True, unique=True, index=True) # unguessable token in the secure link
site_address = Column(String, nullable=True) site_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon" site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=True)
@@ -211,56 +166,6 @@ class Project(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
class ProjectModule(Base):
"""
Modules enabled on a project. Each module unlocks a set of features/tabs.
A project can have zero or more modules (sound_monitoring, vibration_monitoring, etc.).
"""
__tablename__ = "project_modules"
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
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"),)
class SoundReportConfig(Base):
"""
Per-project configuration for the automated nightly sound report
(FTP report pipeline). One row per project. Read by the morning tick in
SchedulerService and by the manual /reports endpoints (as defaults).
New table created by Base.metadata.create_all() on startup; no migration
needed (only a rebuild/restart).
"""
__tablename__ = "sound_report_configs"
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
project_id = Column(String, nullable=False, index=True, unique=True) # FK to projects.id
enabled = Column(Boolean, default=False, nullable=False) # run the daily report?
report_time = Column(String, default="08:00", nullable=False) # local HH:MM to run/send
metric_keys = Column(String, default="lmax,l01,l10,l90", nullable=False) # csv of metric keys
# Baseline source: "captured" = compute from recorded nights in the date range below;
# "reference" = use fixed values typed per location (old-report averages or a spec limit).
baseline_mode = Column(String, default="captured", nullable=False)
baseline_start = Column(Date, nullable=True) # captured-mode range
baseline_end = Column(Date, nullable=True)
recipients = Column(Text, nullable=True) # csv; falls back to REPORT_SMTP_RECIPIENTS env
last_run_date = Column(Date, nullable=True) # evening-date of the last reported night (dedup)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MonitoringLocation(Base): class MonitoringLocation(Base):
@@ -285,20 +190,6 @@ class MonitoringLocation(Base):
# For vibration: {"ground_type": "bedrock", "depth": "10m"} # For vibration: {"ground_type": "bedrock", "depth": "10m"}
location_metadata = Column(Text, nullable=True) location_metadata = Column(Text, nullable=True)
# Soft-removal: NULL means active. When set, the location is hidden from
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
# vibration summary) but historical events generated before this time
# still attribute to it. Mirrors the closed-state pattern used by
# UnitAssignment.assigned_until.
removed_at = Column(DateTime, nullable=True)
removal_reason = Column(Text, nullable=True)
# Display order within the project's location list. Operators can
# drag-and-drop to reorder cards on the project detail page. Lower
# values render first; ties fall back to name (alphabetical). Seeded
# to alphabetical-index on migration; new locations get max+1.
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -323,48 +214,9 @@ class UnitAssignment(Base):
device_type = Column(String, nullable=False) # "slm" | "seismograph" device_type = Column(String, nullable=False) # "slm" | "seismograph"
project_id = Column(String, nullable=False, index=True) # FK to Project.id project_id = Column(String, nullable=False, index=True) # FK to Project.id
# Provenance: how was this assignment created? Used for auditing,
# bulk-undo of parser actions, and the Phase 4 deployment timeline.
# "manual" — operator created via UI
# "metadata_backfill" — auto-created by the metadata parser
# from operator-typed BW event metadata
# (bulk backfill workflow)
# "metadata_backfill_swap" — auto-created by swap-detection
# background job
source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
class MetadataBackfillDecision(Base):
"""
Per-cluster decisions tracked by the metadata-backfill parser.
`cluster_id` is the deterministic SHA1 hash of
(serial, first_event_date, last_event_date), so the same cluster
produces the same id across re-scans. The decisions table lets the
parser remember "I already applied this" or "operator skipped this"
across scan invocations.
"""
__tablename__ = "metadata_backfill_decisions"
cluster_id = Column(String, primary_key=True)
status = Column(String, nullable=False) # pending | applied | skipped | conflict
confidence = Column(String, nullable=False) # high | medium | low
decided_at = Column(DateTime, nullable=True)
decided_by = Column(String, nullable=True) # background | operator | auto-high
applied_assignment_id = Column(String, nullable=True) # FK to unit_assignments.id
notes = Column(Text, nullable=True)
first_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
serial = Column(String, nullable=False, index=True)
project_raw = Column(String, nullable=True)
location_raw = Column(String, nullable=True)
first_event_ts = Column(DateTime, nullable=True)
last_event_ts = Column(DateTime, nullable=True)
event_count = Column(Integer, nullable=False, default=0)
class ScheduledAction(Base): class ScheduledAction(Base):
""" """
Scheduled actions: automation for recording start/stop/download. Scheduled actions: automation for recording start/stop/download.
@@ -377,7 +229,7 @@ class ScheduledAction(Base):
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based) unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based)
action_type = Column(String, nullable=False) # start, stop, download, cycle, calibrate action_type = Column(String, nullable=False) # start, stop, download, calibrate
device_type = Column(String, nullable=False) # "slm" | "seismograph" device_type = Column(String, nullable=False) # "slm" | "seismograph"
scheduled_time = Column(DateTime, nullable=False, index=True) scheduled_time = Column(DateTime, nullable=False, index=True)
@@ -392,21 +244,17 @@ class ScheduledAction(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
class MonitoringSession(Base): class RecordingSession(Base):
""" """
Monitoring sessions: tracks actual monitoring sessions. Recording sessions: tracks actual monitoring sessions.
Created when monitoring starts, updated when it stops. Created when recording starts, updated when it stops.
""" """
__tablename__ = "monitoring_sessions" __tablename__ = "recording_sessions"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
project_id = Column(String, nullable=False, index=True) # FK to Project.id project_id = Column(String, nullable=False, index=True) # FK to Project.id
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads) unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
# Physical device model that produced this session's data (e.g. "NL-43", "NL-53", "NL-32").
# Null for older records; report code falls back to file-content detection when null.
device_model = Column(String, nullable=True)
session_type = Column(String, nullable=False) # sound | vibration session_type = Column(String, nullable=False) # sound | vibration
started_at = Column(DateTime, nullable=False) started_at = Column(DateTime, nullable=False)
@@ -414,25 +262,6 @@ class MonitoringSession(Base):
duration_seconds = Column(Integer, nullable=True) duration_seconds = Column(Integer, nullable=True)
status = Column(String, default="recording") # recording, completed, failed status = Column(String, default="recording") # recording, completed, failed
# Human-readable label auto-derived from date/location, editable by user.
# e.g. "NRL-1 — Sun 2/23 — Night"
session_label = Column(String, nullable=True)
# Period classification for report stats columns.
# weekday_day | weekday_night | weekend_day | weekend_night
period_type = Column(String, nullable=True)
# Effective monitoring window (hours 023). Night sessions cross midnight
# (period_end_hour < period_start_hour). NULL = no filtering applied.
# e.g. Day: start=7, end=19 Night: start=19, end=7
period_start_hour = Column(Integer, nullable=True)
period_end_hour = Column(Integer, nullable=True)
# For day sessions: the specific calendar date to use for report filtering.
# Overrides the automatic "last date with daytime rows" heuristic.
# Null = use heuristic.
report_date = Column(Date, nullable=True)
# Snapshot of device configuration at recording time # Snapshot of device configuration at recording time
session_metadata = Column(Text, nullable=True) # JSON session_metadata = Column(Text, nullable=True) # JSON
@@ -448,7 +277,7 @@ class DataFile(Base):
__tablename__ = "data_files" __tablename__ = "data_files"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
file_path = Column(String, nullable=False) # Relative to data/Projects/ file_path = Column(String, nullable=False) # Relative to data/Projects/
file_type = Column(String, nullable=False) # wav, csv, mseed, json file_type = Column(String, nullable=False) # wav, csv, mseed, json
@@ -492,10 +321,9 @@ class RecurringSchedule(Base):
""" """
Recurring schedule definitions for automated sound monitoring. Recurring schedule definitions for automated sound monitoring.
Supports three schedule types: Supports two schedule types:
- "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am) - "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am)
- "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles - "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles
- "one_off": Single recording session with specific start and end date/time
""" """
__tablename__ = "recurring_schedules" __tablename__ = "recurring_schedules"
@@ -505,7 +333,7 @@ class RecurringSchedule(Base):
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment) unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment)
name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous" name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous"
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval" | "one_off" schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval"
device_type = Column(String, nullable=False) # "slm" | "seismograph" device_type = Column(String, nullable=False) # "slm" | "seismograph"
# Weekly Calendar fields (schedule_type = "weekly_calendar") # Weekly Calendar fields (schedule_type = "weekly_calendar")
@@ -521,11 +349,7 @@ class RecurringSchedule(Base):
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
include_download = Column(Boolean, default=True) # Download data before restart include_download = Column(Boolean, default=True) # Download data before restart
# One-Off fields (schedule_type = "one_off") # Automation options (applies to both schedule types)
start_datetime = Column(DateTime, nullable=True) # Exact start date+time (stored as UTC)
end_datetime = Column(DateTime, nullable=True) # Exact end date+time (stored as UTC)
# Automation options (applies to all schedule types)
auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start
# When True: prevents "overwrite data?" prompts by using a new index each time # When True: prevents "overwrite data?" prompts by using a new index each time
@@ -578,232 +402,3 @@ class Alert(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
# ============================================================================
# Deployment Records
# ============================================================================
class DeploymentRecord(Base):
"""
Deployment records: tracks each time a unit is sent to the field and returned.
Each row represents one deployment. The active deployment is the record
with actual_removal_date IS NULL. The fleet calendar uses this to show
units as "In Field" and surface their expected return date.
project_ref is a freeform string for legacy/vibration jobs like "Fay I-80".
project_id will be populated once those jobs are migrated to proper Project records.
"""
__tablename__ = "deployment_records"
id = Column(String, primary_key=True, index=True) # UUID
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
deployed_date = Column(Date, nullable=True) # When unit left the yard
estimated_removal_date = Column(Date, nullable=True) # Expected return date
actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out
# Project linkage: freeform for legacy jobs, FK for proper project records
project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs)
project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available)
location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001"
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# Fleet Calendar & Job Reservations
# ============================================================================
class JobReservation(Base):
"""
Job reservations: reserve units for future jobs/projects.
Supports two assignment modes:
- "specific": Pick exact units (SN-001, SN-002, etc.)
- "quantity": Reserve a number of units (e.g., "need 8 seismographs")
Used by the Fleet Calendar to visualize unit availability over time.
"""
__tablename__ = "job_reservations"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False) # "Job A - March deployment"
project_id = Column(String, nullable=True, index=True) # Optional FK to Project
# Date range for the reservation
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=True) # Nullable = TBD / ongoing
estimated_end_date = Column(Date, nullable=True) # For planning when end is TBD
end_date_tbd = Column(Boolean, default=False) # True = end date unknown
# Assignment type: "specific" or "quantity"
assignment_type = Column(String, nullable=False, default="quantity")
# For quantity reservations
device_type = Column(String, default="seismograph") # seismograph | slm
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
estimated_units = Column(Integer, nullable=True)
# Full slot list as JSON: [{"location_name": "North Gate", "unit_id": null}, ...]
# Includes empty slots (no unit assigned yet). Filled slots are authoritative in JobReservationUnit.
location_slots = Column(Text, nullable=True)
# Metadata
notes = Column(Text, nullable=True)
color = Column(String, default="#3B82F6") # For calendar display (blue default)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class JobReservationUnit(Base):
"""
Links specific units to job reservations.
Used when:
- assignment_type="specific": Units are directly assigned
- assignment_type="quantity": Units can be filled in later
Supports unit swaps: same reservation can have multiple units with
different date ranges (e.g., BE17353 Feb-Jun, then BE18438 Jun-Nov).
"""
__tablename__ = "job_reservation_units"
id = Column(String, primary_key=True, index=True) # UUID
reservation_id = Column(String, nullable=False, index=True) # FK to JobReservation
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit
# Unit-specific date range (for swaps) - defaults to reservation dates if null
unit_start_date = Column(Date, nullable=True) # When this specific unit starts
unit_end_date = Column(Date, nullable=True) # When this unit ends (swap out date)
unit_end_tbd = Column(Boolean, default=False) # True = end unknown (until cal expires or job ends)
# Track how this assignment was made
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
assigned_at = Column(DateTime, default=datetime.utcnow)
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
# Power requirements for this deployment slot
power_type = Column(String, nullable=True) # "ac" | "solar" | None
# Location identity
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
class PendingDeployment(Base):
"""
Field-captured "I just installed this seismograph" record waiting
to be classified into a project + location.
Lifecycle:
1. Operator captures from the /deploy mobile page photo (EXIF
GPS auto-extracted), optional free-text note. Row created
with status="awaiting".
2. Later, at a desk: operator picks a project + location (existing
or new) and "promotes" the row. A real UnitAssignment is
created, this row's status flips to "assigned", and
resulting_assignment_id points at the new assignment.
3. Mistakes / abandoned captures status="cancelled" with a
cancelled_reason for audit.
Events emitted by the unit before classification are NOT auto-
attributed (no UnitAssignment exists yet). They land in the
"unattributed" bucket on the unit's events tab. Once the pending
deployment is promoted, the new UnitAssignment's window
retroactively attributes them same mechanism the metadata-
backfill tool uses.
Seismograph-only for v1. SLM deployments don't follow the same
"field-install + verify call-home" pattern and are tracked
elsewhere.
"""
__tablename__ = "pending_deployments"
id = Column(String, primary_key=True, index=True) # UUID
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit
captured_at = Column(DateTime, nullable=False) # When the photo was taken
coordinates = Column(String, nullable=True) # "lat,lon" from photo EXIF
operator_note = Column(Text, nullable=True) # Free text — site memo
# Path under data/photos/{unit_id}/. Just the filename; the unit
# context lives in unit_id.
photo_filename = Column(String, nullable=True)
# Lifecycle.
# "awaiting" — captured, not yet classified
# "assigned" — promoted to a UnitAssignment
# "cancelled" — operator marked it as a mistake / abandoned
status = Column(String, nullable=False, default="awaiting", index=True)
promoted_at = Column(DateTime, nullable=True)
resulting_assignment_id = Column(String, nullable=True) # FK to UnitAssignment when promoted
cancelled_at = Column(DateTime, nullable=True)
cancelled_reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md)
# ============================================================================
class Client(Base):
"""A portal client (customer org). Owns one or more Projects via
Project.client_id; their portal surfaces only those projects' locations.
Read-only clients never control devices."""
__tablename__ = "clients"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False) # display name, e.g. "PJ Dick"
slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle
contact_email = Column(String, nullable=True) # for M4 magic-link
active = Column(Boolean, default=True) # False = portal access off
created_at = Column(DateTime, default=datetime.utcnow)
class ClientAccessToken(Base):
"""Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is
shown once on creation; only its sha256 is stored here. Revoke by setting
revoked_at. In M4 this is replaced behind get_current_client() without
touching routes/templates."""
__tablename__ = "client_access_tokens"
id = Column(String, primary_key=True, index=True) # UUID
client_id = Column(String, nullable=False, index=True) # FK -> clients.id
token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret
label = Column(String, nullable=True) # e.g. "Dave's link"
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)
-137
View File
@@ -1,137 +0,0 @@
#!/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()
-231
View File
@@ -1,231 +0,0 @@
# 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
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""
Client-portal admin CLI (M1). Operator tooling run inside the terra-view
container against the live DB. The raw magic-link token is shown ONCE on mint;
only its hash is stored.
# create a client
python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
# attach a project to a client (sets Project.client_id) — by id, number, or name
python3 backend/portal_admin.py link-project --slug myler --project-id <PID>
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
# mint-link is RETIRED — per-client magic URLs (/portal/enter) no longer exist.
# Client access is now per-PROJECT + password: open the project's page in
# Terra-View → "Portal access" to enable it, generate a password, and copy
# the /portal/p/<token> link. (create-client / link-project / list / revoke
# still operate on the underlying Client/token rows.)
# list clients, their projects, and active links
python3 backend/portal_admin.py list
# revoke a link (stops the link AND any live session it minted)
python3 backend/portal_admin.py revoke --token-id <TID>
"""
import os
import sys
import uuid
import argparse
from datetime import datetime
# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import SessionLocal
from backend.models import Client, ClientAccessToken, Project
def _get_client(db, slug):
c = db.query(Client).filter_by(slug=slug).first()
if not c:
sys.exit(f"No client with slug '{slug}'. Create it first.")
return c
def create_client(args):
db = SessionLocal()
try:
if db.query(Client).filter_by(slug=args.slug).first():
sys.exit(f"A client with slug '{args.slug}' already exists.")
c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug,
contact_email=args.email, active=True)
db.add(c)
db.commit()
print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})")
print(" Next: link-project, then mint-link.")
finally:
db.close()
def link_project(args):
db = SessionLocal()
try:
c = _get_client(db, args.slug)
q = db.query(Project)
if args.project_id:
p = q.filter_by(id=args.project_id).first()
elif args.project_number:
p = q.filter_by(project_number=args.project_number).first()
elif args.project_name:
p = q.filter_by(name=args.project_name).first()
else:
sys.exit("Specify --project-id, --project-number, or --project-name.")
if not p:
sys.exit("Project not found.")
p.client_id = c.id
db.commit()
print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'")
finally:
db.close()
def mint_link(args):
# Retired: the per-client magic URL (/portal/enter/...) was removed when the
# portal moved to per-project + password access. Minting a token here would
# only produce a dead link.
sys.exit(
"mint-link is retired: per-client magic URLs (/portal/enter/...) no longer exist.\n"
"Client access is now per-project + password. In Terra-View, open the project's page →\n"
"'Portal access' to enable the portal, generate a password, and copy the /portal/p/<token>\n"
"link to send the client."
)
def revoke(args):
db = SessionLocal()
try:
tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first()
if not tok:
sys.exit("No token with that id.")
if tok.revoked_at:
print("○ Already revoked.")
return
tok.revoked_at = datetime.utcnow()
db.commit()
print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.")
finally:
db.close()
def list_all(args):
db = SessionLocal()
try:
clients = db.query(Client).order_by(Client.name).all()
if not clients:
print("No clients yet.")
return
for c in clients:
state = "" if c.active else " [INACTIVE]"
print(f"\n{c.name} (slug={c.slug}){state}")
projs = db.query(Project).filter_by(client_id=c.id).all()
print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)"))
toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all()
if not toks:
print(" links: (none — run mint-link)")
for t in toks:
status = "revoked" if t.revoked_at else "active"
last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used"
print(f" link {t.id} [{status}] {t.label or ''} (last: {last})")
print()
finally:
db.close()
def main():
ap = argparse.ArgumentParser(description="Client-portal admin (M1)")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("create-client"); p.add_argument("--name", required=True)
p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client)
p = sub.add_parser("link-project"); p.add_argument("--slug", required=True)
p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name")
p.set_defaults(fn=link_project)
p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True)
p.add_argument("--label"); p.set_defaults(fn=mint_link)
p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke)
p = sub.add_parser("list"); p.set_defaults(fn=list_all)
args = ap.parse_args()
args.fn(args)
if __name__ == "__main__":
main()
-193
View File
@@ -1,193 +0,0 @@
"""
Client-portal auth the swappable gate (see docs/CLIENT_PORTAL.md).
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
mints a signed session cookie. Every portal route depends on get_current_client();
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
against the DB on every request, so revoking a link (revoked_at) kills its live
sessions on the next request not just future clicks.
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
"""
import os
import hmac
import json
import time
import uuid
import base64
import hashlib
import logging
import secrets
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Client, ClientAccessToken, Project
logger = logging.getLogger(__name__)
# Signing secret for portal session cookies. MUST be set to a real secret in prod
# (env). The insecure default only exists so dev/test boots without config.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
if SECRET_KEY == "dev-insecure-change-me":
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
class PortalAuthError(Exception):
"""Raised by get_current_client when there's no valid portal session.
Handled centrally in main.py: HTML routes get the access-required page,
/portal/api/* routes get a 401 JSON."""
# -- token + cookie primitives ----------------------------------------------
def hash_token(raw: str) -> str:
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
return hashlib.sha256(raw.encode()).hexdigest()
def _sign(body: str) -> str:
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
def make_session_cookie(token_id: str) -> str:
body = base64.urlsafe_b64encode(
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
).decode()
return f"{body}.{_sign(body)}"
def _read_session_cookie(value: str):
"""Return the token id from a signed cookie, or None if missing/tampered."""
try:
body, sig = value.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()))
if not isinstance(data, dict):
return None
# Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
# browser hint). iat is set by make_session_cookie.
iat = data.get("iat")
if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
return None
return data.get("tid")
except Exception:
return None
# -- the dependency every portal route uses ---------------------------------
def client_from_cookie(cookie_value, db: Session):
"""Resolve a Client from a raw session-cookie value, or None. Re-validates the
access token against the DB each call, so a revoked link / disabled client
drops immediately. Shared by the HTTP dependency and the WebSocket handler
(which can't use Request-based Depends)."""
token_id = _read_session_cookie(cookie_value) if cookie_value else None
if not token_id:
return None
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
if not tok:
return None
return db.query(Client).filter_by(id=tok.client_id, active=True).first()
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
"""Resolve the authenticated client, or raise PortalAuthError."""
client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
if client is None:
raise PortalAuthError()
return client
# --- Phase-1 per-project password gate -------------------------------------------
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
# owning exactly that project. The project is linked to it via project.client_id so
# the existing client-scoped routes (which resolve projects by Project.client_id ==
# client.id) surface exactly this one project for the portal session — per-project
# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
# real per-client model is the deferred multi-tenant work.)
def portal_client_for_project(project, db) -> Client:
"""Get-or-create the dedicated 1:1 portal client for a project, and link the
project to it so the client-scoped routes resolve exactly this project."""
slug = f"portal-{project.id}"
client = db.query(Client).filter_by(slug=slug).first()
if client is None:
client = Client(id=str(uuid.uuid4()),
name=(project.client_name or project.name or "Client"),
slug=slug, active=True)
db.add(client)
db.flush()
if project.client_id != client.id:
project.client_id = client.id # without this, the client owns no projects
db.flush()
return client
def mint_portal_session(project, db) -> str:
"""Ensure the project's portal client + an access token exist; return the token
id to seal into a session cookie. Reuses an existing token to avoid clutter."""
client = portal_client_for_project(project, db)
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
if tok is None:
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(secrets.token_urlsafe(32)),
label="portal")
db.add(tok)
db.commit()
return tok.id
def resolve_project_by_link_token(link_token: str, db):
"""Return the portal-enabled Project for a link token, or None."""
if not link_token:
return None
return db.query(Project).filter_by(
portal_link_token=link_token, portal_enabled=True).first()
# In-memory brute-force lockout, keyed per link_token (the password is shared per
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
# reset the budget by rotating source IPs). Resets on restart; adequate for a
# read-only surface behind the UniFi edge. Single-worker dev; multi-worker would
# need a shared store.
MAX_ATTEMPTS = 5
LOCK_SECONDS = 15 * 60
_failures: dict = {} # key -> (count, first_failure_epoch)
def is_locked(key: str) -> bool:
rec = _failures.get(key)
if not rec:
return False
count, first = rec
if count < MAX_ATTEMPTS:
return False
if (time.time() - first) > LOCK_SECONDS:
_failures.pop(key, None) # window expired
return False
return True
def register_failure(key: str) -> None:
count, first = _failures.get(key, (0, time.time()))
_failures[key] = (count + 1, first)
def clear_failures(key: str) -> None:
_failures.pop(key, None)
+1 -104
View File
@@ -4,30 +4,11 @@ from sqlalchemy import desc
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any from typing import List, Dict, Any
import os
import logging
import httpx
from backend.database import get_db from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit from backend.models import UnitHistory, Emitter, RosterUnit
from backend.services.unit_location import get_active_location
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["activity"]) router = APIRouter(prefix="/api", tags=["activity"])
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
def _humanize_age(seconds: float) -> str:
if seconds < 60:
return "just now"
if seconds < 3600:
return f"{int(seconds / 60)}m ago"
if seconds < 86400:
hrs = seconds / 3600
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
return f"{int(seconds / 86400)}d ago"
PHOTOS_BASE_DIR = Path("data/photos") PHOTOS_BASE_DIR = Path("data/photos")
@@ -141,7 +122,6 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
days = int(hours_ago / 24) days = int(hours_ago / 24)
time_ago = f"{days}d ago" time_ago = f"{days}d ago"
loc = get_active_location(db, emitter.id) if roster_unit else None
call_in = { call_in = {
"unit_id": emitter.id, "unit_id": emitter.id,
"last_seen": emitter.last_seen.isoformat(), "last_seen": emitter.last_seen.isoformat(),
@@ -150,7 +130,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"device_type": roster_unit.device_type if roster_unit else "seismograph", "device_type": roster_unit.device_type if roster_unit else "seismograph",
"deployed": roster_unit.deployed if roster_unit else False, "deployed": roster_unit.deployed if roster_unit else False,
"note": roster_unit.note if roster_unit and roster_unit.note else "", "note": roster_unit.note if roster_unit and roster_unit.note else "",
"location": (loc or {}).get("address") or (loc or {}).get("name") or "" "location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
} }
call_ins.append(call_in) call_ins.append(call_in)
@@ -164,86 +144,3 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"hours": hours, "hours": hours,
"time_threshold": time_threshold.isoformat() "time_threshold": time_threshold.isoformat()
} }
@router.get("/recent-event-callins")
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
"""
Recent unit call-ins derived from SFM event forwards.
Architecture context: the live ACH replacement is on hold, so call-homes
arrive as Blastware ACH event files forwarded by series3-watcher and
landed in the SFM events store. One event one call-in. This is the
forward-looking source of "recent call-ins" that will eventually replace
the heartbeat-based /recent-callins endpoint entirely.
Each row represents one event; multiple consecutive events from the same
serial are intentionally NOT collapsed each one is a distinct call-home.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{SFM_BASE_URL}/db/events",
params={"limit": limit},
)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPError as e:
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
return {"call_ins": [], "total": 0, "error": str(e)}
events = payload.get("events", []) or []
# Bulk-resolve serials → roster (single query, no N+1)
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
roster_map: Dict[str, RosterUnit] = {}
if serials:
roster_map = {
r.id: r
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
}
now = datetime.now(timezone.utc)
call_ins: List[Dict[str, Any]] = []
for ev in events:
serial = ev.get("serial")
if not serial:
continue
roster = roster_map.get(serial)
# created_at = when SFM received the forward. Falls back to the event
# timestamp if the SFM payload didn't carry created_at (older rows).
created_at_str = ev.get("created_at") or ev.get("timestamp")
time_ago = ""
if created_at_str:
try:
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
time_ago = _humanize_age((now - ts).total_seconds())
except ValueError:
pass
call_ins.append({
"unit_id": serial,
"serial": serial,
"event_id": ev.get("id"),
"event_timestamp": ev.get("timestamp"),
"created_at": ev.get("created_at"),
"time_ago": time_ago,
"peak_vector_sum": ev.get("peak_vector_sum"),
"false_trigger": bool(ev.get("false_trigger")),
"sensor_location": ev.get("sensor_location") or "",
"project": ev.get("project") or "",
"device_type": roster.device_type if roster else "seismograph",
"in_roster": roster is not None,
"note": (roster.note if roster else "") or "",
})
return {
"call_ins": call_ins,
"total": len(call_ins),
"source": "sfm-events",
}
-218
View File
@@ -1,218 +0,0 @@
"""
Admin / diagnostic pages for the device modules (SFM, SLMM).
These pages live under /admin/{module} and exist purely so an operator can
peek under the hood and confirm the module is reachable, what data it's
holding, and whether the proxy from terra-view is healthy.
Routes:
GET /admin/sfm SFM diagnostic page
GET /admin/slmm SLMM diagnostic page
API helpers (called by the HTML pages via fetch):
GET /api/admin/sfm/overview aggregated SFM health + db stats in one call
GET /api/admin/slmm/overview aggregated SLMM health + device count
The pages are intentionally read-only. Any actual administration of SFM
or SLMM happens in those modules directly.
"""
import logging
import os
from datetime import datetime, timezone
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.templates_config import templates
log = logging.getLogger(__name__)
router = APIRouter()
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
# ── SFM ───────────────────────────────────────────────────────────────────────
@router.get("/admin/sfm", response_class=HTMLResponse)
def admin_sfm_page(request: Request):
return templates.TemplateResponse("admin_sfm.html", {
"request": request,
"sfm_base_url": SFM_BASE_URL,
})
@router.get("/admin/events", response_class=HTMLResponse)
def admin_events_page(request: Request):
"""SFM Event DB Manager — browse, flag, and delete events across all units."""
return templates.TemplateResponse("admin_events.html", {
"request": request,
"sfm_base_url": SFM_BASE_URL,
})
@router.get("/api/admin/sfm/overview")
async def admin_sfm_overview() -> JSONResponse:
"""Aggregated SFM diagnostic snapshot.
Returns health, db stats, stale-table counts, per-unit summary, and
recent events with forwarding latency. Tolerant of partial failures:
any individual sub-fetch error is captured into its section, so a flaky
sub-endpoint doesn't break the whole page.
"""
overview: Dict[str, Any] = {
"sfm_base_url": SFM_BASE_URL,
"checked_at": datetime.now(timezone.utc).isoformat(),
"health": None,
"reachable": False,
"units": [],
"events": [],
"stale": {
"monitor_log": None,
"sessions": None,
},
"cache_stats": None,
"errors": {},
}
async with httpx.AsyncClient(timeout=5.0) as client:
# Health
try:
r = await client.get(f"{SFM_BASE_URL}/health")
r.raise_for_status()
overview["health"] = r.json()
overview["reachable"] = overview["health"].get("status") == "ok"
except Exception as e: # noqa: BLE001
overview["errors"]["health"] = str(e)
overview["reachable"] = False
# If SFM is down, no point hitting the rest.
if not overview["reachable"]:
return JSONResponse(overview)
# Units
try:
r = await client.get(f"{SFM_BASE_URL}/db/units")
r.raise_for_status()
overview["units"] = r.json() or []
except Exception as e: # noqa: BLE001
overview["errors"]["units"] = str(e)
# Recent events (newest 25 — bigger sample of the call-home stream)
try:
r = await client.get(f"{SFM_BASE_URL}/db/events", params={"limit": 25})
r.raise_for_status()
payload = r.json() or {}
events = payload.get("events", []) or []
# Compute forwarding latency: created_at (SFM ingest) timestamp (event).
now = datetime.now(timezone.utc)
for ev in events:
ev.pop("waveform_blob", None)
ev.pop("a5_pickle_filename", None)
ts_str = ev.get("timestamp")
ca_str = ev.get("created_at")
latency_seconds = None
try:
if ts_str and ca_str:
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
ca = datetime.fromisoformat(ca_str.replace("Z", "+00:00"))
if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc)
if ca.tzinfo is None: ca = ca.replace(tzinfo=timezone.utc)
latency_seconds = (ca - ts).total_seconds()
except ValueError:
pass
ev["forwarding_latency_seconds"] = latency_seconds
overview["events"] = events
except Exception as e: # noqa: BLE001
overview["errors"]["events"] = str(e)
# Stale tables (deprecated by the watcher-forward pipeline but still
# present in SFM's SQLite). Surface as counts only.
for key, path in (("monitor_log", "/db/monitor_log"),
("sessions", "/db/sessions")):
try:
r = await client.get(f"{SFM_BASE_URL}{path}", params={"limit": 1})
r.raise_for_status()
payload = r.json() or {}
# SFM returns count = total when limit covers all rows; we
# query with limit=1 just to be polite, then ask again with
# a high limit if we need the real total.
first_count = payload.get("count")
if first_count is None:
overview["stale"][key] = None
continue
# Re-query with high limit to get the true total.
r2 = await client.get(f"{SFM_BASE_URL}{path}", params={"limit": 100000})
r2.raise_for_status()
overview["stale"][key] = (r2.json() or {}).get("count")
except Exception as e: # noqa: BLE001
overview["errors"][f"stale_{key}"] = str(e)
# Cache stats (in-memory device cache on SFM)
try:
r = await client.get(f"{SFM_BASE_URL}/cache/stats")
r.raise_for_status()
overview["cache_stats"] = r.json()
except Exception as e: # noqa: BLE001
overview["errors"]["cache_stats"] = str(e)
# Aggregate counts the UI can render without re-walking arrays
overview["totals"] = {
"units": len(overview["units"]),
"events_total": sum(u.get("total_events", 0) for u in overview["units"]),
"stale_monitor_log": overview["stale"]["monitor_log"],
"stale_sessions": overview["stale"]["sessions"],
}
return JSONResponse(overview)
# ── SLMM ──────────────────────────────────────────────────────────────────────
@router.get("/admin/slmm", response_class=HTMLResponse)
def admin_slmm_page(request: Request):
return templates.TemplateResponse("admin_slmm.html", {
"request": request,
"slmm_base_url": SLMM_BASE_URL,
})
@router.get("/api/admin/slmm/overview")
async def admin_slmm_overview() -> JSONResponse:
"""Aggregated SLMM diagnostic snapshot."""
overview: Dict[str, Any] = {
"slmm_base_url": SLMM_BASE_URL,
"checked_at": datetime.now(timezone.utc).isoformat(),
"health": None,
"reachable": False,
"devices": [],
"errors": {},
}
async with httpx.AsyncClient(timeout=5.0) as client:
try:
r = await client.get(f"{SLMM_BASE_URL}/health")
r.raise_for_status()
overview["health"] = r.json()
overview["reachable"] = True
except Exception as e: # noqa: BLE001
overview["errors"]["health"] = str(e)
return JSONResponse(overview)
# Pull a roster of configured devices (SLMM exposes per-unit
# config + status under /api/nl43/*). This is a best-effort probe
# — SLMM doesn't expose a "list all devices" endpoint, so we ask
# terra-view's RosterUnit table what serials it knows about for
# SLMs and just check each one. For now, just surface the health
# payload and let the operator click through to /sound-level-meters
# for the per-device details.
return JSONResponse(overview)
-31
View File
@@ -1,31 +0,0 @@
"""
Calibration Sync Router
Endpoints for triggering and inspecting the SFM-driven calibration sync.
The scheduled job runs daily; this router is what the "Sync now" button in
Settings calls, plus a status endpoint for diagnostics.
"""
from fastapi import APIRouter
from typing import Dict, Any
from backend.services.calibration_sync import (
sync_all_calibrations,
get_calibration_sync_scheduler,
)
router = APIRouter(prefix="/api/calibration", tags=["calibration"])
@router.post("/sync")
async def trigger_calibration_sync() -> Dict[str, Any]:
"""Run a full calibration sync now and return the summary."""
summary = await sync_all_calibrations()
get_calibration_sync_scheduler().last_run = summary
return summary
@router.get("/sync/status")
def calibration_sync_status() -> Dict[str, Any]:
"""Return scheduler status and the most recent run's summary."""
return get_calibration_sync_scheduler().status()
-9
View File
@@ -1,6 +1,5 @@
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_
from datetime import datetime, timedelta from datetime import datetime, timedelta
from backend.database import get_db from backend.database import get_db
@@ -49,18 +48,10 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Exclude actions from paused/removed projects
paused_project_ids = [
p.id for p in db.query(Project.id).filter(
Project.status.in_(["on_hold", "archived", "deleted"])
).all()
]
# Query today's actions # Query today's actions
actions = db.query(ScheduledAction).filter( actions = db.query(ScheduledAction).filter(
ScheduledAction.scheduled_time >= today_start_utc, ScheduledAction.scheduled_time >= today_start_utc,
ScheduledAction.scheduled_time < today_end_utc, ScheduledAction.scheduled_time < today_end_utc,
ScheduledAction.project_id.notin_(paused_project_ids),
).order_by(ScheduledAction.scheduled_time.asc()).all() ).order_by(ScheduledAction.scheduled_time.asc()).all()
# Enrich with location/project info and parse results # Enrich with location/project info and parse results
-99
View File
@@ -1,99 +0,0 @@
"""
Fleet-wide deployment-history calendar Phase 2 of the
deployment-history visualisation work (Phase 1 is the per-unit Gantt
on /unit/{id}).
Renders all UnitAssignment windows across all projects on a 12-month
calendar grid styled like the Job Planner. Each day cell shows one
mini-bar per project that had 1 active assignment that day. Click a
day side panel with the (unit, location) pairs active.
Routes:
GET /tools/deployment-history HTML page
GET /api/admin/deployment-history/day JSON list of deployments
on a specific date (used
by the day-detail panel)
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.services.deployment_history import (
get_deployment_history_data,
get_deployments_on_day,
)
from backend.templates_config import templates
router = APIRouter()
@router.get("/tools/deployment-history", response_class=HTMLResponse)
def deployment_history_page(
request: Request,
year: Optional[int] = Query(None),
month: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
"""Fleet-wide deployment history calendar.
Defaults to a 12-month window ending in the current month (so the
operator sees the recent past, not the future). ?year=&month= can
override the START of the window to scroll backward or forward.
"""
today = date.today()
# Default: 12-month window ending this month → start = 11 months back.
if year is None or month is None:
# 11 months back from current month.
m = today.month - 11
y = today.year
while m < 1:
m += 12
y -= 1
start_year, start_month = y, m
else:
start_year, start_month = year, month
calendar = get_deployment_history_data(db, start_year, start_month)
# Build prev/next navigation values.
prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1)
next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1)
return templates.TemplateResponse("admin/deployment_history.html", {
"request": request,
"calendar": calendar,
"today": today.isoformat(),
"prev_year": prev_y,
"prev_month": prev_m,
"next_year": next_y,
"next_month": next_m,
})
@router.get("/api/admin/deployment-history/day")
def deployment_history_day(
target_date: str = Query(..., description="YYYY-MM-DD"),
db: Session = Depends(get_db),
):
"""Return assignments active on a specific calendar day."""
try:
d = date.fromisoformat(target_date)
except ValueError:
return JSONResponse(
{"error": f"Invalid date: {target_date!r}"},
status_code=400,
)
deployments = get_deployments_on_day(db, d)
return JSONResponse({
"date": target_date,
"count": len(deployments),
"deployments": deployments,
})
-154
View File
@@ -1,154 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, date
from typing import Optional
import uuid
from backend.database import get_db
from backend.models import DeploymentRecord, RosterUnit
router = APIRouter(prefix="/api", tags=["deployments"])
def _serialize(record: DeploymentRecord) -> dict:
return {
"id": record.id,
"unit_id": record.unit_id,
"deployed_date": record.deployed_date.isoformat() if record.deployed_date else None,
"estimated_removal_date": record.estimated_removal_date.isoformat() if record.estimated_removal_date else None,
"actual_removal_date": record.actual_removal_date.isoformat() if record.actual_removal_date else None,
"project_ref": record.project_ref,
"project_id": record.project_id,
"location_name": record.location_name,
"notes": record.notes,
"created_at": record.created_at.isoformat() if record.created_at else None,
"updated_at": record.updated_at.isoformat() if record.updated_at else None,
"is_active": record.actual_removal_date is None,
}
@router.get("/deployments/{unit_id}")
def get_deployments(unit_id: str, db: Session = Depends(get_db)):
"""Get all deployment records for a unit, newest first."""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
records = (
db.query(DeploymentRecord)
.filter_by(unit_id=unit_id)
.order_by(DeploymentRecord.deployed_date.desc(), DeploymentRecord.created_at.desc())
.all()
)
return {"deployments": [_serialize(r) for r in records]}
@router.get("/deployments/{unit_id}/active")
def get_active_deployment(unit_id: str, db: Session = Depends(get_db)):
"""Get the current active deployment (actual_removal_date is NULL), or null."""
record = (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.unit_id == unit_id,
DeploymentRecord.actual_removal_date == None
)
.order_by(DeploymentRecord.created_at.desc())
.first()
)
return {"deployment": _serialize(record) if record else None}
@router.post("/deployments/{unit_id}")
def create_deployment(unit_id: str, payload: dict, db: Session = Depends(get_db)):
"""
Create a new deployment record for a unit.
Body fields (all optional):
deployed_date (YYYY-MM-DD)
estimated_removal_date (YYYY-MM-DD)
project_ref (freeform string)
project_id (UUID if linked to Project)
location_name
notes
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
def parse_date(val) -> Optional[date]:
if not val:
return None
if isinstance(val, date):
return val
return date.fromisoformat(str(val))
record = DeploymentRecord(
id=str(uuid.uuid4()),
unit_id=unit_id,
deployed_date=parse_date(payload.get("deployed_date")),
estimated_removal_date=parse_date(payload.get("estimated_removal_date")),
actual_removal_date=None,
project_ref=payload.get("project_ref"),
project_id=payload.get("project_id"),
location_name=payload.get("location_name"),
notes=payload.get("notes"),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(record)
db.commit()
db.refresh(record)
return _serialize(record)
@router.put("/deployments/{unit_id}/{deployment_id}")
def update_deployment(unit_id: str, deployment_id: str, payload: dict, db: Session = Depends(get_db)):
"""
Update a deployment record. Used for:
- Setting/changing estimated_removal_date
- Closing a deployment (set actual_removal_date to mark unit returned)
- Editing project_ref, location_name, notes
"""
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
if not record:
raise HTTPException(status_code=404, detail="Deployment record not found")
def parse_date(val) -> Optional[date]:
if val is None:
return None
if val == "":
return None
if isinstance(val, date):
return val
return date.fromisoformat(str(val))
if "deployed_date" in payload:
record.deployed_date = parse_date(payload["deployed_date"])
if "estimated_removal_date" in payload:
record.estimated_removal_date = parse_date(payload["estimated_removal_date"])
if "actual_removal_date" in payload:
record.actual_removal_date = parse_date(payload["actual_removal_date"])
if "project_ref" in payload:
record.project_ref = payload["project_ref"]
if "project_id" in payload:
record.project_id = payload["project_id"]
if "location_name" in payload:
record.location_name = payload["location_name"]
if "notes" in payload:
record.notes = payload["notes"]
record.updated_at = datetime.utcnow()
db.commit()
db.refresh(record)
return _serialize(record)
@router.delete("/deployments/{unit_id}/{deployment_id}")
def delete_deployment(unit_id: str, deployment_id: str, db: Session = Depends(get_db)):
"""Delete a deployment record."""
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
if not record:
raise HTTPException(status_code=404, detail="Deployment record not found")
db.delete(record)
db.commit()
return {"ok": True}
-930
View File
@@ -1,930 +0,0 @@
"""
Fleet Calendar Router
API endpoints for the Fleet Calendar feature:
- Calendar page and data
- Job reservation CRUD
- Unit assignment management
- Availability checking
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from datetime import datetime, date, timedelta
from typing import Optional, List
import uuid
import logging
from backend.database import get_db
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project, MonitoringLocation, UnitAssignment
)
from backend.templates_config import templates
from backend.services.fleet_calendar_service import (
get_day_summary,
get_calendar_year_data,
get_rolling_calendar_data,
check_calibration_conflicts,
get_available_units_for_period,
get_calibration_status
)
router = APIRouter(tags=["fleet-calendar"])
logger = logging.getLogger(__name__)
# ============================================================================
# Calendar Page
# ============================================================================
@router.get("/fleet-calendar", response_class=HTMLResponse)
async def fleet_calendar_page(
request: Request,
year: Optional[int] = None,
month: Optional[int] = None,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Main Fleet Calendar page with rolling 12-month view."""
today = date.today()
# Default to current month as the start
if year is None:
year = today.year
if month is None:
month = today.month
# Get calendar data for 12 months starting from year/month
calendar_data = get_rolling_calendar_data(db, year, month, device_type)
# Get projects for the reservation form dropdown
projects = db.query(Project).filter(
Project.status.in_(["active", "upcoming", "on_hold"])
).order_by(Project.name).all()
# Build a serializable list of items with dates for calendar bars
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
# Map calendar device_type to project_type_ids
device_type_to_project_types = {
"seismograph": ["vibration_monitoring", "combined"],
"slm": ["sound_monitoring", "combined"],
}
relevant_project_types = device_type_to_project_types.get(device_type, [])
calendar_projects = []
for i, p in enumerate(projects):
if p.start_date and p.project_type_id in relevant_project_types:
calendar_projects.append({
"id": p.id,
"name": p.name,
"start_date": p.start_date.isoformat(),
"end_date": p.end_date.isoformat() if p.end_date else None,
"color": project_colors[i % len(project_colors)],
"confirmed": True,
})
# Add job reservations for this device_type as bars
from sqlalchemy import or_ as _or
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
reservations_for_cal = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= cal_window_end,
_or(
JobReservation.end_date >= date(year, month, 1),
JobReservation.end_date == None,
)
).all()
for res in reservations_for_cal:
end = res.end_date or res.estimated_end_date
calendar_projects.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": end.isoformat() if end else None,
"color": res.color,
"confirmed": bool(res.project_id),
})
# Calculate prev/next month navigation
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return templates.TemplateResponse(
"fleet_calendar.html",
{
"request": request,
"start_year": year,
"start_month": month,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
"device_type": device_type,
"calendar_data": calendar_data,
"projects": projects,
"calendar_projects": calendar_projects,
"today": today.isoformat()
}
)
# ============================================================================
# Calendar Data API
# ============================================================================
@router.get("/api/fleet-calendar/data", response_class=JSONResponse)
async def get_calendar_data(
year: int,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get calendar data for a specific year."""
return get_calendar_year_data(db, year, device_type)
@router.get("/api/fleet-calendar/day/{date_str}", response_class=HTMLResponse)
async def get_day_detail(
request: Request,
date_str: str,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get detailed view for a specific day (HTMX partial)."""
try:
check_date = date.fromisoformat(date_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
day_data = get_day_summary(db, check_date, device_type)
# Get projects for display names
projects = {p.id: p for p in db.query(Project).all()}
return templates.TemplateResponse(
"partials/fleet_calendar/day_detail.html",
{
"request": request,
"day_data": day_data,
"date_str": date_str,
"date_display": check_date.strftime("%B %d, %Y"),
"device_type": device_type,
"projects": projects
}
)
# ============================================================================
# Reservation CRUD
# ============================================================================
@router.post("/api/fleet-calendar/reservations", response_class=JSONResponse)
async def create_reservation(
request: Request,
db: Session = Depends(get_db)
):
"""Create a new job reservation."""
data = await request.json()
# Validate required fields
required = ["name", "start_date", "assignment_type"]
for field in required:
if field not in data:
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
# Need either end_date or end_date_tbd
end_date_tbd = data.get("end_date_tbd", False)
if not end_date_tbd and not data.get("end_date"):
raise HTTPException(status_code=400, detail="End date is required unless marked as TBD")
try:
start_date = date.fromisoformat(data["start_date"])
end_date = date.fromisoformat(data["end_date"]) if data.get("end_date") else None
estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data.get("estimated_end_date") else None
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if end_date and end_date < start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
if estimated_end_date and estimated_end_date < start_date:
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
import json as _json
reservation = JobReservation(
id=str(uuid.uuid4()),
name=data["name"],
project_id=data.get("project_id"),
start_date=start_date,
end_date=end_date,
estimated_end_date=estimated_end_date,
end_date_tbd=end_date_tbd,
assignment_type=data["assignment_type"],
device_type=data.get("device_type", "seismograph"),
quantity_needed=data.get("quantity_needed"),
estimated_units=data.get("estimated_units"),
location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None,
notes=data.get("notes"),
color=data.get("color", "#3B82F6")
)
db.add(reservation)
# If specific units were provided, assign them
if data.get("unit_ids") and data["assignment_type"] == "specific":
for unit_id in data["unit_ids"]:
assignment = JobReservationUnit(
id=str(uuid.uuid4()),
reservation_id=reservation.id,
unit_id=unit_id,
assignment_source="specific"
)
db.add(assignment)
db.commit()
logger.info(f"Created reservation: {reservation.name} ({reservation.id})")
return {
"success": True,
"reservation_id": reservation.id,
"message": f"Created reservation: {reservation.name}"
}
@router.get("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
async def get_reservation(
reservation_id: str,
db: Session = Depends(get_db)
):
"""Get a specific reservation with its assigned units."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
# Get assigned units
assignments = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).all()
# Sort assignments by slot_index so order is preserved
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
unit_ids = [a.unit_id for a in assignments_sorted]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
units_by_id = {u.id: u for u in units}
# Build per-unit lookups from assignments
assignment_map = {a.unit_id: a for a in assignments_sorted}
import json as _json
stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None
return {
"id": reservation.id,
"name": reservation.name,
"project_id": reservation.project_id,
"start_date": reservation.start_date.isoformat(),
"end_date": reservation.end_date.isoformat() if reservation.end_date else None,
"estimated_end_date": reservation.estimated_end_date.isoformat() if reservation.estimated_end_date else None,
"end_date_tbd": reservation.end_date_tbd,
"assignment_type": reservation.assignment_type,
"device_type": reservation.device_type,
"quantity_needed": reservation.quantity_needed,
"estimated_units": reservation.estimated_units,
"location_slots": stored_slots,
"notes": reservation.notes,
"color": reservation.color,
"assigned_units": [
{
"id": uid,
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
"power_type": assignment_map[uid].power_type,
"notes": assignment_map[uid].notes,
"location_name": assignment_map[uid].location_name,
"slot_index": assignment_map[uid].slot_index,
}
for uid in unit_ids
]
}
@router.put("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
async def update_reservation(
reservation_id: str,
request: Request,
db: Session = Depends(get_db)
):
"""Update an existing reservation."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
data = await request.json()
# Update fields if provided
if "name" in data:
reservation.name = data["name"]
if "project_id" in data:
reservation.project_id = data["project_id"]
if "start_date" in data:
reservation.start_date = date.fromisoformat(data["start_date"])
if "end_date" in data:
reservation.end_date = date.fromisoformat(data["end_date"]) if data["end_date"] else None
if "estimated_end_date" in data:
reservation.estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data["estimated_end_date"] else None
if "end_date_tbd" in data:
reservation.end_date_tbd = data["end_date_tbd"]
if "assignment_type" in data:
reservation.assignment_type = data["assignment_type"]
if "quantity_needed" in data:
reservation.quantity_needed = data["quantity_needed"]
if "estimated_units" in data:
reservation.estimated_units = data["estimated_units"]
if "location_slots" in data:
import json as _json
reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None
if "notes" in data:
reservation.notes = data["notes"]
if "color" in data:
reservation.color = data["color"]
reservation.updated_at = datetime.utcnow()
db.commit()
logger.info(f"Updated reservation: {reservation.name} ({reservation.id})")
return {
"success": True,
"message": f"Updated reservation: {reservation.name}"
}
@router.delete("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
async def delete_reservation(
reservation_id: str,
db: Session = Depends(get_db)
):
"""Delete a reservation and its unit assignments."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
# Delete unit assignments first
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
# Delete the reservation
db.delete(reservation)
db.commit()
logger.info(f"Deleted reservation: {reservation.name} ({reservation_id})")
return {
"success": True,
"message": "Reservation deleted"
}
# ============================================================================
# Unit Assignment
# ============================================================================
@router.post("/api/fleet-calendar/reservations/{reservation_id}/assign-units", response_class=JSONResponse)
async def assign_units_to_reservation(
reservation_id: str,
request: Request,
db: Session = Depends(get_db)
):
"""Assign specific units to a reservation."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
data = await request.json()
unit_ids = data.get("unit_ids", [])
# Optional per-unit dicts keyed by unit_id
power_types = data.get("power_types", {})
location_notes = data.get("location_notes", {})
location_names = data.get("location_names", {})
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
slot_indices = data.get("slot_indices", {})
# Verify units exist (allow empty list to clear all assignments)
if unit_ids:
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
found_ids = {u.id for u in units}
missing = set(unit_ids) - found_ids
if missing:
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
# Full replace: delete all existing assignments for this reservation first
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
db.flush()
# Check for conflicts with other reservations and insert new assignments
conflicts = []
for unit_id in unit_ids:
# Check overlapping reservations
if reservation.end_date:
overlapping = db.query(JobReservation).join(
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
).filter(
JobReservationUnit.unit_id == unit_id,
JobReservation.id != reservation_id,
JobReservation.start_date <= reservation.end_date,
JobReservation.end_date >= reservation.start_date
).first()
if overlapping:
conflicts.append({
"unit_id": unit_id,
"conflict_reservation": overlapping.name,
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
})
continue
# Add assignment
assignment = JobReservationUnit(
id=str(uuid.uuid4()),
reservation_id=reservation_id,
unit_id=unit_id,
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
power_type=power_types.get(unit_id),
notes=location_notes.get(unit_id),
location_name=location_names.get(unit_id),
slot_index=slot_indices.get(unit_id),
)
db.add(assignment)
db.commit()
# Check for calibration conflicts
cal_conflicts = check_calibration_conflicts(db, reservation_id)
assigned_count = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).count()
return {
"success": True,
"assigned_count": assigned_count,
"conflicts": conflicts,
"calibration_warnings": cal_conflicts,
"message": f"Assigned {len(unit_ids) - len(conflicts)} units"
}
@router.delete("/api/fleet-calendar/reservations/{reservation_id}/units/{unit_id}", response_class=JSONResponse)
async def remove_unit_from_reservation(
reservation_id: str,
unit_id: str,
db: Session = Depends(get_db)
):
"""Remove a unit from a reservation."""
assignment = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id,
unit_id=unit_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Unit assignment not found")
db.delete(assignment)
db.commit()
return {
"success": True,
"message": f"Removed {unit_id} from reservation"
}
# ============================================================================
# Availability & Conflicts
# ============================================================================
@router.get("/api/fleet-calendar/availability", response_class=JSONResponse)
async def check_availability(
start_date: str,
end_date: str,
device_type: str = "seismograph",
exclude_reservation_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Get units available for a specific date range."""
try:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
available = get_available_units_for_period(
db, start, end, device_type, exclude_reservation_id
)
return {
"start_date": start_date,
"end_date": end_date,
"device_type": device_type,
"available_units": available,
"count": len(available)
}
@router.get("/api/fleet-calendar/reservations/{reservation_id}/conflicts", response_class=JSONResponse)
async def get_reservation_conflicts(
reservation_id: str,
db: Session = Depends(get_db)
):
"""Check for calibration conflicts in a reservation."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
conflicts = check_calibration_conflicts(db, reservation_id)
return {
"reservation_id": reservation_id,
"reservation_name": reservation.name,
"conflicts": conflicts,
"has_conflicts": len(conflicts) > 0
}
# ============================================================================
# HTMX Partials
# ============================================================================
@router.get("/api/fleet-calendar/reservations-list", response_class=HTMLResponse)
async def get_reservations_list(
request: Request,
year: Optional[int] = None,
month: Optional[int] = None,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get list of reservations as HTMX partial."""
from sqlalchemy import or_
today = date.today()
if year is None:
year = today.year
if month is None:
month = today.month
# Calculate 12-month window
start_date = date(year, month, 1)
# End date is 12 months later
end_year = year + ((month + 10) // 12)
end_month = ((month + 10) % 12) + 1
if end_month == 12:
end_date = date(end_year, 12, 31)
else:
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Filter by device_type and date window
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
or_(
JobReservation.end_date >= start_date,
JobReservation.end_date == None # TBD reservations
)
).order_by(JobReservation.start_date).all()
# Get assignment counts
reservation_data = []
for res in reservations:
assignments = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
).all()
assigned_count = len(assignments)
# Enrich assignments with unit details, sorted by slot_index
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
unit_ids = [a.unit_id for a in assignments_sorted]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
units_by_id = {u.id: u for u in units}
assigned_units = [
{
"id": a.unit_id,
"power_type": a.power_type,
"notes": a.notes,
"location_name": a.location_name,
"slot_index": a.slot_index,
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
}
for a in assignments_sorted
]
# Check for calibration conflicts
conflicts = check_calibration_conflicts(db, res.id)
location_count = res.quantity_needed or assigned_count
reservation_data.append({
"reservation": res,
"assigned_count": assigned_count,
"location_count": location_count,
"assigned_units": assigned_units,
"has_conflicts": len(conflicts) > 0,
"conflict_count": len(conflicts)
})
return templates.TemplateResponse(
"partials/fleet_calendar/reservations_list.html",
{
"request": request,
"reservations": reservation_data,
"year": year,
"device_type": device_type
}
)
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
async def get_planner_availability(
device_type: str = "seismograph",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
exclude_reservation_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Get available units for the reservation planner split-panel UI.
Dates are optional if omitted, returns all non-retired units regardless of reservations.
"""
if start_date and end_date:
try:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
else:
# No dates: return all non-retired units of this type, with current reservation info
from backend.models import RosterUnit as RU
from datetime import timedelta
today = date.today()
all_units = db.query(RU).filter(
RU.device_type == device_type,
RU.retired == False
).all()
# Build a map: unit_id -> list of active/upcoming reservations
active_assignments = db.query(JobReservationUnit).join(
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
).filter(
JobReservation.device_type == device_type,
JobReservation.end_date >= today
).all()
unit_reservations = {}
for assignment in active_assignments:
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
if not res:
continue
unit_reservations.setdefault(assignment.unit_id, []).append({
"reservation_id": res.id,
"reservation_name": res.name,
"start_date": res.start_date.isoformat() if res.start_date else None,
"end_date": res.end_date.isoformat() if res.end_date else None,
"color": res.color or "#3B82F6"
})
units = []
for u in all_units:
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
units.append({
"id": u.id,
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"expiry_date": expiry.isoformat() if expiry else None,
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"allocated": getattr(u, 'allocated', False) or False,
"allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "",
"note": u.note or "",
"reservations": unit_reservations.get(u.id, [])
})
# Sort: benched first (easier to assign), then deployed, then by ID
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
return {
"units": units,
"start_date": start_date,
"end_date": end_date,
"count": len(units)
}
@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse)
async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
"""Return at-a-glance info for the planner quick-view modal."""
from backend.models import Emitter
u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not u:
raise HTTPException(status_code=404, detail="Unit not found")
today = date.today()
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
# Active/upcoming reservations
assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all()
reservations = []
for a in assignments:
res = db.query(JobReservation).filter(
JobReservation.id == a.reservation_id,
JobReservation.end_date >= today
).first()
if res:
reservations.append({
"name": res.name,
"start_date": res.start_date.isoformat() if res.start_date else None,
"end_date": res.end_date.isoformat() if res.end_date else None,
"end_date_tbd": res.end_date_tbd,
"color": res.color or "#3B82F6",
"location_name": a.location_name,
})
# Last seen from emitter
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
from backend.services.unit_location import get_active_location
loc = get_active_location(db, u.id)
return {
"id": u.id,
"unit_type": u.unit_type,
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"note": u.note or "",
"project_id": (loc or {}).get("project_id") or u.project_id or "",
"address": (loc or {}).get("address") or "",
"coordinates": (loc or {}).get("coordinates") or "",
"deployed_with_modem_id": u.deployed_with_modem_id or "",
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
"cal_expired": not u.last_calibrated or (expiry and expiry < today),
"last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None,
"reservations": reservations,
}
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
async def get_available_units_partial(
request: Request,
start_date: str,
end_date: str,
device_type: str = "seismograph",
reservation_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Get available units as HTMX partial for the assignment modal."""
try:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
available = get_available_units_for_period(
db, start, end, device_type, reservation_id
)
return templates.TemplateResponse(
"partials/fleet_calendar/available_units.html",
{
"request": request,
"units": available,
"start_date": start_date,
"end_date": end_date,
"device_type": device_type,
"reservation_id": reservation_id
}
)
@router.get("/api/fleet-calendar/month/{year}/{month}", response_class=HTMLResponse)
async def get_month_partial(
request: Request,
year: int,
month: int,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get a single month calendar as HTMX partial."""
calendar_data = get_calendar_year_data(db, year, device_type)
month_data = calendar_data["months"].get(month)
if not month_data:
raise HTTPException(status_code=404, detail="Invalid month")
return templates.TemplateResponse(
"partials/fleet_calendar/month_grid.html",
{
"request": request,
"year": year,
"month": month,
"month_data": month_data,
"device_type": device_type,
"today": date.today().isoformat()
}
)
# ============================================================================
# Promote Reservation to Project
# ============================================================================
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
async def promote_reservation_to_project(
reservation_id: str,
request: Request,
db: Session = Depends(get_db)
):
"""
Promote a job reservation to a full project in the projects DB.
Creates: Project + MonitoringLocations + UnitAssignments.
"""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
data = await request.json()
project_number = data.get("project_number") or None
client_name = data.get("client_name") or None
# Map device_type to project_type_id
if reservation.device_type == "slm":
project_type_id = "sound_monitoring"
location_type = "sound"
else:
project_type_id = "vibration_monitoring"
location_type = "vibration"
# Check for duplicate project name
existing = db.query(Project).filter_by(name=reservation.name).first()
if existing:
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
# Create the project
project_id = str(uuid.uuid4())
project = Project(
id=project_id,
name=reservation.name,
project_number=project_number,
client_name=client_name,
project_type_id=project_type_id,
status="upcoming",
start_date=reservation.start_date,
end_date=reservation.end_date,
description=reservation.notes,
)
db.add(project)
db.flush()
# Load assignments sorted by slot_index
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
locations_created = 0
units_assigned = 0
for i, assignment in enumerate(assignments_sorted):
loc_num = str(i + 1).zfill(3)
loc_name = assignment.location_name or f"Location {i + 1}"
location = MonitoringLocation(
id=str(uuid.uuid4()),
project_id=project_id,
location_type=location_type,
name=loc_name,
description=assignment.notes,
)
db.add(location)
db.flush()
locations_created += 1
if assignment.unit_id:
unit_assignment = UnitAssignment(
id=str(uuid.uuid4()),
unit_id=assignment.unit_id,
location_id=location.id,
project_id=project_id,
device_type=reservation.device_type or "seismograph",
status="active",
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
)
db.add(unit_assignment)
units_assigned += 1
db.commit()
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
return {
"success": True,
"project_id": project_id,
"project_name": reservation.name,
"locations_created": locations_created,
"units_assigned": units_assigned,
}
-401
View File
@@ -1,401 +0,0 @@
"""
Metadata-backfill admin router.
Endpoints under /api/admin/metadata_backfill:
GET /scan run the scan; return clusters + suggestions (JSON).
Cached 5 minutes so the wizard doesn't re-scan on
every page render.
POST /apply apply a list of cluster_ids; body specifies which to
accept and optional per-cluster overrides.
POST /skip mark cluster_ids as skipped (won't reappear).
"""
from __future__ import annotations
import os
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Project, MonitoringLocation
from backend.services import metadata_backfill as svc
router = APIRouter(prefix="/api/admin/metadata_backfill", tags=["metadata-backfill"])
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
# In-process scan cache. Trades memory for not re-hammering SFM on every
# wizard render. TTL: 5 minutes. Singleton per-process; fine for a
# single-worker uvicorn dev setup. For prod multi-worker we'd want to put
# this in the DB or Redis; deferred.
_SCAN_CACHE: dict = {"at": 0.0, "result": None}
_SCAN_CACHE_TTL_SECONDS = 300.0
def _serialise_suggestion(s: svc.Suggestion) -> dict:
c = s.cluster
return {
"cluster_id": c.cluster_id,
"serial": c.serial,
"first_event_ts": c.first_event_ts.isoformat(),
"last_event_ts": c.last_event_ts.isoformat(),
"event_count": c.event_count,
"sample_event_id": c.sample_event_id,
"project_raw": c.project_raw,
"project_root": c.project_root,
"location_raw": c.location_raw,
"client_raw": c.client_raw,
"operator_raw": c.operator_raw,
"is_blank_meta": c.is_blank_meta,
"metadata_consistency": c.metadata_consistency,
"project_match": s.project_match,
"project_existing_id": s.project_existing_id,
"project_existing_name": s.project_existing_name,
"project_match_score": s.project_match_score,
"project_suggested_name": s.project_suggested_name,
"location_match": s.location_match,
"location_existing_id": s.location_existing_id,
"location_existing_name": s.location_existing_name,
"location_match_score": s.location_match_score,
"location_suggested_name": s.location_suggested_name,
"proposed_assigned_at": s.proposed_assigned_at.isoformat(),
"proposed_assigned_until": s.proposed_assigned_until.isoformat() if s.proposed_assigned_until else None,
"confidence": s.confidence,
"blocking_conflict": s.blocking_conflict,
"conflicts": [
{
"existing_assignment_id": cf.existing_assignment_id,
"other_location_id": cf.other_location_id,
"other_location_name": cf.other_location_name,
"other_project_id": cf.other_project_id,
"other_project_name": cf.other_project_name,
}
for cf in s.conflicts
],
}
@router.get("/scan")
async def scan(
force: bool = False,
db: Session = Depends(get_db),
):
"""Run a scan and return clusters + suggestions.
Set force=true to bypass the 5-minute cache.
"""
now = time.time()
if not force and _SCAN_CACHE["result"] is not None \
and (now - _SCAN_CACHE["at"]) < _SCAN_CACHE_TTL_SECONDS:
return _SCAN_CACHE["result"]
result = await svc.scan_clusters_and_build_suggestions(db, SFM_BASE_URL)
# Group suggestions for the wizard UI.
by_confidence = {"high": [], "medium": [], "low": []}
blocking_conflict_count = 0
for s in result.suggestions:
by_confidence[s.confidence].append(_serialise_suggestion(s))
if s.blocking_conflict:
blocking_conflict_count += 1
payload = {
"scanned_event_count": result.scanned_event_count,
"cluster_count": result.cluster_count,
"already_attributed": result.already_attributed,
"skipped_orphans": result.skipped_orphans,
"pending_count": len(result.suggestions),
"blocking_conflict_count": blocking_conflict_count,
"by_confidence": {
"high": by_confidence["high"],
"medium": by_confidence["medium"],
"low": by_confidence["low"],
},
"scanned_at": now,
}
_SCAN_CACHE["result"] = payload
_SCAN_CACHE["at"] = now
return payload
@router.post("/apply")
async def apply(
request: Request,
db: Session = Depends(get_db),
):
"""Apply a list of clusters.
Body:
{
"cluster_ids": ["abc...", "def..."],
"overrides": { "abc...": { "project_name": "...", "location_name": "..." } }
}
To accept ALL non-conflict suggestions in one shot, the UI sends every
pending cluster_id with no overrides.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
cluster_ids = body.get("cluster_ids") or []
overrides = body.get("overrides") or {}
if not isinstance(cluster_ids, list) or not cluster_ids:
raise HTTPException(status_code=400, detail="cluster_ids must be a non-empty list")
# Re-scan to get current suggestions. We don't trust the cached scan
# blindly — the operator might have manually created projects in
# between scan and apply.
scan_result = await svc.scan_clusters_and_build_suggestions(db, SFM_BASE_URL)
suggestions_by_id = {s.cluster.cluster_id: s for s in scan_result.suggestions}
selected: list[svc.Suggestion] = []
not_found: list[str] = []
for cid in cluster_ids:
s = suggestions_by_id.get(cid)
if s is None:
not_found.append(cid)
continue
# Apply overrides. Per-cluster overrides take precedence over the
# parser's suggested match. Four override fields supported:
# project_id — attach to an existing Project (operator picked
# from the typeahead)
# project_name — create new project with this name (operator
# typed a custom name not matching anything)
# location_id — attach to an existing MonitoringLocation
# location_name — create new location with this name
# project_id + location_id pairings: location_id is only honored
# if its project_id matches the chosen project (otherwise treated
# as a create-new).
ov = overrides.get(cid) or {}
if ov.get("project_id"):
target_id = ov["project_id"]
existing = db.query(svc.Project).filter_by(id=target_id).first()
if existing is not None:
s.project_existing_id = existing.id
s.project_existing_name = existing.name
s.project_suggested_name = existing.name
s.project_match = "exact"
else:
# Stale ID — treat as create_new with the cluster's typed name.
s.project_existing_id = None
s.project_match = "create_new"
elif "project_name" in ov:
new_name = (ov["project_name"] or "").strip()
if new_name:
s.project_suggested_name = new_name
s.project_existing_id = None
s.project_existing_name = None
s.project_match = "create_new"
if ov.get("location_id"):
target_id = ov["location_id"]
existing = db.query(svc.MonitoringLocation).filter_by(id=target_id).first()
# Only attach if the location belongs to the (now chosen) project.
chosen_project_id = s.project_existing_id
if existing is not None and (
chosen_project_id is None or existing.project_id == chosen_project_id
):
s.location_existing_id = existing.id
s.location_existing_name = existing.name
s.location_suggested_name = existing.name
s.location_match = "exact"
else:
s.location_existing_id = None
s.location_match = "create_new"
elif "location_name" in ov:
new_name = (ov["location_name"] or "").strip()
if new_name:
s.location_suggested_name = new_name
s.location_existing_id = None
s.location_existing_name = None
s.location_match = "create_new"
selected.append(s)
apply_result = svc.apply_suggestions(db, selected, decided_by="operator")
# Invalidate the scan cache so the next /scan picks up the new state.
_SCAN_CACHE["at"] = 0.0
_SCAN_CACHE["result"] = None
return {
"applied": apply_result.applied,
"failed": [{"cluster_id": cid, "reason": r} for cid, r in apply_result.failed],
"not_found": not_found,
"project_ids_created": apply_result.project_ids_created,
"location_ids_created": apply_result.location_ids_created,
"assignment_ids_created": apply_result.assignment_ids_created,
}
@router.post("/skip")
async def skip(
request: Request,
db: Session = Depends(get_db),
):
"""Mark cluster_ids as skipped — they won't reappear in future scans."""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
cluster_ids = body.get("cluster_ids") or []
if not isinstance(cluster_ids, list):
raise HTTPException(status_code=400, detail="cluster_ids must be a list")
n = svc.skip_clusters(db, cluster_ids, decided_by="operator")
_SCAN_CACHE["at"] = 0.0
_SCAN_CACHE["result"] = None
return {"skipped": n}
@router.get("/projects_search")
def projects_search(
q: str = "",
limit: int = 10,
db: Session = Depends(get_db),
):
"""Typeahead search of existing projects for the wizard's per-cluster
override inputs. Combines case-insensitive substring match with
rapidfuzz scoring so partial typing and slight typos both surface
candidates. Always returns a 'Create new' option at the end so the
operator can confirm they want to create rather than match.
Returns:
{
"matches": [
{"id": "...", "name": "...", "score": 0.91, "location_count": 3},
...
],
"create_new": {"label": "Create new: \"<q>\""}
}
"""
q_clean = (q or "").strip()
q_norm = svc._normalise(q_clean)
projects = (
db.query(Project)
.filter(Project.status != "deleted")
.all()
)
scored: list[tuple[Project, float]] = []
for p in projects:
p_norm = svc._normalise(p.name)
if not q_norm:
# Empty query → return top projects by latest activity
# (cheap heuristic: keep them all and sort by name).
scored.append((p, 0.0))
continue
# Cheap substring boost: if the normalised query is a substring,
# treat that as 1.0 regardless of WRatio.
if q_norm in p_norm:
scored.append((p, 1.0))
continue
score = svc.similarity(q_norm, p_norm)
if score >= 0.50: # surfacing threshold; not the match threshold
scored.append((p, score))
# Sort: score desc, then name asc.
scored.sort(key=lambda t: (-t[1], t[0].name.lower()))
scored = scored[:limit]
# Compute location counts in one batch query.
loc_counts: dict[str, int] = {}
if scored:
from sqlalchemy import func
ids = [p.id for p, _ in scored]
rows = (
db.query(MonitoringLocation.project_id, func.count(MonitoringLocation.id))
.filter(MonitoringLocation.project_id.in_(ids))
.group_by(MonitoringLocation.project_id)
.all()
)
loc_counts = {pid: cnt for pid, cnt in rows}
return {
"matches": [
{
"id": p.id,
"name": p.name,
"project_number": p.project_number,
"client_name": p.client_name,
"score": round(score, 3),
"location_count": loc_counts.get(p.id, 0),
}
for p, score in scored
],
"create_new": {"label": f'Create new: "{q_clean}"' if q_clean else None},
}
@router.get("/locations_search")
def locations_search(
project_id: str,
q: str = "",
limit: int = 10,
db: Session = Depends(get_db),
):
"""Typeahead search of existing locations within a project."""
if not project_id:
raise HTTPException(status_code=400, detail="project_id required")
q_clean = (q or "").strip()
q_norm = svc._normalise(q_clean)
locations = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == project_id)
.filter(MonitoringLocation.location_type == "vibration")
# Don't propose creating assignments at removed locations — they
# were intentionally decommissioned and shouldn't be backfill targets.
.filter(MonitoringLocation.removed_at == None) # noqa: E711
.all()
)
scored: list[tuple[MonitoringLocation, float]] = []
for l in locations:
l_norm = svc._normalise(l.name)
if not q_norm:
scored.append((l, 0.0))
continue
if q_norm in l_norm:
scored.append((l, 1.0))
continue
# Use the location-specific scorer (token_set_ratio + multi-digit
# penalty) instead of WRatio — same reason as the cluster-match
# path: location names share too much boilerplate vocabulary for
# WRatio to discriminate reliably.
score = svc.location_similarity(q_norm, l_norm)
if score >= 0.50:
scored.append((l, score))
scored.sort(key=lambda t: (-t[1], t[0].name.lower()))
scored = scored[:limit]
return {
"matches": [
{
"id": l.id,
"name": l.name,
"address": l.address,
"score": round(score, 3),
}
for l, score in scored
],
"create_new": {"label": f'Create new: "{q_clean}"' if q_clean else None},
}
+6 -151
View File
@@ -14,7 +14,6 @@ import logging
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,7 +85,8 @@ async def get_modem_units(
(RosterUnit.id.ilike(search_term)) | (RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) | (RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.hardware_model.ilike(search_term)) | (RosterUnit.hardware_model.ilike(search_term)) |
(RosterUnit.phone_number.ilike(search_term)) (RosterUnit.phone_number.ilike(search_term)) |
(RosterUnit.location.ilike(search_term))
) )
modems = query.order_by( modems = query.order_by(
@@ -128,8 +128,6 @@ async def get_modem_units(
if filter_status and status != filter_status: if filter_status and status != filter_status:
continue continue
# Inherit location from the paired device's active assignment.
loc = get_active_location(db, modem.id) if paired else None
modem_list.append({ modem_list.append({
"id": modem.id, "id": modem.id,
"ip_address": modem.ip_address, "ip_address": modem.ip_address,
@@ -137,8 +135,8 @@ async def get_modem_units(
"hardware_model": modem.hardware_model, "hardware_model": modem.hardware_model,
"deployed": modem.deployed, "deployed": modem.deployed,
"retired": modem.retired, "retired": modem.retired,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": modem.location,
"project_id": (loc or {}).get("project_id") or modem.project_id, "project_id": modem.project_id,
"paired_device": paired, "paired_device": paired,
"status": status "status": status
}) })
@@ -167,15 +165,14 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
).first() ).first()
if device: if device:
loc = get_active_location(db, device.id)
return { return {
"paired": True, "paired": True,
"device": { "device": {
"id": device.id, "id": device.id,
"device_type": device.device_type, "device_type": device.device_type,
"deployed": device.deployed, "deployed": device.deployed,
"project_id": (loc or {}).get("project_id") or device.project_id, "project_id": device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "" "location": device.location or device.address
} }
} }
@@ -287,145 +284,3 @@ async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)):
"carrier": None, "carrier": None,
"connection_type": None # LTE, 5G, etc. "connection_type": None # LTE, 5G, etc.
} }
@router.get("/{modem_id}/pairable-devices")
async def get_pairable_devices(
modem_id: str,
db: Session = Depends(get_db),
search: str = Query(None),
hide_paired: bool = Query(True)
):
"""
Get list of devices (seismographs and SLMs) that can be paired with this modem.
Used by the device picker modal in unit_detail.html.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Query seismographs and SLMs
query = db.query(RosterUnit).filter(
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
RosterUnit.retired == False
)
# Filter by search term if provided
if search:
search_term = f"%{search}%"
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
devices = query.order_by(
RosterUnit.deployed.desc(),
RosterUnit.device_type.asc(),
RosterUnit.id.asc()
).all()
# Build device list
device_list = []
for device in devices:
# Skip already paired devices if hide_paired is True
is_paired_to_other = (
device.deployed_with_modem_id is not None and
device.deployed_with_modem_id != modem_id
)
is_paired_to_this = device.deployed_with_modem_id == modem_id
if hide_paired and is_paired_to_other:
continue
loc = get_active_location(db, device.id)
device_list.append({
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": (loc or {}).get("project_id") or device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"note": device.note,
"paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this,
"is_paired_to_other": is_paired_to_other
})
return {"devices": device_list, "modem_id": modem_id}
@router.post("/{modem_id}/pair")
async def pair_device_to_modem(
modem_id: str,
db: Session = Depends(get_db),
device_id: str = Query(..., description="ID of the device to pair")
):
"""
Pair a device (seismograph or SLM) to this modem.
Updates the device's deployed_with_modem_id field.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Find the device
device = db.query(RosterUnit).filter(
RosterUnit.id == device_id,
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
RosterUnit.retired == False
).first()
if not device:
return {"status": "error", "detail": f"Device {device_id} not found"}
# Unpair any device currently paired to this modem
currently_paired = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id
).all()
for paired_device in currently_paired:
paired_device.deployed_with_modem_id = None
# Pair the new device
device.deployed_with_modem_id = modem_id
db.commit()
return {
"status": "success",
"modem_id": modem_id,
"device_id": device_id,
"message": f"Device {device_id} paired to modem {modem_id}"
}
@router.post("/{modem_id}/unpair")
async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)):
"""
Unpair any device currently paired to this modem.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Find and unpair device
device = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id
).first()
if device:
old_device_id = device.id
device.deployed_with_modem_id = None
db.commit()
return {
"status": "success",
"modem_id": modem_id,
"unpaired_device_id": old_device_id,
"message": f"Device {old_device_id} unpaired from modem {modem_id}"
}
return {
"status": "success",
"modem_id": modem_id,
"message": "No device was paired to this modem"
}
-111
View File
@@ -1,111 +0,0 @@
"""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
-115
View File
@@ -1,115 +0,0 @@
"""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}
-538
View File
@@ -1,538 +0,0 @@
"""
Pending deployments field-captured "I just installed this seismograph"
records waiting to be classified into a project + location.
Routes:
POST /api/deployments/capture capture a new pending deployment
GET /api/deployments/pending list awaiting captures
GET /api/deployments/pending/{id} single capture detail
POST /api/deployments/pending/{id}/promote classify create UnitAssignment
POST /api/deployments/pending/{id}/cancel abandon
See backend/models.py PendingDeployment docstring for the full lifecycle.
Seismograph-only for v1; capture refuses if unit_id is anything else.
"""
from __future__ import annotations
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import (
PendingDeployment,
RosterUnit,
Project,
MonitoringLocation,
UnitAssignment,
UnitHistory,
)
from backend.routers.photos import extract_exif_data
router = APIRouter(prefix="/api/deployments", tags=["pending-deployments"])
PHOTOS_BASE_DIR = Path("data/photos")
_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}
def _record_history(
db: Session,
unit_id: str,
change_type: str,
*,
old_value: Optional[str] = None,
new_value: Optional[str] = None,
notes: Optional[str] = None,
source: str = "manual",
) -> None:
"""Mirror of project_locations._record_assignment_history — kept local
so this router doesn't depend on a project_locations import cycle."""
db.add(UnitHistory(
unit_id=unit_id,
change_type=change_type,
field_name="pending_deployment",
old_value=old_value,
new_value=new_value,
changed_at=datetime.utcnow(),
source=source,
notes=notes,
))
@router.get("/seismograph-picker")
def seismograph_picker(
q: str = "",
limit: int = 20,
db: Session = Depends(get_db),
):
"""JSON list of seismograph units for the /deploy mobile picker.
Filters out retired units. Sorts by recency of pending captures
first, then alphabetically so units the operator is actively
deploying with surface at the top.
"""
q_clean = (q or "").strip()
qb = db.query(RosterUnit).filter(
RosterUnit.device_type == "seismograph",
RosterUnit.retired == False, # noqa: E712
)
if q_clean:
qb = qb.filter(
(RosterUnit.id.ilike(f"%{q_clean}%"))
| (RosterUnit.note.ilike(f"%{q_clean}%"))
)
units = qb.order_by(RosterUnit.id).limit(limit).all()
# Annotate with "has an awaiting pending deployment" so the picker
# can de-emphasize / warn on units that are already mid-deploy.
pending_unit_ids = {
r[0] for r in db.query(PendingDeployment.unit_id)
.filter_by(status="awaiting").distinct().all()
}
return {
"units": [
{
"id": u.id,
"note": u.note,
"deployed": u.deployed,
"has_pending": u.id in pending_unit_ids,
}
for u in units
],
}
@router.post("/capture")
async def capture_deployment(
unit_id: str = Form(...),
operator_note: str = Form(""),
captured_at_iso: str = Form(""),
photo: UploadFile = File(...),
db: Session = Depends(get_db),
):
"""Field-capture endpoint.
Multipart form:
unit_id seismograph being deployed
operator_note optional free-text site memo
captured_at_iso optional override of the capture timestamp
(default: photo's EXIF DateTimeOriginal, or now)
photo install photo (EXIF GPS extracted if present)
Refuses if unit_id isn't a seismograph (SLM deployments don't follow
the same field-install pattern).
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id!r} not found.")
if unit.device_type != "seismograph":
raise HTTPException(
status_code=400,
detail=f"Pending deployments are for seismographs only "
f"(this unit is {unit.device_type}).",
)
# Validate + save the photo.
file_ext = Path(photo.filename or "photo.jpg").suffix.lower()
if file_ext not in _ALLOWED_PHOTO_EXTS:
raise HTTPException(
status_code=400,
detail=f"Invalid photo type {file_ext!r}. Allowed: {sorted(_ALLOWED_PHOTO_EXTS)}",
)
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
unit_photo_dir.mkdir(parents=True, exist_ok=True)
capture_id = str(uuid.uuid4())
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"install_{ts_str}_{capture_id[:8]}{file_ext}"
file_path = unit_photo_dir / filename
try:
with open(file_path, "wb") as buf:
shutil.copyfileobj(photo.file, buf)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save photo: {e}")
# Extract EXIF — best-effort. No EXIF / no GPS is fine; operator
# can fill coordinates manually later in the promote step.
metadata = extract_exif_data(file_path)
coords = metadata.get("coordinates") # "lat,lon" or None
photo_ts = metadata.get("timestamp") # datetime or None
if captured_at_iso:
try:
captured_at = datetime.fromisoformat(captured_at_iso)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid captured_at_iso: {captured_at_iso!r}")
elif photo_ts:
captured_at = photo_ts
else:
captured_at = datetime.utcnow()
pd = PendingDeployment(
id = capture_id,
unit_id = unit_id,
captured_at = captured_at,
coordinates = coords,
operator_note = (operator_note or "").strip() or None,
photo_filename = filename,
status = "awaiting",
)
db.add(pd)
_record_history(
db, unit_id=unit_id,
change_type="pending_deployment_captured",
new_value=f"awaiting classification @ {captured_at:%Y-%m-%d %H:%M}"
+ (f"{coords}" if coords else ""),
notes=(operator_note or None),
)
db.commit()
db.refresh(pd)
return JSONResponse({
"success": True,
"pending_deployment": _to_dict(pd, unit=unit),
"photo_url": f"/api/unit/{unit_id}/photo/{filename}",
"extracted_coords": coords,
"extracted_timestamp": photo_ts.isoformat() if photo_ts else None,
})
@router.get("/pending")
def list_pending(
status: str = "awaiting",
db: Session = Depends(get_db),
):
"""List pending deployments by status (default: awaiting classification)."""
rows = (
db.query(PendingDeployment)
.filter_by(status=status)
.order_by(PendingDeployment.captured_at.desc())
.all()
)
# Bulk-resolve unit references in one query (avoid N+1).
unit_ids = {r.unit_id for r in rows}
units = {u.id: u for u in db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()} \
if unit_ids else {}
return {
"count": len(rows),
"status": status,
"pending_deployments": [_to_dict(r, unit=units.get(r.unit_id)) for r in rows],
}
@router.get("/pending/{pending_id}")
def get_pending(pending_id: str, db: Session = Depends(get_db)):
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
if not pd:
raise HTTPException(status_code=404, detail="Pending deployment not found.")
unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
return _to_dict(pd, unit=unit, detail=True)
@router.post("/pending/{pending_id}/promote")
async def promote_pending(
pending_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""Classify a pending deployment → create a UnitAssignment.
Body JSON one of two shapes:
1. Assign to existing location:
{
"location_id": "<uuid>",
"notes": "<optional>"
}
2. Create a new location under (existing or new) project:
{
"project_id": "<existing>" | null, # null means create new
"project_name": "<required if project_id is null>",
"project_type_id": "<existing project_type id, e.g. 'vibration_monitoring'>",
# required if creating new project
"location_name": "<required>",
"use_captured_coords": true | false, # default true — write the
# pending's coordinates onto
# the new location
"notes": "<optional>"
}
Status flips to "assigned"; resulting_assignment_id is populated.
"""
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 != "awaiting":
raise HTTPException(
status_code=400,
detail=f"Pending deployment is {pd.status!r}, not awaiting — already classified?",
)
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body.")
notes = (payload.get("notes") or "").strip() or None
# Resolve / create the location.
location_id = payload.get("location_id")
if location_id:
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
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")
if not project_id:
project_name = (payload.get("project_name") or "").strip()
project_type_id = (payload.get("project_type_id") or "").strip()
if not project_name:
raise HTTPException(
status_code=400,
detail="Either project_id, or project_name + project_type_id, required.",
)
if not project_type_id:
raise HTTPException(
status_code=400,
detail="project_type_id required when creating a new project.",
)
new_project = Project(
id=str(uuid.uuid4()),
name=project_name,
project_type_id=project_type_id,
status="active",
)
db.add(new_project)
db.flush()
project_id = new_project.id
loc_name = (payload.get("location_name") or "").strip()
if not loc_name:
raise HTTPException(status_code=400, detail="location_name required.")
use_coords = payload.get("use_captured_coords", True)
location = MonitoringLocation(
id=str(uuid.uuid4()),
project_id=project_id,
location_type="vibration", # seismographs only
name=loc_name,
coordinates=(pd.coordinates if use_coords else None),
)
db.add(location)
db.flush()
# If this location already has an active assignment, the /deploy
# capture means someone replaced that unit in the field — close the
# old assignment, break the outgoing unit's modem pairing, and bench
# it so the heartbeat / polling subsystem stops chasing it.
existing_active = db.query(UnitAssignment).filter(
UnitAssignment.location_id == location.id,
UnitAssignment.assigned_until == None, # noqa: E711
).first()
if existing_active and existing_active.unit_id != pd.unit_id:
existing_active.assigned_until = pd.captured_at
existing_active.status = "completed"
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
if old_unit:
if old_unit.deployed_with_modem_id:
old_modem = db.query(RosterUnit).filter_by(
id=old_unit.deployed_with_modem_id, device_type="modem"
).first()
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
old_modem.deployed_with_unit_id = None
old_unit.deployed_with_modem_id = None
if old_unit.deployed:
old_unit.deployed = False
_record_history(
db, unit_id=existing_active.unit_id,
change_type="assignment_swapped",
old_value=location.name,
new_value=f"superseded by /deploy capture → {pd.unit_id}",
notes=notes,
)
# Create the assignment. assigned_at = pending capture time (so
# events emitted after the install are correctly attributed back
# to this location).
assignment = UnitAssignment(
id=str(uuid.uuid4()),
unit_id=pd.unit_id,
location_id=location.id,
project_id=project_id,
device_type="seismograph",
assigned_at=pd.captured_at,
assigned_until=None,
status="active",
notes=notes,
source="manual",
)
db.add(assignment)
db.flush()
# Incoming unit is in the field again — flip it back to deployed
# if it was on the bench (mirrors the swap endpoint).
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
if incoming_unit and not incoming_unit.deployed:
incoming_unit.deployed = True
# Promote the pending row.
pd.status = "assigned"
pd.promoted_at = datetime.utcnow()
pd.resulting_assignment_id = assignment.id
pd.updated_at = datetime.utcnow()
_record_history(
db, unit_id=pd.unit_id,
change_type="pending_deployment_promoted",
old_value="awaiting",
new_value=f"{location.name} (assignment {assignment.id[:8]})",
notes=notes,
)
db.commit()
db.refresh(pd)
db.refresh(assignment)
return {
"success": True,
"assignment_id": assignment.id,
"location_id": location.id,
"project_id": project_id,
"promoted_at": pd.promoted_at.isoformat(),
}
@router.post("/pending/{pending_id}/cancel")
async def cancel_pending(
pending_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""Mark a pending deployment as cancelled (operator captured by mistake)."""
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 != "awaiting":
raise HTTPException(
status_code=400,
detail=f"Pending deployment is {pd.status!r}, not awaiting.",
)
try:
payload = await request.json()
except Exception:
payload = {}
reason = (payload.get("reason") or "").strip() or None
pd.status = "cancelled"
pd.cancelled_at = datetime.utcnow()
pd.cancelled_reason = reason
pd.updated_at = datetime.utcnow()
_record_history(
db, unit_id=pd.unit_id,
change_type="pending_deployment_cancelled",
old_value="awaiting",
new_value="cancelled",
notes=reason,
)
db.commit()
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:
out = {
"id": pd.id,
"unit_id": pd.unit_id,
"captured_at": pd.captured_at.isoformat() if pd.captured_at else None,
"coordinates": pd.coordinates,
"operator_note": pd.operator_note,
"photo_filename": pd.photo_filename,
"photo_url": f"/api/unit/{pd.unit_id}/photo/{pd.photo_filename}"
if pd.photo_filename else None,
"status": pd.status,
"created_at": pd.created_at.isoformat() if pd.created_at else None,
}
if pd.status == "assigned":
out["promoted_at"] = pd.promoted_at.isoformat() if pd.promoted_at else None
out["resulting_assignment_id"] = pd.resulting_assignment_id
if pd.status == "cancelled":
out["cancelled_at"] = pd.cancelled_at.isoformat() if pd.cancelled_at else None
out["cancelled_reason"] = pd.cancelled_reason
if unit:
out["unit"] = {
"id": unit.id,
"device_type": unit.device_type,
"note": unit.note,
"deployed": unit.deployed,
}
return out
-375
View File
@@ -1,375 +0,0 @@
"""
Client portal read-only, scoped client view (see docs/CLIENT_PORTAL.md).
A client opens a per-project secure link (/portal/p/{link_token}), enters the
shared password, and gets a signed session cookie scoped to that project; they
then see that project's locations (overview) and per-location read-only live
data sourced from SLMM's cache. Every data route re-checks ownership.
"""
import os
import json
import asyncio
import logging
from datetime import datetime
import httpx
import websockets
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form
from fastapi.responses import RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from backend.database import get_db, SessionLocal
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates
from backend.portal_auth import (
get_current_client, client_from_cookie, make_session_cookie,
COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE,
resolve_project_by_link_token, mint_portal_session,
is_locked, register_failure, clear_failures,
)
from backend.auth_passwords import verify_password
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/portal", tags=["portal"])
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
# Whitelist of fields the portal exposes to a client — sound metrics + run state
# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed.
_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time",
"lp", "leq", "lmax", "lpeak", "ln1", "ln2")
# -- scoping (every data route gates through these) --------------------------
def _client_project_ids(client: Client, db: Session) -> list:
return [r[0] for r in db.query(Project.id).filter(
Project.client_id == client.id, Project.status != "deleted").all()]
def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation:
"""Ownership gate: location must be a sound location in one of the client's
active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so
we never leak whether a location exists."""
loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first()
if (not loc or loc.location_type != "sound"
or loc.project_id not in _client_project_ids(client, db)):
raise HTTPException(status_code=404, detail="Location not found")
return loc
def active_unit_for_location(location_id: str, db: Session):
"""The SLM unit currently assigned to this location, or None."""
now = datetime.utcnow()
asg = (db.query(UnitAssignment)
.filter(UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.device_type == "slm",
or_(UnitAssignment.assigned_until.is_(None),
UnitAssignment.assigned_until > now))
.order_by(UnitAssignment.assigned_at.desc()).first())
return asg.unit_id if asg else None
def _client_locations(client: Client, db: Session) -> list:
"""The client's active sound locations (for the overview tiles + map)."""
pids = _client_project_ids(client, db)
if not pids:
return []
projs = {p.id: p.name for p in
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
locs = (db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id.in_(pids),
MonitoringLocation.location_type == "sound",
MonitoringLocation.removed_at.is_(None))
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
return [{
"id": loc.id, "name": loc.name,
"address": loc.address, "coordinates": loc.coordinates,
"project_name": projs.get(loc.project_id),
"has_device": active_unit_for_location(loc.id, db) is not None,
} for loc in locs]
@router.get("/logout")
def portal_logout():
resp = RedirectResponse(url="/portal/access", status_code=303)
resp.delete_cookie(COOKIE_NAME)
return resp
@router.get("/access")
def portal_access(request: Request):
"""Landing for an unauthenticated visitor (no valid link)."""
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"}
)
@router.get("/p/{link_token}")
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
"""Secure per-project link: resolve the project from the token, prompt for the
shared password. Generic page if the token is unknown/disabled (no leak)."""
project = resolve_project_by_link_token(link_token, db)
if not project or not project.portal_password_hash:
# unknown token, disabled portal, or enabled-but-no-password-set — all look
# identical to a client (no existence/config leak, no self-lockout on a
# passwordless project).
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "invalid"},
status_code=404)
return templates.TemplateResponse("portal/password.html", {
"request": request, "link_token": link_token,
"project_name": project.name, "error": None})
@router.post("/p/{link_token}")
def portal_password_submit(link_token: str, request: Request,
password: str = Form(...), db: Session = Depends(get_db)):
"""Verify the shared password; on success mint a project-scoped session cookie."""
project = resolve_project_by_link_token(link_token, db)
if not project or not project.portal_password_hash:
# unknown token, disabled portal, or enabled-but-no-password-set — all look
# identical to a client (no existence/config leak, no self-lockout on a
# passwordless project).
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "invalid"},
status_code=404)
# Shared per-project password → lock per token. (Keying on IP too only enabled a
# bypass via source-IP rotation, and behind the reverse proxy every client shares
# one IP anyway.)
lock_key = link_token
if is_locked(lock_key):
return templates.TemplateResponse("portal/password.html", {
"request": request, "link_token": link_token, "project_name": project.name,
"error": "Too many attempts. Try again in 15 minutes."}, status_code=200)
if not verify_password(password, project.portal_password_hash):
register_failure(lock_key)
return templates.TemplateResponse("portal/password.html", {
"request": request, "link_token": link_token, "project_name": project.name,
"error": "Incorrect password."}, status_code=200)
clear_failures(lock_key)
token_id = mint_portal_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
return resp
@router.get("")
def portal_home(request: Request, client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Client overview — their active sound locations with live tiles + a map."""
return templates.TemplateResponse(
"portal/overview.html",
{"request": request, "client": client,
"locations": _client_locations(client, db)},
)
@router.get("/location/{location_id}")
def portal_location(location_id: str, request: Request,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Read-only live view for one of the client's locations (404 if not owned)."""
loc = resolve_client_location(client, location_id, db)
return templates.TemplateResponse("portal/location.html", {
"request": request, "client": client, "location": loc,
"has_device": active_unit_for_location(location_id, db) is not None,
})
# -- scoped data (cache reads only — never hits the device) ------------------
@router.get("/api/location/{location_id}/live")
async def portal_location_live(location_id: str,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Scrubbed cached live reading for a location the client owns."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "data": None, "reason": "no_device"}
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
except Exception:
return {"status": "ok", "data": None, "reason": "unreachable"}
if r.status_code != 200:
return {"status": "ok", "data": None, "reason": "no_data"}
full = (r.json() or {}).get("data", {}) or {}
return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}}
@router.get("/api/location/{location_id}/history")
async def portal_location_history(location_id: str, hours: float = 2.0,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Cached chart trail for a location the client owns. (Trail rows are already
just timestamp + lp/leq/lmax/ln1/ln2 safe to pass through.)"""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "readings": []}
hours = max(0.1, min(hours, 48.0))
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history",
params={"hours": hours})
except Exception:
return {"status": "ok", "readings": []}
if r.status_code != 200:
return {"status": "ok", "readings": []}
raw = (r.json() or {}).get("readings", [])
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
"onset_value", "peak_value", "clear_at", "status")
@router.get("/api/location/{location_id}/events")
async def portal_location_events(location_id: str, limit: int = 20,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Scrubbed breach history for a location the client owns (read-only)."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "events": []}
limit = max(1, min(limit, 100))
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
params={"limit": limit})
except Exception:
return {"status": "ok", "events": []}
if r.status_code != 200:
return {"status": "ok", "events": []}
raw = (r.json() or {}).get("events", [])
events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
# hysteresis internals).
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
"schedule_start", "schedule_end", "schedule_days")
@router.get("/api/location/{location_id}/thresholds")
async def portal_location_thresholds(location_id: str,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""The active alert limits for a location the client owns (enabled rules only),
so the client can see what they're being alerted on. Read-only, scrubbed."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "rules": []}
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
except Exception:
return {"status": "ok", "rules": []}
if r.status_code != 200:
return {"status": "ok", "rules": []}
raw = (r.json() or {}).get("rules", [])
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
return {"status": "ok", "rules": rules}
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
def _scrub_frame(raw: str):
"""Project a monitor frame down to the portal whitelist. Drops internal fields
(unit_id, raw_payload, lmin) before it reaches a client; passes control fields
(feed_status, heartbeat) + timestamp through. Returns None for a non-JSON frame
so the caller drops it rather than forwarding anything unscrubbed."""
try:
d = json.loads(raw)
except Exception:
return None
out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d}
if "timestamp" in d:
out["timestamp"] = d["timestamp"]
for ctrl in ("feed_status", "heartbeat"):
if ctrl in d:
out[ctrl] = d[ctrl]
return json.dumps(out)
@router.websocket("/api/location/{location_id}/stream")
async def portal_location_stream(websocket: WebSocket, location_id: str):
"""Live ~1Hz feed for a location the client owns. Auths via the session cookie,
enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed
to the browser (scrubbed). A viewer is just one more subscriber to the one
device feed no extra device connection."""
await websocket.accept()
# Auth + ownership on a short-lived session, then release it for the long bridge.
db = SessionLocal()
try:
client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db)
if client is None:
await websocket.close(code=1008) # policy violation (not authenticated)
return
try:
resolve_client_location(client, location_id, db)
except HTTPException:
await websocket.close(code=1008)
return
unit_id = active_unit_for_location(location_id, db)
finally:
db.close()
if not unit_id:
try:
await websocket.send_json({"feed_status": "no_device"})
finally:
await websocket.close(code=1000)
return
target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target)
async def forward_to_client():
async for message in backend_ws:
frame = _scrub_frame(message)
if frame is not None:
await websocket.send_text(frame)
async def watch_client():
while True:
await websocket.receive_text()
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.warning(f"[PORTAL] stream {location_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
File diff suppressed because it is too large Load Diff
+607 -3076
View File
File diff suppressed because it is too large Load Diff
+2 -59
View File
@@ -186,41 +186,6 @@ async def create_recurring_schedule(
created_schedules = [] created_schedules = []
base_name = data.get("name", "Unnamed Schedule") base_name = data.get("name", "Unnamed Schedule")
# Parse one-off datetime fields if applicable
one_off_start = None
one_off_end = None
if data.get("schedule_type") == "one_off":
from zoneinfo import ZoneInfo
tz = ZoneInfo(data.get("timezone", "America/New_York"))
start_dt_str = data.get("start_datetime")
end_dt_str = data.get("end_datetime")
if not start_dt_str or not end_dt_str:
raise HTTPException(status_code=400, detail="One-off schedules require start and end date/time")
try:
start_local = datetime.fromisoformat(start_dt_str).replace(tzinfo=tz)
end_local = datetime.fromisoformat(end_dt_str).replace(tzinfo=tz)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid datetime format")
duration = end_local - start_local
if duration.total_seconds() < 900:
raise HTTPException(status_code=400, detail="Duration must be at least 15 minutes")
if duration.total_seconds() > 86400:
raise HTTPException(status_code=400, detail="Duration cannot exceed 24 hours")
from datetime import timezone as dt_timezone
now_local = datetime.now(tz)
if start_local <= now_local:
raise HTTPException(status_code=400, detail="Start time must be in the future")
# Convert to UTC for storage
one_off_start = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
one_off_end = end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Create a schedule for each location # Create a schedule for each location
for location in locations: for location in locations:
# Determine device type from location # Determine device type from location
@@ -242,8 +207,6 @@ async def create_recurring_schedule(
include_download=data.get("include_download", True), include_download=data.get("include_download", True),
auto_increment_index=data.get("auto_increment_index", True), auto_increment_index=data.get("auto_increment_index", True),
timezone=data.get("timezone", "America/New_York"), timezone=data.get("timezone", "America/New_York"),
start_datetime=one_off_start,
end_datetime=one_off_end,
) )
# Generate actions immediately so they appear right away # Generate actions immediately so they appear right away
@@ -367,35 +330,19 @@ async def disable_schedule(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Disable a schedule and cancel all its pending actions. Disable a schedule.
""" """
service = get_recurring_schedule_service(db) service = get_recurring_schedule_service(db)
# Count pending actions before disabling (for response message)
from sqlalchemy import and_
from backend.models import ScheduledAction
pending_count = db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
)
).count()
schedule = service.disable_schedule(schedule_id) schedule = service.disable_schedule(schedule_id)
if not schedule: if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
message = "Schedule disabled"
if pending_count > 0:
message += f" and {pending_count} pending action(s) cancelled"
return { return {
"success": True, "success": True,
"schedule_id": schedule.id, "schedule_id": schedule.id,
"enabled": schedule.enabled, "enabled": schedule.enabled,
"cancelled_actions": pending_count, "message": "Schedule disabled",
"message": message,
} }
@@ -497,9 +444,6 @@ async def get_schedule_list_partial(
""" """
Return HTML partial for schedule list. Return HTML partial for schedule list.
""" """
project = db.query(Project).filter_by(id=project_id).first()
project_status = project.status if project else "active"
schedules = db.query(RecurringSchedule).filter_by( schedules = db.query(RecurringSchedule).filter_by(
project_id=project_id project_id=project_id
).order_by(RecurringSchedule.created_at.desc()).all() ).order_by(RecurringSchedule.created_at.desc()).all()
@@ -518,5 +462,4 @@ async def get_schedule_list_partial(
"request": request, "request": request,
"project_id": project_id, "project_id": project_id,
"schedules": schedule_data, "schedules": schedule_data,
"project_status": project_status,
}) })
-434
View File
@@ -1,434 +0,0 @@
"""
Nightly Report Router.
Manual triggers for the night-vs-baseline sound report the same entry point
the scheduled morning tick will reuse. Two endpoints:
GET /reports/nightly/view render and return the HTML inline (preview).
No write, no email. Browser-friendly.
POST /reports/nightly/run full run: build write report.html/json to
disk (dry-run) email. Returns JSON result.
Dates are the *evening* date of the night being reported (the 7/7 in "night of
7/7 morning 7/8"). Defaults to last night. Baseline is optional; pass the
baseline-week range to populate the comparison.
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from datetime import datetime, timedelta, date
from html import escape
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Project, SoundReportConfig, MonitoringLocation
from backend.services.report_pipeline import (
METRIC_REGISTRY, DEFAULT_METRICS, DEFAULT_WINDOWS, _location_reference_baseline,
)
from backend.services.report_orchestrator import run_nightly_report
from backend.utils.timezone import utc_to_local
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects/{project_id}/reports", tags=["reports"])
def _default_night_date() -> date:
"""Last night = yesterday in the user's local timezone."""
return (utc_to_local(datetime.utcnow()) - timedelta(days=1)).date()
def _parse_date(s: Optional[str], field: str) -> Optional[date]:
if not s:
return None
try:
return datetime.strptime(s, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail=f"{field} must be YYYY-MM-DD (got {s!r})")
def _parse_metrics(s: Optional[str]) -> list[str]:
if not s:
return list(DEFAULT_METRICS)
keys = [k.strip().lower() for k in s.split(",") if k.strip()]
unknown = [k for k in keys if k not in METRIC_REGISTRY]
if unknown:
raise HTTPException(
status_code=400,
detail=f"Unknown metric(s): {unknown}. Known: {sorted(METRIC_REGISTRY)}",
)
return keys or list(DEFAULT_METRICS)
def _validate_hhmm(s) -> str:
"""Validate a local HH:MM (24h) time string."""
try:
hh, mm = str(s).split(":")
h, m = int(hh), int(mm)
if 0 <= h < 24 and 0 <= m < 60:
return f"{h:02d}:{m:02d}"
except (ValueError, AttributeError):
pass
raise HTTPException(status_code=400, detail=f"report_time must be HH:MM 24-hour (got {s!r})")
def _config_dict(cfg: Optional[SoundReportConfig], project_id: str) -> dict:
"""Serialise a config row (or defaults if none yet) to JSON."""
return {
"project_id": project_id,
"exists": cfg is not None,
"enabled": cfg.enabled if cfg else False,
"report_time": cfg.report_time if cfg else "08:00",
"metric_keys": cfg.metric_keys if cfg else ",".join(DEFAULT_METRICS),
"baseline_mode": cfg.baseline_mode if cfg else "captured",
"baseline_start": cfg.baseline_start.isoformat() if cfg and cfg.baseline_start else None,
"baseline_end": cfg.baseline_end.isoformat() if cfg and cfg.baseline_end else None,
"recipients": (cfg.recipients if cfg and cfg.recipients else ""),
"last_run_date": cfg.last_run_date.isoformat() if cfg and cfg.last_run_date else None,
}
@router.get("/config")
async def get_report_config(project_id: str, db: Session = Depends(get_db)):
"""Return the project's nightly-report config (or defaults if not set yet)."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
return _config_dict(cfg, project_id)
@router.put("/config")
async def put_report_config(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Create or update the project's nightly-report config (JSON body)."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
data = await request.json()
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
created = cfg is None
if cfg is None:
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
db.add(cfg)
if "enabled" in data:
cfg.enabled = bool(data["enabled"])
if "report_time" in data:
cfg.report_time = _validate_hhmm(data["report_time"])
if "metric_keys" in data:
mk = data["metric_keys"]
mk = mk if isinstance(mk, str) else ",".join(mk or [])
cfg.metric_keys = ",".join(_parse_metrics(mk))
if "baseline_mode" in data:
bm = str(data["baseline_mode"]).lower()
if bm not in ("captured", "reference"):
raise HTTPException(status_code=400, detail="baseline_mode must be 'captured' or 'reference'")
cfg.baseline_mode = bm
if "baseline_start" in data or "baseline_end" in data:
bs = _parse_date(data.get("baseline_start") or None, "baseline_start")
be = _parse_date(data.get("baseline_end") or None, "baseline_end")
if (bs and not be) or (be and not bs):
raise HTTPException(status_code=400, detail="Provide both baseline dates, or neither.")
if bs and be and bs > be:
raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.")
cfg.baseline_start, cfg.baseline_end = bs, be
if "recipients" in data:
recips = data["recipients"]
if isinstance(recips, list):
recips = ",".join(recips)
cfg.recipients = (recips or "").strip() or None
db.commit()
db.refresh(cfg)
return {**_config_dict(cfg, project_id), "created": created}
def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics):
"""Validate inputs and resolve the baseline source.
Explicit baseline dates in the query override (captured mode with those
dates). Otherwise the project's saved config supplies the baseline (its
mode + dates) and the default metric set so the manual view/run match
what the scheduled report does.
Returns (night_date, baseline_mode, baseline_start, baseline_end, metric_keys).
"""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
nd = _parse_date(night_date, "night_date") or _default_night_date()
bs = _parse_date(baseline_start, "baseline_start")
be = _parse_date(baseline_end, "baseline_end")
if (bs and not be) or (be and not bs):
raise HTTPException(status_code=400, detail="Provide both baseline_start and baseline_end, or neither.")
if bs and be and bs > be:
raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.")
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
if bs and be:
baseline_mode = "captured" # explicit dates win
elif cfg:
baseline_mode = cfg.baseline_mode # fall back to saved config
bs, be = cfg.baseline_start, cfg.baseline_end
else:
baseline_mode = "captured"
if metrics:
metric_keys = _parse_metrics(metrics)
elif cfg and cfg.metric_keys:
metric_keys = _parse_metrics(cfg.metric_keys)
else:
metric_keys = list(DEFAULT_METRICS)
return nd, baseline_mode, bs, be, metric_keys
@router.get("/nightly/view", response_class=HTMLResponse)
async def view_nightly_report(
project_id: str,
night_date: Optional[str] = Query(None, description="Evening date of the night (YYYY-MM-DD). Default: last night."),
baseline_start: Optional[str] = Query(None, description="Baseline range start (YYYY-MM-DD)."),
baseline_end: Optional[str] = Query(None, description="Baseline range end (YYYY-MM-DD)."),
metrics: Optional[str] = Query(None, description="Comma list, e.g. lmax,l01,l10,l90. Default: house set."),
db: Session = Depends(get_db),
):
"""Render the night report and return the HTML inline (preview — no write, no email)."""
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
try:
result = run_nightly_report(
db, project_id, nd,
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
send=False, # preview: no email
)
except HTTPException:
raise
except Exception as e: # noqa: BLE001
logger.error("nightly/view failed for %s (%s): %s", project_id, nd, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
return HTMLResponse(result["html"])
@router.post("/nightly/run")
async def run_nightly_report_endpoint(
project_id: str,
night_date: Optional[str] = Query(None, description="Evening date of the night (YYYY-MM-DD). Default: last night."),
baseline_start: Optional[str] = Query(None, description="Baseline range start (YYYY-MM-DD)."),
baseline_end: Optional[str] = Query(None, description="Baseline range end (YYYY-MM-DD)."),
metrics: Optional[str] = Query(None, description="Comma list, e.g. lmax,l01,l10,l90. Default: house set."),
send: bool = Query(True, description="Attempt email (dry-run until SMTP is configured)."),
db: Session = Depends(get_db),
):
"""Run the night report: build → write report.html/report.json to disk → email (best-effort).
This is the same path the scheduled morning tick will call. The `html` field
is omitted from the JSON response (it's large and on disk); use /view to see it.
"""
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
try:
result = run_nightly_report(
db, project_id, nd,
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
send=send,
)
except HTTPException:
raise
except Exception as e: # noqa: BLE001
logger.error("nightly/run failed for %s (%s): %s", project_id, nd, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
result.pop("html", None) # keep the JSON response lean — view it via /view or the file
result["view_url"] = (
f"/api/projects/{project_id}/reports/nightly/view"
f"?night_date={nd:%Y-%m-%d}"
+ (f"&baseline_start={bs:%Y-%m-%d}&baseline_end={be:%Y-%m-%d}" if bs and be else "")
+ (f"&metrics={','.join(metric_keys)}")
)
return result
# ============================================================================
# Test email + generated-report archive
# ============================================================================
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@router.post("/test-email")
async def send_test_email(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Send a small test email to verify the SMTP relay (dry-run if unconfigured).
Recipients: JSON body {"recipients": "..."} overrides; else the project's
configured recipients; else the REPORT_SMTP_RECIPIENTS env default.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
try:
data = await request.json()
except Exception:
data = {}
raw = (data or {}).get("recipients")
if not raw:
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
raw = cfg.recipients if cfg else None
recipients = None
if raw:
if isinstance(raw, list):
raw = ",".join(raw)
recipients = [r.strip() for r in raw.split(",") if r.strip()]
from backend.services.report_email import send_report_email
body = (
"<div style=\"font:14px Arial,sans-serif\">"
f"Terra-View test email for <b>{escape(project.name)}</b>.<br>"
"If you got this, the nightly sound-report email path is working.</div>"
)
return send_report_email("Terra-View — nightly report test email", body, recipients=recipients)
@router.get("/list")
async def list_reports(project_id: str, db: Session = Depends(get_db)):
"""List the generated report artifacts on disk for this project (newest first)."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
base = Path("data/reports") / project_id
out = []
if base.exists():
for d in sorted((p for p in base.iterdir() if p.is_dir()), key=lambda p: p.name, reverse=True):
html_file = d / "report.html"
if html_file.exists():
st = html_file.stat()
out.append({
"night_date": d.name,
"view_url": f"/api/projects/{project_id}/reports/archive/{d.name}",
"xlsx_url": (f"/api/projects/{project_id}/reports/archive/{d.name}/xlsx"
if (d / "report.xlsx").exists() else None),
"size_bytes": st.st_size,
"generated_at": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
})
return {"reports": out, "count": len(out)}
@router.get("/archive/{night_date}", response_class=HTMLResponse)
async def view_archived_report(project_id: str, night_date: str, db: Session = Depends(get_db)):
"""Serve a previously generated report.html from disk (the actual artifact)."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
if not _DATE_RE.match(night_date):
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
safe = _parse_date(night_date, "night_date") # also guards path traversal
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.html"
if not path.exists():
raise HTTPException(status_code=404, detail="No saved report for that date")
return HTMLResponse(path.read_text(encoding="utf-8"))
@router.get("/archive/{night_date}/xlsx")
async def download_archived_xlsx(project_id: str, night_date: str, db: Session = Depends(get_db)):
"""Download a previously generated report.xlsx from disk."""
from fastapi.responses import Response
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
if not _DATE_RE.match(night_date):
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
safe = _parse_date(night_date, "night_date")
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.xlsx"
if not path.exists():
raise HTTPException(status_code=404, detail="No saved spreadsheet for that date")
return Response(
content=path.read_bytes(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="night_report_{safe:%Y-%m-%d}.xlsx"'},
)
# ============================================================================
# Reference baseline (fixed values typed per location — limits / prior averages)
# ============================================================================
@router.get("/baseline")
async def get_baseline(project_id: str, db: Session = Depends(get_db)):
"""Return the baseline mode + per-location reference values + the metric/window
grid to render the editor."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
mode = cfg.baseline_mode if cfg else "captured"
metric_keys = _parse_metrics(cfg.metric_keys) if cfg and cfg.metric_keys else list(DEFAULT_METRICS)
locations = db.query(MonitoringLocation).filter_by(
project_id=project_id, location_type="sound",
).order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
locations = [l for l in locations if getattr(l, "removed_at", None) is None]
return {
"mode": mode,
"windows": [{"key": w.key, "label": w.label} for w in DEFAULT_WINDOWS],
"metrics": [{"key": k, "label": METRIC_REGISTRY[k].label} for k in metric_keys],
"locations": [
{"id": loc.id, "name": loc.name, "values": _location_reference_baseline(loc)}
for loc in locations
],
}
@router.put("/baseline")
async def put_baseline(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Save the baseline mode (on config) and per-location reference values
(on each location's metadata). Body:
{"mode": "reference",
"locations": {"<loc_id>": {"nighttime": {"l10": 85}, "evening": {...}}}}
"""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
data = await request.json()
if "mode" in data:
bm = str(data["mode"]).lower()
if bm not in ("captured", "reference"):
raise HTTPException(status_code=400, detail="mode must be 'captured' or 'reference'")
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
if cfg is None:
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
db.add(cfg)
cfg.baseline_mode = bm
loc_values = data.get("locations") or {}
updated = 0
for loc_id, windows in loc_values.items():
loc = db.query(MonitoringLocation).filter_by(id=loc_id, project_id=project_id).first()
if not loc or not isinstance(windows, dict):
continue
try:
meta = json.loads(loc.location_metadata or "{}")
except (json.JSONDecodeError, TypeError):
meta = {}
clean: dict = {}
for wkey, mvals in windows.items():
if not isinstance(mvals, dict):
continue
cm = {}
for mkey, val in mvals.items():
if val in (None, ""):
continue
try:
cm[mkey] = round(float(val), 1)
except (ValueError, TypeError):
continue
if cm:
clean[wkey] = cm
if clean:
meta["report_baseline"] = clean
else:
meta.pop("report_baseline", None)
loc.location_metadata = json.dumps(meta)
updated += 1
db.commit()
return {"ok": True, "locations_updated": updated}
+70 -177
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, date, timedelta from datetime import datetime, date
import csv import csv
import io import io
import logging import logging
@@ -9,58 +9,16 @@ import httpx
import os import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
import uuid
from backend.services.slmm_sync import sync_slm_to_slmm from backend.services.slmm_sync import sync_slm_to_slmm
from backend.services.unit_location import get_active_location
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_calibration_interval(db: Session) -> int:
"""Get calibration interval from user preferences, default 365 days."""
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
# SLMM backend URL for syncing device configs to cache # SLMM backend URL for syncing device configs to cache
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool):
"""
Keep DeploymentRecord in sync with the deployed flag.
deployed True open a new DeploymentRecord if none is already open.
deployed False close the active DeploymentRecord by setting actual_removal_date = today.
"""
if new_deployed:
existing = db.query(DeploymentRecord).filter(
DeploymentRecord.unit_id == unit.id,
DeploymentRecord.actual_removal_date == None
).first()
if not existing:
record = DeploymentRecord(
id=str(uuid.uuid4()),
unit_id=unit.id,
project_ref=unit.project_id or None,
deployed_date=date.today(),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(record)
else:
active = db.query(DeploymentRecord).filter(
DeploymentRecord.unit_id == unit.id,
DeploymentRecord.actual_removal_date == None
).first()
if active:
active.actual_removal_date = date.today()
active.updated_at = datetime.utcnow()
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None, def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None): old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
"""Helper function to record a change in unit history""" """Helper function to record a change in unit history"""
@@ -180,9 +138,11 @@ async def add_roster_unit(
unit_type: str = Form("series3"), unit_type: str = Form("series3"),
deployed: str = Form(None), deployed: str = Form(None),
retired: str = Form(None), retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -209,7 +169,6 @@ async def add_roster_unit(
# Convert boolean strings to actual booleans # Convert boolean strings to actual booleans
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
# Convert port strings to integers # Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -226,13 +185,8 @@ async def add_roster_unit(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
# Auto-calculate next_calibration_due from last_calibrated using calibration interval
next_cal_date = None next_cal_date = None
if last_cal_date: if next_calibration_due:
cal_interval = get_calibration_interval(db)
next_cal_date = last_cal_date + timedelta(days=cal_interval)
elif next_calibration_due:
# Fallback: allow explicit setting if no last_calibrated
try: try:
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
except ValueError: except ValueError:
@@ -244,9 +198,11 @@ async def add_roster_unit(
unit_type=unit_type, unit_type=unit_type,
deployed=deployed_bool, deployed=deployed_bool,
retired=retired_bool, retired=retired_bool,
out_for_calibration=out_for_calibration_bool,
note=note, note=note,
project_id=project_id, project_id=project_id,
location=location,
address=address,
coordinates=coordinates,
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated=last_cal_date, last_calibrated=last_cal_date,
@@ -268,35 +224,21 @@ async def add_roster_unit(
slm_measurement_range=slm_measurement_range if slm_measurement_range else None, slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
) )
# Auto-fill data from modem if pairing and fields are empty. # Auto-fill location data from modem if pairing and fields are empty
# Location/address/coordinates now come from MonitoringLocation via the
# active UnitAssignment, so there's nothing to copy from the modem row.
if deployed_with_modem_id: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem" RosterUnit.device_type == "modem"
).first() ).first()
if modem: if modem:
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note:
unit.note = modem.note
# Bidirectional pairing sync for new units
if device_type in ("seismograph", "slm") and deployed_with_modem_id:
modem_to_update = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem_to_update:
# Clear old device's reference if modem was paired elsewhere
if modem_to_update.deployed_with_unit_id and modem_to_update.deployed_with_unit_id != id:
old_device = db.query(RosterUnit).filter(
RosterUnit.id == modem_to_update.deployed_with_unit_id
).first()
if old_device and old_device.deployed_with_modem_id == deployed_with_modem_id:
old_device.deployed_with_modem_id = None
modem_to_update.deployed_with_unit_id = id
db.add(unit) db.add(unit)
db.commit() db.commit()
@@ -484,24 +426,17 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
if not unit: if not unit:
raise HTTPException(status_code=404, detail="Unit not found") raise HTTPException(status_code=404, detail="Unit not found")
active_loc = get_active_location(db, unit_id)
return { return {
"id": unit.id, "id": unit.id,
"device_type": unit.device_type or "seismograph", "device_type": unit.device_type or "seismograph",
"unit_type": unit.unit_type, "unit_type": unit.unit_type,
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"out_for_calibration": unit.out_for_calibration or False,
"allocated": getattr(unit, 'allocated', False) or False,
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
"note": unit.note or "", "note": unit.note or "",
"project_id": unit.project_id or "", "project_id": unit.project_id or "",
"active_location": active_loc, "location": unit.location or "",
# Convenience fields so the unit-detail page can read the same shape "address": unit.address or "",
# whether or not there's an active assignment. "coordinates": unit.coordinates or "",
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("coordinates") or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "", "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -528,11 +463,11 @@ async def edit_roster_unit(
unit_type: str = Form("series3"), unit_type: str = Form("series3"),
deployed: str = Form(None), deployed: str = Form(None),
retired: str = Form(None), retired: str = Form(None),
out_for_calibration: str = Form(None),
allocated: str = Form(None),
allocated_to_project_id: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -557,6 +492,8 @@ async def edit_roster_unit(
cascade_deployed: str = Form(None), cascade_deployed: str = Form(None),
cascade_retired: str = Form(None), cascade_retired: str = Form(None),
cascade_project: str = Form(None), cascade_project: str = Form(None),
cascade_location: str = Form(None),
cascade_coordinates: str = Form(None),
cascade_note: str = Form(None), cascade_note: str = Form(None),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
@@ -567,8 +504,6 @@ async def edit_roster_unit(
# Convert boolean strings to actual booleans # Convert boolean strings to actual booleans
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False
# Convert port strings to integers # Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -582,13 +517,8 @@ async def edit_roster_unit(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
# Auto-calculate next_calibration_due from last_calibrated using calibration interval
next_cal_date = None next_cal_date = None
if last_cal_date: if next_calibration_due:
cal_interval = get_calibration_interval(db)
next_cal_date = last_cal_date + timedelta(days=cal_interval)
elif next_calibration_due:
# Fallback: allow explicit setting if no last_calibrated
try: try:
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
except ValueError: except ValueError:
@@ -598,18 +528,17 @@ async def edit_roster_unit(
old_note = unit.note old_note = unit.note
old_deployed = unit.deployed old_deployed = unit.deployed
old_retired = unit.retired old_retired = unit.retired
old_out_for_calibration = unit.out_for_calibration
# Update all fields # Update all fields
unit.device_type = device_type unit.device_type = device_type
unit.unit_type = unit_type unit.unit_type = unit_type
unit.deployed = deployed_bool unit.deployed = deployed_bool
unit.retired = retired_bool unit.retired = retired_bool
unit.out_for_calibration = out_for_calibration_bool
unit.allocated = allocated_bool
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
unit.note = note unit.note = note
unit.project_id = project_id unit.project_id = project_id
unit.location = location
unit.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
@@ -617,19 +546,22 @@ async def edit_roster_unit(
unit.next_calibration_due = next_cal_date unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Auto-fill data from modem if pairing and fields are empty. # Auto-fill location data from modem if pairing and fields are empty
# Location/address/coordinates live on MonitoringLocation now, nothing
# to copy across roster rows.
if deployed_with_modem_id: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem" RosterUnit.device_type == "modem"
).first() ).first()
if modem: if modem:
# Only fill if the device field is empty
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note:
unit.note = modem.note
# Modem-specific fields # Modem-specific fields
unit.ip_address = ip_address if ip_address else None unit.ip_address = ip_address if ip_address else None
@@ -648,51 +580,6 @@ async def edit_roster_unit(
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
# Bidirectional pairing sync
new_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
new_unit_pair_id = deployed_with_unit_id if deployed_with_unit_id else None
# When a device (seismograph/SLM) sets deployed_with_modem_id, update modem's deployed_with_unit_id
if device_type in ("seismograph", "slm"):
# Clear old modem's reference if modem changed
old_modem_id = db.query(RosterUnit.deployed_with_modem_id).filter(
RosterUnit.id == unit_id
).scalar()
# old_modem_id is already the new value at this point since we set it above,
# but we need to check the *previous* modem. We already set it, so check if
# there's a modem pointing to us that we're no longer paired with.
if new_modem_id:
modem_to_update = db.query(RosterUnit).filter(
RosterUnit.id == new_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem_to_update and modem_to_update.deployed_with_unit_id != unit_id:
# Clear old device's reference to this modem if modem was paired elsewhere
if modem_to_update.deployed_with_unit_id:
old_device = db.query(RosterUnit).filter(
RosterUnit.id == modem_to_update.deployed_with_unit_id
).first()
if old_device and old_device.deployed_with_modem_id == new_modem_id:
old_device.deployed_with_modem_id = None
modem_to_update.deployed_with_unit_id = unit_id
# When a modem sets deployed_with_unit_id, update device's deployed_with_modem_id
if device_type == "modem":
if new_unit_pair_id:
device_to_update = db.query(RosterUnit).filter(
RosterUnit.id == new_unit_pair_id,
RosterUnit.device_type.in_(["seismograph", "slm"])
).first()
if device_to_update and device_to_update.deployed_with_modem_id != unit_id:
# Clear old modem's reference to this device if device was paired elsewhere
if device_to_update.deployed_with_modem_id:
old_modem = db.query(RosterUnit).filter(
RosterUnit.id == device_to_update.deployed_with_modem_id
).first()
if old_modem and old_modem.deployed_with_unit_id == new_unit_pair_id:
old_modem.deployed_with_unit_id = None
device_to_update.deployed_with_modem_id = unit_id
# Record history entries for changed fields # Record history entries for changed fields
if old_note != note: if old_note != note:
record_history(db, unit_id, "note_change", "note", old_note, note, "manual") record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
@@ -701,18 +588,12 @@ async def edit_roster_unit(
status_text = "deployed" if deployed else "benched" status_text = "deployed" if deployed else "benched"
old_status_text = "deployed" if old_deployed else "benched" old_status_text = "deployed" if old_deployed else "benched"
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual") record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
sync_deployment_record(db, unit, deployed_bool)
if old_retired != retired: if old_retired != retired:
status_text = "retired" if retired else "active" status_text = "retired" if retired else "active"
old_status_text = "retired" if old_retired else "active" old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual") record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
if old_out_for_calibration != out_for_calibration_bool:
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
# Handle cascade to paired device # Handle cascade to paired device
cascaded_unit_id = None cascaded_unit_id = None
if cascade_to_unit_id and cascade_to_unit_id.strip(): if cascade_to_unit_id and cascade_to_unit_id.strip():
@@ -751,6 +632,26 @@ async def edit_roster_unit(
record_history(db, paired_unit.id, "project_change", "project_id", record_history(db, paired_unit.id, "project_change", "project_id",
old_paired_project or "", project_id or "", f"cascade from {unit_id}") old_paired_project or "", project_id or "", f"cascade from {unit_id}")
# Cascade address/location
if cascade_location in ['true', 'True', '1', 'yes']:
old_paired_address = paired_unit.address
old_paired_location = paired_unit.location
paired_unit.address = address
paired_unit.location = location
paired_unit.last_updated = datetime.utcnow()
if old_paired_address != address:
record_history(db, paired_unit.id, "address_change", "address",
old_paired_address or "", address or "", f"cascade from {unit_id}")
# Cascade coordinates
if cascade_coordinates in ['true', 'True', '1', 'yes']:
old_paired_coords = paired_unit.coordinates
paired_unit.coordinates = coordinates
paired_unit.last_updated = datetime.utcnow()
if old_paired_coords != coordinates:
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
# Cascade note # Cascade note
if cascade_note in ['true', 'True', '1', 'yes']: if cascade_note in ['true', 'True', '1', 'yes']:
old_paired_note = paired_unit.note old_paired_note = paired_unit.note
@@ -798,7 +699,6 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
new_value=status_text, new_value=status_text,
source="manual" source="manual"
) )
sync_deployment_record(db, unit, deployed)
db.commit() db.commit()
@@ -973,8 +873,9 @@ async def import_csv(
- retired: Boolean - retired: Boolean
- note: Notes about the unit - note: Notes about the unit
- project_id: Project identifier - project_id: Project identifier
(Location / address / coordinates are not roster fields anymore they - location: Location description
live on the MonitoringLocation a unit is assigned to.) - address: Street address
- coordinates: GPS coordinates (lat;lon or lat,lon)
Seismograph-specific: Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD) - last_calibrated: Date (YYYY-MM-DD)
@@ -1087,18 +988,15 @@ async def import_csv(
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note) existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id) existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow() existing_unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
if row.get('last_calibrated'): if row.get('last_calibrated'):
last_cal = _parse_date(row.get('last_calibrated')) existing_unit.last_calibrated = _parse_date(row.get('last_calibrated'))
existing_unit.last_calibrated = last_cal if row.get('next_calibration_due'):
# Auto-calculate next_calibration_due using calibration interval
if last_cal:
cal_interval = get_calibration_interval(db)
existing_unit.next_calibration_due = last_cal + timedelta(days=cal_interval)
elif row.get('next_calibration_due'):
# Only use explicit next_calibration_due if no last_calibrated
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due')) existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
if row.get('deployed_with_modem_id'): if row.get('deployed_with_modem_id'):
existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id') existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id')
@@ -1135,14 +1033,6 @@ async def import_csv(
results["updated"].append(unit_id) results["updated"].append(unit_id)
else: else:
# Calculate next_calibration_due from last_calibrated
last_cal = _parse_date(row.get('last_calibrated', ''))
if last_cal:
cal_interval = get_calibration_interval(db)
next_cal = last_cal + timedelta(days=cal_interval)
else:
next_cal = _parse_date(row.get('next_calibration_due', ''))
# Create new unit with all fields # Create new unit with all fields
new_unit = RosterUnit( new_unit = RosterUnit(
id=unit_id, id=unit_id,
@@ -1152,10 +1042,13 @@ async def import_csv(
retired=_parse_bool(row.get('retired', '')), retired=_parse_bool(row.get('retired', '')),
note=_get_csv_value(row, 'note', ''), note=_get_csv_value(row, 'note', ''),
project_id=_get_csv_value(row, 'project_id'), project_id=_get_csv_value(row, 'project_id'),
location=_get_csv_value(row, 'location'),
address=_get_csv_value(row, 'address'),
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated # Seismograph fields
last_calibrated=last_cal, last_calibrated=_parse_date(row.get('last_calibrated', '')),
next_calibration_due=next_cal, next_calibration_due=_parse_date(row.get('next_calibration_due', '')),
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'), deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
# Modem fields # Modem fields
ip_address=_get_csv_value(row, 'ip_address'), ip_address=_get_csv_value(row, 'ip_address'),
+4 -4
View File
@@ -92,15 +92,15 @@ async def rename_unit(
except Exception as e: except Exception as e:
logger.warning(f"Could not update unit_assignments: {e}") logger.warning(f"Could not update unit_assignments: {e}")
# Update monitoring_sessions table (if exists) # Update recording_sessions table (if exists)
try: try:
from backend.models import MonitoringSession from backend.models import RecordingSession
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update( db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
{"unit_id": new_id}, {"unit_id": new_id},
synchronize_session=False synchronize_session=False
) )
except Exception as e: except Exception as e:
logger.warning(f"Could not update monitoring_sessions: {e}") logger.warning(f"Could not update recording_sessions: {e}")
# Commit all changes # Commit all changes
db.commit() db.commit()
+8 -156
View File
@@ -3,13 +3,11 @@ Seismograph Dashboard API Router
Provides endpoints for the seismograph-specific dashboard Provides endpoints for the seismograph-specific dashboard
""" """
from datetime import date, datetime, timedelta from fastapi import APIRouter, Request, Depends, Query
from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, UnitHistory, UserPreferences from backend.models import RosterUnit
from backend.templates_config import templates from backend.templates_config import templates
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
@@ -28,8 +26,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
total = len(seismos) total = len(seismos)
deployed = sum(1 for s in seismos if s.deployed) deployed = sum(1 for s in seismos if s.deployed)
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration) benched = sum(1 for s in seismos if not s.deployed)
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
# Count modems assigned to deployed seismographs # Count modems assigned to deployed seismographs
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id) with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
@@ -42,7 +39,6 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
"total": total, "total": total,
"deployed": deployed, "deployed": deployed,
"benched": benched, "benched": benched,
"out_for_calibration": out_for_calibration,
"with_modem": with_modem, "with_modem": with_modem,
"without_modem": without_modem "without_modem": without_modem
} }
@@ -53,14 +49,10 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
async def get_seismo_units( async def get_seismo_units(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
search: str = Query(None), search: str = Query(None)
sort: str = Query("id"),
order: str = Query("asc"),
status: str = Query(None),
modem: str = Query(None)
): ):
""" """
Returns HTML partial with filterable and sortable seismograph unit list Returns HTML partial with filterable seismograph unit list
""" """
query = db.query(RosterUnit).filter_by( query = db.query(RosterUnit).filter_by(
device_type="seismograph", device_type="seismograph",
@@ -69,160 +61,20 @@ async def get_seismo_units(
# Apply search filter # Apply search filter
if search: if search:
search_lower = search.lower()
query = query.filter( query = query.filter(
(RosterUnit.id.ilike(f"%{search}%")) | (RosterUnit.id.ilike(f"%{search}%")) |
(RosterUnit.note.ilike(f"%{search}%")) | (RosterUnit.note.ilike(f"%{search}%")) |
(RosterUnit.address.ilike(f"%{search}%")) (RosterUnit.address.ilike(f"%{search}%"))
) )
# Apply status filter seismos = query.order_by(RosterUnit.id).all()
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
elif status == "out_for_calibration":
query = query.filter(RosterUnit.out_for_calibration == True)
# Apply modem filter
if modem == "with":
query = query.filter(RosterUnit.deployed_with_modem_id.isnot(None))
elif modem == "without":
query = query.filter(RosterUnit.deployed_with_modem_id.is_(None))
# Apply sorting
sort_column_map = {
"id": RosterUnit.id,
"status": RosterUnit.deployed,
"modem": RosterUnit.deployed_with_modem_id,
"location": RosterUnit.address,
"last_calibrated": RosterUnit.last_calibrated,
"notes": RosterUnit.note
}
sort_column = sort_column_map.get(sort, RosterUnit.id)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
seismos = query.all()
return templates.TemplateResponse( return templates.TemplateResponse(
"partials/seismo_unit_list.html", "partials/seismo_unit_list.html",
{ {
"request": request, "request": request,
"units": seismos, "units": seismos,
"search": search or "", "search": search or ""
"sort": sort,
"order": order,
"status": status or "",
"modem": modem or "",
"today": date.today()
} }
) )
def _get_calibration_interval(db: Session) -> int:
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
def _row_context(request: Request, unit: RosterUnit) -> dict:
return {"request": request, "unit": unit, "today": date.today()}
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
async def quick_update_seismo_unit(
unit_id: str,
request: Request,
db: Session = Depends(get_db),
status: str = Form(...),
last_calibrated: str = Form(""),
note: str = Form(""),
):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
# --- Status ---
old_deployed = unit.deployed
old_out_for_cal = unit.out_for_calibration
if status == "deployed":
unit.deployed = True
unit.out_for_calibration = False
elif status == "out_for_calibration":
unit.deployed = False
unit.out_for_calibration = True
else:
unit.deployed = False
unit.out_for_calibration = False
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
db.add(UnitHistory(
unit_id=unit_id,
change_type="deployed_change",
field_name="status",
old_value=old_status,
new_value=status,
source="manual",
))
# --- Last calibrated ---
old_cal = unit.last_calibrated
if last_calibrated:
try:
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
unit.last_calibrated = new_cal
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
else:
unit.last_calibrated = None
unit.next_calibration_due = None
if unit.last_calibrated != old_cal:
db.add(UnitHistory(
unit_id=unit_id,
change_type="calibration_status_change",
field_name="last_calibrated",
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
new_value=last_calibrated or None,
source="manual",
))
# --- Note ---
old_note = unit.note
unit.note = note or None
if unit.note != old_note:
db.add(UnitHistory(
unit_id=unit_id,
change_type="note_change",
field_name="note",
old_value=old_note,
new_value=unit.note,
source="manual",
))
db.commit()
db.refresh(unit)
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
+9 -14
View File
@@ -12,7 +12,6 @@ from pathlib import Path
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
from backend.services.database_backup import DatabaseBackupService from backend.services.database_backup import DatabaseBackupService
from backend.services.unit_location import bulk_active_locations
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -22,14 +21,11 @@ def export_roster_csv(db: Session = Depends(get_db)):
"""Export all roster units to CSV""" """Export all roster units to CSV"""
units = db.query(RosterUnit).all() units = db.query(RosterUnit).all()
# Create CSV in memory. Location lives on MonitoringLocation now, so # Create CSV in memory
# we don't export legacy address/coordinates/location columns here —
# round-trip CSV editing would otherwise look like it edits unit
# location, when it can't.
output = io.StringIO() output = io.StringIO()
fieldnames = [ fieldnames = [
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired', 'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
'note', 'project_id', 'note', 'project_id', 'location', 'address', 'coordinates',
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id', 'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
'ip_address', 'phone_number', 'hardware_model' 'ip_address', 'phone_number', 'hardware_model'
] ]
@@ -46,6 +42,9 @@ def export_roster_csv(db: Session = Depends(get_db)):
'retired': 'true' if unit.retired else 'false', 'retired': 'true' if unit.retired else 'false',
'note': unit.note or '', 'note': unit.note or '',
'project_id': unit.project_id or '', 'project_id': unit.project_id or '',
'location': unit.location or '',
'address': unit.address or '',
'coordinates': unit.coordinates or '',
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '', 'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '', 'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
'deployed_with_modem_id': unit.deployed_with_modem_id or '', 'deployed_with_modem_id': unit.deployed_with_modem_id or '',
@@ -83,7 +82,6 @@ def get_table_stats(db: Session = Depends(get_db)):
def get_all_roster_units(db: Session = Depends(get_db)): def get_all_roster_units(db: Session = Depends(get_db)):
"""Get all roster units for management table""" """Get all roster units for management table"""
units = db.query(RosterUnit).order_by(RosterUnit.id).all() units = db.query(RosterUnit).order_by(RosterUnit.id).all()
active_locs = bulk_active_locations(db, units)
return [{ return [{
"id": unit.id, "id": unit.id,
@@ -92,10 +90,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"note": unit.note or "", "note": unit.note or "",
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "", "project_id": unit.project_id or "",
"address": (active_locs.get(unit.id) or {}).get("address") or "", "location": unit.location or "",
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "", "address": unit.address or "",
"location_name": (active_locs.get(unit.id) or {}).get("name") or "", "coordinates": unit.coordinates or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None, "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None, "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -269,7 +267,6 @@ class PreferencesUpdate(BaseModel):
calibration_warning_days: Optional[int] = None calibration_warning_days: Optional[int] = None
status_ok_threshold_hours: Optional[int] = None status_ok_threshold_hours: Optional[int] = None
status_pending_threshold_hours: Optional[int] = None status_pending_threshold_hours: Optional[int] = None
mic_unit_pref: Optional[str] = None
@router.get("/preferences") @router.get("/preferences")
@@ -296,7 +293,6 @@ def get_preferences(db: Session = Depends(get_db)):
"calibration_warning_days": prefs.calibration_warning_days, "calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "psi",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
@@ -338,7 +334,6 @@ def update_preferences(
"calibration_warning_days": prefs.calibration_warning_days, "calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "psi",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
-130
View File
@@ -1,130 +0,0 @@
"""
SFM (Seismograph Field Module) Proxy Router
Proxies requests from terra-view to the standalone SFM backend service.
SFM runs on port 8200 and handles MiniMate Plus seismograph communication
and event database queries.
SFM endpoints are at root level (e.g. /db/units, /device/info) no /api/ prefix.
"""
from fastapi import APIRouter, HTTPException, Request, Response
import httpx
import logging
import os
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sfm", tags=["sfm"])
# SFM backend URL - configurable via environment variable
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
@router.get("/health")
async def check_sfm_health():
"""
Check if the SFM backend service is reachable and healthy.
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{SFM_BASE_URL}/health")
if response.status_code == 200:
data = response.json()
return {
"status": "ok",
"sfm_status": "connected",
"sfm_url": SFM_BASE_URL,
"sfm_response": data
}
else:
return {
"status": "degraded",
"sfm_status": "error",
"sfm_url": SFM_BASE_URL,
"detail": f"SFM returned status {response.status_code}"
}
except httpx.ConnectError:
return {
"status": "error",
"sfm_status": "unreachable",
"sfm_url": SFM_BASE_URL,
"detail": "Cannot connect to SFM backend. Is it running?"
}
except Exception as e:
return {
"status": "error",
"sfm_status": "error",
"sfm_url": SFM_BASE_URL,
"detail": str(e)
}
# HTTP catch-all — proxies everything else to SFM backend
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_sfm(path: str, request: Request):
"""
Proxy all requests to the SFM backend service.
SFM endpoints have no /api/ prefix target URL is {SFM_BASE_URL}/{path}.
Timeout is 60s to allow for live device round-trips (event downloads can
take 30-45s for a full event list).
"""
# Build target URL — SFM endpoints live at root, not /api/
target_url = f"{SFM_BASE_URL}/{path}"
# Forward query params
query_params = dict(request.query_params)
# Read body for mutation requests
body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
body = await request.body()
except Exception as e:
logger.error(f"Failed to read request body: {e}")
body = None
# Strip hop-by-hop headers
headers = dict(request.headers)
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
logger.info(f"Proxying {request.method} {path} → SFM: {target_url}")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.request(
method=request.method,
url=target_url,
params=query_params,
headers=proxy_headers,
content=body
)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type")
)
except httpx.ConnectError:
logger.error(f"Failed to connect to SFM backend at {SFM_BASE_URL}")
raise HTTPException(
status_code=503,
detail=f"SFM backend service unavailable. Is SFM running on {SFM_BASE_URL}?"
)
except httpx.TimeoutException:
logger.error(f"Timeout connecting to SFM backend at {SFM_BASE_URL}")
raise HTTPException(
status_code=504,
detail="SFM backend timeout"
)
except Exception as e:
logger.error(f"Error proxying to SFM: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to proxy request to SFM: {str(e)}"
)
+42 -64
View File
@@ -91,43 +91,29 @@ async def get_slm_units(
one_hour_ago = datetime.utcnow() - timedelta(hours=1) one_hour_ago = datetime.utcnow() - timedelta(hours=1)
for unit in units: for unit in units:
# Legacy default from the roster field; refined from SLMM's cached status below.
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
unit.measurement_state = None
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
if include_measurement: if include_measurement:
# SLMM's /roster carries each unit's CACHED status (last_seen, async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
# real monitoring without sending Measure? to the device (which the old
# /measurement-state did) and competing with DOD polling. One call covers all.
slmm_status = {}
try: try:
async with httpx.AsyncClient(timeout=3.0) as client: response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster") if response.status_code == 200:
if r.status_code == 200: return response.json().get("measurement_state")
for dev in (r.json().get("devices") or []):
slmm_status[dev.get("unit_id")] = dev.get("status") or {}
except Exception: except Exception:
slmm_status = {} return None
return None
# "Recent" = the monitor has a fresh successful read. last_seen only advances deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
# on a successful poll, so staleness == the device isn't being reached. if deployed_units:
recent_cutoff = datetime.utcnow() - timedelta(minutes=5) async with httpx.AsyncClient(timeout=3.0) as client:
for unit in units: tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
st = slmm_status.get(unit.id) results = await asyncio.gather(*tasks, return_exceptions=True)
if not st:
continue for unit, state in zip(deployed_units, results):
unit.measurement_state = st.get("measurement_state") if isinstance(state, Exception):
last_seen = st.get("last_seen") unit.measurement_state = None
if last_seen: else:
try: unit.measurement_state = state
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
unit.is_recent = ls > recent_cutoff
unit.cache_last_seen = ls # the real freshness the monitor updates
except Exception:
pass
return templates.TemplateResponse("partials/slm_device_list.html", { return templates.TemplateResponse("partials/slm_device_list.html", {
"request": request, "request": request,
@@ -171,18 +157,25 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
is_measuring = False is_measuring = False
try: try:
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor async with httpx.AsyncClient(timeout=10.0) as client:
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we # Get measurement state
# no longer fire Measure? + a fresh DOD read at the device on every command- state_response = await client.get(
# center load (which competed with DOD polling for the single connection). f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
async with httpx.AsyncClient(timeout=5.0) as client: )
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status") if state_response.status_code == 200:
if r.status_code == 200: state_data = state_response.json()
current_status = r.json().get("data", {}) measurement_state = state_data.get("measurement_state", "Unknown")
measurement_state = current_status.get("measurement_state") is_measuring = state_data.get("is_measuring", False)
is_measuring = measurement_state in ("Start", "Measure")
# Get live status (measurement_start_time is already stored in SLMM database)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)
if status_response.status_code == 200:
status_data = status_response.json()
current_status = status_data.get("data", {})
except Exception as e: except Exception as e:
logger.error(f"Failed to get cached status for {unit_id}: {e}") logger.error(f"Failed to get status for {unit_id}: {e}")
return templates.TemplateResponse("partials/slm_live_view.html", { return templates.TemplateResponse("partials/slm_live_view.html", {
"request": request, "request": request,
@@ -207,39 +200,24 @@ async def control_slm(unit_id: str, action: str):
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
try: try:
# 30s: a real device start confirms over cellular in a few seconds, but async with httpx.AsyncClient(timeout=10.0) as client:
# 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( response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
) )
if response.status_code == 200: if response.status_code == 200:
return response.json() 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 { return {
"status": "error", "status": "error",
"detail": ( "detail": f"SLMM returned status {response.status_code}"
f"Timed out waiting for the device to {action}. "
f"The command may still have been applied — refresh to confirm."
),
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to control {unit_id}: {e}") logger.error(f"Failed to control {unit_id}: {e}")
# Never return an empty detail — it renders to users as "Unknown error". return {
return {"status": "error", "detail": str(e) or f"{type(e).__name__}"} "status": "error",
"detail": str(e)
}
@router.get("/config/{unit_id}", response_class=HTMLResponse) @router.get("/config/{unit_id}", response_class=HTMLResponse)
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
+2 -4
View File
@@ -14,7 +14,6 @@ import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,14 +58,13 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
except Exception as e: except Exception as e:
logger.warning(f"Failed to get SLM status for {unit_id}: {e}") logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
loc = get_active_location(db, unit_id)
return { return {
"unit_id": unit_id, "unit_id": unit_id,
"device_type": "slm", "device_type": "slm",
"deployed": unit.deployed, "deployed": unit.deployed,
"model": unit.slm_model or "NL-43", "model": unit.slm_model or "NL-43",
"location": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": unit.address or unit.location,
"coordinates": (loc or {}).get("coordinates") or "", "coordinates": unit.coordinates,
"note": unit.note, "note": unit.note,
"status": status_data, "status": status_data,
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None, "last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
-70
View File
@@ -231,76 +231,6 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
logger.info(f"WebSocket proxy closed for {unit_id} (live)") logger.info(f"WebSocket proxy closed for {unit_id} (live)")
@router.websocket("/{unit_id}/monitor")
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
"""
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
(no single-connection contention) and it carries L1/L10 (which the DRD
/stream cannot). Preferred over /stream for the live view.
"""
await websocket.accept()
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target_ws_url)
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
async def forward_to_client():
"""Backend monitor frames -> browser."""
async for message in backend_ws:
await websocket.send_text(message)
async def watch_client():
"""Drain client frames; raises WebSocketDisconnect on close so we can
tear the pair down (the monitor feed is server->client only)."""
while True:
await websocket.receive_text()
# When EITHER side ends (browser disconnects or backend closes), cancel the
# other immediately — avoids sending into a closed socket (the
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
# Await ALL tasks (the done one AND the cancelled one) and swallow both
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
# BaseException, so a bare `except Exception` misses it — that's what leaked
# the traceback on stop; and awaiting only `pending` left the done task's
# exception unretrieved.
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except websockets.exceptions.WebSocketException as e:
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
try:
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
except Exception:
pass
except Exception as e:
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
# HTTP catch-all route MUST come after specific routes (including WebSocket routes) # HTTP catch-all route MUST come after specific routes (including WebSocket routes)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_slmm(path: str, request: Request): async def proxy_to_slmm(path: str, request: Request):
+18 -112
View File
@@ -1,11 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from typing import Dict, Any, Optional from typing import Dict, Any
from backend.database import get_db from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.services.unit_location import get_active_location
from backend.models import RosterUnit from backend.models import RosterUnit
router = APIRouter(prefix="/api", tags=["units"]) router = APIRouter(prefix="/api", tags=["units"])
@@ -14,8 +13,7 @@ router = APIRouter(prefix="/api", tags=["units"])
@router.get("/unit/{unit_id}") @router.get("/unit/{unit_id}")
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
""" """
Returns detailed data for a single unit, including its active deployment Returns detailed data for a single unit.
location (or None if benched / unassigned).
""" """
snapshot = emit_status_snapshot() snapshot = emit_status_snapshot()
@@ -23,7 +21,17 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
unit_data = snapshot["units"][unit_id] unit_data = snapshot["units"][unit_id]
active_loc = get_active_location(db, unit_id)
# Mock coordinates for now (will be replaced with real data)
mock_coords = {
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
}
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
return { return {
"id": unit_id, "id": unit_id,
@@ -33,7 +41,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"last_file": unit_data.get("fname", ""), "last_file": unit_data.get("fname", ""),
"deployed": unit_data["deployed"], "deployed": unit_data["deployed"],
"note": unit_data.get("note", ""), "note": unit_data.get("note", ""),
"active_location": active_loc, "coordinates": coords
} }
@@ -41,16 +49,12 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
""" """
Get unit data directly from the roster (for settings/configuration). Get unit data directly from the roster (for settings/configuration).
Address/coordinates come from the active MonitoringLocation, not the
roster row.
""" """
unit = db.query(RosterUnit).filter_by(id=unit_id).first() unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit: if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
active_loc = get_active_location(db, unit_id)
return { return {
"id": unit.id, "id": unit.id,
"unit_type": unit.unit_type, "unit_type": unit.unit_type,
@@ -58,9 +62,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"note": unit.note, "note": unit.note,
"active_location": active_loc, "location": unit.location,
"address": (active_loc or {}).get("address") or "", "address": unit.address,
"coordinates": (active_loc or {}).get("coordinates") or "", "coordinates": unit.coordinates,
"slm_host": unit.slm_host, "slm_host": unit.slm_host,
"slm_tcp_port": unit.slm_tcp_port, "slm_tcp_port": unit.slm_tcp_port,
"slm_ftp_port": unit.slm_ftp_port, "slm_ftp_port": unit.slm_ftp_port,
@@ -68,101 +72,3 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"slm_serial_number": unit.slm_serial_number, "slm_serial_number": unit.slm_serial_number,
"deployed_with_modem_id": unit.deployed_with_modem_id "deployed_with_modem_id": unit.deployed_with_modem_id
} }
@router.get("/units/{unit_id}/events")
async def get_unit_events(
unit_id: str,
bucket: str = Query("all", regex="^(all|attributed|unattributed)$"),
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),
):
"""
Return SFM events for a single unit, annotated with assignment attribution.
Each event includes an `attribution` object pointing at the project/location
it falls into (or null if outside every assignment window). Unattributed
events also carry a `nearest_assignment` field with `delta_days` so the
operator can see how far off the nearest assignment is useful for
deciding whether to backdate the assignment to absorb the event.
Bucket filter:
- all (default): every event
- attributed: only events inside an assignment window
- unattributed: only orphan events (the diagnostic bucket)
Non-seismograph units return an empty events list. The route does not
404 for SLMs/modems so the unit detail page can render the section
conditionally without depending on the response shape.
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
if unit.device_type != "seismograph":
return {
"events": [],
"count": 0,
"stats": {
"event_count": 0,
"unattributed_count": 0,
"peak_pvs": None,
"peak_pvs_at": None,
"peak_pvs_serial": None,
"last_event": None,
"false_trigger_count": 0,
},
"assignments_total": 0,
"device_type": unit.device_type,
}
from backend.services.sfm_events import events_for_unit
result = await events_for_unit(
db,
unit_id,
bucket=bucket,
from_dt=from_dt,
to_dt=to_dt,
false_trigger=false_trigger,
limit=limit,
)
result["device_type"] = unit.device_type
return result
@router.get("/units/{unit_id}/deployment_timeline")
async def get_unit_deployment_timeline(
unit_id: str,
include_events: bool = Query(True),
db: Session = Depends(get_db),
):
"""
Return a chronological deployment timeline for a unit.
Merges three sources:
1. unit_assignments authoritative project/location deployments
2. unit_history state changes (calibration, retirement, etc.)
3. SFM events per-assignment overlay (count + peak PVS + last event)
Replaces the legacy /api/deployments/{unit_id} (which read the
deprecated `deployment_records` table) and the
/api/roster/history/{unit_id} timeline endpoint, unifying them into
a single derived view.
Gaps >= 1 day between consecutive assignments are surfaced as
synthetic "gap" entries.
Pass include_events=false to skip the SFM event overlay (saves N
HTTP calls; useful for fast text-only history dumps).
"""
from backend.services.deployment_timeline import deployment_timeline_for_unit
return await deployment_timeline_for_unit(
db,
unit_id,
include_event_overlay=include_events,
)
-133
View File
@@ -1,133 +0,0 @@
"""
Watcher Manager admin API for series3-watcher and thor-watcher agents.
Endpoints:
GET /api/admin/watchers list all watcher agents
GET /api/admin/watchers/{agent_id} get single agent detail
POST /api/admin/watchers/{agent_id}/trigger-update flag agent for update
POST /api/admin/watchers/{agent_id}/clear-update clear update flag
GET /api/admin/watchers/{agent_id}/update-check polled by watcher on heartbeat
Page:
GET /admin/watchers HTML admin page
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional
from backend.database import get_db
from backend.models import WatcherAgent
from backend.templates_config import templates
router = APIRouter(tags=["admin"])
# ── helpers ──────────────────────────────────────────────────────────────────
def _agent_to_dict(agent: WatcherAgent) -> dict:
last_seen = agent.last_seen
if last_seen:
now_utc = datetime.utcnow()
age_minutes = int((now_utc - last_seen).total_seconds() // 60)
if age_minutes > 60:
status = "missing"
else:
status = "ok"
else:
age_minutes = None
status = "missing"
return {
"id": agent.id,
"source_type": agent.source_type,
"version": agent.version,
"last_seen": last_seen.isoformat() if last_seen else None,
"age_minutes": age_minutes,
"status": status,
"ip_address": agent.ip_address,
"log_tail": agent.log_tail,
"update_pending": bool(agent.update_pending),
"update_version": agent.update_version,
}
# ── API routes ────────────────────────────────────────────────────────────────
@router.get("/api/admin/watchers")
def list_watchers(db: Session = Depends(get_db)):
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
return [_agent_to_dict(a) for a in agents]
@router.get("/api/admin/watchers/{agent_id}")
def get_watcher(agent_id: str, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
return _agent_to_dict(agent)
class TriggerUpdateRequest(BaseModel):
version: Optional[str] = None # target version label (informational)
@router.post("/api/admin/watchers/{agent_id}/trigger-update")
def trigger_update(agent_id: str, body: TriggerUpdateRequest, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
agent.update_pending = True
agent.update_version = body.version
db.commit()
return {"ok": True, "agent_id": agent_id, "update_pending": True}
@router.post("/api/admin/watchers/{agent_id}/clear-update")
def clear_update(agent_id: str, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
agent.update_pending = False
agent.update_version = None
db.commit()
return {"ok": True, "agent_id": agent_id, "update_pending": False}
@router.get("/api/admin/watchers/{agent_id}/update-check")
def update_check(agent_id: str, db: Session = Depends(get_db)):
"""
Polled by watcher agents on each heartbeat cycle.
Returns update_available=True when an update has been triggered via the UI.
Automatically clears the flag after the watcher acknowledges it.
"""
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
return {"update_available": False}
pending = bool(agent.update_pending)
if pending:
# Clear the flag — the watcher will now self-update
agent.update_pending = False
db.commit()
return {
"update_available": pending,
"version": agent.update_version,
}
# ── HTML page ─────────────────────────────────────────────────────────────────
@router.get("/admin/watchers", response_class=HTMLResponse)
def admin_watchers_page(request: Request, db: Session = Depends(get_db)):
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
agents_data = [_agent_to_dict(a) for a in agents]
return templates.TemplateResponse("admin_watchers.html", {
"request": request,
"agents": agents_data,
})
+4 -72
View File
@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Optional, List from typing import Optional, List
from backend.database import get_db from backend.database import get_db
from backend.models import Emitter, WatcherAgent from backend.models import Emitter
router = APIRouter() router = APIRouter()
@@ -107,35 +107,6 @@ def get_fleet_status(db: Session = Depends(get_db)):
emitters = db.query(Emitter).all() emitters = db.query(Emitter).all()
return emitters return emitters
# ── Watcher agent upsert helper ───────────────────────────────────────────────
def _upsert_watcher_agent(db: Session, source_id: str, source_type: str,
version: str, ip_address: str, log_tail: str,
status: str) -> None:
"""Create or update the WatcherAgent row for a given source_id."""
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first()
if agent:
agent.source_type = source_type
agent.version = version
agent.last_seen = datetime.utcnow()
agent.status = status
if ip_address:
agent.ip_address = ip_address
if log_tail is not None:
agent.log_tail = log_tail
else:
agent = WatcherAgent(
id=source_id,
source_type=source_type,
version=version,
last_seen=datetime.utcnow(),
status=status,
ip_address=ip_address,
log_tail=log_tail,
)
db.add(agent)
# series3v1.1 Standardized Heartbeat Schema (multi-unit) # series3v1.1 Standardized Heartbeat Schema (multi-unit)
from fastapi import Request from fastapi import Request
@@ -149,11 +120,6 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
source = payload.get("source_id") source = payload.get("source_id")
units = payload.get("units", []) units = payload.get("units", [])
version = payload.get("version")
log_tail = payload.get("log_tail") # list of strings or None
import json as _json
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
client_ip = request.client.host if request.client else None
print("\n=== Series 3 Heartbeat ===") print("\n=== Series 3 Heartbeat ===")
print("Source:", source) print("Source:", source)
@@ -216,27 +182,13 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
results.append({"unit": uid, "status": status}) results.append({"unit": uid, "status": status})
if source:
_upsert_watcher_agent(db, source, "series3_watcher", version,
client_ip, log_tail_str, "ok")
db.commit()
# Check if an update has been triggered for this agent
update_available = False
if source:
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
if agent and agent.update_pending:
update_available = True
agent.update_pending = False
db.commit() db.commit()
return { return {
"message": "Heartbeat processed", "message": "Heartbeat processed",
"source": source, "source": source,
"units_processed": len(results), "units_processed": len(results),
"results": results, "results": results
"update_available": update_available,
} }
@@ -267,14 +219,8 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
""" """
payload = await request.json() payload = await request.json()
# Accept source_id (new standard field) with fallback to legacy "source" key source = payload.get("source", "series4_emitter")
source = payload.get("source_id") or payload.get("source", "series4_emitter")
units = payload.get("units", []) units = payload.get("units", [])
version = payload.get("version")
log_tail = payload.get("log_tail")
import json as _json
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
client_ip = request.client.host if request.client else None
print("\n=== Series 4 Heartbeat ===") print("\n=== Series 4 Heartbeat ===")
print("Source:", source) print("Source:", source)
@@ -330,25 +276,11 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
results.append({"unit": uid, "status": status}) results.append({"unit": uid, "status": status})
if source:
_upsert_watcher_agent(db, source, "series4_watcher", version,
client_ip, log_tail_str, "ok")
db.commit()
# Check if an update has been triggered for this agent
update_available = False
if source:
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
if agent and agent.update_pending:
update_available = True
agent.update_pending = False
db.commit() db.commit()
return { return {
"message": "Heartbeat processed", "message": "Heartbeat processed",
"source": source, "source": source,
"units_processed": len(results), "units_processed": len(results),
"results": results, "results": results
"update_available": update_available,
} }
+2 -2
View File
@@ -199,7 +199,7 @@ class AlertService:
Args: Args:
schedule_id: The ScheduledAction or RecurringSchedule ID schedule_id: The ScheduledAction or RecurringSchedule ID
action_type: start, stop, download, cycle action_type: start, stop, download
unit_id: Related unit unit_id: Related unit
error_message: Error from execution error_message: Error from execution
project_id: Related project project_id: Related project
@@ -235,7 +235,7 @@ class AlertService:
Args: Args:
schedule_id: The ScheduledAction ID schedule_id: The ScheduledAction ID
action_type: start, stop, download, cycle action_type: start, stop, download
unit_id: Related unit unit_id: Related unit
project_id: Related project project_id: Related project
location_id: Related location location_id: Related location
-295
View File
@@ -1,295 +0,0 @@
"""
Calibration Sync Service
Pulls device-reported calibration dates from SFM event sidecars and updates
RosterUnit.last_calibrated when the device has a newer record than what
Terra-View has stored.
Conflict rule: events-as-truth, but don't go backwards.
- If the newest event's calibration_date == unit.last_calibrated → no-op.
- If the last UnitHistory change for last_calibrated is newer than the
newest event's timestamp → skip (a manual edit was made after this
event landed; manual wins until a fresher event arrives).
- Otherwise write the event's calibration_date, recompute
next_calibration_due, and log a UnitHistory row with source='sfm_event'.
"""
import asyncio
import logging
import os
import threading
import time
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, List
import httpx
import schedule
from sqlalchemy.orm import Session
from backend.database import SessionLocal
from backend.models import RosterUnit, UnitHistory, UserPreferences
logger = logging.getLogger(__name__)
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
def _get_cal_interval(db: Session) -> int:
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
def _parse_event_ts(value: Any) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None) if value.tzinfo else value
try:
s = str(value).replace("Z", "")
if "+" in s:
s = s.split("+", 1)[0]
return datetime.fromisoformat(s)
except (ValueError, TypeError):
logger.warning(f"Could not parse event timestamp: {value!r}")
return None
def _parse_cal_date(value: Any) -> Optional[date]:
if not value:
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
try:
return datetime.fromisoformat(str(value)).date()
except (ValueError, TypeError):
try:
return datetime.strptime(str(value), "%Y-%m-%d").date()
except (ValueError, TypeError):
logger.warning(f"Could not parse calibration_date: {value!r}")
return None
async def _get_latest_event(client: httpx.AsyncClient, serial: str) -> Optional[Dict[str, Any]]:
try:
resp = await client.get(
f"{SFM_BASE_URL}/db/events",
params={"serial": serial, "limit": 1},
)
resp.raise_for_status()
data = resp.json()
events = data.get("events", [])
return events[0] if events else None
except (httpx.HTTPError, ValueError) as e:
logger.warning(f"Failed to fetch latest event for {serial}: {e}")
return None
async def _get_event_sidecar(client: httpx.AsyncClient, event_id: str) -> Optional[Dict[str, Any]]:
try:
resp = await client.get(f"{SFM_BASE_URL}/db/events/{event_id}/sidecar")
resp.raise_for_status()
return resp.json()
except (httpx.HTTPError, ValueError) as e:
logger.warning(f"Failed to fetch sidecar for event {event_id}: {e}")
return None
async def sync_unit_calibration(
db: Session,
unit: RosterUnit,
client: httpx.AsyncClient,
) -> Dict[str, Any]:
"""Sync calibration for one seismograph unit. Returns a result dict."""
result: Dict[str, Any] = {
"unit_id": unit.id,
"action": "checked",
"old": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"new": None,
"event_id": None,
}
event = await _get_latest_event(client, unit.id)
if not event:
result["action"] = "no_event"
return result
sidecar = await _get_event_sidecar(client, event["id"])
if not sidecar:
result["action"] = "no_sidecar"
return result
device = sidecar.get("device") or {}
event_cal = _parse_cal_date(device.get("calibration_date"))
if not event_cal:
result["action"] = "no_cal_in_sidecar"
return result
result["event_id"] = event["id"]
result["new"] = event_cal.isoformat()
if unit.last_calibrated == event_cal:
result["action"] = "already_in_sync"
return result
event_ts = _parse_event_ts(event.get("timestamp"))
last_change = (
db.query(UnitHistory)
.filter(
UnitHistory.unit_id == unit.id,
UnitHistory.field_name == "last_calibrated",
)
.order_by(UnitHistory.changed_at.desc())
.first()
)
if last_change and event_ts and last_change.changed_at > event_ts:
result["action"] = "skipped_manual_newer"
return result
old_cal = unit.last_calibrated
unit.last_calibrated = event_cal
unit.next_calibration_due = event_cal + timedelta(days=_get_cal_interval(db))
db.add(UnitHistory(
unit_id=unit.id,
change_type="calibration_status_change",
field_name="last_calibrated",
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
new_value=event_cal.strftime("%Y-%m-%d"),
source="sfm_event",
notes=f"Synced from event {event['id']}",
))
result["action"] = "updated"
return result
async def sync_all_calibrations(db: Optional[Session] = None) -> Dict[str, Any]:
"""Sync calibration for every non-retired seismograph.
If `db` is provided the caller owns the session and commit. Otherwise
a session is opened, committed, and closed locally this is what the
scheduled job uses.
"""
owns_session = db is None
if owns_session:
db = SessionLocal()
summary: Dict[str, Any] = {
"started_at": datetime.utcnow().isoformat(),
"checked": 0,
"updated": 0,
"skipped_manual_newer": 0,
"already_in_sync": 0,
"no_event": 0,
"no_sidecar": 0,
"no_cal_in_sidecar": 0,
"errors": 0,
"results": [],
}
try:
units = (
db.query(RosterUnit)
.filter(
RosterUnit.retired == False,
RosterUnit.device_type == "seismograph",
)
.all()
)
async with httpx.AsyncClient(timeout=15.0) as client:
for unit in units:
summary["checked"] += 1
try:
r = await sync_unit_calibration(db, unit, client)
except Exception as e:
logger.exception(f"Error syncing calibration for {unit.id}")
summary["errors"] += 1
summary["results"].append({"unit_id": unit.id, "action": "error", "error": str(e)})
continue
summary["results"].append(r)
action = r["action"]
if action in summary:
summary[action] += 1
if owns_session:
db.commit()
finally:
if owns_session:
db.close()
summary["finished_at"] = datetime.utcnow().isoformat()
logger.info(
f"Calibration sync done: checked={summary['checked']} "
f"updated={summary['updated']} skipped_manual={summary['skipped_manual_newer']} "
f"in_sync={summary['already_in_sync']} errors={summary['errors']}"
)
return summary
# ---------------------------------------------------------------------------
# Background scheduler — runs once daily. Modeled on backup_scheduler.
# ---------------------------------------------------------------------------
class CalibrationSyncScheduler:
"""Runs sync_all_calibrations() once per day at a fixed local time."""
def __init__(self, run_at: str = "03:15"):
self.run_at = run_at
self.is_running = False
self.thread: Optional[threading.Thread] = None
self.last_run: Optional[Dict[str, Any]] = None
def _job_wrapper(self):
"""Run the async sync in a fresh event loop (we're on a worker thread)."""
try:
self.last_run = asyncio.run(sync_all_calibrations())
except Exception as e:
logger.exception(f"Calibration sync job failed: {e}")
self.last_run = {"error": str(e), "finished_at": datetime.utcnow().isoformat()}
def start(self):
if self.is_running:
return
logger.info(f"Starting calibration sync scheduler (daily at {self.run_at})")
schedule.every().day.at(self.run_at).do(self._job_wrapper)
self.is_running = True
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _loop(self):
while self.is_running:
schedule.run_pending()
time.sleep(60)
def stop(self):
if not self.is_running:
return
logger.info("Stopping calibration sync scheduler")
self.is_running = False
if self.thread:
self.thread.join(timeout=5)
def status(self) -> Dict[str, Any]:
return {
"running": self.is_running,
"run_at": self.run_at,
"last_run": self.last_run,
}
_scheduler: Optional[CalibrationSyncScheduler] = None
def get_calibration_sync_scheduler() -> CalibrationSyncScheduler:
global _scheduler
if _scheduler is None:
_scheduler = CalibrationSyncScheduler()
return _scheduler
-387
View File
@@ -1,387 +0,0 @@
"""
Deployment-history calendar service builds the data structure for the
fleet-wide deployment-history grid (`/tools/deployment-history`).
For each calendar day in a 12-month window, computes which projects had
at least one unit assigned to a location on that day. Renders as
multi-month grid (job-planner style) with project-colored bars per day.
Distinct from `services/fleet_calendar_service.py` which renders
forward-looking RESERVATIONS for the planner. This one is purely
historical / current it walks `unit_assignments` instead of
`job_reservations`.
"""
from __future__ import annotations
import hashlib
from datetime import date, datetime, timedelta
from calendar import monthrange
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import Project, UnitAssignment
# Color palette for projects without an explicit color attribute. Chosen
# to have decent contrast on both light and dark backgrounds; cycles
# deterministically by SHA1(project_id).
_PROJECT_COLOR_PALETTE = [
"#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d",
"#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8",
"#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c",
]
def _color_for_project(project_id: str) -> str:
"""Deterministic color assignment from a fixed palette."""
h = hashlib.sha1(project_id.encode("utf-8")).digest()[0]
return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)]
def _month_short(m: int) -> str:
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
def _month_full(m: int) -> str:
return ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"][m - 1]
def get_deployment_history_data(
db: Session,
start_year: int,
start_month: int,
) -> dict:
"""
Build the calendar data structure for a 12-month window starting at
(start_year, start_month).
Returns:
{
"months": [
{
"year": int,
"month": int, # 1-12
"name": "January",
"short_name": "Jan",
"year_short": "26",
"num_days": int,
"first_weekday": int, # 0=Mon..6=Sun (datetime.weekday())
"active_days": {
day_num: [project_id, project_id, ...] # projects with
# ≥1 active assignment
# on that day
},
},
... # 12 entries
],
"projects": [
{
"id": str,
"name": str,
"color": str,
"status": str,
"client_name": str | None,
"assignment_count": int, # total assignments contributing to
# this 12-month window
"first_active": "YYYY-MM-DD" | None,
"last_active": "YYYY-MM-DD" | None,
},
... # only projects with
# ≥1 assignment in the
# window, sorted by
# first_active ASC
],
"total_assignments": int,
"total_active_units": int, # distinct unit_ids across the window
"window": {
"start_year": int,
"start_month": int,
"end_year": int,
"end_month": int,
"first_date": "YYYY-MM-DD",
"last_date": "YYYY-MM-DD",
},
}
"""
# Compute window edges.
first_date = date(start_year, start_month, 1)
# 12 months → end on day-1 of (start + 12)
end_year = start_year + ((start_month + 10) // 12)
end_month = ((start_month + 10) % 12) + 1
last_date = date(end_year, end_month, monthrange(end_year, end_month)[1])
now = datetime.utcnow()
# Fetch every assignment that overlaps the window. An assignment
# overlaps if assigned_at <= last_date AND (assigned_until is NULL
# OR assigned_until >= first_date).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time()))
.filter(
or_(
UnitAssignment.assigned_until == None, # noqa: E711 — active
UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()),
)
)
.all()
)
# Resolve referenced projects in one query.
proj_ids = {a.project_id for a in assignments}
proj_map = {
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
} if proj_ids else {}
# Resolve location names in one batch query (used by the Gantt view
# for per-bar tooltips).
from backend.models import MonitoringLocation
loc_ids = {a.location_id for a in assignments}
loc_name_map = {
l.id: l.name for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
} if loc_ids else {}
# Compute "active days per project" by walking each assignment and
# adding every day in its [start, end] ∩ [first_date, last_date].
# O(N_assignments × avg_window_days); for a typical fleet this is
# bounded (hundreds of assignments × hundreds of days = manageable).
# Also collect raw per-assignment bar data for the Gantt view.
project_active_days: dict[str, set[date]] = {}
project_first_active: dict[str, date] = {}
project_last_active: dict[str, date] = {}
project_assignment_count: dict[str, int] = {}
project_bars: dict[str, list[dict]] = {}
distinct_units: set[str] = set()
for a in assignments:
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
end_dt = a.assigned_until or now
end = min(end_dt.date(), last_date)
if end < start:
continue
days = project_active_days.setdefault(a.project_id, set())
d = start
while d <= end:
days.add(d)
d += timedelta(days=1)
project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1
distinct_units.add(a.unit_id)
# Track first/last active dates in the window.
prev_first = project_first_active.get(a.project_id)
if prev_first is None or start < prev_first:
project_first_active[a.project_id] = start
prev_last = project_last_active.get(a.project_id)
if prev_last is None or end > prev_last:
project_last_active[a.project_id] = end
# Per-assignment bar data — used by the Gantt view's renderer.
# `is_active` reflects whether the assignment_until was still NULL
# at fetch time (open-ended deployment); the clipped `end` here
# is just for visual bar drawing.
project_bars.setdefault(a.project_id, []).append({
"unit_id": a.unit_id,
"location_id": a.location_id,
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
"start": start.isoformat(),
"end": end.isoformat(),
"is_active": a.assigned_until is None,
"source": a.source,
})
# Build the projects array (sorted by first_active ascending so the
# legend reads in deployment-order).
projects_data = []
for pid, days in project_active_days.items():
p = proj_map.get(pid)
if not p:
# Assignment references a deleted project — surface it anyway
# with a placeholder name, since the bars still need a label.
projects_data.append({
"id": pid,
"name": "(deleted project)",
"color": _color_for_project(pid),
"status": "deleted",
"client_name": None,
"assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
"bars": project_bars.get(pid, []),
})
continue
projects_data.append({
"id": pid,
"name": p.name,
"color": _color_for_project(pid),
"status": p.status or "active",
"client_name": p.client_name,
"assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
"bars": project_bars.get(pid, []),
})
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
# ── Per-unit view data (Gantt-by-Unit tab) ────────────────────────
# Same source assignments, re-grouped by unit_id. Each bar carries
# the project's color + name so the renderer can paint by job
# without doing a second lookup.
unit_bars: dict[str, list[dict]] = {}
project_lookup = {p["id"]: p for p in projects_data}
for a in assignments:
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
end_dt = a.assigned_until or now
end = min(end_dt.date(), last_date)
if end < start:
continue
p_info = project_lookup.get(a.project_id, {})
unit_bars.setdefault(a.unit_id, []).append({
"project_id": a.project_id,
"project_name": p_info.get("name", "(deleted project)"),
"project_color": p_info.get("color", _color_for_project(a.project_id)),
"location_id": a.location_id,
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
"start": start.isoformat(),
"end": end.isoformat(),
"is_active": a.assigned_until is None,
"source": a.source,
})
# Sort units by first-active date so the most-recently-deployed
# units sit at the top. Reverse if we want oldest-first.
units_data = []
for uid, bars in unit_bars.items():
bars.sort(key=lambda b: b["start"])
first_start = bars[0]["start"]
# "active now" flag = any bar is still active
any_active = any(b["is_active"] for b in bars)
units_data.append({
"id": uid,
"bars": bars,
"first_active": first_start,
"assignment_count": len(bars),
"any_active": any_active,
})
units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"]))
# Now build the months array.
months_data = []
cur_year, cur_month = start_year, start_month
for _ in range(12):
num_days = monthrange(cur_year, cur_month)[1]
first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun
active_days: dict[int, list[str]] = {}
for day_num in range(1, num_days + 1):
d = date(cur_year, cur_month, day_num)
day_projects = [
pid for pid, days in project_active_days.items()
if d in days
]
if day_projects:
# Sort by the project's color-stable order so bars don't
# jitter between days.
day_projects.sort()
active_days[day_num] = day_projects
months_data.append({
"year": cur_year,
"month": cur_month,
"name": _month_full(cur_month),
"short_name": _month_short(cur_month),
"year_short": f"{cur_year % 100:02d}",
"num_days": num_days,
"first_weekday": first_weekday,
"active_days": active_days,
})
# Advance one month.
if cur_month == 12:
cur_year += 1
cur_month = 1
else:
cur_month += 1
return {
"months": months_data,
"projects": projects_data,
"units": units_data,
"total_assignments": len(assignments),
"total_active_units": len(distinct_units),
"window": {
"start_year": start_year,
"start_month": start_month,
"end_year": end_year,
"end_month": end_month,
"first_date": first_date.isoformat(),
"last_date": last_date.isoformat(),
},
}
def get_deployments_on_day(
db: Session,
target_date: date,
) -> list[dict]:
"""
Return the list of (unit, location, project) tuples that were
actively assigned on a specific calendar date. Used for the
day-detail side panel when an operator clicks a day cell.
"""
from backend.models import MonitoringLocation, RosterUnit
day_start = datetime.combine(target_date, datetime.min.time())
day_end = datetime.combine(target_date, datetime.max.time())
rows = (
db.query(UnitAssignment)
.filter(UnitAssignment.assigned_at <= day_end)
.filter(
or_(
UnitAssignment.assigned_until == None, # noqa: E711
UnitAssignment.assigned_until >= day_start,
)
)
.order_by(UnitAssignment.project_id, UnitAssignment.unit_id)
.all()
)
if not rows:
return []
loc_ids = {a.location_id for a in rows}
proj_ids = {a.project_id for a in rows}
loc_map = {
l.id: l for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
}
proj_map = {
p.id: p for p in db.query(Project).filter(
Project.id.in_(proj_ids)
).all()
}
results = []
for a in rows:
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
results.append({
"assignment_id": a.id,
"unit_id": a.unit_id,
"location_id": a.location_id,
"location_name": loc.name if loc else "(unknown location)",
"project_id": a.project_id,
"project_name": proj.name if proj else "(deleted project)",
"project_color": _color_for_project(a.project_id),
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
"assigned_until": a.assigned_until.isoformat() if a.assigned_until else None,
"is_active": a.assigned_until is None,
"source": a.source,
})
return results
-318
View File
@@ -1,318 +0,0 @@
"""
Deployment timeline service replaces the legacy `deployment_records`-driven
timeline on the seismograph unit detail page.
Architecture:
- `unit_assignments` is the authoritative source for "where was this unit"
(one row per location/time-window). Auto-written by the project location
swap/assign/unassign/update workflows.
- `unit_history` is the audit log for non-location state changes
(calibration toggles, retirement, allocation, etc.).
- SFM events are overlaid per assignment window to show "what was the unit
actually doing during this deployment" (count + peak PVS + last-event).
Gaps between assignments are emitted as synthetic "gap" entries so operators
can see when the unit was idle vs out-of-service.
`deployment_records` is being deprecated; this module does not read it.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional
import httpx
from sqlalchemy.orm import Session
from backend.models import (
UnitAssignment,
UnitHistory,
MonitoringLocation,
Project,
RosterUnit,
)
from backend.services.sfm_events import (
SFM_BASE_URL,
_fetch_events_for_serial,
_iso_utc,
)
from backend.utils.timezone import utc_to_local
log = logging.getLogger("backend.services.deployment_timeline")
def _iso_local(dt) -> Optional[str]:
"""Serialize a datetime / ISO-string in the user's configured timezone.
The timeline frontend slices these strings to character 19 to produce
"YYYY-MM-DD HH:MM:SS" no JS-side timezone conversion happens. We
therefore emit *already-local* timestamps here so the displayed time
matches what the operator actually saw on the wall clock.
Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM
response). Returns ``None`` for ``None`` input. Naive ISO strings
from SFM are interpreted as UTC.
"""
if dt is None:
return None
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T"))
except ValueError:
return dt # give up gracefully — emit whatever SFM sent
local = utc_to_local(dt)
if local is None:
return None
return local.replace(tzinfo=None).isoformat()
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
# clutter from a sub-second handoff during a swap workflow.
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
# When detecting "mergeable" groups of consecutive same-location assignments,
# treat assignments separated by no more than this many seconds as adjacent.
# Generous enough to catch overnight handoffs and weekend gaps where the
# operator forgot to log, but tight enough that genuinely separate
# deployments months apart don't get suggested for merging.
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
# Per-call timeout when querying SFM for the event overlay.
_SFM_TIMEOUT = 10.0
_SFM_FETCH_CEILING = 5000
# ── Public API ────────────────────────────────────────────────────────────────
async def deployment_timeline_for_unit(
db: Session,
unit_id: str,
*,
include_event_overlay: bool = True,
) -> dict:
"""Build a chronological timeline for a unit.
Returns:
{
"unit_id": str,
"device_type": str,
"entries": [
{
"kind": "assignment" | "gap" | "state_change",
"starts_at": ISO timestamp,
"ends_at": ISO timestamp | None,
"duration_days": float | None,
# — assignment-only fields —
"assignment_id": str,
"location_id": str,
"location_name": str,
"project_id": str,
"project_name": str,
"is_active": bool,
"event_overlay": {event_count, peak_pvs, peak_pvs_at, last_event}
or None if include_event_overlay=False,
"notes": str | None,
# — gap-only fields —
"context": "between assignments" | None,
# — state_change-only fields —
"change_type": str,
"field_name": str | None,
"old_value": str | None,
"new_value": str | None,
"source": str,
"history_notes": str | None,
},
... # newest first
],
}
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
return {"unit_id": unit_id, "device_type": None, "entries": []}
# 1. Load assignments + their location/project lookups in bulk.
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.unit_id == unit_id)
.order_by(UnitAssignment.assigned_at.asc())
.all()
)
loc_ids = {a.location_id for a in assignments}
proj_ids = {a.project_id for a in assignments}
loc_map = {
l.id: l for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
} if loc_ids else {}
proj_map = {
p.id: p for p in db.query(Project).filter(
Project.id.in_(proj_ids)
).all()
} if proj_ids else {}
# 2. Load relevant unit_history rows. We surface state changes that
# operators care about on a deployment timeline: calibration status,
# retirement, deployed flag, allocation, calibration date, and the
# assignment_* events we just added (those are redundant with the
# assignment rows themselves, so we skip them to avoid double-rendering).
interesting_change_types = (
"calibration_status_change",
"retired_change",
"deployed_change",
"allocation_change",
"last_calibrated_change",
"next_calibration_due_change",
)
history = (
db.query(UnitHistory)
.filter(UnitHistory.unit_id == unit_id)
.filter(UnitHistory.change_type.in_(interesting_change_types))
.order_by(UnitHistory.changed_at.asc())
.all()
)
now = datetime.utcnow()
# 3. Optionally fetch SFM event overlay for each assignment window.
# Concurrent fan-out via httpx + asyncio.gather.
overlays: dict[str, dict] = {}
if include_event_overlay and assignments and unit.device_type == "seismograph":
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT) as client:
results = await asyncio.gather(
*(
_fetch_events_for_serial(
client,
serial=unit_id,
from_dt=a.assigned_at,
to_dt=a.assigned_until or now,
false_trigger=None,
limit=_SFM_FETCH_CEILING,
)
for a in assignments
),
return_exceptions=False,
)
for a, events in zip(assignments, results):
peak = None
peak_at = None
last_ev = None
for ev in events:
pvs = ev.get("peak_vector_sum")
if pvs is not None and (peak is None or pvs > peak):
peak = pvs
peak_at = ev.get("timestamp")
ts = ev.get("timestamp")
if ts and (last_ev is None or ts > last_ev):
last_ev = ts
overlays[a.id] = {
"event_count": len(events),
"peak_pvs": peak,
"peak_pvs_at": _iso_local(peak_at),
"last_event": _iso_local(last_ev),
}
# 4. Build entries. Start by emitting assignment rows + gap rows between
# consecutive assignments, then add state-change rows from unit_history.
entries: list[dict] = []
for idx, a in enumerate(assignments):
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
is_active = a.assigned_until is None
ends_at = a.assigned_until or now
duration_days = (ends_at - a.assigned_at).total_seconds() / 86400 if a.assigned_at else None
entry = {
"kind": "assignment",
"starts_at": _iso_local(a.assigned_at),
"ends_at": _iso_local(a.assigned_until),
"duration_days": round(duration_days, 1) if duration_days is not None else None,
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"is_active": is_active,
"notes": a.notes,
"event_overlay": overlays.get(a.id),
}
entries.append(entry)
# Gap detection: from the end of this assignment to the start of the
# next one. Only emit gaps that are at least _MIN_GAP_SECONDS long
# so trivial sub-second handoffs during swaps don't clutter the view.
if idx + 1 < len(assignments):
next_a = assignments[idx + 1]
gap_start = a.assigned_until or now
gap_end = next_a.assigned_at
gap_seconds = (gap_end - gap_start).total_seconds() if gap_end and gap_start else 0
if gap_seconds >= _MIN_GAP_SECONDS:
entries.append({
"kind": "gap",
"starts_at": _iso_local(gap_start),
"ends_at": _iso_local(gap_end),
"duration_days": round(gap_seconds / 86400, 1),
"context": "between assignments",
})
# 5. State changes — interleaved by timestamp. Skip no-op rows where
# old_value == new_value (an artifact of the legacy record_history()
# being called on every save regardless of whether the field changed).
for h in history:
if h.old_value == h.new_value:
continue
entries.append({
"kind": "state_change",
"starts_at": _iso_local(h.changed_at),
"ends_at": None,
"duration_days": None,
"change_type": h.change_type,
"field_name": h.field_name,
"old_value": h.old_value,
"new_value": h.new_value,
"source": h.source,
"history_notes": h.notes,
})
# 6. Detect mergeable groups — runs of consecutive assignments to the
# same location with small gaps between them. Each group becomes a
# list of assignment_ids; the UI offers a "Merge into one" action
# on any group >= 2.
merge_groups: list[list[str]] = []
if len(assignments) >= 2:
# Sort ascending for the linear scan.
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
for a in sorted_assignments[1:]:
prev = cur_group[-1]
same_location = a.location_id == prev.location_id
prev_end = prev.assigned_until or now
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
# Within tolerance and same location → extend the current group.
# Negative gaps (overlap) also count as adjacent.
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
cur_group.append(a)
else:
if len(cur_group) >= 2:
merge_groups.append([x.id for x in cur_group])
cur_group = [a]
if len(cur_group) >= 2:
merge_groups.append([x.id for x in cur_group])
# 7. Sort newest first. Active assignments (no end) sort by start time,
# same as everything else.
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
return {
"unit_id": unit.id,
"device_type": unit.device_type,
"entries": entries,
# List of assignment_id lists; each inner list is a mergeable group.
# Empty if nothing is mergeable. UI shows a "Merge" button on any
# row whose assignment_id appears in a group.
"merge_groups": merge_groups,
}
-725
View File
@@ -1,725 +0,0 @@
"""
Fleet Calendar Service
Business logic for:
- Calculating unit availability on any given date
- Calibration status tracking (valid, expiring soon, expired)
- Job reservation management
- Conflict detection (calibration expires mid-job)
"""
from datetime import date, datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project, DeploymentRecord
)
def get_calibration_status(
unit: RosterUnit,
check_date: date,
warning_days: int = 30
) -> str:
"""
Determine calibration status for a unit on a specific date.
Returns:
"valid" - Calibration is good on this date
"expiring_soon" - Within warning_days of expiry
"expired" - Calibration has expired
"needs_calibration" - No calibration date set
"""
if not unit.last_calibrated:
return "needs_calibration"
# Calculate expiry date (1 year from last calibration)
expiry_date = unit.last_calibrated + timedelta(days=365)
if check_date >= expiry_date:
return "expired"
elif check_date >= expiry_date - timedelta(days=warning_days):
return "expiring_soon"
else:
return "valid"
def get_unit_reservations_on_date(
db: Session,
unit_id: str,
check_date: date
) -> List[JobReservation]:
"""Get all reservations that include this unit on the given date."""
# Get reservation IDs that have this unit assigned
assigned_reservation_ids = db.query(JobReservationUnit.reservation_id).filter(
JobReservationUnit.unit_id == unit_id
).subquery()
# Get reservations that:
# 1. Have this unit assigned AND date is within range
reservations = db.query(JobReservation).filter(
JobReservation.id.in_(assigned_reservation_ids),
JobReservation.start_date <= check_date,
JobReservation.end_date >= check_date
).all()
return reservations
def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]:
"""Return the active (unreturned) deployment record for a unit, or None."""
return (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.unit_id == unit_id,
DeploymentRecord.actual_removal_date == None
)
.order_by(DeploymentRecord.created_at.desc())
.first()
)
def is_unit_available_on_date(
db: Session,
unit: RosterUnit,
check_date: date,
warning_days: int = 30
) -> Tuple[bool, str, Optional[str]]:
"""
Check if a unit is available on a specific date.
Returns:
(is_available, status, reservation_name)
- is_available: True if unit can be assigned to new work
- status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field"
- reservation_name: Name of blocking reservation or project ref (if any)
"""
# Check if retired
if unit.retired:
return False, "retired", None
# Check calibration status
cal_status = get_calibration_status(unit, check_date, warning_days)
if cal_status == "expired":
return False, "expired", None
if cal_status == "needs_calibration":
return False, "needs_calibration", None
# Check for an active deployment record (unit is physically in the field)
active_deployment = get_active_deployment(db, unit.id)
if active_deployment:
label = active_deployment.project_ref or "Field deployment"
return False, "in_field", label
# Check if already reserved
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
if reservations:
return False, "reserved", reservations[0].name
# Unit is available (even if expiring soon - that's just a warning)
return True, "available", None
def get_day_summary(
db: Session,
check_date: date,
device_type: str = "seismograph"
) -> Dict:
"""
Get a complete summary of fleet status for a specific day.
Returns dict with:
- available_units: List of available unit IDs with calibration info
- reserved_units: List of reserved unit IDs with reservation info
- expired_units: List of units with expired calibration
- expiring_soon_units: List of units expiring within warning period
- reservations: List of active reservations on this date
- counts: Summary counts
"""
# Get user preferences for warning days
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all non-retired units of the specified device type
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
available_units = []
reserved_units = []
expired_units = []
expiring_soon_units = []
needs_calibration_units = []
in_field_units = []
cal_expiring_today = [] # Units whose calibration expires ON this day
for unit in units:
is_avail, status, reservation_name = is_unit_available_on_date(
db, unit, check_date, warning_days
)
cal_status = get_calibration_status(unit, check_date, warning_days)
expiry_date = None
if unit.last_calibrated:
expiry_date = (unit.last_calibrated + timedelta(days=365)).isoformat()
unit_info = {
"id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"expiry_date": expiry_date,
"calibration_status": cal_status,
"deployed": unit.deployed,
"note": unit.note or ""
}
# Check if calibration expires ON this specific day
if unit.last_calibrated:
unit_expiry_date = unit.last_calibrated + timedelta(days=365)
if unit_expiry_date == check_date:
cal_expiring_today.append(unit_info)
if status == "available":
available_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "in_field":
unit_info["project_ref"] = reservation_name
in_field_units.append(unit_info)
elif status == "reserved":
unit_info["reservation_name"] = reservation_name
reserved_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "expired":
expired_units.append(unit_info)
elif status == "needs_calibration":
needs_calibration_units.append(unit_info)
# Get active reservations on this date
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= check_date,
JobReservation.end_date >= check_date
).all()
reservation_list = []
for res in reservations:
# Count assigned units for this reservation
assigned_count = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id == res.id
).count()
reservation_list.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": res.end_date.isoformat(),
"assignment_type": res.assignment_type,
"quantity_needed": res.quantity_needed,
"assigned_count": assigned_count,
"color": res.color,
"project_id": res.project_id
})
return {
"date": check_date.isoformat(),
"device_type": device_type,
"available_units": available_units,
"in_field_units": in_field_units,
"reserved_units": reserved_units,
"expired_units": expired_units,
"expiring_soon_units": expiring_soon_units,
"needs_calibration_units": needs_calibration_units,
"cal_expiring_today": cal_expiring_today,
"reservations": reservation_list,
"counts": {
"available": len(available_units),
"in_field": len(in_field_units),
"reserved": len(reserved_units),
"expired": len(expired_units),
"expiring_soon": len(expiring_soon_units),
"needs_calibration": len(needs_calibration_units),
"cal_expiring_today": len(cal_expiring_today),
"total": len(units)
}
}
def get_calendar_year_data(
db: Session,
year: int,
device_type: str = "seismograph"
) -> Dict:
"""
Get calendar data for an entire year.
For performance, this returns summary counts per day rather than
full unit lists. Use get_day_summary() for detailed day data.
"""
# Get user preferences
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all units
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Get all reservations that overlap with this year
# Include TBD reservations (end_date is null) that started before year end
year_start = date(year, 1, 1)
year_end = date(year, 12, 31)
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= year_end,
or_(
JobReservation.end_date >= year_start,
JobReservation.end_date == None # TBD reservations
)
).all()
# Get all unit assignments for these reservations
reservation_ids = [r.id for r in reservations]
assignments = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id.in_(reservation_ids)
).all() if reservation_ids else []
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
# For TBD reservations, use estimated_end_date if available, or a far future date
unit_reservations = {}
for res in reservations:
res_assignments = [a for a in assignments if a.reservation_id == res.id]
for assignment in res_assignments:
unit_id = assignment.unit_id
# Use unit-specific dates if set, otherwise use reservation dates
start_d = assignment.unit_start_date or res.start_date
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
# TBD: use estimated date or far future for availability calculation
end_d = res.estimated_end_date or date(year + 5, 12, 31)
else:
end_d = assignment.unit_end_date or res.end_date or date(year + 5, 12, 31)
if unit_id not in unit_reservations:
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Build set of unit IDs that have an active deployment record (still in the field)
unit_ids = [u.id for u in units]
active_deployments = db.query(DeploymentRecord.unit_id).filter(
DeploymentRecord.unit_id.in_(unit_ids),
DeploymentRecord.actual_removal_date == None
).all()
unit_in_field = {row.unit_id for row in active_deployments}
# Generate data for each month
months_data = {}
for month in range(1, 13):
# Get first and last day of month
first_day = date(year, month, 1)
if month == 12:
last_day = date(year, 12, 31)
else:
last_day = date(year, month + 1, 1) - timedelta(days=1)
days_data = {}
current_day = first_day
while current_day <= last_day:
available = 0
in_field = 0
reserved = 0
expired = 0
expiring_soon = 0
needs_cal = 0
cal_expiring_on_day = 0 # Units whose calibration expires ON this day
cal_expired_on_day = 0 # Units whose calibration expired ON this day
for unit in units:
# Check calibration
cal_status = get_calibration_status(unit, current_day, warning_days)
# Check if calibration expires/expired ON this specific day
if unit.last_calibrated:
unit_expiry = unit.last_calibrated + timedelta(days=365)
if unit_expiry == current_day:
cal_expiring_on_day += 1
# Check if expired yesterday (first day of being expired)
elif unit_expiry == current_day - timedelta(days=1):
cal_expired_on_day += 1
if cal_status == "expired":
expired += 1
continue
if cal_status == "needs_calibration":
needs_cal += 1
continue
# Check active deployment record (in field)
if unit.id in unit_in_field:
in_field += 1
continue
# Check if reserved
is_reserved = False
if unit.id in unit_reservations:
for start_d, end_d, _ in unit_reservations[unit.id]:
if start_d <= current_day <= end_d:
is_reserved = True
break
if is_reserved:
reserved += 1
else:
available += 1
if cal_status == "expiring_soon":
expiring_soon += 1
days_data[current_day.day] = {
"available": available,
"in_field": in_field,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
"needs_calibration": needs_cal,
"cal_expiring_on_day": cal_expiring_on_day,
"cal_expired_on_day": cal_expired_on_day
}
current_day += timedelta(days=1)
months_data[month] = {
"name": first_day.strftime("%B"),
"short_name": first_day.strftime("%b"),
"days": days_data,
"first_weekday": first_day.weekday(), # 0=Monday, 6=Sunday
"num_days": last_day.day
}
# Also include reservation summary for the year
reservation_list = []
for res in reservations:
assigned_count = len([a for a in assignments if a.reservation_id == res.id])
reservation_list.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": res.end_date.isoformat(),
"quantity_needed": res.quantity_needed,
"assigned_count": assigned_count,
"color": res.color
})
return {
"year": year,
"device_type": device_type,
"months": months_data,
"reservations": reservation_list,
"total_units": len(units)
}
def get_rolling_calendar_data(
db: Session,
start_year: int,
start_month: int,
device_type: str = "seismograph"
) -> Dict:
"""
Get calendar data for 12 months starting from a specific month/year.
This supports the rolling calendar view where users can scroll through
months one at a time, viewing any 12-month window.
"""
# Get user preferences
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all units
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Calculate the date range for 12 months
first_date = date(start_year, start_month, 1)
# Calculate end date (12 months later)
end_year = start_year + 1 if start_month == 1 else start_year
end_month = 12 if start_month == 1 else start_month - 1
if start_month == 1:
end_year = start_year
end_month = 12
else:
# 12 months from start_month means we end at start_month - 1 next year
end_year = start_year + 1
end_month = start_month - 1
# Actually, simpler: go 11 months forward from start
end_year = start_year + ((start_month + 10) // 12)
end_month = ((start_month + 10) % 12) + 1
if end_month == 12:
last_date = date(end_year, 12, 31)
else:
last_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Get all reservations that overlap with this 12-month range
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= last_date,
or_(
JobReservation.end_date >= first_date,
JobReservation.end_date == None # TBD reservations
)
).all()
# Get all unit assignments for these reservations
reservation_ids = [r.id for r in reservations]
assignments = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id.in_(reservation_ids)
).all() if reservation_ids else []
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
unit_reservations = {}
for res in reservations:
res_assignments = [a for a in assignments if a.reservation_id == res.id]
for assignment in res_assignments:
unit_id = assignment.unit_id
start_d = assignment.unit_start_date or res.start_date
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
end_d = res.estimated_end_date or date(start_year + 5, 12, 31)
else:
end_d = assignment.unit_end_date or res.end_date or date(start_year + 5, 12, 31)
if unit_id not in unit_reservations:
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Build set of unit IDs that have an active deployment record (still in the field)
unit_ids = [u.id for u in units]
active_deployments = db.query(DeploymentRecord.unit_id).filter(
DeploymentRecord.unit_id.in_(unit_ids),
DeploymentRecord.actual_removal_date == None
).all()
unit_in_field = {row.unit_id for row in active_deployments}
# Generate data for each of the 12 months
months_data = []
current_year = start_year
current_month = start_month
for i in range(12):
# Calculate this month's year and month
m_year = start_year + ((start_month - 1 + i) // 12)
m_month = ((start_month - 1 + i) % 12) + 1
first_day = date(m_year, m_month, 1)
if m_month == 12:
last_day = date(m_year, 12, 31)
else:
last_day = date(m_year, m_month + 1, 1) - timedelta(days=1)
days_data = {}
current_day = first_day
while current_day <= last_day:
available = 0
reserved = 0
expired = 0
expiring_soon = 0
needs_cal = 0
cal_expiring_on_day = 0
cal_expired_on_day = 0
for unit in units:
cal_status = get_calibration_status(unit, current_day, warning_days)
if unit.last_calibrated:
unit_expiry = unit.last_calibrated + timedelta(days=365)
if unit_expiry == current_day:
cal_expiring_on_day += 1
elif unit_expiry == current_day - timedelta(days=1):
cal_expired_on_day += 1
if cal_status == "expired":
expired += 1
continue
if cal_status == "needs_calibration":
needs_cal += 1
continue
is_reserved = False
if unit.id in unit_reservations:
for start_d, end_d, _ in unit_reservations[unit.id]:
if start_d <= current_day <= end_d:
is_reserved = True
break
if is_reserved:
reserved += 1
else:
available += 1
if cal_status == "expiring_soon":
expiring_soon += 1
days_data[current_day.day] = {
"available": available,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
"needs_calibration": needs_cal,
"cal_expiring_on_day": cal_expiring_on_day,
"cal_expired_on_day": cal_expired_on_day
}
current_day += timedelta(days=1)
months_data.append({
"year": m_year,
"month": m_month,
"name": first_day.strftime("%B"),
"short_name": first_day.strftime("%b"),
"year_short": first_day.strftime("%y"),
"days": days_data,
"first_weekday": first_day.weekday(),
"num_days": last_day.day
})
return {
"start_year": start_year,
"start_month": start_month,
"device_type": device_type,
"months": months_data,
"total_units": len(units)
}
def check_calibration_conflicts(
db: Session,
reservation_id: str
) -> List[Dict]:
"""
Check if any units assigned to a reservation will have their
calibration expire during the reservation period.
Returns list of conflicts with unit info and expiry date.
"""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
return []
# Get assigned units
assigned = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).all()
conflicts = []
for assignment in assigned:
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if not unit or not unit.last_calibrated:
continue
expiry_date = unit.last_calibrated + timedelta(days=365)
# Check if expiry falls within reservation period
if reservation.start_date < expiry_date <= reservation.end_date:
conflicts.append({
"unit_id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat(),
"expiry_date": expiry_date.isoformat(),
"reservation_name": reservation.name,
"days_into_job": (expiry_date - reservation.start_date).days
})
return conflicts
def get_available_units_for_period(
db: Session,
start_date: date,
end_date: date,
device_type: str = "seismograph",
exclude_reservation_id: Optional[str] = None
) -> List[Dict]:
"""
Get units that are available for the entire specified period.
A unit is available if:
- Not retired
- Calibration is valid through the end date
- Not assigned to any other reservation that overlaps the period
"""
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Get reservations that overlap with this period
overlapping_reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
JobReservation.end_date >= start_date
)
if exclude_reservation_id:
overlapping_reservations = overlapping_reservations.filter(
JobReservation.id != exclude_reservation_id
)
overlapping_reservations = overlapping_reservations.all()
# Get all units assigned to overlapping reservations
reserved_unit_ids = set()
for res in overlapping_reservations:
assigned = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
).all()
for a in assigned:
reserved_unit_ids.add(a.unit_id)
# Get units with active deployment records (still in the field)
unit_ids = [u.id for u in units]
active_deps = db.query(DeploymentRecord.unit_id).filter(
DeploymentRecord.unit_id.in_(unit_ids),
DeploymentRecord.actual_removal_date == None
).all()
in_field_unit_ids = {row.unit_id for row in active_deps}
available_units = []
for unit in units:
# Check if already reserved
if unit.id in reserved_unit_ids:
continue
# Check if currently in the field
if unit.id in in_field_unit_ids:
continue
if unit.last_calibrated:
expiry_date = unit.last_calibrated + timedelta(days=365)
cal_status = get_calibration_status(unit, end_date, warning_days)
else:
expiry_date = None
cal_status = "needs_calibration"
available_units.append({
"id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"expiry_date": expiry_date.isoformat() if expiry_date else None,
"calibration_status": cal_status,
"deployed": unit.deployed,
"out_for_calibration": unit.out_for_calibration or False,
"note": unit.note or ""
})
return available_units
File diff suppressed because it is too large Load Diff
-435
View File
@@ -1,435 +0,0 @@
"""
project_merge.py consolidate a duplicate project into another.
Use case: the metadata-backfill parser (and operators) create projects with
slight name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
"Trumbull-Brayman-Mont Dam"). Operator picks a SOURCE project to merge
into a TARGET project; everything attached to source moves to target,
same-named locations consolidate, and source is soft-deleted.
Public API:
preview(db, source_id, target_id) MergePreview
execute(db, source_id, target_id, *, decided_by="operator") MergeResult
Both raise HTTPException with appropriate 4xx codes for validation failures.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from backend.models import (
Project,
ProjectModule,
MonitoringLocation,
UnitAssignment,
UnitHistory,
MonitoringSession,
DataFile,
)
log = logging.getLogger("backend.services.project_merge")
# ── Dataclasses ───────────────────────────────────────────────────────────────
@dataclass
class LocationMergePlan:
source_id: str
source_name: str
target_id: Optional[str] # None = will be inserted as-new under target project
target_name: Optional[str] # name in target after merge
action: str # "move" | "consolidate"
assignments_moving: int
sessions_moving: int
@dataclass
class MergePreview:
source_project_id: str
source_project_name: str
target_project_id: str
target_project_name: str
location_plans: list[LocationMergePlan] = field(default_factory=list)
total_assignments_moving: int = 0
total_sessions_moving: int = 0
total_data_files_moving: int = 0
modules_to_add: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@dataclass
class MergeResult:
source_project_id: str
target_project_id: str
assignments_moved: int
locations_moved: int
locations_consolidated: int
sessions_moved: int
data_files_moved: int
modules_added: list[str]
audit_rows_written: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _normalise_name(s: Optional[str]) -> str:
"""Case-insensitive, whitespace-collapsing name normalisation.
Lighter than metadata_backfill._normalise (no punctuation stripping)
for merging we want "Loc 1" and "Loc 1" to match but NOT "Loc 1"
and "Loc-1" (those might be intentionally different). If operators
DO want loose matching, they can rename one before merging.
"""
if not s:
return ""
import re
return re.sub(r"\s+", " ", s.strip()).casefold()
def _validate_pair(db: Session, source_id: str, target_id: str) -> tuple[Project, Project]:
if source_id == target_id:
raise HTTPException(status_code=400, detail="Cannot merge a project into itself.")
source = db.query(Project).filter_by(id=source_id).first()
target = db.query(Project).filter_by(id=target_id).first()
if source is None:
raise HTTPException(status_code=404, detail=f"Source project not found.")
if target is None:
raise HTTPException(status_code=404, detail=f"Target project not found.")
if source.status == "deleted":
raise HTTPException(status_code=400, detail=f"Source project '{source.name}' is already deleted.")
if target.status == "deleted":
raise HTTPException(status_code=400, detail=f"Target project '{target.name}' is deleted.")
return source, target
# ── Preview ───────────────────────────────────────────────────────────────────
def preview(db: Session, source_id: str, target_id: str) -> MergePreview:
"""Build a preview of what the merge will do. No writes."""
source, target = _validate_pair(db, source_id, target_id)
# Locations in source vs target.
source_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == source_id)
.all()
)
target_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == target_id)
.all()
)
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
location_plans: list[LocationMergePlan] = []
total_assignments_moving = 0
total_sessions_moving = 0
for sl in source_locs:
n = _normalise_name(sl.name)
tl = target_by_norm.get(n)
a_count = (
db.query(UnitAssignment)
.filter(UnitAssignment.location_id == sl.id)
.count()
)
s_count = (
db.query(MonitoringSession)
.filter(MonitoringSession.location_id == sl.id)
.count()
)
total_assignments_moving += a_count
total_sessions_moving += s_count
if tl is not None:
location_plans.append(LocationMergePlan(
source_id = sl.id,
source_name = sl.name,
target_id = tl.id,
target_name = tl.name,
action = "consolidate",
assignments_moving = a_count,
sessions_moving = s_count,
))
else:
location_plans.append(LocationMergePlan(
source_id = sl.id,
source_name = sl.name,
target_id = None,
target_name = sl.name,
action = "move",
assignments_moving = a_count,
sessions_moving = s_count,
))
# DataFiles attached to the source project (if the table exists with a
# project_id column). Optional — terra-view's DataFile model may not
# always FK to project, so handle gracefully.
df_count = 0
try:
df_count = (
db.query(DataFile)
.filter(DataFile.project_id == source_id)
.count()
)
except Exception:
df_count = 0
total_data_files_moving = df_count
# Modules: add anything in source missing from target.
src_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
.all()
}
tgt_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
.all()
}
modules_to_add = sorted(src_modules - tgt_modules)
warnings: list[str] = []
# Surface conditions the operator should think about.
consolidations = sum(1 for p in location_plans if p.action == "consolidate")
if consolidations:
warnings.append(
f"{consolidations} location(s) with matching names will be consolidated "
f"(source assignments will move to the target's existing location). "
f"If your same-named locations are actually different sites, rename one first."
)
if source.client_name and target.client_name and source.client_name.strip().casefold() != target.client_name.strip().casefold():
warnings.append(
f"Client names differ: source is \"{source.client_name}\", target is "
f"\"{target.client_name}\". Target's client name will be kept."
)
return MergePreview(
source_project_id = source.id,
source_project_name = source.name,
target_project_id = target.id,
target_project_name = target.name,
location_plans = location_plans,
total_assignments_moving = total_assignments_moving,
total_sessions_moving = total_sessions_moving,
total_data_files_moving = total_data_files_moving,
modules_to_add = modules_to_add,
warnings = warnings,
)
# ── Execute ───────────────────────────────────────────────────────────────────
def execute(
db: Session,
source_id: str,
target_id: str,
*,
decided_by: str = "operator",
) -> MergeResult:
"""Perform the merge in a single transaction.
Steps:
1. Re-validate the pair.
2. For each location in source:
- if a same-name location exists in target "consolidate" mode:
move source's assignments + sessions to target's location id,
delete source's location.
- else "move" mode: just re-point the location's project_id.
3. Move any remaining direct-to-project FK rows (DataFiles).
4. Ensure target has all of source's modules.
5. Soft-delete source project.
6. Write a UnitHistory row per assignment that was moved
(change_type='assignment_merged') so the deployment timeline
on each affected unit reflects the merge.
7. Commit.
"""
source, target = _validate_pair(db, source_id, target_id)
src_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
.all()
}
tgt_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
.all()
}
modules_to_add = sorted(src_modules - tgt_modules)
# ── 1. Locations + their dependents ───────────────────────────────
source_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == source_id)
.all()
)
target_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == target_id)
.all()
)
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
assignments_moved = 0
sessions_moved = 0
locations_moved = 0
locations_consolidated = 0
audit_rows_written = 0
for sl in source_locs:
n = _normalise_name(sl.name)
tl = target_by_norm.get(n)
# Pull this location's assignments + sessions (we'll re-point them).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.location_id == sl.id)
.all()
)
sessions = (
db.query(MonitoringSession)
.filter(MonitoringSession.location_id == sl.id)
.all()
)
if tl is not None:
# Consolidate: move dependents to target's existing location;
# then delete the source location.
for a in assignments:
old_loc_id = a.location_id
a.location_id = tl.id
a.project_id = target.id
db.add(UnitHistory(
unit_id = a.unit_id,
change_type = "assignment_merged",
field_name = "unit_assignment.project_id",
old_value = f"{source.name} / {sl.name}",
new_value = f"{target.name} / {tl.name}",
changed_at = datetime.utcnow(),
source = "project_merge",
notes = (
f"Project merge: '{source.name}''{target.name}'. "
f"Location consolidated by name match. "
f"By: {decided_by}."
),
))
audit_rows_written += 1
assignments_moved += 1
for s in sessions:
s.location_id = tl.id
s.project_id = target.id
sessions_moved += 1
# Delete the now-empty source location.
db.delete(sl)
locations_consolidated += 1
else:
# Move: just re-point this location to the target project.
sl.project_id = target.id
for a in assignments:
old_proj_id = a.project_id
a.project_id = target.id
db.add(UnitHistory(
unit_id = a.unit_id,
change_type = "assignment_merged",
field_name = "unit_assignment.project_id",
old_value = f"{source.name} / {sl.name}",
new_value = f"{target.name} / {sl.name}",
changed_at = datetime.utcnow(),
source = "project_merge",
notes = (
f"Project merge: '{source.name}''{target.name}'. "
f"Location moved as-is. By: {decided_by}."
),
))
audit_rows_written += 1
assignments_moved += 1
for s in sessions:
s.project_id = target.id
sessions_moved += 1
locations_moved += 1
# ── 2. Direct-to-project rows (DataFiles, ScheduledActions) ──────
data_files_moved = 0
try:
data_files = (
db.query(DataFile)
.filter(DataFile.project_id == source_id)
.all()
)
for df in data_files:
df.project_id = target.id
data_files_moved += 1
except Exception as e:
log.warning("DataFile move skipped (model may differ): %s", e)
# ── 3. UnitAssignments that point directly at source.project_id with
# no location (shouldn't happen but be defensive) ──────────────
orphan_assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.project_id == source_id)
.all()
)
for a in orphan_assignments:
# Already moved if its location was moved. Catch any stragglers.
if a.project_id == source_id:
a.project_id = target.id
# ── 4. Modules ────────────────────────────────────────────────────
import uuid
for mod_type in modules_to_add:
db.add(ProjectModule(
id = str(uuid.uuid4()),
project_id = target.id,
module_type = mod_type,
enabled = True,
))
# Disable source's modules (defensive — source is being soft-deleted
# but its modules table rows could still be inspected).
for m in db.query(ProjectModule).filter(ProjectModule.project_id == source_id).all():
m.enabled = False
# ── 5. Soft-delete source ─────────────────────────────────────────
source.status = "deleted"
source.deleted_at = datetime.utcnow()
# Final audit row on the source project itself (operator-facing).
# We don't have a Project-level history table, so log on every
# affected unit as a marker. Already done per-assignment above.
db.commit()
return MergeResult(
source_project_id = source.id,
target_project_id = target.id,
assignments_moved = assignments_moved,
locations_moved = locations_moved,
locations_consolidated = locations_consolidated,
sessions_moved = sessions_moved,
data_files_moved = data_files_moved,
modules_added = modules_to_add,
audit_rows_written = audit_rows_written,
)
-235
View File
@@ -1,235 +0,0 @@
"""
project_tidy.py find duplicate-looking projects + offer bulk merge.
The metadata-backfill parser is good at clustering events into candidate
projects but doesn't compare its proposed project names against EACH OTHER
(it only checks against existing terra-view projects). After a bulk
apply, you can end up with many near-duplicate projects typo variants,
abbreviation differences, etc. This module surfaces them as pairs the
operator can merge.
Pairs vs clusters: a fully-connected group like (A, B, C) where each pair
scores >= threshold becomes 3 pairs. The operator has to do 2 merges to
fully consolidate. We don't try to be smarter about transitive grouping —
in practice operators want to review the highest-similarity pair first
anyway, and the list re-computes after each merge.
Public API:
find_duplicate_pairs(db, *, threshold=0.85, max_pairs=200) list[DuplicatePair]
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
import rapidfuzz
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.models import (
Project,
MonitoringLocation,
UnitAssignment,
)
from backend.services.metadata_backfill import _normalise as _meta_normalise
log = logging.getLogger("backend.services.project_tidy")
DEFAULT_THRESHOLD = 0.85 # WRatio similarity above which we surface a pair
DEFAULT_MAX_PAIRS = 200 # Cap the result list to keep response small
MIN_NORMALISED_LENGTH = 4 # Skip projects whose normalised name is too short
# to fuzzy-match safely (avoids "1" / "1" pairs).
@dataclass
class ProjectSummary:
id: str
name: str
project_number: Optional[str]
client_name: Optional[str]
source: str # 'manual' | 'metadata_backfill' | ...
status: str
location_count: int
assignment_count: int
event_count_total: int # approx — sum across assignments
@dataclass
class DuplicatePair:
a: ProjectSummary
b: ProjectSummary
score: float
suggested_target_id: str # the recommended "keep" side
reason: str # why we picked that target
# ── Helpers ──────────────────────────────────────────────────────────────────
def _normalise_project_name(name: str) -> str:
"""Project-name normalisation for tidy comparison.
Reuses the metadata_backfill normaliser (lowercase, punctuationspace,
collapse whitespace). Returns "" for None or all-punctuation names.
"""
return _meta_normalise(name)
def _summarise_projects(db: Session) -> list[ProjectSummary]:
"""One row per active project with cached counts. Excludes deleted."""
projects = (
db.query(Project)
.filter(Project.status != "deleted")
.all()
)
# Bulk lookup: assignment counts + location counts per project.
loc_counts: dict[str, int] = dict(
db.query(MonitoringLocation.project_id, func.count(MonitoringLocation.id))
.filter(MonitoringLocation.project_id.in_([p.id for p in projects]) if projects else False)
.group_by(MonitoringLocation.project_id)
.all()
)
asgn_counts: dict[str, int] = dict(
db.query(UnitAssignment.project_id, func.count(UnitAssignment.id))
.filter(UnitAssignment.project_id.in_([p.id for p in projects]) if projects else False)
.group_by(UnitAssignment.project_id)
.all()
)
summaries: list[ProjectSummary] = []
for p in projects:
summaries.append(ProjectSummary(
id = p.id,
name = p.name,
project_number = p.project_number,
client_name = p.client_name,
source = None, # filled below per assignment
status = p.status or "active",
location_count = loc_counts.get(p.id, 0),
assignment_count = asgn_counts.get(p.id, 0),
event_count_total = 0, # not cheap to compute here; left 0
))
# Determine each project's dominant assignment source. Used to break ties
# when picking the "keep" target — prefer manual over parser-created.
rows = (
db.query(UnitAssignment.project_id, UnitAssignment.source, func.count(UnitAssignment.id))
.group_by(UnitAssignment.project_id, UnitAssignment.source)
.all()
)
by_proj_src: dict[str, dict[str, int]] = {}
for proj_id, src, cnt in rows:
by_proj_src.setdefault(proj_id, {})[src or "manual"] = cnt
for s in summaries:
src_map = by_proj_src.get(s.id, {})
if not src_map:
s.source = "manual"
else:
# Dominant source (most assignments).
s.source = max(src_map.items(), key=lambda kv: kv[1])[0]
return summaries
def _pick_target(a: ProjectSummary, b: ProjectSummary) -> tuple[str, str]:
"""Decide which project should be the merge target (the one we keep).
Priorities (in order):
1. The one with `source='manual'` over `source='metadata_backfill'`
operator-curated projects beat parser-created ones.
2. The one with a populated `project_number`.
3. The one with more locations (more curation history).
4. The one with more assignments.
5. The one with the shorter, cleaner name (tiebreaker).
Returns (target_id, reason_string).
"""
# 1. Source provenance.
a_manual = a.source == "manual"
b_manual = b.source == "manual"
if a_manual and not b_manual:
return a.id, "A is manually-created; B is parser-created"
if b_manual and not a_manual:
return b.id, "B is manually-created; A is parser-created"
# 2. project_number populated.
if a.project_number and not b.project_number:
return a.id, "A has a project_number; B doesn't"
if b.project_number and not a.project_number:
return b.id, "B has a project_number; A doesn't"
# 3. More locations.
if a.location_count > b.location_count:
return a.id, f"A has more locations ({a.location_count} vs {b.location_count})"
if b.location_count > a.location_count:
return b.id, f"B has more locations ({b.location_count} vs {a.location_count})"
# 4. More assignments.
if a.assignment_count > b.assignment_count:
return a.id, f"A has more assignments ({a.assignment_count} vs {b.assignment_count})"
if b.assignment_count > a.assignment_count:
return b.id, f"B has more assignments ({b.assignment_count} vs {a.assignment_count})"
# 5. Shorter name (less likely to have baked-in junk).
if len(a.name) <= len(b.name):
return a.id, "A has the shorter / cleaner name"
return b.id, "B has the shorter / cleaner name"
# ── Public ───────────────────────────────────────────────────────────────────
def find_duplicate_pairs(
db: Session,
*,
threshold: float = DEFAULT_THRESHOLD,
max_pairs: int = DEFAULT_MAX_PAIRS,
) -> list[DuplicatePair]:
"""Compute all project-pair similarities above `threshold`.
O(N^2) over the project count fine up to ~500 projects; beyond that
we'd want a blocked / token-indexed approach. In practice
`metadata_backfill` projects tend to share tokens, so a simple
pre-filter (skip pairs that share NO tokens) would cheaply cut the
inner loop. Deferred until profiling motivates it.
"""
summaries = _summarise_projects(db)
# Pre-compute normalised names; skip too-short ones.
norm_by_id: dict[str, str] = {}
candidates: list[ProjectSummary] = []
for s in summaries:
n = _normalise_project_name(s.name)
if len(n) < MIN_NORMALISED_LENGTH:
continue
norm_by_id[s.id] = n
candidates.append(s)
pairs: list[DuplicatePair] = []
n = len(candidates)
for i in range(n):
a = candidates[i]
a_norm = norm_by_id[a.id]
for j in range(i + 1, n):
b = candidates[j]
b_norm = norm_by_id[b.id]
score = rapidfuzz.fuzz.WRatio(a_norm, b_norm) / 100.0
if score < threshold:
continue
target_id, reason = _pick_target(a, b)
pairs.append(DuplicatePair(
a = a,
b = b,
score = score,
suggested_target_id = target_id,
reason = reason,
))
# Sort by score desc, then by total content (more data → review first).
pairs.sort(key=lambda p: (-p.score, -(p.a.assignment_count + p.b.assignment_count)))
return pairs[:max_pairs]
+77 -129
View File
@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,8 +49,6 @@ class RecurringScheduleService:
include_download: bool = True, include_download: bool = True,
auto_increment_index: bool = True, auto_increment_index: bool = True,
timezone: str = "America/New_York", timezone: str = "America/New_York",
start_datetime: datetime = None,
end_datetime: datetime = None,
) -> RecurringSchedule: ) -> RecurringSchedule:
""" """
Create a new recurring schedule. Create a new recurring schedule.
@@ -59,7 +57,7 @@ class RecurringScheduleService:
project_id: Project ID project_id: Project ID
location_id: Monitoring location ID location_id: Monitoring location ID
name: Schedule name name: Schedule name
schedule_type: "weekly_calendar", "simple_interval", or "one_off" schedule_type: "weekly_calendar" or "simple_interval"
device_type: "slm" or "seismograph" device_type: "slm" or "seismograph"
unit_id: Specific unit (optional, can use assignment) unit_id: Specific unit (optional, can use assignment)
weekly_pattern: Dict of day patterns for weekly_calendar weekly_pattern: Dict of day patterns for weekly_calendar
@@ -68,8 +66,6 @@ class RecurringScheduleService:
include_download: Whether to download data on cycle include_download: Whether to download data on cycle
auto_increment_index: Whether to auto-increment store index before start auto_increment_index: Whether to auto-increment store index before start
timezone: Timezone for schedule times timezone: Timezone for schedule times
start_datetime: Start date+time in UTC (one_off only)
end_datetime: End date+time in UTC (one_off only)
Returns: Returns:
Created RecurringSchedule Created RecurringSchedule
@@ -89,8 +85,6 @@ class RecurringScheduleService:
auto_increment_index=auto_increment_index, auto_increment_index=auto_increment_index,
enabled=True, enabled=True,
timezone=timezone, timezone=timezone,
start_datetime=start_datetime,
end_datetime=end_datetime,
) )
# Calculate next occurrence # Calculate next occurrence
@@ -175,25 +169,8 @@ class RecurringScheduleService:
return self.update_schedule(schedule_id, enabled=True) return self.update_schedule(schedule_id, enabled=True)
def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]: def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
"""Disable a schedule and cancel its pending actions.""" """Disable a schedule."""
schedule = self.update_schedule(schedule_id, enabled=False) return self.update_schedule(schedule_id, enabled=False)
if schedule:
# Cancel all pending actions generated by this schedule
pending_actions = self.db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
)
).all()
for action in pending_actions:
action.execution_status = "cancelled"
if pending_actions:
self.db.commit()
logger.info(f"Cancelled {len(pending_actions)} pending actions for disabled schedule {schedule.name}")
return schedule
def generate_actions_for_schedule( def generate_actions_for_schedule(
self, self,
@@ -219,8 +196,6 @@ class RecurringScheduleService:
actions = self._generate_weekly_calendar_actions(schedule, horizon_days) actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
elif schedule.schedule_type == "simple_interval": elif schedule.schedule_type == "simple_interval":
actions = self._generate_interval_actions(schedule, horizon_days) actions = self._generate_interval_actions(schedule, horizon_days)
elif schedule.schedule_type == "one_off":
actions = self._generate_one_off_actions(schedule)
else: else:
logger.warning(f"Unknown schedule type: {schedule.schedule_type}") logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
return [] return []
@@ -332,12 +307,10 @@ class RecurringScheduleService:
) )
actions.append(start_action) actions.append(start_action)
# Create STOP action (stop_cycle handles download when include_download is True) # Create STOP action
stop_notes = json.dumps({ stop_notes = json.dumps({
"schedule_name": schedule.name, "schedule_name": schedule.name,
"schedule_id": schedule.id, "schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
"include_download": schedule.include_download,
}) })
stop_action = ScheduledAction( stop_action = ScheduledAction(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -352,6 +325,27 @@ class RecurringScheduleService:
) )
actions.append(stop_action) actions.append(stop_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = end_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
})
download_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="download",
device_type=schedule.device_type,
scheduled_time=download_time,
execution_status="pending",
notes=download_notes,
)
actions.append(download_action)
return actions return actions
def _generate_interval_actions( def _generate_interval_actions(
@@ -390,92 +384,18 @@ class RecurringScheduleService:
if cycle_utc <= now_utc: if cycle_utc <= now_utc:
continue continue
# Check if cycle action already exists # Check if action already exists
if self._action_exists(schedule.project_id, schedule.location_id, "cycle", cycle_utc): if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc):
continue continue
# Build notes with metadata for cycle action # Build notes with metadata
cycle_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
"include_download": schedule.include_download,
"auto_increment_index": schedule.auto_increment_index,
})
# Create single CYCLE action that handles stop -> download -> start
# The scheduler's _execute_cycle method handles the full workflow with delays
cycle_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="cycle",
device_type=schedule.device_type,
scheduled_time=cycle_utc,
execution_status="pending",
notes=cycle_notes,
)
actions.append(cycle_action)
return actions
def _generate_one_off_actions(
self,
schedule: RecurringSchedule,
) -> List[ScheduledAction]:
"""
Generate start and stop actions for a one-off recording.
Unlike recurring types, this generates exactly one start and one stop action
using the schedule's start_datetime and end_datetime directly.
"""
if not schedule.start_datetime or not schedule.end_datetime:
logger.warning(f"One-off schedule {schedule.id} missing start/end datetime")
return []
actions = []
now_utc = datetime.utcnow()
unit_id = self._resolve_unit_id(schedule)
# Skip if end time has already passed
if schedule.end_datetime <= now_utc:
return []
# Check if actions already exist for this schedule
if self._action_exists(schedule.project_id, schedule.location_id, "start", schedule.start_datetime):
return []
# Create START action (only if start time hasn't passed)
if schedule.start_datetime > now_utc:
start_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "one_off",
"auto_increment_index": schedule.auto_increment_index,
})
start_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="start",
device_type=schedule.device_type,
scheduled_time=schedule.start_datetime,
execution_status="pending",
notes=start_notes,
)
actions.append(start_action)
# Create STOP action
stop_notes = json.dumps({ stop_notes = json.dumps({
"schedule_name": schedule.name, "schedule_name": schedule.name,
"schedule_id": schedule.id, "schedule_id": schedule.id,
"schedule_type": "one_off", "cycle_type": "daily",
"include_download": schedule.include_download,
}) })
# Create STOP action
stop_action = ScheduledAction( stop_action = ScheduledAction(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=schedule.project_id, project_id=schedule.project_id,
@@ -483,12 +403,55 @@ class RecurringScheduleService:
unit_id=unit_id, unit_id=unit_id,
action_type="stop", action_type="stop",
device_type=schedule.device_type, device_type=schedule.device_type,
scheduled_time=schedule.end_datetime, scheduled_time=cycle_utc,
execution_status="pending", execution_status="pending",
notes=stop_notes, notes=stop_notes,
) )
actions.append(stop_action) actions.append(stop_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = cycle_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
})
download_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="download",
device_type=schedule.device_type,
scheduled_time=download_time,
execution_status="pending",
notes=download_notes,
)
actions.append(download_action)
# Create START action (2 minutes after stop, or 1 minute after download)
start_offset = 2 if schedule.include_download else 1
start_time = cycle_utc + timedelta(minutes=start_offset)
start_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
"auto_increment_index": schedule.auto_increment_index,
})
start_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="start",
device_type=schedule.device_type,
scheduled_time=start_time,
execution_status="pending",
notes=start_notes,
)
actions.append(start_action)
return actions return actions
def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]: def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]:
@@ -531,13 +494,6 @@ class RecurringScheduleService:
if cycle_utc > now_utc: if cycle_utc > now_utc:
return cycle_utc return cycle_utc
elif schedule.schedule_type == "one_off":
if schedule.start_datetime and schedule.start_datetime > now_utc:
return schedule.start_datetime
elif schedule.end_datetime and schedule.end_datetime > now_utc:
return schedule.end_datetime
return None
return None return None
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]: def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:
@@ -594,16 +550,8 @@ class RecurringScheduleService:
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all() return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
def get_enabled_schedules(self) -> List[RecurringSchedule]: def get_enabled_schedules(self) -> List[RecurringSchedule]:
"""Get all enabled recurring schedules for projects that are not on hold or deleted.""" """Get all enabled recurring schedules."""
active_project_ids = [ return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
p.id for p in self.db.query(Project.id).filter(
Project.status.notin_(["on_hold", "archived", "deleted"])
).all()
]
return self.db.query(RecurringSchedule).filter(
RecurringSchedule.enabled == True,
RecurringSchedule.project_id.in_(active_project_ids),
).all()
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService: def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
-172
View File
@@ -1,172 +0,0 @@
"""
Report email sender config-driven SMTP via the Python standard library.
Connection settings come from environment variables so the mail backend
(internal relay / Microsoft 365 / Gmail / SendGrid) can be swapped without code
changes see the build plan: terra-mechanics.com is on M365 and has a smarthost
relay that already sends the seismograph alerts as remote@terra-mechanics.com;
reuse that relay's settings here.
DRY-RUN: if SMTP isn't configured (no host/from), the message is built and
logged but NOT sent, and the call still succeeds. This keeps report generation
working before the relay is wired up, and means a missing/incomplete mail config
can never crash the nightly pipeline.
Env vars
--------
REPORT_SMTP_HOST e.g. smtp.office365.com (unset dry-run)
REPORT_SMTP_PORT default 587
REPORT_SMTP_SECURITY starttls (default) | ssl | none
REPORT_SMTP_USER optional omit for IP-authenticated relays
REPORT_SMTP_PASSWORD optional
REPORT_SMTP_FROM e.g. "TMI Monitoring <monitoring@terra-mechanics.com>"
REPORT_SMTP_RECIPIENTS comma-separated default recipient list
REPORT_SMTP_TIMEOUT seconds, default 30
"""
from __future__ import annotations
import logging
import os
import smtplib
import ssl
from dataclasses import dataclass, field
from email.message import EmailMessage
from typing import Optional
logger = logging.getLogger(__name__)
# Convenient MIME type for the Excel attachment.
XLSX_MIME = ("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@dataclass
class Attachment:
filename: str
content: bytes
maintype: str = "application"
subtype: str = "octet-stream"
@dataclass
class SMTPConfig:
host: str = ""
port: int = 587
security: str = "starttls" # "starttls" | "ssl" | "none"
user: str = ""
password: str = ""
sender: str = ""
recipients: list[str] = field(default_factory=list)
timeout: float = 30.0
@classmethod
def from_env(cls) -> "SMTPConfig":
rec = os.getenv("REPORT_SMTP_RECIPIENTS", "")
return cls(
host=os.getenv("REPORT_SMTP_HOST", "").strip(),
port=int(os.getenv("REPORT_SMTP_PORT", "587") or 587),
security=os.getenv("REPORT_SMTP_SECURITY", "starttls").strip().lower(),
user=os.getenv("REPORT_SMTP_USER", "").strip(),
password=os.getenv("REPORT_SMTP_PASSWORD", ""),
sender=os.getenv("REPORT_SMTP_FROM", "").strip(),
recipients=[r.strip() for r in rec.split(",") if r.strip()],
timeout=float(os.getenv("REPORT_SMTP_TIMEOUT", "30") or 30),
)
@property
def configured(self) -> bool:
"""True only when we have enough to actually send (host + from)."""
return bool(self.host and self.sender)
def build_message(
cfg: SMTPConfig,
subject: str,
html_body: str,
recipients: list[str],
attachments: Optional[list[Attachment]] = None,
text_body: Optional[str] = None,
) -> EmailMessage:
"""Assemble a multipart message: plain-text fallback + HTML + attachments."""
msg = EmailMessage()
msg["From"] = cfg.sender or "terra-view@localhost"
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
# Plain-text part first, then the HTML alternative (clients prefer the HTML).
msg.set_content(text_body or "This report is best viewed in an HTML email client.")
msg.add_alternative(html_body, subtype="html")
for att in (attachments or []):
msg.add_attachment(
att.content, maintype=att.maintype, subtype=att.subtype, filename=att.filename,
)
return msg
def send_report_email(
subject: str,
html_body: str,
*,
attachments: Optional[list[Attachment]] = None,
recipients: Optional[list[str]] = None,
text_body: Optional[str] = None,
cfg: Optional[SMTPConfig] = None,
) -> dict:
"""Send (or dry-run) the report email.
Returns a result dict: {sent, dry_run, recipients, error}. Never raises on
a send failure it logs and returns error, so the orchestrator can record
the failure without aborting the rest of the pipeline.
"""
cfg = cfg or SMTPConfig.from_env()
recipients = recipients if recipients is not None else cfg.recipients
result = {"sent": False, "dry_run": False, "recipients": recipients, "error": None}
if not recipients:
result["error"] = "No recipients configured"
logger.warning("Report email: no recipients set; skipping send of %r", subject)
return result
msg = build_message(cfg, subject, html_body, recipients, attachments, text_body)
if not cfg.configured:
result["dry_run"] = True
logger.info(
"Report email DRY-RUN (SMTP not configured): would send %r to %s with %d attachment(s)",
subject, recipients, len(attachments or []),
)
return result
# Validate the security mode: an unrecognized value (typo) must NOT silently
# fall through to a plaintext connection while still sending credentials.
sec = cfg.security if cfg.security in ("ssl", "starttls", "none") else "starttls"
if sec != cfg.security:
logger.warning("Unknown REPORT_SMTP_SECURITY=%r — falling back to 'starttls'", cfg.security)
try:
if sec == "ssl":
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=ctx) as s:
if cfg.user:
s.login(cfg.user, cfg.password)
s.send_message(msg)
else:
with smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout) as s:
s.ehlo()
if sec == "starttls":
s.starttls(context=ssl.create_default_context())
s.ehlo()
if cfg.user:
if sec == "none":
logger.warning(
"Sending SMTP credentials over an UNENCRYPTED connection "
"(REPORT_SMTP_SECURITY=none) — set starttls/ssl if the relay supports it."
)
s.login(cfg.user, cfg.password)
s.send_message(msg)
result["sent"] = True
logger.info("Report email sent: %r to %s", subject, recipients)
except Exception as e: # noqa: BLE001 — surface as result, never abort the pipeline
result["error"] = str(e)
logger.error("Report email send failed: %s", e, exc_info=True)
return result
-150
View File
@@ -1,150 +0,0 @@
"""
Nightly Report Orchestrator.
Ties the pieces together: compute render write-to-disk email.
This is what the daily cycle (or a manual trigger) calls. It ALWAYS writes the
rendered report to disk `data/reports/{project_id}/{night_date}/report.html`
(+ `report.json` with the raw numbers) so there's a viewable artifact even
when email is in dry-run (SMTP not configured yet). The email step is
best-effort and never aborts the run.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from pathlib import Path
from typing import Optional
from sqlalchemy.orm import Session
from backend.services.report_pipeline import (
ProjectNightReport, build_project_night_report, Window,
)
from backend.services.report_renderers import render_html_summary, render_excel
from backend.services.report_email import send_report_email, Attachment, XLSX_MIME
logger = logging.getLogger(__name__)
DEFAULT_OUTPUT_ROOT = "data/reports"
def _report_to_dict(report: ProjectNightReport) -> dict:
"""Serialise the report data model to plain JSON (for the on-disk record)."""
return {
"project_id": report.project_id,
"project_name": report.project_name,
"night_date": report.night_date.isoformat(),
"metrics": [m.key for m in report.metrics],
"locations": [
{
"name": loc.location_name,
"night_interval_count": loc.night_interval_count,
"baseline_nights_used": loc.baseline_nights_used,
"notes": loc.notes,
"windows": {
w.key: {
"label": w.label,
"metrics": {
m.key: {
"label": m.label,
"last_night": loc.table[w.key][m.key].last_night,
"baseline": loc.table[w.key][m.key].baseline,
"delta": loc.table[w.key][m.key].delta,
}
for m in loc.metrics
},
}
for w in loc.windows
},
}
for loc in report.locations
],
}
def run_nightly_report(
db: Session,
project_id: str,
night_date: date,
*,
metric_keys: Optional[list[str]] = None,
windows: Optional[list[Window]] = None,
baseline_mode: str = "captured",
baseline_start: Optional[date] = None,
baseline_end: Optional[date] = None,
recipients: Optional[list[str]] = None,
output_root: str = DEFAULT_OUTPUT_ROOT,
send: bool = True,
) -> dict:
"""Build, persist, and (dry-run) email the night report for a project.
Returns a result dict with the on-disk artifact paths and the email result.
Designed to be called from the daily cycle or a manual trigger.
"""
report = build_project_night_report(
db, project_id, night_date,
metric_keys=metric_keys, windows=windows,
baseline_mode=baseline_mode,
baseline_start=baseline_start, baseline_end=baseline_end,
)
html = render_html_summary(report)
subject = f"{report.project_name} — night report {night_date:%m/%d/%y}"
# --- Always persist a viewable copy ---
out_dir = Path(output_root) / project_id / f"{night_date:%Y-%m-%d}"
out_dir.mkdir(parents=True, exist_ok=True)
html_path = out_dir / "report.html"
html_path.write_text(html, encoding="utf-8")
json_path = out_dir / "report.json"
json_path.write_text(json.dumps(_report_to_dict(report), indent=2), encoding="utf-8")
# --- Excel (the email attachment; also written to disk for the archive) ---
attachments: list[Attachment] = []
xlsx_path = None
try:
xlsx_bytes = render_excel(report)
xlsx_path = out_dir / "report.xlsx"
xlsx_path.write_bytes(xlsx_bytes)
safe_name = "".join(c for c in report.project_name if c.isalnum() or c in " -_").strip().replace(" ", "_")
attachments.append(Attachment(
f"{safe_name or 'report'}_{night_date:%Y-%m-%d}_night_report.xlsx",
xlsx_bytes, *XLSX_MIME,
))
except Exception as e: # noqa: BLE001 — never let the spreadsheet sink the report
logger.error("Excel render failed for %s (%s): %s", project_id, night_date, e, exc_info=True)
# --- Email (best-effort; dry-run until SMTP is configured) ---
email_result = {"sent": False, "dry_run": False, "skipped": True, "error": None}
if send:
try:
email_result = send_report_email(
subject, html, attachments=attachments, recipients=recipients,
)
except Exception as e: # noqa: BLE001 — artifacts are already written; never abort on email
logger.error("send_report_email raised for %s (%s): %s", project_id, night_date, e, exc_info=True)
email_result = {"sent": False, "dry_run": False, "skipped": False, "error": str(e)}
result = {
"project_id": project_id,
"project_name": report.project_name,
"night_date": night_date.isoformat(),
"subject": subject,
"location_count": len(report.locations),
"html_path": str(html_path),
"json_path": str(json_path),
"xlsx_path": str(xlsx_path) if xlsx_path else None,
"html": html, # for callers that want to display it inline
"email": email_result,
}
logger.info(
"Nightly report for %s (%s): %d location(s) → %s; email=%s",
report.project_name, night_date, len(report.locations), html_path,
"sent" if email_result.get("sent") else
("dry-run" if email_result.get("dry_run") else
("skipped" if email_result.get("skipped") else f"error: {email_result.get('error')}")),
)
return result
-432
View File
@@ -1,432 +0,0 @@
"""
Nightly Report Pipeline computation core.
Builds the data model for the John-Myler-style "last night vs. baseline" sound
report. Source-agnostic: it reads the same on-disk Leq `.rnd` files the manual
upload + FTP-pull ingest produce (see `project_locations.ingest_nrl_zip`).
Design notes
------------
* **Ingest everything, report selectively.** Ingest preserves every column of
the Leq file; this layer chooses which *metrics* to surface via `metric_keys`
(a future report wizard is just a UI over that list).
* **House format match.** Defaults reproduce the existing Excel report:
LAmax (max of interval maxima), LA01 / LA10 (arithmetic average), split into
Evening (710PM) and Nighttime (10PM7AM) windows. L90 (background) is added
for the baseline comparison.
* **Metric labelling from the device.** The LNpercentile assignment is
reconfigurable per job; we resolve which `LNx(Main)` column is L90/L10/etc.
from the percentile map captured in the session metadata at ingest, falling
back to the NL-43 default order.
* **Correct averaging.** Leq is energy-averaged (logarithmic); percentiles and
Lmax are arithmetic. Baseline references combine the per-night values into a
"typical night" (arithmetic mean of per-night values so baseline Lmax is the
typical nightly peak, not the worst-of-week).
"""
from __future__ import annotations
import json
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, date
from typing import Optional
from sqlalchemy.orm import Session
from backend.models import MonitoringSession, DataFile, MonitoringLocation, Project
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Metric registry
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Metric:
"""A reportable metric.
`agg` is the *within-night* aggregation used to collapse a window's 15-min
intervals into one value:
- "max" loudest interval (LAmax)
- "arith" arithmetic mean (percentiles: L01/L10/L90)
- "log" energy/logarithmic mean (Leq only)
`column` pins a fixed .rnd column; `percentile` instead resolves the LNx
column from the session's captured percentile map.
"""
key: str
label: str
agg: str
column: Optional[str] = None
percentile: Optional[float] = None
METRIC_REGISTRY: dict[str, Metric] = {
"lmax": Metric("lmax", "LAmax", "max", column="Lmax(Main)"),
"leq": Metric("leq", "LAeq", "log", column="Leq(Main)"),
"lmin": Metric("lmin", "LAmin", "arith", column="Lmin(Main)"),
"l01": Metric("l01", "LA01", "arith", percentile=1.0),
"l10": Metric("l10", "LA10", "arith", percentile=10.0),
"l50": Metric("l50", "LA50", "arith", percentile=50.0),
"l90": Metric("l90", "LA90", "arith", percentile=90.0),
"l95": Metric("l95", "LA95", "arith", percentile=95.0),
}
# House report metrics + L90 (background) for the baseline comparison.
DEFAULT_METRICS: list[str] = ["lmax", "l01", "l10", "l90"]
# NL-43 default percentile→slot assignment, used when a session has no captured map.
_DEFAULT_SLOT_FOR_PCT: dict[float, int] = {1.0: 1, 10.0: 2, 50.0: 3, 90.0: 4, 95.0: 5}
def _resolve_column(metric: Metric, pct_map: dict) -> Optional[str]:
"""Resolve the .rnd column for a metric, using the session's percentile map."""
if metric.column:
return metric.column
if metric.percentile is None:
return None
# pct_map: {"1": "1.0", "2": "10.0", "4": "90.0", ...} → slot : percentile
if pct_map:
for slot, pval in pct_map.items():
try:
if float(pval) == metric.percentile:
return f"LN{int(slot)}(Main)"
except (ValueError, TypeError):
continue
slot = _DEFAULT_SLOT_FOR_PCT.get(metric.percentile)
return f"LN{slot}(Main)" if slot else None
# ---------------------------------------------------------------------------
# Time windows
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Window:
key: str
label: str
start_hour: int
end_hour: int
def contains(self, hour: int) -> bool:
if self.start_hour < self.end_hour:
return self.start_hour <= hour < self.end_hour
return hour >= self.start_hour or hour < self.end_hour
# Matches the existing Excel report's stats table.
DEFAULT_WINDOWS: list[Window] = [
Window("evening", "Evening (7PM10PM)", 19, 22),
Window("nighttime", "Nighttime (10PM7AM)", 22, 7),
]
# The full night used to select which intervals belong to "last night".
NIGHT_START_HOUR = 19
NIGHT_LENGTH_HOURS = 12
# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------
def _aggregate(values: list, method: str) -> Optional[float]:
"""Collapse a window's interval values into one number per `method`."""
vals = [v for v in values if isinstance(v, (int, float))]
if not vals:
return None
if method == "max":
return round(max(vals), 1)
if method == "log":
return round(10 * math.log10(sum(10 ** (v / 10.0) for v in vals) / len(vals)), 1)
return round(sum(vals) / len(vals), 1) # arithmetic
def _combine_across_nights(per_night: list, method: str) -> Optional[float]:
"""Combine per-night window values into a baseline 'typical night' value.
Arithmetic mean for max/arith metrics (so baseline Lmax = typical nightly
peak, the agreed default), logarithmic mean for Leq.
"""
vals = [v for v in per_night if v is not None]
if not vals:
return None
if method == "log":
return round(10 * math.log10(sum(10 ** (v / 10.0) for v in vals) / len(vals)), 1)
return round(sum(vals) / len(vals), 1)
# ---------------------------------------------------------------------------
# Row gathering
# ---------------------------------------------------------------------------
def _parse_dt(s: str) -> Optional[datetime]:
try:
return datetime.strptime(s, "%Y/%m/%d %H:%M:%S")
except (ValueError, TypeError):
return None
def _location_leq_rows(db: Session, location_id: str) -> list[tuple[datetime, dict, dict]]:
"""All Leq intervals at a location as (interval_dt, row, percentile_map).
Reuses the same .rnd readers as the report endpoints so parsing stays
identical. Times are the meter's local clock (as written in the file).
"""
# Lazy import avoids a service→router import cycle at module load.
from backend.routers.projects import (
_read_rnd_file_rows, _normalize_rnd_rows, _is_leq_file, _peek_rnd_headers,
)
from pathlib import Path
out: list[tuple[datetime, dict, dict]] = []
sessions = db.query(MonitoringSession).filter_by(
location_id=location_id, session_type="sound",
).all()
for s in sessions:
try:
meta = json.loads(s.session_metadata or "{}")
except (json.JSONDecodeError, TypeError):
meta = {}
pct_map = meta.get("percentiles", {}) or {}
for f in db.query(DataFile).filter_by(session_id=s.id).all():
if not f.file_path or not f.file_path.lower().endswith(".rnd"):
continue
peek = _peek_rnd_headers(Path("data") / f.file_path)
if not _is_leq_file(f.file_path, peek):
continue
rows = _read_rnd_file_rows(f.file_path)
rows, _ = _normalize_rnd_rows(rows)
for r in rows:
dt = _parse_dt(r.get("Start Time", ""))
if dt:
out.append((dt, r, pct_map))
out.sort(key=lambda t: t[0])
return out
def _rows_in_night(rows: list, night_date: date) -> list:
"""Rows falling in the night that *starts* on night_date (19:00 → +12h)."""
start = datetime(night_date.year, night_date.month, night_date.day, NIGHT_START_HOUR, 0)
end = start + timedelta(hours=NIGHT_LENGTH_HOURS)
return [(dt, r, p) for (dt, r, p) in rows if start <= dt < end]
def _eligible_nights(rows: list, start_date: date, end_date: date) -> list[date]:
"""Evening-dates in [start_date, end_date] that actually have night data."""
nights = []
cur = start_date
while cur <= end_date:
if _rows_in_night(rows, cur):
nights.append(cur)
cur += timedelta(days=1)
return nights
def _window_value(rows: list, metric: Metric, window: Window) -> Optional[float]:
"""Single aggregated value for one metric over one window of `rows`."""
vals = []
for dt, r, pct_map in rows:
if window.contains(dt.hour):
col = _resolve_column(metric, pct_map)
if col:
vals.append(r.get(col))
return _aggregate(vals, metric.agg)
# ---------------------------------------------------------------------------
# Report data model
# ---------------------------------------------------------------------------
@dataclass
class CellPair:
last_night: Optional[float]
baseline: Optional[float]
@property
def delta(self) -> Optional[float]:
if self.last_night is None or self.baseline is None:
return None
return round(self.last_night - self.baseline, 1)
@dataclass
class LocationNightReport:
location_id: str
location_name: str
night_date: date
metrics: list[Metric]
windows: list[Window]
# table[window_key][metric_key] = CellPair
table: dict[str, dict[str, CellPair]]
interval_series: list[dict]
night_interval_count: int
baseline_nights_used: int
notes: list[str] = field(default_factory=list)
def _location_reference_baseline(loc) -> dict:
"""A location's manually-entered reference baseline, from its metadata.
Shape: {window_key: {metric_key: float}} e.g. {"nighttime": {"l10": 85.0}}.
Used when baseline_mode == "reference" fixed targets/limits or prior-report
averages typed in, rather than computed from captured nights.
"""
if not loc:
return {}
try:
meta = json.loads(loc.location_metadata or "{}")
except (json.JSONDecodeError, TypeError):
return {}
ref = meta.get("report_baseline") or {}
out: dict[str, dict[str, float]] = {}
if isinstance(ref, dict):
for wkey, mvals in ref.items():
if not isinstance(mvals, dict):
continue
clean = {}
for mkey, val in mvals.items():
try:
clean[mkey] = float(val)
except (ValueError, TypeError):
continue
if clean:
out[wkey] = clean
return out
def build_location_night_report(
db: Session,
location_id: str,
night_date: date,
*,
metric_keys: Optional[list[str]] = None,
windows: Optional[list[Window]] = None,
baseline_mode: str = "captured",
baseline_start: Optional[date] = None,
baseline_end: Optional[date] = None,
) -> LocationNightReport:
"""Build the night-vs-baseline data model for one location.
`night_date` is the *evening* date of the night being reported (e.g. the
7/7 in "night of 7/7 → morning 7/8"). Baseline comes from one of:
- "captured": the typical-night value across eligible nights in
[baseline_start, baseline_end] (computed from recorded data);
- "reference": fixed values typed per location (a spec limit like
"L10 = 85", or a prior report's averages).
"""
metric_keys = metric_keys or DEFAULT_METRICS
metrics = [METRIC_REGISTRY[k] for k in metric_keys]
windows = windows or DEFAULT_WINDOWS
loc = db.query(MonitoringLocation).filter_by(id=location_id).first()
loc_name = loc.name if loc else location_id
all_rows = _location_leq_rows(db, location_id)
night_rows = _rows_in_night(all_rows, night_date)
reference = _location_reference_baseline(loc) if baseline_mode == "reference" else {}
baseline_nights: list[date] = []
if baseline_mode != "reference" and baseline_start and baseline_end:
baseline_nights = _eligible_nights(all_rows, baseline_start, baseline_end)
# Don't let the reported night double as its own baseline.
baseline_nights = [n for n in baseline_nights if n != night_date]
table: dict[str, dict[str, CellPair]] = {}
for w in windows:
table[w.key] = {}
for m in metrics:
last_night_val = _window_value(night_rows, m, w)
if baseline_mode == "reference":
baseline_val = reference.get(w.key, {}).get(m.key)
elif baseline_nights:
per_night = [
_window_value(_rows_in_night(all_rows, nd), m, w)
for nd in baseline_nights
]
baseline_val = _combine_across_nights(per_night, m.agg)
else:
baseline_val = None
table[w.key][m.key] = CellPair(last_night_val, baseline_val)
interval_series = []
for dt, r, pct_map in night_rows:
entry = {"dt": dt, "time": dt.strftime("%H:%M")}
for m in metrics:
col = _resolve_column(m, pct_map)
val = r.get(col) if col else None
entry[m.key] = val if isinstance(val, (int, float)) else None
interval_series.append(entry)
notes: list[str] = []
if not night_rows:
notes.append(f"No data found for the night of {night_date:%m/%d/%y}.")
if baseline_mode == "reference":
if not any(reference.values()):
notes.append("Reference-baseline mode is on but no reference values are set for this location.")
elif (baseline_start or baseline_end) and not baseline_nights:
notes.append("No baseline nights with data in the configured range.")
return LocationNightReport(
location_id=location_id,
location_name=loc_name,
night_date=night_date,
metrics=metrics,
windows=windows,
table=table,
interval_series=interval_series,
night_interval_count=len(night_rows),
baseline_nights_used=len(baseline_nights),
notes=notes,
)
@dataclass
class ProjectNightReport:
project_id: str
project_name: str
night_date: date
metrics: list[Metric]
locations: list[LocationNightReport]
def build_project_night_report(
db: Session,
project_id: str,
night_date: date,
*,
metric_keys: Optional[list[str]] = None,
windows: Optional[list[Window]] = None,
baseline_mode: str = "captured",
baseline_start: Optional[date] = None,
baseline_end: Optional[date] = None,
) -> ProjectNightReport:
"""Build the night report for every active sound location in a project."""
metric_keys = metric_keys or DEFAULT_METRICS
project = db.query(Project).filter_by(id=project_id).first()
project_name = project.name if project else project_id
locations = db.query(MonitoringLocation).filter_by(
project_id=project_id, location_type="sound",
).order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
locations = [l for l in locations if getattr(l, "removed_at", None) is None]
reports = [
build_location_night_report(
db, loc.id, night_date,
metric_keys=metric_keys, windows=windows,
baseline_mode=baseline_mode,
baseline_start=baseline_start, baseline_end=baseline_end,
)
for loc in locations
]
return ProjectNightReport(
project_id=project_id,
project_name=project_name,
night_date=night_date,
metrics=[METRIC_REGISTRY[k] for k in metric_keys],
locations=reports,
)
-240
View File
@@ -1,240 +0,0 @@
"""
Nightly Report Renderers.
Pluggable renderers over the `report_pipeline` data model. v1 ships the HTML
email body + the Excel attachment; PDF and an inline chart image are v1.1
(each needs a new dependency). Keeping renderers separate from the compute
core means a future report wizard just toggles metrics/renderers the data
model is unchanged.
Email-client constraints: the HTML uses a table layout with **inline styles
only** (no <style> blocks, no external CSS, no fl/grid), which is the reliable
common denominator across Outlook / Gmail / Apple Mail.
"""
from __future__ import annotations
from html import escape
from backend.services.report_pipeline import ProjectNightReport, LocationNightReport
# Colours: louder-than-baseline reads as a concern (red), quieter as fine (green).
_RED = "#b00020"
_GREEN = "#1a7f37"
_GREY = "#888888"
def _fmt_value(v) -> str:
return f"{v:.1f}" if isinstance(v, (int, float)) else ""
def _fmt_delta(v) -> str:
"""Signed delta with colour; positive (louder) = red, negative (quieter) = green."""
if not isinstance(v, (int, float)):
return f'<span style="color:{_GREY}">—</span>'
if v > 0:
return f'<span style="color:{_RED}">+{v:.1f}</span>'
if v < 0:
return f'<span style="color:{_GREEN}">{v:.1f}</span>'
return f'<span style="color:{_GREY}">0.0</span>'
def _location_table(loc: LocationNightReport) -> str:
"""One location block: heading + Metric × (window: Last / Base / Δ) table."""
th = ('padding:5px 9px;border:1px solid #ccc;background:#f2f2f2;'
'font:bold 12px Arial,sans-serif;text-align:center')
sub = ('padding:4px 8px;border:1px solid #ccc;background:#fafafa;'
'font:11px Arial,sans-serif;text-align:center;color:#555')
td = 'padding:4px 9px;border:1px solid #ccc;font:12px Arial,sans-serif;text-align:center'
td_l = 'padding:4px 9px;border:1px solid #ccc;font:bold 12px Arial,sans-serif;text-align:left'
# Top header: blank label cell + each window spanning Last/Base/Δ
top = f'<th rowspan="2" style="{th}">Metric (dBA)</th>'
for w in loc.windows:
top += f'<th colspan="3" style="{th}">{escape(w.label)}</th>'
sub_row = ''.join(
f'<th style="{sub}">Last</th><th style="{sub}">Base</th><th style="{sub}">&Delta;</th>'
for _ in loc.windows
)
body = ''
for m in loc.metrics:
cells = ''
for w in loc.windows:
cp = loc.table[w.key][m.key]
cells += (f'<td style="{td}">{_fmt_value(cp.last_night)}</td>'
f'<td style="{td}">{_fmt_value(cp.baseline)}</td>'
f'<td style="{td}">{_fmt_delta(cp.delta)}</td>')
body += f'<tr><td style="{td_l}">{escape(m.label)}</td>{cells}</tr>'
meta = (f'{loc.night_interval_count} intervals'
+ (f' · baseline = {loc.baseline_nights_used} night(s)'
if loc.baseline_nights_used else ' · no baseline yet'))
notes = ''
if loc.notes:
notes = ('<div style="font:11px Arial,sans-serif;color:#b00020;margin:2px 0 0">'
+ '<br>'.join(escape(n) for n in loc.notes) + '</div>')
return (
f'<h3 style="font:bold 15px Arial,sans-serif;margin:18px 0 4px">{escape(loc.location_name)}</h3>'
f'<div style="font:11px Arial,sans-serif;color:#666;margin:0 0 6px">{escape(meta)}</div>'
f'<table style="border-collapse:collapse;border:1px solid #ccc">'
f'<thead><tr>{top}</tr><tr>{sub_row}</tr></thead>'
f'<tbody>{body}</tbody></table>{notes}'
)
def render_html_summary(report: ProjectNightReport) -> str:
"""Render the full email-body HTML for a project's night report."""
windows_desc = ", ".join(w.label for w in (report.locations[0].windows if report.locations else []))
header = (
f'<h2 style="font:bold 18px Arial,sans-serif;margin:0 0 2px">'
f'{escape(report.project_name)} — Night Report</h2>'
f'<div style="font:13px Arial,sans-serif;color:#444;margin:0 0 4px">'
f'Night of {report.night_date:%a %m/%d/%y} &nbsp;·&nbsp; last night vs. baseline</div>'
f'<div style="font:11px Arial,sans-serif;color:#888;margin:0 0 10px">'
f'Windows: {escape(windows_desc)}. '
f'&Delta; = last night minus baseline (<span style="color:{_RED}">+ louder</span>, '
f'<span style="color:{_GREEN}"> quieter</span>). '
f'LAmax = loudest interval; L-values are arithmetic averages; '
f'baseline = typical night.</div>'
)
if not report.locations:
body = ('<div style="font:13px Arial,sans-serif;color:#b00020">'
'No sound locations found for this project.</div>')
else:
body = ''.join(_location_table(loc) for loc in report.locations)
footer = ('<div style="font:10px Arial,sans-serif;color:#aaa;margin-top:18px">'
'Automated report — Terra-View. Full interval data in the attached spreadsheet.</div>')
return (f'<!DOCTYPE html><html><body style="margin:0;padding:16px;background:#fff">'
f'{header}{body}{footer}</body></html>')
# ---------------------------------------------------------------------------
# Excel renderer (the email attachment) — one sheet per location:
# interval table + line chart + a Last/Baseline/Δ summary per window.
# Metric-driven, so it adapts to whatever metric set is configured.
# ---------------------------------------------------------------------------
def _safe_sheet_name(name: str) -> str:
bad = set('[]:*?/\\')
cleaned = "".join(c for c in (name or "Location") if c not in bad).strip()
return (cleaned or "Location")[:31]
def render_excel(report: ProjectNightReport) -> bytes:
"""Render the night report as an .xlsx (bytes). One worksheet per location."""
import io as _io
import openpyxl
from openpyxl.chart import LineChart, Reference
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
wb = openpyxl.Workbook()
wb.remove(wb.active)
f_title = Font(name="Arial", bold=True, size=13)
f_h = Font(name="Arial", bold=True, size=10)
f_d = Font(name="Arial", size=10)
f_note = Font(name="Arial", size=9, italic=True, color="888888")
center = Alignment(horizontal="center", vertical="center")
hdr_fill = PatternFill("solid", fgColor="F2F2F2")
thin = Side(style="thin")
box = Border(left=thin, right=thin, top=thin, bottom=thin)
if not report.locations:
ws = wb.create_sheet("No data")
ws["A1"] = f"{report.project_name} — no sound locations"
ws["A1"].font = f_title
used_names: set = set()
for loc in report.locations:
sheet_name = _safe_sheet_name(loc.location_name)
n, base = sheet_name, sheet_name
i = 2
while n in used_names:
n = (base[:28] + f"_{i}"); i += 1
used_names.add(n)
ws = wb.create_sheet(n)
metrics = loc.metrics
ws["A1"] = f"{report.project_name} — Night Report"; ws["A1"].font = f_title
ws["A2"] = loc.location_name; ws["A2"].font = f_h
ws["A3"] = f"Night of {loc.night_date:%m/%d/%y} · 7PM7AM"; ws["A3"].font = f_d
# --- interval table ---
hr = 5
cols = ["Interval #", "Date", "Time"] + [m.label for m in metrics] + ["Comments"]
for ci, label in enumerate(cols, 1):
c = ws.cell(row=hr, column=ci, value=label)
c.font = f_h; c.alignment = center; c.fill = hdr_fill; c.border = box
r = hr + 1
for idx, entry in enumerate(loc.interval_series, 1):
ws.cell(row=r, column=1, value=idx).border = box
dt = entry.get("dt")
ws.cell(row=r, column=2, value=(dt.strftime("%m/%d/%y") if dt else "")).border = box
ws.cell(row=r, column=3, value=entry.get("time", "")).border = box
for mi, m in enumerate(metrics):
v = entry.get(m.key)
cc = ws.cell(row=r, column=4 + mi, value=(v if isinstance(v, (int, float)) else None))
cc.border = box; cc.alignment = center
ws.cell(row=r, column=4 + len(metrics), value="").border = box
r += 1
data_end = max(r - 1, hr + 1)
ws.column_dimensions["A"].width = 9
ws.column_dimensions["B"].width = 10
ws.column_dimensions["C"].width = 8
for mi in range(len(metrics)):
ws.column_dimensions[get_column_letter(4 + mi)].width = 11
ws.column_dimensions[get_column_letter(4 + len(metrics))].width = 22
# --- chart ---
if loc.interval_series and metrics:
chart = LineChart()
chart.title = f"{loc.location_name}{loc.night_date:%m/%d/%y}"
chart.y_axis.title = "dBA"; chart.x_axis.title = "Time"
chart.height = 9; chart.width = 18
data_ref = Reference(ws, min_col=4, max_col=3 + len(metrics), min_row=hr, max_row=data_end)
cats = Reference(ws, min_col=3, min_row=hr + 1, max_row=data_end)
chart.add_data(data_ref, titles_from_data=True)
chart.set_categories(cats)
ws.add_chart(chart, f"{get_column_letter(6 + len(metrics))}5")
# --- summary: Metric × window (Last / Base / Δ) ---
sr = data_end + 3
ws.cell(row=sr, column=1, value="Summary — last night vs baseline").font = f_h
sr += 1
ws.cell(row=sr, column=1, value="Metric").font = f_h
win_col = {}
col = 2
for w in loc.windows:
c = ws.cell(row=sr, column=col, value=w.label); c.font = f_h; c.alignment = center
ws.merge_cells(start_row=sr, start_column=col, end_row=sr, end_column=col + 2)
win_col[w.key] = col
col += 3
sr += 1
for w in loc.windows:
b = win_col[w.key]
for j, lbl in enumerate(["Last", "Base", "Δ"]):
cc = ws.cell(row=sr, column=b + j, value=lbl); cc.font = f_h; cc.alignment = center
sr += 1
for m in metrics:
ws.cell(row=sr, column=1, value=m.label).font = f_d
for w in loc.windows:
cp = loc.table[w.key][m.key]
b = win_col[w.key]
ws.cell(row=sr, column=b + 0, value=cp.last_night).alignment = center
ws.cell(row=sr, column=b + 1, value=cp.baseline).alignment = center
ws.cell(row=sr, column=b + 2, value=cp.delta).alignment = center
sr += 1
if loc.notes:
ws.cell(row=sr + 1, column=1, value="; ".join(loc.notes)).font = f_note
out = _io.BytesIO()
wb.save(out)
return out.getvalue()
+13 -539
View File
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
from backend.services.device_controller import get_device_controller, DeviceControllerError from backend.services.device_controller import get_device_controller, DeviceControllerError
from backend.services.alert_service import get_alert_service from backend.services.alert_service import get_alert_service
import uuid import uuid
@@ -78,9 +78,6 @@ class SchedulerService:
# Execute pending actions # Execute pending actions
await self.execute_pending_actions() await self.execute_pending_actions()
# Run any due nightly sound reports (FTP report pipeline)
await self.run_due_reports()
# Generate actions from recurring schedules (every hour) # Generate actions from recurring schedules (every hour)
now = datetime.utcnow() now = datetime.utcnow()
if (now - last_generation_check).total_seconds() >= 3600: if (now - last_generation_check).total_seconds() >= 3600:
@@ -110,19 +107,10 @@ class SchedulerService:
try: try:
# Find pending actions that are due # Find pending actions that are due
now = datetime.utcnow() now = datetime.utcnow()
# Only execute actions for active/completed projects (not on_hold, archived, or deleted)
active_project_ids = [
p.id for p in db.query(Project.id).filter(
Project.status.notin_(["on_hold", "archived", "deleted"])
).all()
]
pending_actions = db.query(ScheduledAction).filter( pending_actions = db.query(ScheduledAction).filter(
and_( and_(
ScheduledAction.execution_status == "pending", ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time <= now, ScheduledAction.scheduled_time <= now,
ScheduledAction.project_id.in_(active_project_ids),
) )
).order_by(ScheduledAction.scheduled_time).all() ).order_by(ScheduledAction.scheduled_time).all()
@@ -197,8 +185,6 @@ class SchedulerService:
response = await self._execute_stop(action, unit_id, db) response = await self._execute_stop(action, unit_id, db)
elif action.action_type == "download": elif action.action_type == "download":
response = await self._execute_download(action, unit_id, db) response = await self._execute_download(action, unit_id, db)
elif action.action_type == "cycle":
response = await self._execute_cycle(action, unit_id, db)
else: else:
raise Exception(f"Unknown action type: {action.action_type}") raise Exception(f"Unknown action type: {action.action_type}")
@@ -275,7 +261,7 @@ class SchedulerService:
) )
# Create recording session # Create recording session
session = MonitoringSession( session = RecordingSession(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=action.project_id, project_id=action.project_id,
location_id=action.location_id, location_id=action.location_id,
@@ -307,12 +293,8 @@ class SchedulerService:
stop_cycle handles: stop_cycle handles:
1. Stop measurement 1. Stop measurement
2. Enable FTP 2. Enable FTP
3. Download measurement folder to SLMM local storage 3. Download measurement folder
4. Verify download
After stop_cycle, if download succeeded, this method ingests the folder
into Terra-View through the shared NRL ingest (same path as cycle and the
manual SD-card upload) so the resulting session is Leq-only, has its
`.rnh` parsed (percentile slot map + weightings), and is deduped.
""" """
# Parse notes for download preference # Parse notes for download preference
include_download = True include_download = True
@@ -324,7 +306,7 @@ class SchedulerService:
pass # Notes is plain text, not JSON pass # Notes is plain text, not JSON
# Execute the full stop cycle via device controller # Execute the full stop cycle via device controller
# SLMM handles stop, FTP enable, and download to SLMM-local storage # SLMM handles stop, FTP enable, and download
cycle_response = await self.device_controller.stop_cycle( cycle_response = await self.device_controller.stop_cycle(
unit_id, unit_id,
action.device_type, action.device_type,
@@ -332,11 +314,11 @@ class SchedulerService:
) )
# Find and update the active recording session # Find and update the active recording session
active_session = db.query(MonitoringSession).filter( active_session = db.query(RecordingSession).filter(
and_( and_(
MonitoringSession.location_id == action.location_id, RecordingSession.location_id == action.location_id,
MonitoringSession.unit_id == unit_id, RecordingSession.unit_id == unit_id,
MonitoringSession.status == "recording", RecordingSession.status == "recording",
) )
).first() ).first()
@@ -356,41 +338,10 @@ class SchedulerService:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
db.commit()
# If SLMM downloaded the folder successfully, ingest it into Terra-View
# through the shared NRL ingest (the same path cycle and the manual SD
# upload use): keeps only the .rnh + Leq .rnd, parses the header
# (percentile slot map + weightings), dedups, and links the unit. The
# transient "recording" marker session is dropped in favour of the clean
# ingested row. (Replaces the old inline unzip that stored every file —
# incl. the 1-second _Lp_ data — without parsing the .rnh.)
ingest_result = None
ingested_session_id = None
if (include_download and cycle_response.get("download_success")
and active_session and action.device_type == "slm"):
folder_name = cycle_response.get("downloaded_folder") # e.g. "Auto_0058"
if folder_name:
try:
ingest_result = await self._ingest_and_link(
db,
location_id=action.location_id,
unit_id=unit_id,
folder_name=folder_name,
placeholder_session=active_session,
)
ingested_session_id = ingest_result.get("session_id")
logger.info(f"[STOP] Ingested {folder_name}: {ingest_result}")
except Exception as e:
logger.error(f"Failed to ingest {folder_name} on stop: {e}", exc_info=True)
# Don't fail the stop action — the device was stopped successfully
ingest_result = {"success": False, "error": str(e)}
return { return {
"status": "stopped", "status": "stopped",
"session_id": ingested_session_id or (active_session.id if active_session else None), "session_id": active_session.id if active_session else None,
"cycle_response": cycle_response, "cycle_response": cycle_response,
"ingest": ingest_result,
} }
async def _execute_download( async def _execute_download(
@@ -424,13 +375,8 @@ class SchedulerService:
f"{location.name}/session-{session_timestamp}/" f"{location.name}/session-{session_timestamp}/"
) )
# Step 1: Disable FTP first to reset any stale connection state # Step 1: Enable FTP on device
# Then enable FTP on device logger.info(f"Enabling FTP on {unit_id} for download")
logger.info(f"Resetting FTP on {unit_id} for download (disable then enable)")
try:
await self.device_controller.disable_ftp(unit_id, action.device_type)
except Exception as e:
logger.warning(f"FTP disable failed (may already be off): {e}")
await self.device_controller.enable_ftp(unit_id, action.device_type) await self.device_controller.enable_ftp(unit_id, action.device_type)
# Step 2: Download current measurement folder # Step 2: Download current measurement folder
@@ -443,391 +389,14 @@ class SchedulerService:
files=None, # Download all files in current measurement folder files=None, # Download all files in current measurement folder
) )
# Ingest the downloaded folder into Terra-View via the shared NRL ingest # TODO: Create DataFile records for downloaded files
# (same path as stop/cycle): clean Leq-only session, .rnh parsed
# (percentiles + weightings), deduped, unit linked. No placeholder
# session here — a standalone download isn't tied to a "recording" marker.
ingest_result = None
if action.device_type == "slm":
folder_name = (response or {}).get("folder_name")
if not folder_name:
try:
folder_name = f"Auto_{int((response or {}).get('index_number')):04d}"
except (ValueError, TypeError):
folder_name = None
if not folder_name:
ingest_result = {"success": False, "error": "no folder_name/index_number from download"}
else:
try:
ingest_result = await self._ingest_and_link(
db,
location_id=action.location_id,
unit_id=unit_id,
folder_name=folder_name,
)
logger.info(f"[DOWNLOAD] Ingested {folder_name}: {ingest_result}")
except Exception as e:
logger.error(f"Failed to ingest {folder_name} on download: {e}", exc_info=True)
ingest_result = {"success": False, "error": str(e)}
return { return {
"status": "downloaded", "status": "downloaded",
"destination_path": destination_path, "destination_path": destination_path,
"device_response": response, "device_response": response,
"ingest": ingest_result,
} }
async def _execute_cycle(
self,
action: ScheduledAction,
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a full 'cycle' action: stop -> download -> start.
This combines stop, download, and start into a single action with
appropriate delays between steps to ensure device stability.
Workflow:
0. Pause background polling to prevent command conflicts
1. Stop measurement (wait 10s)
2. Disable FTP to reset state (wait 10s)
3. Enable FTP (wait 10s)
4. Download current measurement folder
5. Wait 30s for device to settle
6. Start new measurement cycle
7. Re-enable background polling
Total time: ~70-90 seconds depending on download size
"""
logger.info(f"[CYCLE] === Starting full cycle for {unit_id} ===")
result = {
"status": "cycle_complete",
"steps": {},
"old_session_id": None,
"new_session_id": None,
"polling_paused": False,
}
# Step 0: Pause background polling for this device to prevent command conflicts
# NL-43 devices only support one TCP connection at a time
logger.info(f"[CYCLE] Step 0: Pausing background polling for {unit_id}")
polling_was_enabled = False
try:
if action.device_type == "slm":
# Get current polling state to restore later
from backend.services.slmm_client import get_slmm_client
slmm = get_slmm_client()
try:
polling_config = await slmm.get_device_polling_config(unit_id)
polling_was_enabled = polling_config.get("poll_enabled", False)
except Exception:
polling_was_enabled = True # Assume enabled if can't check
# Disable polling during cycle
await slmm.update_device_polling_config(unit_id, poll_enabled=False)
result["polling_paused"] = True
logger.info(f"[CYCLE] Background polling paused for {unit_id}")
except Exception as e:
logger.warning(f"[CYCLE] Failed to pause polling (continuing anyway): {e}")
try:
# Step 1: Stop measurement
logger.info(f"[CYCLE] Step 1/7: Stopping measurement on {unit_id}")
try:
stop_response = await self.device_controller.stop_recording(unit_id, action.device_type)
result["steps"]["stop"] = {"success": True, "response": stop_response}
logger.info(f"[CYCLE] Measurement stopped, waiting 10s...")
except Exception as e:
logger.warning(f"[CYCLE] Stop failed (may already be stopped): {e}")
result["steps"]["stop"] = {"success": False, "error": str(e)}
await asyncio.sleep(10)
# Step 2: Disable FTP to reset any stale state
logger.info(f"[CYCLE] Step 2/7: Disabling FTP on {unit_id}")
try:
await self.device_controller.disable_ftp(unit_id, action.device_type)
result["steps"]["ftp_disable"] = {"success": True}
logger.info(f"[CYCLE] FTP disabled, waiting 10s...")
except Exception as e:
logger.warning(f"[CYCLE] FTP disable failed (may already be off): {e}")
result["steps"]["ftp_disable"] = {"success": False, "error": str(e)}
await asyncio.sleep(10)
# Step 3: Enable FTP
logger.info(f"[CYCLE] Step 3/7: Enabling FTP on {unit_id}")
try:
await self.device_controller.enable_ftp(unit_id, action.device_type)
result["steps"]["ftp_enable"] = {"success": True}
logger.info(f"[CYCLE] FTP enabled, waiting 10s...")
except Exception as e:
logger.error(f"[CYCLE] FTP enable failed: {e}")
result["steps"]["ftp_enable"] = {"success": False, "error": str(e)}
# Continue anyway - download will fail but we can still try to start
await asyncio.sleep(10)
# Step 4: Download current measurement folder
logger.info(f"[CYCLE] Step 4/7: Downloading measurement data from {unit_id}")
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
project = db.query(Project).filter_by(id=action.project_id).first()
if location and project:
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
destination_path = (
f"data/Projects/{project.id}/{location_type_dir}/"
f"{location.name}/session-{session_timestamp}/"
)
try:
download_response = await self.device_controller.download_files(
unit_id,
action.device_type,
destination_path,
files=None,
)
result["steps"]["download"] = {"success": True, "response": download_response}
logger.info(f"[CYCLE] Download complete")
except Exception as e:
logger.error(f"[CYCLE] Download failed: {e}")
result["steps"]["download"] = {"success": False, "error": str(e)}
else:
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
# Close out the old recording session
active_session = db.query(MonitoringSession).filter(
and_(
MonitoringSession.location_id == action.location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status == "recording",
)
).first()
if active_session:
active_session.stopped_at = datetime.utcnow()
active_session.status = "completed"
active_session.duration_seconds = int(
(active_session.stopped_at - active_session.started_at).total_seconds()
)
result["old_session_id"] = active_session.id
# Step 4b: Ingest the just-finished Auto_#### folder into Terra-View
# (clean session + DataFiles via ingest_nrl_zip — filters Lp, parses the
# .rnh, dedups). This is what gives the nightly report its data.
if action.device_type == "slm" and result["steps"].get("download", {}).get("success"):
idx = None
try:
idx = int((result["steps"]["download"].get("response") or {}).get("index_number"))
except (ValueError, TypeError):
idx = None
if idx is None:
result["steps"]["ingest"] = {"success": False, "error": "no index_number from download"}
else:
folder_name = f"Auto_{idx:04d}"
try:
ing = await self._ingest_and_link(
db,
location_id=action.location_id,
unit_id=unit_id,
folder_name=folder_name,
placeholder_session=active_session,
)
result["steps"]["ingest"] = ing
# The marker session was dropped; repoint old_session_id at the real row.
if ing.get("placeholder_dropped") and ing.get("session_id"):
result["old_session_id"] = ing["session_id"]
logger.info(f"[CYCLE] Ingested {folder_name}: {ing}")
except Exception as e:
logger.error(f"[CYCLE] Ingest failed for {folder_name}: {e}", exc_info=True)
result["steps"]["ingest"] = {"success": False, "error": str(e)}
# Step 5: Wait for device to settle before starting new measurement
logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...")
await asyncio.sleep(30)
# Step 6: Start new measurement cycle
logger.info(f"[CYCLE] Step 6/7: Starting new measurement on {unit_id}")
try:
cycle_response = await self.device_controller.start_cycle(
unit_id,
action.device_type,
sync_clock=True,
)
result["steps"]["start"] = {"success": True, "response": cycle_response}
# Create new recording session
new_session = MonitoringSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,
unit_id=unit_id,
session_type="sound" if action.device_type == "slm" else "vibration",
started_at=datetime.utcnow(),
status="recording",
session_metadata=json.dumps({
"scheduled_action_id": action.id,
"cycle_response": cycle_response,
"action_type": "cycle",
}),
)
db.add(new_session)
result["new_session_id"] = new_session.id
logger.info(f"[CYCLE] New measurement started, session {new_session.id}")
# Step 6b: Verify the meter actually resumed measuring (fresh DOD).
# Polling is still paused here, so query directly. If it didn't
# resume, retry ONCE with a plain start (start_recording — does NOT
# re-index, unlike start_cycle) before alerting: a meter left
# stopped overnight is the costly failure, and a transient restart
# hiccup is common on the NL-43. We retry only on a *confident*
# not-measuring reading — never on a failed/inconclusive DOD read —
# so a flaky read can't disrupt an already-running measurement.
if action.device_type == "slm":
async def _check_measuring():
"""Return (measuring, state); measuring is None if the DOD read failed."""
try:
await asyncio.sleep(2)
live = await self.device_controller.get_live_data(unit_id, action.device_type)
state = ((live or {}).get("measurement_state")
or ((live or {}).get("data") or {}).get("measurement_state") or "")
ok = str(state).strip().lower() in ("start", "measure", "measuring", "run", "running")
return ok, state
except Exception as e:
logger.warning(f"[CYCLE] Restart-verify DOD read failed: {e}")
return None, None
measuring, state = await _check_measuring()
if measuring is False:
logger.warning(f"[CYCLE] {unit_id} not measuring after restart (state={state!r}) — retrying start once.")
result["steps"]["restart_retry"] = True
try:
await self.device_controller.start_recording(unit_id, action.device_type)
measuring, state = await _check_measuring()
except Exception as e:
logger.error(f"[CYCLE] Restart retry (start_recording) failed for {unit_id}: {e}")
result["steps"]["restart_verified"] = measuring
if measuring:
logger.info(f"[CYCLE] Restart verified — {unit_id} is measuring (state={state}).")
elif measuring is False:
logger.error(f"[CYCLE] Restart NOT verified for {unit_id} after retry — state={state!r}")
try:
get_alert_service(db).create_schedule_failed_alert(
schedule_id=action.id, action_type="cycle", unit_id=unit_id,
error_message=f"Meter did not resume measuring after the cycle + one retry (state={state!r}).",
project_id=action.project_id, location_id=action.location_id,
)
except Exception as ae:
logger.warning(f"[CYCLE] restart-verify alert failed: {ae}")
else:
logger.warning(f"[CYCLE] Restart verification inconclusive for {unit_id} (DOD read failed); keepalive poll will re-confirm.")
except Exception as e:
logger.error(f"[CYCLE] Start failed: {e}")
result["steps"]["start"] = {"success": False, "error": str(e)}
raise # Re-raise to mark the action as failed
finally:
# Step 7: Re-enable background polling (always runs, even on failure)
if result.get("polling_paused") and polling_was_enabled:
logger.info(f"[CYCLE] Step 7/7: Re-enabling background polling for {unit_id}")
try:
if action.device_type == "slm":
from backend.services.slmm_client import get_slmm_client
slmm = get_slmm_client()
await slmm.update_device_polling_config(unit_id, poll_enabled=True)
logger.info(f"[CYCLE] Background polling re-enabled for {unit_id}")
except Exception as e:
logger.error(f"[CYCLE] Failed to re-enable polling: {e}")
# Don't raise - cycle completed, just log the error
logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===")
return result
async def _ingest_cycle_folder(self, db, location_id: str, unit_id: str, folder_name: str) -> dict:
"""Fetch a just-finished Auto_#### folder from SLMM (FTP proxy) and ingest
it into Terra-View (clean MonitoringSession + DataFiles via ingest_nrl_zip).
Returns the ingest result dict, or {"success": False, "error": ...}.
Used by _execute_cycle Step 4b.
"""
import os
import httpx
from backend.routers.project_locations import ingest_nrl_zip, IngestError
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
remote_path = f"/NL-43/{folder_name}"
try:
async with httpx.AsyncClient(timeout=600.0) as client:
resp = await client.post(
f"{slmm_base}/api/nl43/{unit_id}/ftp/download-folder",
json={"remote_path": remote_path},
)
except Exception as e:
return {"success": False, "error": f"download-folder request failed: {e}"}
if not resp.is_success or len(resp.content) <= 22: # 22 bytes = empty-zip
return {"success": False, "error": f"empty/failed ZIP from SLMM (status {resp.status_code})"}
try:
res = ingest_nrl_zip(location_id, resp.content, db, source="ftp_cycle", dedupe=True)
return {"success": True, **res}
except IngestError as e:
return {"success": False, "error": str(e)}
async def _ingest_and_link(
self,
db,
*,
location_id: str,
unit_id: str,
folder_name: str,
placeholder_session=None,
) -> dict:
"""Ingest a just-finished Auto_#### folder and tie it to the unit.
This is the ONE ingest path that stop / cycle / download all funnel
through, so every route produces the same clean session: Leq-only,
`.rnh` parsed (percentile slot map + weightings captured), deduped.
Steps:
1. Fetch + ingest the folder via the shared NRL ingest
(`_ingest_cycle_folder` `ingest_nrl_zip`).
2. `ingest_nrl_zip` leaves `unit_id` None link it to the unit that
recorded the data so the session stays attributed.
3. If a `placeholder_session` (the transient "recording" marker) was
passed and it never accumulated DataFiles of its own, drop it its
data now lives in the ingested, unit-linked session.
Returns the ingest result dict (with `success`); adds `placeholder_dropped`
when step 3 removed the marker. On ingest failure the placeholder is
left untouched.
"""
ing = await self._ingest_cycle_folder(db, location_id, unit_id, folder_name)
if not ing.get("success"):
return ing
sid = ing.get("session_id")
if sid:
s = db.query(MonitoringSession).filter_by(id=sid).first()
if s and not s.unit_id:
s.unit_id = unit_id
db.commit()
if placeholder_session is not None and sid:
from backend.models import DataFile
if db.query(DataFile).filter_by(session_id=placeholder_session.id).count() == 0:
db.delete(placeholder_session)
db.commit()
ing["placeholder_dropped"] = True
return ing
# ======================================================================== # ========================================================================
# Recurring Schedule Generation # Recurring Schedule Generation
# ======================================================================== # ========================================================================
@@ -858,15 +427,6 @@ class SchedulerService:
for schedule in schedules: for schedule in schedules:
try: try:
# Auto-disable one-off schedules whose end time has passed
if schedule.schedule_type == "one_off" and schedule.end_datetime:
if schedule.end_datetime <= datetime.utcnow():
schedule.enabled = False
schedule.next_occurrence = None
db.commit()
logger.info(f"Auto-disabled completed one-off schedule: {schedule.name}")
continue
actions = service.generate_actions_for_schedule(schedule, horizon_days=7) actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
total_generated += len(actions) total_generated += len(actions)
except Exception as e: except Exception as e:
@@ -921,92 +481,6 @@ class SchedulerService:
return cleaned return cleaned
# ========================================================================
# Nightly Sound Report (FTP report pipeline)
# ========================================================================
async def run_due_reports(self):
"""Run any project nightly sound reports that are due.
For each enabled SoundReportConfig: if local time is past report_time
and we haven't already reported last night, build the report (writes a
file always; emails if SMTP is configured, else dry-run) and stamp
last_run_date. Idempotent across restarts via last_run_date.
"""
from backend.models import SoundReportConfig
from backend.utils.timezone import utc_to_local
# Decide what's due (cheap, on the loop); run each OFF the event loop.
due_jobs = []
db = SessionLocal()
try:
configs = db.query(SoundReportConfig).filter_by(enabled=True).all()
if not configs:
return
local_now = utc_to_local(datetime.utcnow())
night_date = local_now.date() - timedelta(days=1) # last night's evening date
for cfg in configs:
try:
hh, mm = (int(x) for x in cfg.report_time.split(":"))
except (ValueError, AttributeError):
hh, mm = 8, 0
if (local_now.hour, local_now.minute) < (hh, mm):
continue
if cfg.last_run_date == night_date:
continue
due_jobs.append({
"project_id": cfg.project_id,
"metric_keys": [m.strip() for m in (cfg.metric_keys or "").split(",") if m.strip()] or None,
"recipients": [r.strip() for r in (cfg.recipients or "").split(",") if r.strip()] or None,
"baseline_mode": cfg.baseline_mode,
"baseline_start": cfg.baseline_start,
"baseline_end": cfg.baseline_end,
})
finally:
db.close()
# run_nightly_report is synchronous (blocking file I/O + smtplib up to the
# SMTP timeout). Run it in a worker thread so it never stalls the scheduler
# loop (which also drives time-sensitive device cycles).
for job in due_jobs:
try:
logger.info(f"[REPORT] Running nightly report for project {job['project_id']} (night {night_date})")
result = await asyncio.to_thread(self._run_one_report, night_date, job)
email = (result or {}).get("email", {})
logger.info(
f"[REPORT] project {job['project_id']}: {(result or {}).get('location_count')} location(s); "
f"email={'sent' if email.get('sent') else ('dry-run' if email.get('dry_run') else (email.get('error') or 'skipped'))}"
)
except Exception as e:
logger.error(f"[REPORT] Failed nightly report for project {job['project_id']}: {e}", exc_info=True)
def _run_one_report(self, night_date, job) -> Dict[str, Any]:
"""Sync worker: build/send one project's report and stamp last_run_date.
Uses its own DB session (runs in a thread, off the event loop)."""
from backend.models import SoundReportConfig
from backend.services.report_orchestrator import run_nightly_report
db = SessionLocal()
try:
result = run_nightly_report(
db, job["project_id"], night_date,
metric_keys=job["metric_keys"],
baseline_mode=job["baseline_mode"],
baseline_start=job["baseline_start"],
baseline_end=job["baseline_end"],
recipients=job["recipients"],
)
cfg = db.query(SoundReportConfig).filter_by(project_id=job["project_id"]).first()
if cfg:
cfg.last_run_date = night_date
db.commit()
return result
except Exception:
db.rollback()
raise
finally:
db.close()
# ======================================================================== # ========================================================================
# Manual Execution (for testing/debugging) # Manual Execution (for testing/debugging)
# ======================================================================== # ========================================================================
-601
View File
@@ -1,601 +0,0 @@
"""
SFM events service bridge between terra-view's UnitAssignment time-windows
and the SFM (seismo-relay) events store.
Architecture:
1. Terra-view owns the *assignment graph*: which seismograph was at which
monitoring location during which time window (UnitAssignment rows).
2. SFM owns the *events store*: triggered waveform events keyed by
(serial, timestamp), forwarded from Blastware ACH by series3-watcher.
3. This module fans out the assignments for a given location, queries SFM
for the events emitted by each (serial, window) pair concurrently, and
unions/sorts/paginates the results.
SFM remains the single source of truth for events. Terra-view does not
copy events into its own DB; every query hits SFM live.
The events_for_location helper is also reused by Phase 3 (project-level
roll-up) to aggregate across every location in a project.
"""
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy.orm import Session
from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project
log = logging.getLogger("backend.services.sfm_events")
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
# Per-request timeout when calling SFM /db/events. SFM is local on the
# docker network so this should be fast; bump if you start seeing timeouts.
_SFM_TIMEOUT_SECONDS = 10.0
# Max events we ever fetch per (serial, window) call to SFM. Must match
# SFM's own /db/events max limit (currently 5000). The user-facing display
# limit is independent — we over-fetch up to this cap so summary stats are
# accurate, then trim the displayed list to the requested limit.
_SFM_FETCH_CEILING = 5000
# ── Helpers ───────────────────────────────────────────────────────────────────
def _iso_utc(dt: Optional[datetime]) -> Optional[str]:
"""Render a datetime in the ISO format SFM /db/events expects."""
if dt is None:
return None
# SFM parses naive ISO strings as UTC; strip tzinfo for consistency.
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.isoformat(sep=" ", timespec="seconds")
def _intersect_window(
assignment_start: datetime,
assignment_end: Optional[datetime],
filter_from: Optional[datetime],
filter_to: Optional[datetime],
now: datetime,
) -> Optional[tuple[datetime, datetime]]:
"""Intersect an assignment window with the requested filter window.
Returns (effective_start, effective_end) or None if there's no overlap.
Open-ended assignments (assigned_until=NULL) are bounded by `now`.
"""
a_end = assignment_end or now
if filter_from and a_end <= filter_from:
return None
if filter_to and assignment_start >= filter_to:
return None
start = max(assignment_start, filter_from) if filter_from else assignment_start
end = min(a_end, filter_to) if filter_to else a_end
if end <= start:
return None
return (start, end)
async def _fetch_events_for_serial(
client: httpx.AsyncClient,
serial: str,
*,
from_dt: datetime,
to_dt: datetime,
false_trigger: Optional[bool],
limit: int,
) -> list[dict]:
"""Issue one /db/events call to SFM for one (serial, window) pair."""
params: dict[str, str] = {
"serial": serial,
"from_dt": _iso_utc(from_dt) or "",
"to_dt": _iso_utc(to_dt) or "",
"limit": str(limit),
}
if false_trigger is not None:
params["false_trigger"] = "true" if false_trigger else "false"
try:
resp = await client.get(f"{SFM_BASE_URL}/db/events", params=params)
resp.raise_for_status()
except httpx.HTTPError as e:
log.warning("SFM /db/events failed for serial=%s: %s", serial, e)
return []
payload = resp.json()
events = payload.get("events", []) or []
# Strip waveform_blob if present — it's the big per-event binary and we
# don't render it in the list view. SFM returns it by default.
for ev in events:
ev.pop("waveform_blob", None)
ev.pop("a5_pickle_filename", None)
return events
# ── Public API ────────────────────────────────────────────────────────────────
async def events_for_location(
db: Session,
location_id: str,
*,
from_dt: Optional[datetime] = None,
to_dt: Optional[datetime] = None,
false_trigger: Optional[bool] = None,
limit: int = 500,
) -> dict:
"""Fan out UnitAssignment rows for `location_id` and union SFM events.
Returns:
{
"events": [merged event dicts, newest first, capped at limit],
"count": total events found across all windows (pre-cap),
"stats": {event_count, peak_pvs, peak_pvs_at,
last_event, false_trigger_count},
"assignments_used": [{unit_id, assigned_at, assigned_until,
events_in_window}, ...],
}
The "events outside any assignment window" rule (Phase 1 design decision):
events whose timestamp falls outside every assignment window are simply
not fetched we only ask SFM for events inside the intersected windows.
Those orphan events surface under the per-unit detail page in Phase 2.
"""
# 1. Fetch all assignments (active + closed) for the location.
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.location_id == location_id)
.filter(UnitAssignment.device_type == "seismograph")
.order_by(UnitAssignment.assigned_at.asc())
.all()
)
if not assignments:
return {
"events": [],
"count": 0,
"stats": _empty_stats(),
"assignments_used": [],
}
now = datetime.utcnow()
# 2. For each assignment, compute the effective (start, end) window after
# intersecting with the requested filter range. Drop assignments that
# don't overlap the filter window.
fetch_specs: list[tuple[UnitAssignment, datetime, datetime]] = []
for a in assignments:
window = _intersect_window(a.assigned_at, a.assigned_until, from_dt, to_dt, now)
if window is not None:
fetch_specs.append((a, window[0], window[1]))
if not fetch_specs:
return {
"events": [],
"count": 0,
"stats": _empty_stats(),
"assignments_used": [
{
"unit_id": a.unit_id,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
"events_in_window": 0,
}
for a in assignments
],
}
# 3. Concurrent SFM fetches. We over-fetch (up to _SFM_FETCH_CEILING per
# window) so summary stats reflect the true peak/last/count across the
# full filter window, not just what fits in the user's display limit.
# The displayed event list is trimmed to `limit` after merge.
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
per_window_lists = await asyncio.gather(
*(
_fetch_events_for_serial(
client,
serial=a.unit_id,
from_dt=start,
to_dt=end,
false_trigger=false_trigger,
limit=_SFM_FETCH_CEILING,
)
for a, start, end in fetch_specs
),
return_exceptions=False,
)
# 4. Build the per-assignment event counts (transparency for the operator).
spec_event_counts: dict[str, int] = {}
for (a, _start, _end), evs in zip(fetch_specs, per_window_lists):
spec_event_counts[a.id] = len(evs)
# 5. Union, sort newest-first, cap.
merged: list[dict] = []
for evs in per_window_lists:
merged.extend(evs)
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
total_count = len(merged)
capped = merged[:limit]
# 6. Compute summary stats over the full merged set (not the capped one).
stats = _compute_stats(merged)
# 7. Build the assignments_used report (every assignment, in chronological
# order, with its event count — even ones that fell outside the filter
# window so the operator sees them but with count=0).
assignments_used = []
for a in assignments:
assignments_used.append(
{
"unit_id": a.unit_id,
"assignment_id": a.id,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
"events_in_window": spec_event_counts.get(a.id, 0),
"status": a.status,
}
)
return {
"events": capped,
"count": total_count,
"stats": stats,
"assignments_used": assignments_used,
}
# ── Per-unit (cross-project) view ─────────────────────────────────────────────
async def events_for_unit(
db: Session,
unit_id: str,
*,
bucket: str = "all", # "all" | "attributed" | "unattributed"
from_dt: Optional[datetime] = None,
to_dt: Optional[datetime] = None,
false_trigger: Optional[bool] = None,
limit: int = 500,
) -> dict:
"""Return events for a unit annotated with their assignment attribution.
Unlike events_for_location (which queries SFM per assignment window), this
helper queries SFM for ALL events for the serial within the optional
[from_dt, to_dt] filter, then walks each event against the unit's
UnitAssignment intervals to compute attribution.
Bucket semantics:
- "all": every event, attributed or not
- "attributed": events that fall inside at least one assignment window
- "unattributed": events with no overlapping assignment (the diagnostic
bucket operator should fix assignment dates to
attribute these)
Each event gets an extra `attribution` field:
{assignment_id, location_id, location_name, project_id, project_name,
assigned_at, assigned_until} or None
Unattributed events also get a `nearest_assignment` field with the
same shape plus `delta_days` (signed; negative = event before assignment).
"""
# 1. Pull all assignments for this unit (any device_type — caller has
# already filtered by seismograph in the route). Order matters: we
# want the earliest-start assignment first so attribution prefers the
# chronologically-first overlap when there are simultaneous active
# assignments at different locations (rare but possible).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.unit_id == unit_id)
.order_by(UnitAssignment.assigned_at.asc())
.all()
)
# Resolve location + project names once.
loc_ids = {a.location_id for a in assignments}
proj_ids = {a.project_id for a in assignments}
loc_map = {
l.id: l for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
} if loc_ids else {}
proj_map = {
p.id: p for p in db.query(Project).filter(
Project.id.in_(proj_ids)
).all()
} if proj_ids else {}
now = datetime.utcnow()
def _attr_dict(a: UnitAssignment) -> dict:
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
return {
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
# Soft-removal indicator so the UI can render a "(removed)"
# badge next to historical attributions whose location is no
# longer actively monitored.
"location_removed_at": (loc.removed_at.isoformat()
if loc and loc.removed_at else None),
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
}
# 2. Fetch all events for this serial in one shot.
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
events = await _fetch_events_for_serial(
client,
serial=unit_id,
from_dt=from_dt or datetime(1970, 1, 1),
to_dt=to_dt or now,
false_trigger=false_trigger,
limit=_SFM_FETCH_CEILING,
)
# 3. For each event, walk the assignment list and find the first
# overlapping window. O(N * M) but both are small in practice.
for ev in events:
ts_str = ev.get("timestamp")
if not ts_str:
ev["attribution"] = None
continue
try:
# SFM returns ISO with "T" separator; tolerate both.
ts = datetime.fromisoformat(ts_str.replace(" ", "T"))
except ValueError:
ev["attribution"] = None
continue
matched: Optional[UnitAssignment] = None
for a in assignments:
a_end = a.assigned_until or now
if a.assigned_at <= ts <= a_end:
matched = a
break
if matched is not None:
ev["attribution"] = _attr_dict(matched)
else:
ev["attribution"] = None
# Find the nearest assignment (chronologically) for diagnostic.
if assignments:
nearest = min(
assignments,
key=lambda a: min(
abs((ts - a.assigned_at).total_seconds()),
abs((ts - (a.assigned_until or now)).total_seconds()),
),
)
# Signed delta in days from the nearest boundary
# (negative = event BEFORE that boundary).
if ts < nearest.assigned_at:
delta_seconds = (ts - nearest.assigned_at).total_seconds()
elif ts > (nearest.assigned_until or now):
delta_seconds = (ts - (nearest.assigned_until or now)).total_seconds()
else:
delta_seconds = 0
ev["nearest_assignment"] = {
**_attr_dict(nearest),
"delta_days": round(delta_seconds / 86400, 1),
}
# 4. Apply bucket filter.
if bucket == "attributed":
filtered = [e for e in events if e.get("attribution") is not None]
elif bucket == "unattributed":
filtered = [e for e in events if e.get("attribution") is None]
else:
filtered = events
filtered.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
total_count = len(filtered)
capped = filtered[:limit]
# 5. Stats: compute over the ENTIRE event set (not the filtered bucket)
# so the unattributed_count tile is always meaningful regardless of
# which bucket the operator has selected.
base_stats = _compute_stats(events)
unattributed_count = sum(
1 for e in events if e.get("attribution") is None
)
base_stats["unattributed_count"] = unattributed_count
return {
"events": capped,
"count": total_count,
"stats": base_stats,
"assignments_total": len(assignments),
}
# ── Project-level roll-up (aggregates across all vibration locations) ─────────
async def vibration_summary_for_project(
db: Session,
project_id: str,
*,
from_dt: Optional[datetime] = None,
to_dt: Optional[datetime] = None,
) -> dict:
"""Aggregate SFM events across every vibration location in a project.
Returns:
{
"project_id": str,
"total_events": int,
"peak_pvs": float | None,
"peak_pvs_at": ISO timestamp | None,
"peak_pvs_location_id": str | None,
"peak_pvs_location_name": str | None,
"last_event": ISO timestamp | None,
"false_trigger_count": int,
"per_location": [
{"location_id", "location_name", "event_count",
"peak_pvs", "last_event"},
... # sorted by event_count DESC
],
"vibration_location_count": int,
}
"""
locations = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == project_id)
.filter(MonitoringLocation.location_type == "vibration")
.all()
)
if not locations:
return {
"project_id": project_id,
"total_events": 0,
"peak_pvs": None,
"peak_pvs_at": None,
"peak_pvs_location_id": None,
"peak_pvs_location_name": None,
"last_event": None,
"false_trigger_count": 0,
"per_location": [],
"vibration_location_count": 0,
}
# Fan out across locations. Each call internally fans out across that
# location's UnitAssignment rows, so this is a nested fan-out. Both
# tiers happen concurrently because asyncio.gather + httpx pool.
results = await asyncio.gather(
*(
events_for_location(
db,
loc.id,
from_dt=from_dt,
to_dt=to_dt,
false_trigger=None,
limit=1, # We only need stats; events list itself is ignored.
)
for loc in locations
),
return_exceptions=False,
)
per_location: list[dict] = []
total_events = 0
peak_pvs = None
peak_pvs_at = None
peak_pvs_location_id = None
peak_pvs_location_name = None
last_event = None
false_trigger_count = 0
for loc, res in zip(locations, results):
st = res.get("stats", {}) or {}
ec = st.get("event_count", 0) or 0
total_events += ec
false_trigger_count += st.get("false_trigger_count", 0) or 0
ev_last = st.get("last_event")
if ev_last and (last_event is None or ev_last > last_event):
last_event = ev_last
ev_peak = st.get("peak_pvs")
if ev_peak is not None and (peak_pvs is None or ev_peak > peak_pvs):
peak_pvs = ev_peak
peak_pvs_at = st.get("peak_pvs_at")
peak_pvs_location_id = loc.id
peak_pvs_location_name = loc.name
per_location.append({
"location_id": loc.id,
"location_name": loc.name,
"event_count": ec,
"peak_pvs": ev_peak,
"last_event": ev_last,
# Soft-removal state — UI can show a "(removed)" badge in the
# per-location list so operators see at a glance that a row's
# numbers are historical-only.
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
})
per_location.sort(key=lambda r: r["event_count"], reverse=True)
return {
"project_id": project_id,
"total_events": total_events,
"peak_pvs": peak_pvs,
"peak_pvs_at": peak_pvs_at,
"peak_pvs_location_id": peak_pvs_location_id,
"peak_pvs_location_name": peak_pvs_location_name,
"last_event": last_event,
"false_trigger_count": false_trigger_count,
"per_location": per_location,
"vibration_location_count": len(locations),
}
# ── Stats helpers ─────────────────────────────────────────────────────────────
def _empty_stats() -> dict:
return {
"event_count": 0,
"peak_pvs": None,
"peak_pvs_at": None,
"peak_pvs_serial": None,
"last_event": None,
"false_trigger_count": 0,
}
def _compute_stats(events: list[dict]) -> dict:
"""Roll up summary stats from a merged event list. Cheap O(N) pass.
The "Overall Peak" stat (peak_pvs) EXCLUDES events flagged as false
triggers operators care about the highest REAL event, not the
biggest sensor glitch. false_trigger_count still includes them so
operators can see how many were filtered out. last_event uses
every event regardless (it's about activity recency, not magnitude).
"""
if not events:
return _empty_stats()
peak_pvs = None
peak_pvs_at = None
peak_pvs_serial = None
last_event = None
false_trigger_count = 0
for ev in events:
is_false_trigger = bool(ev.get("false_trigger"))
if is_false_trigger:
false_trigger_count += 1
# Peak calculation: skip flagged false triggers.
if not is_false_trigger:
pvs = ev.get("peak_vector_sum")
if pvs is not None and (peak_pvs is None or pvs > peak_pvs):
peak_pvs = pvs
peak_pvs_at = ev.get("timestamp")
peak_pvs_serial = ev.get("serial")
ts = ev.get("timestamp")
if ts and (last_event is None or ts > last_event):
last_event = ts
return {
"event_count": len(events),
"peak_pvs": peak_pvs,
"peak_pvs_at": peak_pvs_at,
"peak_pvs_serial": peak_pvs_serial,
"last_event": last_event,
"false_trigger_count": false_trigger_count,
}
-4
View File
@@ -70,10 +70,6 @@ async def sync_slm_status_to_emitters() -> Dict[str, Any]:
# Convert to naive UTC for consistency with existing code # Convert to naive UTC for consistency with existing code
if last_seen.tzinfo: if last_seen.tzinfo:
last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None) last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None)
elif is_reachable:
# Device is reachable but no last_success yet (first poll or just started)
# Use current time so it shows as OK, not Missing
last_seen = datetime.utcnow()
else: else:
last_seen = None last_seen = None
+9 -85
View File
@@ -109,71 +109,7 @@ class SLMMClient:
f"SLMM operation failed: {error_detail}" f"SLMM operation failed: {error_detail}"
) )
except Exception as e: except Exception as e:
error_msg = str(e) if str(e) else type(e).__name__ raise SLMMClientError(f"Unexpected error: {str(e)}")
raise SLMMClientError(f"Unexpected error: {error_msg}")
async def _download_request(
self,
endpoint: str,
data: Dict[str, Any],
unit_id: str,
) -> Dict[str, Any]:
"""
Make a download request to SLMM that returns binary file content (not JSON).
Saves the file locally and returns metadata about the download.
"""
url = f"{self.api_base}{endpoint}"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
response = await client.post(url, json=data)
response.raise_for_status()
# Determine filename from Content-Disposition header or generate one
content_disp = response.headers.get("content-disposition", "")
filename = None
if "filename=" in content_disp:
filename = content_disp.split("filename=")[-1].strip('" ')
if not filename:
remote_path = data.get("remote_path", "download")
base = os.path.basename(remote_path.rstrip("/"))
filename = f"{base}.zip" if not base.endswith(".zip") else base
# Save to local downloads directory
download_dir = os.path.join("data", "downloads", unit_id)
os.makedirs(download_dir, exist_ok=True)
local_path = os.path.join(download_dir, filename)
with open(local_path, "wb") as f:
f.write(response.content)
return {
"success": True,
"local_path": local_path,
"filename": filename,
"size_bytes": len(response.content),
}
except httpx.ConnectError as e:
raise SLMMConnectionError(
f"Cannot connect to SLMM backend at {self.base_url}. "
f"Is SLMM running? Error: {str(e)}"
)
except httpx.HTTPStatusError as e:
error_detail = "Unknown error"
try:
error_data = e.response.json()
error_detail = error_data.get("detail", str(error_data))
except Exception:
error_detail = e.response.text or str(e)
raise SLMMDeviceError(f"SLMM download failed: {error_detail}")
except (SLMMConnectionError, SLMMDeviceError):
raise
except Exception as e:
error_msg = str(e) if str(e) else type(e).__name__
raise SLMMClientError(f"Download error: {error_msg}")
# ======================================================================== # ========================================================================
# Unit Management # Unit Management
@@ -600,13 +536,10 @@ class SLMMClient:
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav") remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
Returns: Returns:
Dict with local_path, filename, size_bytes Binary file content (as response)
""" """
return await self._download_request( data = {"remote_path": remote_path}
f"/{unit_id}/ftp/download", return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
{"remote_path": remote_path},
unit_id,
)
async def download_folder( async def download_folder(
self, self,
@@ -623,13 +556,10 @@ class SLMMClient:
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000") remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
Returns: Returns:
Dict with local_path, folder_name, size_bytes Dict with local_path, folder_name, file_count, zip_size_bytes
""" """
return await self._download_request( data = {"remote_path": remote_path}
f"/{unit_id}/ftp/download-folder", return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
{"remote_path": remote_path},
unit_id,
)
async def download_current_measurement( async def download_current_measurement(
self, self,
@@ -649,17 +579,11 @@ class SLMMClient:
""" """
# Get current index number from device # Get current index number from device
index_info = await self.get_index_number(unit_id) index_info = await self.get_index_number(unit_id)
index_number_raw = index_info.get("index_number", 0) index_number = index_info.get("index_number", 0)
# Convert to int - device returns string like "0000" or "0001"
try:
index_number = int(index_number_raw)
except (ValueError, TypeError):
index_number = 0
# Format as Auto_XXXX folder name # Format as Auto_XXXX folder name
folder_name = f"Auto_{index_number:04d}" folder_name = f"Auto_{index_number:04d}"
remote_path = f"/NL-43/{folder_name}" remote_path = f"/NL43_DATA/{folder_name}"
# Download the folder # Download the folder
result = await self.download_folder(unit_id, remote_path) result = await self.download_folder(unit_id, remote_path)
+18 -185
View File
@@ -1,77 +1,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import logging
import os
import threading
import time
from typing import Optional
import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db_session from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit from backend.models import Emitter, RosterUnit, IgnoredUnit
from backend.services.unit_location import bulk_active_locations
log = logging.getLogger(__name__)
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
# Tiny module-level cache: /api/status-snapshot is polled every 10s by the
# dashboard, and we don't want to hammer SFM with one /db/units roundtrip per
# call. 15s TTL keeps the cache mostly hot, with occasional refreshes.
_SFM_CACHE_TTL_SECONDS = 15.0
_sfm_cache_lock = threading.Lock()
_sfm_cache: dict = {"fetched_at": 0.0, "data": None, "reachable": False}
def _parse_sfm_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
"""SFM /db/units returns naive ISO timestamps (no tz suffix). Treat them
as UTC, mirroring how the watcher heartbeat stores Emitter.last_seen."""
if not ts_str:
return None
try:
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
except ValueError:
return None
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts
def fetch_sfm_unit_last_seen() -> tuple[dict[str, datetime], bool]:
"""Return ({serial: last_seen_utc}, sfm_reachable).
Cached for _SFM_CACHE_TTL_SECONDS. On any HTTP error returns ({}, False)
so callers transparently fall back to the watcher-heartbeat path.
"""
now = time.monotonic()
with _sfm_cache_lock:
if _sfm_cache["data"] is not None and (now - _sfm_cache["fetched_at"]) < _SFM_CACHE_TTL_SECONDS:
return _sfm_cache["data"], _sfm_cache["reachable"]
data: dict[str, datetime] = {}
reachable = False
try:
with httpx.Client(timeout=4.0) as client:
resp = client.get(f"{SFM_BASE_URL}/db/units")
resp.raise_for_status()
payload = resp.json() or []
for row in payload:
serial = row.get("serial")
ts = _parse_sfm_timestamp(row.get("last_seen"))
if serial and ts is not None:
data[serial] = ts
reachable = True
except httpx.HTTPError as e:
log.warning("SFM /db/units unreachable for status snapshot: %s", e)
except Exception as e: # noqa: BLE001 — defensive against malformed payload
log.warning("SFM /db/units parse error: %s", e)
with _sfm_cache_lock:
_sfm_cache["fetched_at"] = now
_sfm_cache["data"] = data
_sfm_cache["reachable"] = reachable
return data, reachable
def ensure_utc(dt): def ensure_utc(dt):
@@ -138,15 +69,6 @@ def emit_status_snapshot():
emitters = {e.id: e for e in db.query(Emitter).all()} emitters = {e.id: e for e in db.query(Emitter).all()}
ignored = {i.id for i in db.query(IgnoredUnit).all()} ignored = {i.id for i in db.query(IgnoredUnit).all()}
# Active-assignment location lookup for all roster units (direct only;
# modems inherit from their paired device below in the derive loop).
active_locs = bulk_active_locations(db, list(roster.values()))
# SFM event-forwards are now the primary "last seen" signal for
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
# or hasn't seen a serial, we fall back to Emitter.last_seen.
sfm_last_seen_map, sfm_reachable = fetch_sfm_unit_last_seen()
units = {} units = {}
# --- Merge roster entries first --- # --- Merge roster entries first ---
@@ -158,69 +80,29 @@ def emit_status_snapshot():
age = "N/A" age = "N/A"
last_seen = None last_seen = None
fname = "" fname = ""
elif r.out_for_calibration:
# Out for calibration units get separated later
status = "Out for Calibration"
age = "N/A"
last_seen = None
fname = ""
elif getattr(r, 'allocated', False) and not r.deployed:
# Allocated: staged for an upcoming job, not yet physically deployed
status = "Allocated"
age = "N/A"
last_seen = None
fname = ""
else: else:
device_type = r.device_type or "seismograph" if e:
emitter_last_seen = ensure_utc(e.last_seen) if e else None last_seen = ensure_utc(e.last_seen)
fname = e.last_file if e else "" # RECALCULATE status based on current time, not stored value
# SFM-primary, heartbeat-backup logic — only for seismographs.
# (SLMs / modems aren't forwarded into SFM's events store.)
sfm_last_seen = sfm_last_seen_map.get(unit_id) if device_type == "seismograph" else None
if sfm_last_seen and emitter_last_seen:
# Both sources reported — use whichever is more recent.
if sfm_last_seen >= emitter_last_seen:
last_seen = sfm_last_seen
last_seen_source = "sfm"
else:
last_seen = emitter_last_seen
last_seen_source = "heartbeat"
elif sfm_last_seen:
last_seen = sfm_last_seen
last_seen_source = "sfm"
elif emitter_last_seen:
last_seen = emitter_last_seen
# If SFM was reachable but doesn't have this serial, it
# means the unit is calling home to the watcher but not
# being forwarded — still a working state for now.
last_seen_source = "heartbeat"
else:
last_seen = None
last_seen_source = "none"
if last_seen is not None:
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold) status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
age = format_age(last_seen) age = format_age(last_seen)
fname = e.last_file
else: else:
# Rostered but no emitter data
status = "Missing" status = "Missing"
last_seen = None
age = "N/A" age = "N/A"
fname = ""
units[unit_id] = { units[unit_id] = {
"id": unit_id, "id": unit_id,
"status": status, "status": status,
"age": age, "age": age,
"last": last_seen.isoformat() if last_seen else None, "last": last_seen.isoformat() if last_seen else None,
"last_seen_source": last_seen_source,
"sfm_reachable": sfm_reachable,
"fname": fname, "fname": fname,
"deployed": r.deployed, "deployed": r.deployed,
"note": r.note or "", "note": r.note or "",
"retired": r.retired, "retired": r.retired,
"out_for_calibration": r.out_for_calibration or False,
"allocated": getattr(r, 'allocated', False) or False,
"allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
# Device type and type-specific fields # Device type and type-specific fields
"device_type": r.device_type or "seismograph", "device_type": r.device_type or "seismograph",
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None, "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
@@ -230,42 +112,27 @@ def emit_status_snapshot():
"ip_address": r.ip_address, "ip_address": r.ip_address,
"phone_number": r.phone_number, "phone_number": r.phone_number,
"hardware_model": r.hardware_model, "hardware_model": r.hardware_model,
# Location for mapping — sourced from active UnitAssignment # Location for mapping
# → MonitoringLocation. Empty for benched / unassigned. "location": r.location or "",
"address": (active_locs.get(unit_id) or {}).get("address") or "", "address": r.address or "",
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "", "coordinates": r.coordinates or "",
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
} }
# --- Add unexpected emitter-only units --- # --- Add unexpected emitter-only units ---
for unit_id, e in emitters.items(): for unit_id, e in emitters.items():
if unit_id not in roster: if unit_id not in roster:
emitter_last_seen = ensure_utc(e.last_seen) last_seen = ensure_utc(e.last_seen)
sfm_last_seen = sfm_last_seen_map.get(unit_id)
if sfm_last_seen and (not emitter_last_seen or sfm_last_seen >= emitter_last_seen):
last_seen = sfm_last_seen
last_seen_source = "sfm"
else:
last_seen = emitter_last_seen
last_seen_source = "heartbeat"
# RECALCULATE status for unknown units too # RECALCULATE status for unknown units too
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold) status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
units[unit_id] = { units[unit_id] = {
"id": unit_id, "id": unit_id,
"status": status, "status": status,
"age": format_age(last_seen), "age": format_age(last_seen),
"last": last_seen.isoformat() if last_seen else None, "last": last_seen.isoformat(),
"last_seen_source": last_seen_source,
"sfm_reachable": sfm_reachable,
"fname": e.last_file, "fname": e.last_file,
"deployed": False, # default "deployed": False, # default
"note": "", "note": "",
"retired": False, "retired": False,
"out_for_calibration": False,
"allocated": False,
"allocated_to_project_id": "",
# Device type and type-specific fields (defaults for unknown units) # Device type and type-specific fields (defaults for unknown units)
"device_type": "seismograph", # default "device_type": "seismograph", # default
"last_calibrated": None, "last_calibrated": None,
@@ -275,62 +142,37 @@ def emit_status_snapshot():
"ip_address": None, "ip_address": None,
"phone_number": None, "phone_number": None,
"hardware_model": None, "hardware_model": None,
# Location fields — unknown units have no assignment # Location fields
"location": "",
"address": "", "address": "",
"coordinates": "", "coordinates": "",
"location_name": "",
"project_id": "",
"location_id": "",
} }
# --- Derive modem status from paired devices --- # --- Derive modem status from paired devices ---
# Modems don't have their own check-in system, so we inherit status # Modems don't have their own check-in system, so we inherit status
# from whatever device they're paired with (seismograph or SLM) # from whatever device they're paired with (seismograph or SLM)
# Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id
for unit_id, unit_data in units.items(): for unit_id, unit_data in units.items():
if unit_data.get("device_type") == "modem" and not unit_data.get("retired"): if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
paired_unit_id = None
roster_unit = roster.get(unit_id) roster_unit = roster.get(unit_id)
# First, check if modem has deployed_with_unit_id set
if roster_unit and roster_unit.deployed_with_unit_id: if roster_unit and roster_unit.deployed_with_unit_id:
paired_unit_id = roster_unit.deployed_with_unit_id paired_unit_id = roster_unit.deployed_with_unit_id
else:
# Fallback: check if any device has this modem in deployed_with_modem_id
for other_id, other_roster in roster.items():
if other_roster.deployed_with_modem_id == unit_id:
paired_unit_id = other_id
break
if paired_unit_id:
paired_unit = units.get(paired_unit_id) paired_unit = units.get(paired_unit_id)
if paired_unit: if paired_unit:
# Inherit status from paired device # Inherit status from paired device
unit_data["status"] = paired_unit.get("status", "Missing") unit_data["status"] = paired_unit.get("status", "Missing")
unit_data["age"] = paired_unit.get("age", "N/A") unit_data["age"] = paired_unit.get("age", "N/A")
unit_data["last"] = paired_unit.get("last") unit_data["last"] = paired_unit.get("last")
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
unit_data["derived_from"] = paired_unit_id unit_data["derived_from"] = paired_unit_id
# Inherit deployment location too — modems don't carry
# their own UnitAssignment.
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
if not unit_data.get(k):
unit_data[k] = paired_unit.get(k, "")
# Separate buckets for UI # Separate buckets for UI
active_units = { active_units = {
uid: u for uid, u in units.items() uid: u for uid, u in units.items()
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored if not u["retired"] and u["deployed"] and uid not in ignored
} }
benched_units = { benched_units = {
uid: u for uid, u in units.items() uid: u for uid, u in units.items()
if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored if not u["retired"] and not u["deployed"] and uid not in ignored
}
allocated_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
} }
retired_units = { retired_units = {
@@ -338,11 +180,6 @@ def emit_status_snapshot():
if u["retired"] if u["retired"]
} }
out_for_calibration_units = {
uid: u for uid, u in units.items()
if u["out_for_calibration"]
}
# Unknown units - emitters that aren't in the roster and aren't ignored # Unknown units - emitters that aren't in the roster and aren't ignored
unknown_units = { unknown_units = {
uid: u for uid, u in units.items() uid: u for uid, u in units.items()
@@ -354,17 +191,13 @@ def emit_status_snapshot():
"units": units, "units": units,
"active": active_units, "active": active_units,
"benched": benched_units, "benched": benched_units,
"allocated": allocated_units,
"retired": retired_units, "retired": retired_units,
"out_for_calibration": out_for_calibration_units,
"unknown": unknown_units, "unknown": unknown_units,
"summary": { "summary": {
"total": len(active_units) + len(benched_units) + len(allocated_units), "total": len(active_units) + len(benched_units),
"active": len(active_units), "active": len(active_units),
"benched": len(benched_units), "benched": len(benched_units),
"allocated": len(allocated_units),
"retired": len(retired_units), "retired": len(retired_units),
"out_for_calibration": len(out_for_calibration_units),
"unknown": len(unknown_units), "unknown": len(unknown_units),
# Status counts only for deployed units (active_units) # Status counts only for deployed units (active_units)
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"), "ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
-125
View File
@@ -1,125 +0,0 @@
"""
Active-assignment location resolution for roster units.
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
The current source of truth for "where is this unit deployed right now" is the
active `UnitAssignment` (assigned_until IS NULL) pointing at a
`MonitoringLocation`, which carries the canonical address/coordinates/name.
Modems don't get their own `UnitAssignment` — they're paired with a
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
location of its paired device's active assignment.
Returned dict shape (or None if no active assignment resolvable):
{
"location_id": "uuid",
"project_id": "uuid",
"name": "NRL-001",
"address": "123 Main St" | None,
"coordinates": "34.0522,-118.2437" | None,
"via_paired_unit_id": "BE1234" | None, # set only for modems
}
"""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
return {
"location_id": loc.id,
"project_id": loc.project_id,
"name": loc.name,
"address": loc.address or None,
"coordinates": loc.coordinates or None,
"via_paired_unit_id": via_paired_unit_id,
}
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
row = (
db.query(MonitoringLocation)
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
.filter(
UnitAssignment.unit_id == unit_id,
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.first()
)
return row
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
"""
Resolve the active deployment location for a unit.
Seismographs / SLMs: their own active UnitAssignment.
Modems: follow `deployed_with_unit_id` to the paired device's active
assignment (modems don't carry their own assignment).
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if unit is None:
return None
if (unit.device_type or "seismograph") == "modem":
paired_id = unit.deployed_with_unit_id
if not paired_id:
return None
loc = _active_location_for_unit_id(db, paired_id)
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
loc = _active_location_for_unit_id(db, unit_id)
return _serialize(loc) if loc else None
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
"""
Resolve active locations for many units in two queries. Use this from
snapshot-style loops to avoid N+1 lookups.
Returns {unit_id: <serialized location dict>} only populated for units
that resolve to an active assignment. Modems are resolved by walking
`deployed_with_unit_id` to the paired device's entry in the same map.
"""
if not units:
return {}
direct_unit_ids = [
u.id for u in units
if (u.device_type or "seismograph") != "modem"
]
direct: dict[str, MonitoringLocation] = {}
if direct_unit_ids:
rows = (
db.query(UnitAssignment.unit_id, MonitoringLocation)
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
.filter(
UnitAssignment.unit_id.in_(direct_unit_ids),
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.all()
)
# First row wins per unit_id (most recent assigned_at).
for unit_id, loc in rows:
direct.setdefault(unit_id, loc)
out: dict[str, dict] = {
uid: _serialize(loc) for uid, loc in direct.items()
}
# Modems inherit from paired device.
for u in units:
if (u.device_type or "seismograph") != "modem":
continue
paired_id = u.deployed_with_unit_id
if paired_id and paired_id in direct:
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
return out
-865
View File
@@ -1,865 +0,0 @@
/* event-modal.js shared event-detail modal.
*
* Used by:
* - /sfm (admin Events tab)
* - /projects/{p}/nrl/{l} (project-location Events tab)
* - /unit/{id} (unit-detail SFM Events table)
*
* Pages must include partials/event_detail_modal.html in the body
* before this script is loaded.
*
* Public API:
* showEventDetail(eventId)
* Open the modal and fetch /api/sfm/db/events/{id}/sidecar to
* populate the rich BW report fields (peaks, ZC freq, sensor
* self-check, device info, etc.) into a tabbed/sectioned view.
*
* closeEventDetailModal()
* Close the modal.
*
* Notes:
* - Fetches sidecar live from SFM via terra-view's /api/sfm proxy.
* - Renders gracefully when the sidecar lacks a bw_report block
* (older events forwarded before the _ASCII.TXT pairing fix).
* - All functions are global on window so inline onclick handlers
* can reach them across all three host pages.
*/
(function () {
const MODAL_ID = 'event-detail-modal';
// ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
const _CHANNEL_COLORS = {
MicL: '#e066ff', // purple — distinct from the geo channels
Long: '#3b82f6', // blue
Vert: '#22c55e', // green
Tran: '#ef4444', // red
};
const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
// dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
const DBL_REF = 2.9e-9;
// Mic display floor — sound-pressure AC samples sit at the digitisation
// noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
// a floor, the chart looks like a sparse pattern of "moments when sound
// briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
const MIC_DBL_FLOOR = 60;
let _charts = {}; // ch → Chart instance
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
let _micUnitPrefLoaded = false; // one-shot fetch guard
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _fmt(v, digits = 4, suffix = '') {
if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—';
if (typeof v === 'number') {
return v.toFixed(digits) + (suffix ? ` ${suffix}` : '');
}
return _esc(v) + (suffix ? ` ${suffix}` : '');
}
function _ppvClass(v) {
if (v == null) return 'text-gray-400';
if (v < 0.5) return 'text-green-600 dark:text-green-400';
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
return 'text-red-600 dark:text-red-400 font-semibold';
}
function _kvCard(label, value, options = {}) {
// Single key-value tile. `value` is pre-rendered HTML (or text).
const colorCls = options.colorCls || '';
const valCls = `font-mono font-semibold ${colorCls}`;
return `<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">${_esc(label)}</div>
<div class="${valCls} mt-1">${value}</div>
${options.sub ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${options.sub}</div>` : ''}
</div>`;
}
function _deriveRecordType(filename, fallback) {
// SFM currently hardcodes record_type="Waveform" for every event.
// The actual type is encoded in the LAST character of the Blastware
// filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram).
// We derive it client-side until SFM is fixed; if the suffix isn't
// a known code we fall back to whatever SFM reported.
if (!filename) return fallback || '—';
const dotIdx = filename.lastIndexOf('.');
if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—';
const ext = filename.slice(dotIdx + 1);
const lastChar = ext.slice(-1).toUpperCase();
const typeMap = {
'H': 'Histogram',
'W': 'Waveform',
'M': 'Manual',
'E': 'Event',
'C': 'Combo',
};
return typeMap[lastChar] || (fallback || '—');
}
function _sectionHeader(title, sub) {
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
</h4>`;
}
// ── Section renderers ────────────────────────────────────────────
function _renderEventHeader(s) {
const ev = s.event || {};
const bw = s.blastware || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type);
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(recType)}</span></div>
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? ''} sps</span></div>
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : ''}</span></div>
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '')}</span></div>
</div>`;
}
function _renderUserNotes(s) {
// The "user notes" metadata the operator typed into the BW device.
// These are the strings the future metadata-driven parser will use.
// NOTE: SFM's sidecar JSON still names this block `project_info` —
// we render it as "User Notes" (the actual BW term) but read the
// field by its SFM-API name. Rename in SFM is a future cleanup.
const p = s.project_info || {};
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '')}</span></div>
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '')}</span></div>
<div><span class="text-gray-500">Operator</span> <span class="font-medium ml-1">${_esc(p.operator || '')}</span></div>
<div><span class="text-gray-500">Sensor Location</span> <span class="font-medium ml-1">${_esc(p.sensor_location || '')}</span></div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Values are as typed into the seismograph at session start not the terra-view project/location assignment.
</p>`;
}
function _renderPeakValues(s) {
// Prefer bw_report.peaks for richer per-channel data; fall back to peak_values.
const bwPeaks = (s.bw_report && s.bw_report.peaks) || null;
const pv = s.peak_values || {};
const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse;
const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical;
const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal;
const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum;
const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
${_kvCard('Transverse', `<span class="${_ppvClass(tran)}">${_fmt(tran, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Vertical', `<span class="${_ppvClass(vert)}">${_fmt(vert, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Longitudinal', `<span class="${_ppvClass(lng)}">${_fmt(lng, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Peak Vector Sum', `<span class="${_ppvClass(pvs)} text-base">${_fmt(pvs, 4)}</span>`, {
sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s',
})}
</div>`;
}
function _renderMic(s) {
// Operators only care about dB(L); PSI tile was dropped 2026-05.
// We still render the row if any mic data is present so ZC freq /
// time-of-peak stay visible even when bw_report.mic is missing.
const mic = (s.bw_report && s.bw_report.mic) || null;
const pv = s.peak_values || {};
if (!mic && pv.mic_psi == null) return '';
const dbl = mic?.pspl_dbl;
const zcHz = mic?.zc_freq_hz;
const tPk = mic?.time_of_peak_s;
const wt = mic?.weighting;
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
</div>`;
}
function _sensorRow(label, ch) {
if (!ch) {
return `<tr><td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
<td class="px-3 py-2 text-sm text-gray-400" colspan="3"></td></tr>`;
}
const result = ch.result || '—';
const resultCls = result === 'Passed'
? 'text-green-600 dark:text-green-400'
: (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500');
// Geo channels have freq + ratio; mic has freq + amplitude.
const rightCol = (ch.amplitude_mv != null)
? `<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.amplitude_mv, 1, 'mV')}</td>`
: `<td class="px-3 py-2 text-sm font-mono">${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}</td>`;
return `<tr>
<td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.freq_hz, 1, 'Hz')}</td>
${rightCol}
<td class="px-3 py-2 text-sm ${resultCls}">${_esc(result)}</td>
</tr>`;
}
function _renderSensorCheck(s) {
const sc = s.bw_report && s.bw_report.sensor_check;
if (!sc) return '';
return `<table class="w-full text-left rounded overflow-hidden border border-gray-200 dark:border-gray-700">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Channel</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Frequency</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Amplitude/Ratio</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Result</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-slate-800">
${_sensorRow('Transverse', sc.tran)}
${_sensorRow('Vertical', sc.vert)}
${_sensorRow('Longitudinal', sc.long)}
${_sensorRow('Microphone', sc.mic)}
</tbody>
</table>`;
}
function _renderDeviceMetadata(s) {
const bw = s.bw_report || {};
const dev = bw.device || {};
const rec = bw.recording || {};
return `<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Firmware</span> <span class="font-mono text-xs ml-1">${_esc(bw.version || '')}</span></div>
<div><span class="text-gray-500">Battery</span> <span class="font-medium ml-1">${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : ''}</span></div>
<div><span class="text-gray-500">Calibrated</span> <span class="font-medium ml-1">${_esc(dev.calibration_date || '')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}</span></div>
<div><span class="text-gray-500">Geo Range</span> <span class="font-medium ml-1">${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : ''}</span></div>
<div><span class="text-gray-500">Stop Mode</span> <span class="font-medium ml-1">${_esc(rec.stop_mode || '')}</span></div>
<div><span class="text-gray-500">Units</span> <span class="font-medium ml-1">${_esc(rec.units || '')}</span></div>
</div>`;
}
function _renderReview(s, eventId) {
const rev = s.review || {};
const ft = !!rev.false_trigger;
const reviewer = rev.reviewer || '';
const notes = rev.notes || '';
const reviewedAt = rev.reviewed_at
? rev.reviewed_at.replace('T', ' ').slice(0, 19)
: null;
return `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
<span class="font-medium">Flag as false trigger</span>
</label>
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
placeholder="Initials or name"
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<div class="mt-3">
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
<textarea id="event-review-notes" rows="2"
placeholder="Optional context — what caused the FT, follow-up actions, etc."
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
</div>
<div class="flex items-center justify-between gap-3 mt-3">
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
</span>
<button type="button"
onclick="window.saveEventReview('${_esc(eventId)}')"
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
Save
</button>
</div>
</div>`;
}
// ── Waveform / histogram chart helpers ──────────────────────────
async function _loadMicUnitPref() {
if (_micUnitPrefLoaded) return _micUnitPref;
try {
const r = await fetch('/api/settings/preferences');
if (r.ok) {
const prefs = await r.json();
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
}
} catch (e) {
// Network error → silent fall back to default 'psi'.
}
_micUnitPrefLoaded = true;
return _micUnitPref;
}
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Rectifying psi→dBL converter for per-sample values — see comments in
// sfm_webapp.html:2592-2607 for the floor rationale.
function _psiToDblForChart(psi) {
if (psi == null) return MIC_DBL_FLOOR;
const a = Math.abs(psi);
if (a === 0) return MIC_DBL_FLOOR;
const dbl = 20 * Math.log10(a / DBL_REF);
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
}
// Adaptive decimal formatter — sensible precision in the normal range,
// scientific notation only at the extremes.
function _fmtPeak(v, unit) {
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
if (v === 0) return '0' + (unit ? ' ' + unit : '');
const a = Math.abs(v);
const u = unit ? ' ' + unit : '';
if (a >= 0.0001 && a < 10000) {
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
return v.toFixed(d) + u;
}
return v.toExponential(2) + u;
}
function _destroyCharts() {
Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
_charts = {};
}
// Returns true when Tailwind dark mode is active (the `dark` class is
// toggled on <html> by Terra-View's theme handler). Drives chart grid
// + tick colors so they have contrast on both backgrounds.
function _isDark() {
return document.documentElement.classList.contains('dark');
}
function _renderWaveformInto(containerId, data, micUnit) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
_destroyCharts();
const channels = data.channels || {};
const ta = data.time_axis || {};
const sr = ta.sample_rate || 1024;
const dtMs = ta.dt_ms || (1000.0 / sr);
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
const withData = _CHANNEL_ORDER.filter(ch =>
channels[ch] && (channels[ch].values || []).length > 0
);
const lastCh = withData[withData.length - 1];
// Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
// light is white. Grids + ticks need contrast on both.
const dark = _isDark();
const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const tickColor = dark ? '#94a3b8' : '#64748b';
if (withData.length === 0) {
container.innerHTML = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
No waveform samples decoded codec walker returned 0 valid blocks for this event.
</div>`;
return;
}
for (const ch of _CHANNEL_ORDER) {
const chData = channels[ch];
if (!chData) continue;
let values = chData.values || [];
let chUnit = chData.unit || '';
let chPeak = chData.peak;
// Mic: convert psi → dBL when the user pref is dBL (default).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDblForChart);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
const wrap = document.createElement('div');
wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
const lbl = document.createElement('div');
lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
lbl.style.color = _CHANNEL_COLORS[ch];
const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
lbl.innerHTML = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
wrap.appendChild(lbl);
if (values.length === 0) {
const e = document.createElement('div');
e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
e.textContent = 'no samples decoded';
wrap.appendChild(e);
container.appendChild(wrap);
continue;
}
const canvasWrap = document.createElement('div');
canvasWrap.className = 'relative';
canvasWrap.style.height = '100px';
const canvas = document.createElement('canvas');
canvasWrap.appendChild(canvas);
wrap.appendChild(canvasWrap);
container.appendChild(wrap);
// X-axis: waveforms use ms-relative-to-trigger; histograms use
// the BW-reported interval timestamps (HH:MM:SS) when the server
// aggregated to BW intervals, else interval index.
let times;
if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering when very long.
const MAX = 3000;
let rT = times, rV = values;
if (values.length > MAX) {
const step = Math.ceil(values.length / MAX);
rT = times.filter((_, i) => i % step === 0);
rV = values.filter((_, i) => i % step === 0);
}
const showX = (ch === lastCh);
const xAxisLabel = isHistogram ? '' : ' ms';
const fmtTick = i => {
const v = rT[i];
if (typeof v === 'number') {
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
return s + xAxisLabel;
}
return String(v) + xAxisLabel;
};
// Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
let yBounds = {};
const isGeo = ch !== 'MicL';
if (isGeo && !isHistogram) {
let absMax = 0;
for (const v of values) {
const a = Math.abs(v);
if (a > absMax) absMax = a;
}
const padded = (absMax || 1) * 1.10;
yBounds = { min: -padded, max: padded };
} else if (isGeo && isHistogram) {
const HIST_GEO_MIN_INS = 0.05;
let peak = 0;
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
} else if (ch === 'MicL' && micUnit === 'dBL') {
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
? chPeak + 5 : 100;
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
const HIST_MIC_MIN_PSI = 0.001;
let peak = 0;
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
}
_charts[ch] = new Chart(canvas, {
type: isHistogram ? 'bar' : 'line',
data: {
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
datasets: isHistogram ? [{
data: rV,
backgroundColor: _CHANNEL_COLORS[ch],
borderWidth: 0,
barPercentage: 1.0,
categoryPercentage: 1.0,
}] : [{
data: rV,
borderColor: _CHANNEL_COLORS[ch],
borderWidth: 1,
pointRadius: 0,
tension: 0,
}],
},
options: {
animation: false, responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
title: items => isHistogram
? `interval ${items[0].label}`
: `t = ${items[0].label} ms`,
label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
},
},
},
scales: {
x: {
type: 'category', display: showX,
ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
grid: { color: gridColor, drawTicks: showX },
},
y: {
...yBounds,
ticks: { color: tickColor, maxTicksLimit: 4 },
grid: { color: gridColor },
title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
},
},
},
plugins: isHistogram ? [] : [{
id: 'overlays',
afterDraw(chart) {
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
const zi = rT.findIndex(t => parseFloat(t) >= 0);
if (zi >= 0) {
const px = x.getPixelForValue(zi);
ctx.save();
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
ctx.save();
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
ctx.closePath(); ctx.fill();
ctx.restore();
}
const zy = y.getPixelForValue(0);
if (zy >= y.top && zy <= y.bottom) {
ctx.save();
ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
ctx.setLineDash([2, 2]);
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
ctx.restore();
ctx.save();
ctx.fillStyle = tickColor; ctx.font = '10px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('0.0', x.right + 6, zy);
ctx.restore();
}
},
}],
});
}
}
function _renderFileInfo(s, eventId) {
const bw = s.blastware || {};
const src = s.source || {};
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
const txtFilename = src && src.txt_filename;
const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
const downloadButtons = `
<div class="flex flex-wrap gap-2 mb-4">
<button type="button"
onclick="window.toggleEventPdfPreview()"
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium 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="M9 12h6m-6 4h6m2 5H7a2 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>
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
</button>
<a href="${reportPdfUrl}" download
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download PDF
</a>
${canDownloadBinary ? `
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
download="${_esc(bw.filename)}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Blastware binary
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
</a>
` : ''}
${txtFilename ? `
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm 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="M9 12h6m-6 4h6m2 5H7a2 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>
Original .TXT report
</a>
` : ''}
<button type="button"
onclick="window.toggleEventJsonViewer()"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm 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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
<span id="event-json-toggle-label">View JSON</span>
</button>
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar"
download="${_esc((bw.filename || 'event') + '.sfm.json')}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm 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="M9 12h6m-6 4h6m2 5H7a2 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>
Download sidecar JSON
</a>
</div>
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
class="w-full" style="height:80vh; min-height:600px; border:0;"
data-pdf-url="${reportPdfUrl}"></iframe>
</div>
<div id="event-json-viewer" class="hidden mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
<button type="button" onclick="window.copyEventJson()"
class="text-xs text-seismo-orange hover:text-seismo-navy">
<span id="event-json-copy-label">Copy</span>
</button>
</div>
<pre id="event-json-pre" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-4 rounded-lg max-h-96 overflow-auto whitespace-pre">${_esc(JSON.stringify(s, null, 2))}</pre>
</div>
`;
return `${downloadButtons}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '')}</span></div>
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '')}</span>
</div>
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '')}</span></div>
</div>`;
}
// ── Public API ───────────────────────────────────────────────────
window.showEventDetail = async function (eventId) {
const modal = document.getElementById(MODAL_ID);
if (!modal) {
console.warn('event-modal: include event_detail_modal.html partial on this page.');
return;
}
modal.classList.remove('hidden');
document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail';
document.getElementById(MODAL_ID + '-content').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 event detail
</div>`;
let s;
try {
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`);
if (!r.ok) {
throw new Error('HTTP ' + r.status + ' fetching sidecar');
}
s = await r.json();
} catch (e) {
document.getElementById(MODAL_ID + '-content').innerHTML = `
<div class="text-center py-8 text-red-500 text-sm">
Failed to load event detail: ${_esc(e.message)}
</div>`;
return;
}
const ev = s.event || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '';
document.getElementById(MODAL_ID + '-title').textContent =
`Event — ${ev.serial || '?'} @ ${ts}`;
const hasReport = !!s.bw_report;
const reportNote = hasReport
? ''
: `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-300 mb-4">
<strong>No BW ASCII report paired with this event.</strong>
Older events forwarded before the watcher's <code class="font-mono text-xs">_ASCII.TXT</code> pairing fix landed lack this data.
PPV is still available from the binary event file.
</div>`;
document.getElementById(MODAL_ID + '-content').innerHTML = `
${reportNote}
${_sectionHeader('Event')}
${_renderEventHeader(s)}
${_sectionHeader('User Notes')}
${_renderUserNotes(s)}
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
${_sectionHeader('Waveform')}
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform</div>
<div id="event-waveform-charts" class="space-y-0.5"></div>
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')}
${_renderMic(s)}
` : ''}
${hasReport ? `
${_sectionHeader('Sensor Self-Check')}
${_renderSensorCheck(s)}
${_sectionHeader('Device & Recording Metadata')}
${_renderDeviceMetadata(s)}
` : ''}
${_sectionHeader('Review')}
${_renderReview(s, eventId)}
${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)}
`;
// Waveform load runs after the sidecar content is in the DOM, in
// parallel with the mic-unit-pref fetch. Either may complete first.
try {
const [wfRes, micUnit] = await Promise.all([
fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
_loadMicUnitPref(),
]);
if (wfRes.status === 404) {
document.getElementById('event-waveform-status').textContent =
'No waveform data — codec returned 0 valid blocks for this event.';
return;
}
if (!wfRes.ok) {
document.getElementById('event-waveform-status').textContent =
'Failed to load waveform: HTTP ' + wfRes.status;
return;
}
const wfData = await wfRes.json();
document.getElementById('event-waveform-status').textContent = '';
_renderWaveformInto('event-waveform-charts', wfData, micUnit);
} catch (e) {
const st = document.getElementById('event-waveform-status');
if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
}
};
window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden');
_destroyCharts();
};
window.toggleEventJsonViewer = function () {
const viewer = document.getElementById('event-json-viewer');
const label = document.getElementById('event-json-toggle-label');
if (!viewer) return;
const isHidden = viewer.classList.toggle('hidden');
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
};
window.toggleEventPdfPreview = function () {
const preview = document.getElementById('event-pdf-preview');
const iframe = document.getElementById('event-pdf-iframe');
const label = document.getElementById('event-pdf-toggle-label');
if (!preview || !iframe) return;
const isHidden = preview.classList.toggle('hidden');
// Lazy-load the PDF: only set the iframe src on first reveal, so
// closing the event modal without opening the PDF never spends
// bandwidth on it.
if (!isHidden && !iframe.src) {
iframe.src = iframe.dataset.pdfUrl || '';
}
if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
// Scroll the iframe into view on first reveal so the operator
// doesn't have to hunt for it after clicking.
if (!isHidden) {
preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
window.saveEventReview = async function (eventId) {
const ft = document.getElementById('event-review-ft');
const reviewer = document.getElementById('event-review-reviewer');
const notes = document.getElementById('event-review-notes');
const status = document.getElementById('event-review-status');
if (!ft || !reviewer || !notes) return;
const payload = {
review: {
false_trigger: ft.checked,
reviewer: reviewer.value.trim() || null,
notes: notes.value.trim() || null,
}
};
if (status) {
status.textContent = 'Saving…';
status.className = 'text-xs text-gray-500 dark:text-gray-400';
}
try {
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) {
const t = await r.text().catch(() => '');
throw new Error('HTTP ' + r.status + (t ? `${t.slice(0, 120)}` : ''));
}
if (status) {
status.textContent = 'Saved.';
status.className = 'text-xs text-green-600 dark:text-green-400';
}
// Notify the host page so its event-list FT badge / row state
// can refresh. Pages opt in by listening for this event.
window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
detail: { eventId, review: payload.review },
}));
} catch (e) {
if (status) {
status.textContent = 'Save failed: ' + e.message;
status.className = 'text-xs text-red-600 dark:text-red-400';
}
}
};
window.copyEventJson = function () {
const pre = document.getElementById('event-json-pre');
const label = document.getElementById('event-json-copy-label');
if (!pre) return;
navigator.clipboard.writeText(pre.textContent).then(() => {
if (label) {
label.textContent = 'Copied!';
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
}
}).catch(err => {
console.error('clipboard write failed', err);
if (label) {
label.textContent = 'Failed';
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
}
});
};
// Close on Escape.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.closeEventDetailModal();
});
})();
+2 -11
View File
@@ -1,27 +1,18 @@
/* Service Worker for Seismo Fleet Manager PWA */ /* Service Worker for Seismo Fleet Manager PWA */
/* Network-first strategy with cache fallback for real-time data */ /* Network-first strategy with cache fallback for real-time data */
// IMPORTANT: bump this on every release that touches a precached or const CACHE_VERSION = 'v1';
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
// templates served at /, etc.). The activate handler deletes any cache
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
// 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.16.0';
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
// Files to precache (critical app shell). event-modal.js is included // Files to precache (critical app shell)
// so its cache lifecycle is tied to the SW version bump explicitly.
const STATIC_FILES = [ const STATIC_FILES = [
'/', '/',
'/static/style.css', '/static/style.css',
'/static/mobile.css', '/static/mobile.css',
'/static/mobile.js', '/static/mobile.js',
'/static/offline-db.js', '/static/offline-db.js',
'/static/event-modal.js',
'/static/manifest.json', '/static/manifest.json',
'https://cdn.tailwindcss.com', 'https://cdn.tailwindcss.com',
'https://unpkg.com/htmx.org@1.9.10', 'https://unpkg.com/htmx.org@1.9.10',
-51
View File
@@ -5,7 +5,6 @@ All routers should import `templates` from this module to get consistent
filter and global function registration. filter and global function registration.
""" """
import json as _json
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
# Import timezone utilities # Import timezone utilities
@@ -33,58 +32,8 @@ def jinja_timezone_abbr():
# Create templates instance # Create templates instance
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
def jinja_local_date(dt, fmt="%m-%d-%y"):
"""Jinja filter: format a UTC datetime as a local date string (e.g. 02-19-26)."""
return format_local_datetime(dt, fmt)
def jinja_fromjson(s):
"""Jinja filter: parse a JSON string into a dict (returns {} on failure)."""
if not s:
return {}
try:
return _json.loads(s)
except Exception:
return {}
def jinja_same_date(dt1, dt2) -> bool:
"""Jinja global: True if two datetimes fall on the same local date."""
if not dt1 or not dt2:
return False
try:
d1 = format_local_datetime(dt1, "%Y-%m-%d")
d2 = format_local_datetime(dt2, "%Y-%m-%d")
return d1 == d2
except Exception:
return False
def jinja_log_tail_display(s):
"""Jinja filter: decode a JSON-encoded log tail array into a plain-text string."""
if not s:
return ""
try:
lines = _json.loads(s)
if isinstance(lines, list):
return "\n".join(str(l) for l in lines)
return str(s)
except Exception:
return str(s)
def jinja_local_datetime_input(dt):
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
# Register Jinja filters and globals # Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time templates.env.filters["local_time"] = jinja_local_time
templates.env.filters["local_date"] = jinja_local_date
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone templates.env.globals["get_user_timezone"] = get_user_timezone
templates.env.globals["same_date"] = jinja_same_date
templates.env.filters["log_tail_display"] = jinja_log_tail_display
@@ -0,0 +1,10 @@
{
"filename": "snapshot_20251216_201738.db",
"created_at": "20251216_201738",
"created_at_iso": "2025-12-16T20:17:38.638982",
"description": "Auto-backup before restore",
"size_bytes": 57344,
"size_mb": 0.05,
"original_db_size_bytes": 57344,
"type": "manual"
}
@@ -0,0 +1,9 @@
{
"filename": "snapshot_uploaded_20251216_201732.db",
"created_at": "20251216_201732",
"created_at_iso": "2025-12-16T20:17:32.574205",
"description": "Uploaded: snapshot_20251216_200259.db",
"size_bytes": 77824,
"size_mb": 0.07,
"type": "uploaded"
}
+27 -49
View File
@@ -1,7 +1,9 @@
services: services:
web-app: # --- TERRA-VIEW PRODUCTION ---
terra-view:
build: . build: .
container_name: terra-view
ports: ports:
- "8001:8001" - "8001:8001"
volumes: volumes:
@@ -10,26 +12,9 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=production - ENVIRONMENT=production
- SLMM_BASE_URL=http://host.docker.internal:8100 - SLMM_BASE_URL=http://host.docker.internal:8100
- SFM_BASE_URL=http://sfm:8200
# Client-portal session-cookie signing. Set SECRET_KEY to a real secret (e.g.
# in a .env file beside this compose) BEFORE the portal faces the internet —
# the dev default is public/forgeable and logs a warning at boot. Set
# COOKIE_SECURE=true once served over HTTPS (leave false on plain HTTP, or the
# 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.
- TZ=America/New_York
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- slmm - slmm
- sfm
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
healthcheck: healthcheck:
@@ -39,11 +24,34 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# --- TERRA-VIEW DEVELOPMENT ---
# terra-view-dev:
# build: .
# container_name: terra-view-dev
# ports:
# - "1001:8001"
# volumes:
# - ./data-dev:/app/data
# environment:
# - PYTHONUNBUFFERED=1
# - ENVIRONMENT=development
# - SLMM_BASE_URL=http://slmm:8100
# restart: unless-stopped
# depends_on:
# - slmm
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 40s
# --- SLMM (Sound Level Meter Manager) --- # --- SLMM (Sound Level Meter Manager) ---
slmm: slmm:
build: build:
context: ../slmm context: ../slmm
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: slmm
network_mode: host network_mode: host
volumes: volumes:
- ../slmm/data:/app/data - ../slmm/data:/app/data
@@ -51,8 +59,6 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PORT=8100 - PORT=8100
- CORS_ORIGINS=* - CORS_ORIGINS=*
- TCP_IDLE_TTL=-1
- TCP_MAX_AGE=-1
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8100/health"] test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
@@ -61,34 +67,6 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
# --- SFM (Seismo Fleet Manager) ---
sfm:
build:
context: ../seismo-relay
dockerfile: Dockerfile
ports:
- "8200:8200"
volumes:
- ../seismo-relay/sfm/data:/app/sfm/data
- ../seismo-relay/bridges/captures:/app/bridges/captures
# The DB + waveform store inside bridges/captures are symlinks
# pointing at the prod-snap directory. Mount its host path at
# the same absolute path inside the container so the symlinks
# resolve. Needed for SFM to query the events DB.
- ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
environment:
- PYTHONUNBUFFERED=1
- PORT=8200
# Display timezone — affects server log timestamps, the PDF
# report renderer's UTC→local conversions, and matplotlib's
# datetime axes. DB columns (created_at etc.) are always UTC.
- TZ=America/New_York
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes: volumes:
data: data:
data-dev:
-214
View File
@@ -1,214 +0,0 @@
# Client Portal — Design & Build Plan
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
> **Update (Phase-1 auth landed):** the interim magic-link gate described below is
> **retired** — client access is now a per-project secure link + shared password
> (argon2). See the design at `docs/superpowers/specs/2026-06-15-portal-auth-design.md`
> and the build plan at `docs/superpowers/plans/2026-06-15-portal-auth.md`. The
> operator manages access from each project's **Portal access** panel.
A client-facing, **read-only**, **scoped** view into a client's own monitoring
data. The first internet-facing-with-real-clients surface in the system. Built
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
reads and Terra-View's report generation — Terra-View stays the UI/business layer;
SLMM stays the device layer.
## Principles
1. **Read-only.** No device control (start/stop/config), no roster editing, no
internal pages. A client can look, never touch.
2. **Strictly scoped.** A client only ever sees data for *their* projects. Every
portal endpoint verifies ownership server-side — never trust a `unit_id` /
`location_id` from the request.
3. **Cache-first, no device contention.** Portal live data comes from SLMM's
cache (the same cached `/status` + `/history` the internal dashboard uses).
No device-hitting calls from the portal — a client can't make us hammer the
NL-43. Freshness depends on **keepalive being on** for the client's units.
4. **Auth is a swappable gate.** Every route depends on one resolver,
`get_current_client()`. M1M3 ride on an interim signed "magic URL"; M4
replaces the resolver's backing without touching routes or templates.
## The data chain (how a client maps to live data)
```
Client.id
└─ Project (client_id == Client.id, status != deleted)
└─ MonitoringLocation (project_id, location_type == "sound", removed_at IS NULL)
└─ UnitAssignment (location_id, status == "active", device_type == "slm",
assigned_until IS NULL or future)
└─ unit_id == RosterUnit.id == SLMM unit_id
└─ SLMM cached /status + /history (read-only)
```
So the portal shows a client their **locations**, each surfacing the live sound
level from whatever SLM is currently assigned there.
## Data model (new)
```python
class Client(Base): # the customer org
id, name, slug (unique, URL-safe), contact_email (nullable, for M4),
active (bool), created_at
class ClientAccessToken(Base): # the interim "magic URL" gate
id, client_id, token_hash (sha256 — raw shown once on creation),
label, created_at, last_used_at, revoked_at (nullable)
```
Plus a migration adding **`Project.client_id`** (nullable FK → `clients.id`).
The existing free-text `Project.client_name` stays for display/back-compat;
`client_id` is the authoritative link.
## Auth — the swappable gate
```python
def get_current_client(request, db) -> Client: # every /portal route depends on this
# M1M3: read signed `portal_client` cookie -> load Client
# M4: same signature, backed by real sessions (magic-link / password)
```
**Interim "magic URL" flow (M1M3):**
- Operator creates a `Client` + an access token → gets a one-time-display URL:
`https://…/portal/enter/{token}`.
- Client clicks it → token is hashed, looked up (must be un-revoked) →
sets a **signed session cookie** (`portal_client`, HMAC via a new `SECRET_KEY`
env) → redirects to `/portal`. `last_used_at` updated.
- `get_current_client` reads + verifies the cookie thereafter. No valid cookie →
"link invalid / expired" page.
- Revoke = set `revoked_at`; the link (and any cookie minted from it) stops working.
Unguessable + revocable + per-person, no email infra or passwords yet — and M4
slots in behind the same `get_current_client` with zero route/template churn.
## Routes (`/portal/*`)
| Route | Purpose |
|-------|---------|
| `GET /portal/enter/{token}` | validate token → set cookie → redirect to `/portal` |
| `GET /portal` | client's locations overview (status tiles + map) |
| `GET /portal/location/{id}` | read-only live panel for that location's SLM |
| `GET /portal/api/location/{id}/live` | **scoped** cached `/status` for the location's unit |
| `GET /portal/api/location/{id}/history` | **scoped** cached trail for the chart |
| `GET /portal/logout` | clear cookie |
**Scoping helper** (used by every data route):
`resolve_client_location(client, location_id, db) -> (location, unit_id)` — raises
403 if the location isn't in one of the client's projects. The portal never calls
the open `/api/slmm/{unit}/*` endpoints with a client-supplied id.
## Templates (`templates/portal/`)
- `portal/base.html` — minimal client-branded shell (no internal sidebar/nav).
- `portal/overview.html` — location tiles (live cards mini) + a locations map.
- `portal/location.html` — the read-only live panel: cards (Lp/Leq/Lmax/L1/L10),
L1/L10 chart, measuring + freshness badge. Reuses the cache-populate JS from the
internal panel, **stripped** of start/stop, config, and the device-hitting
refresh (cache + 15s auto-poll only).
---
## Milestones
### M1 — Live view only *(current)*
Interim magic-URL gate; a client sees their locations and per-location read-only
live data, all from cache.
- [ ] `Client` + `ClientAccessToken` models; `Project.client_id` migration.
- [ ] `SECRET_KEY` env + signed-cookie session helper.
- [ ] `get_current_client` dependency + `/portal/enter/{token}` + logout.
- [ ] Scoping helper `resolve_client_location`.
- [ ] `/portal` overview + `/portal/location/{id}` (read-only live panel).
- [ ] Scoped `/portal/api/location/{id}/live` + `/history`.
- [ ] Portal templates (base, overview, location).
- [ ] Minimal admin: create client + mint/revoke access link (small `/admin`
page or a script for now).
### M2 — Dashboard + alerts
- Richer client dashboard (multi-location at-a-glance, status rollup).
- **Live project map** — upgrade the overview's basic location pins into a real
project map: pins colored by measuring/level, popups showing each location's
current reading, centered/zoomed to the project. (M1 ships the plain pin map;
this makes it a live status map.)
- Surface each location's **threshold-alert status** (read-only) + an event/inbox
view. Leans on the SLMM alert engine + dispatch.
### Notes carried from M1
- Tile headline metric is **Leq** (energy-average, the sound-monitoring compliance
metric) — chosen over the twitchy instantaneous Lp. If clients ever want a
different headline (e.g. Lmax for peaks), make it a per-deployment setting.
### M3 — Reports
- Client-facing list + download of the daily baseline-comparison reports.
- Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and
being wired into the portal's scoped routes.
### M4 — Full auth system
- Replace the interim token behind `get_current_client` with a real auth design:
magic-link (passwordless email) and/or accounts, proper sessions, password
reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture.
## Going to prod (M1)
1. **Run the migration on the prod DB**`migrate_add_client_portal.py` adds
`projects.client_id` (the new tables auto-create via `create_all`). Skipping it
500s anything that touches `Project.client_id`. This is the silent killer.
```bash
docker compose exec web-app python3 backend/migrate_add_client_portal.py
```
2. **Set a real `SECRET_KEY`** in the prod env (compose). The portal signs session
cookies with it; the insecure dev default (it logs a warning at boot) is
forgeable. Non-negotiable for an internet-facing portal.
3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the
`:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 +
chart backfill) prod SLMM must be on the `dev` build with its migrations
(`migrate_add_ln_percentiles`, `migrate_add_monitor_enabled`) and **keepalive on**
for the client's units — otherwise the portal degrades gracefully (cards show
`--`, chart empty), it just isn't fully populated.
4. **Seed real clients** with the CLI (`backend/portal_admin.py`): `create-client`
`link-project` (a real sound project with an active SLM assignment) →
`mint-link` → send the client the printed URL (shown once).
5. **Exposure** — portal routes are auth-gated, but port 8001 still serves the
whole *internal* app with no auth. Before real clients are on it, the portal
should sit behind the reverse proxy with only `/portal/*` exposed (or the app
restricted). This is the point where the parked reverse-proxy/TLS work becomes
load-bearing.
## Security notes
- Portal is auth-gated from day one (even the interim gate) — never wide-open like
the internal app.
- All scoping enforced server-side; client-supplied ids are always re-checked.
- `SECRET_KEY` must be a real secret in prod (env, not committed).
- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` once behind TLS.
- Tokens stored hashed; raw shown once. Revocation is immediate.
## Security hardening backlog ("Fest 2026")
The to-do for the dedicated hardening pass, roughly highest-impact first. Until
then the portal runs on security-by-obscurity (open port + interim links) — fine
for a not-in-use demo, not for real clients.
**Exposure (the big one):** port 8001 serves the *entire operator app* (roster,
projects, `/admin/*`, device config, the SLMM proxy) with **zero auth**, so an
open port exposes far more than the read-only portal.
- [ ] Reverse proxy (NPM/Caddy/Nginx) in front, exposing **only `/portal/*`** to
the internet; keep the operator app reachable on the LAN only.
- [ ] TLS everywhere (Let's Encrypt). Then set portal cookies `Secure`.
- [ ] Don't port-forward the raw app; if a quick gate is wanted before M4, an
auth proxy (Authelia / Authentik) can front the portal without writing auth.
**Config musts:**
- [ ] Set a real `SECRET_KEY` env (signs session cookies; default is public).
- [ ] `PORTAL_OPEN_LINKS=false` in any internet-facing env (it defaults off now).
**M4 — real auth** (replaces the interim token behind `get_current_client`):
- [ ] Magic-link email and/or accounts; proper sessions + password reset.
- [ ] Authenticate the **operator** app too (it currently has none).
- [ ] Gate the operator-only endpoints that are presently unauthenticated:
`/projects/{id}/portal-preview`, `/projects/{id}/portal-link*`,
`/portal/open/*`.
**Smaller items from the pre-merge code review:**
- [ ] Keepalive isn't auto-turned-off when the last alert rule on a unit is
deleted (intentional "never auto-off"; revisit if it wastes cellular).
- [ ] Consider rate-limiting the scoped portal endpoints once public.
-67
View File
@@ -1,67 +0,0 @@
# Terra-View Roadmap
Living document — captures known deferred work, in-flight initiatives, and longer-term ideas.
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-23 (Terra-View v0.16.0)
---
## In Flight
Work that's started or has obvious next steps in the code.
- **SFM Integration Phase 2 — device control** — expose `/device/*` (start, stop, erase, push-config)
through the Terra-View proxy. Blocked on SFM growing an auth layer; placeholder TODOs already in
`backend/services/device_controller.py` (lines 73, 109, 207, 282, 582).
- **Calibration sync from SFM events** — done in v0.13.x. Daily 03:15 job + Settings "Sync now" button.
Future: surface "last sync" timestamp on unit detail; per-unit "sync this one" action.
- **Synology NAS deployment** — doc lives at `docs/SYNOLOGY_DEPLOYMENT.md`. Need to actually deploy
+ write up what tripped us up vs. the doc's expectations.
## Near-Term
Concrete things scoped but not started.
- **Migrate GPS coord parse in `photos.py`** — currently writes to dead `RosterUnit.coordinates`
field. Should write to the active `MonitoringLocation` instead (matches the location-as-truth
refactor done elsewhere). Helper: `backend/services/unit_location.py`.
- **Phase 3 — drag-to-resize deployment bars** on the fleet-wide deployment-history Gantt
(`/tools/deployment-history`). Phase 2 (the calendar + Gantt tabs) shipped in v0.12.0.
- **Phase 5c — swap-detection daily job** — placeholder card already in `templates/tools.html:162`.
Auto-detects unit swaps in the field (BE12345 → BE67890 at the same project+location) from
operator-typed metadata. Pairs with a notification inbox.
- **Geocoding for address strings** — TODO in `templates/dashboard.html:913`. Lets locations without
explicit coordinates still appear on maps.
- **ModemManager backend**`backend/routers/modem_dashboard.py:279` has a TODO for querying a real
modem backend. Currently the modem dashboard is mostly read-only metadata.
## Medium-Term
Bigger features, sketched but not designed in detail.
- **Alerting** — email/SMS for missing units, calibration-expiring-soon, sync failures.
README's "Future Enhancements" has had this for a while; would pair well with the existing
`UserPreferences` thresholds.
- **Multi-user auth** — currently single-tenant, no login. Probably the prerequisite for any
cloud-hosted multi-customer deployment.
- **Notification inbox** — central place for swap-detection alerts, sync errors, calibration
warnings, FT-flag review queue, etc.
- **Audit log UI**`UnitHistory` already records everything; expose a filterable view.
## Long-Term / Wishlist
Speculative. Promote up the list once there's a concrete need.
- PostgreSQL backend for larger deployments (SQLite is fine for now)
- Advanced filtering / saved searches on roster + events
- Export roster in additional formats (XLSX, GeoJSON)
- Public-facing project status pages (read-only, share-link gated)
- SLM module parity with seismographs — modal-based event/measurement detail similar to SFM modal
- Weather station / accelerometer / GPS tracker modules (new device-type modules following the
SLMM pattern — see `CLAUDE.md` → "Adding a New Device Type Module")
## Done / Reference
For shipped items, see `CHANGELOG.md`. For architecture decisions, see `CLAUDE.md`.
-436
View File
@@ -1,436 +0,0 @@
# Synology NAS Deployment Guide
This guide covers migrating the terra-view stack from a generic Linux host
(currently the home server at `10.0.0.44`) to an always-on Synology NAS in
the office, including data migration and the minimal external-access
networking layer.
## Table of Contents
1. [Architecture overview](#architecture-overview)
2. [Pre-requisites](#pre-requisites)
3. [Phase 1 — Pre-stage on the NAS (no downtime)](#phase-1--pre-stage-on-the-nas-no-downtime)
4. [Phase 2 — Data migration (~10 min window)](#phase-2--data-migration-10-min-window)
5. [Phase 3 — Repoint the watcher (download2-PC)](#phase-3--repoint-the-watcher-download2-pc)
6. [Phase 4 — External access for remote operators](#phase-4--external-access-for-remote-operators)
7. [Phase 5 — Decommission home server](#phase-5--decommission-home-server)
8. [Verification checklist](#verification-checklist)
9. [Rollback plan](#rollback-plan)
10. [Gotchas](#gotchas)
---
## Architecture overview
The terra-view stack is three containers:
| Service | Port | What writes to it | Where it lives |
|---------------|-------|-----------------------------|----------------|
| terra-view | 8001 | Operators (UI), watchers (heartbeat) | Synology NAS |
| SFM | 8200 | Watchers (Blastware ACH forwards) | Synology NAS |
| SLMM | 8100 | terra-view (proxied), SLMs on LAN | Synology NAS |
Everything that **writes** to the stack lives inside the office LAN:
- **download2-PC** is the series3-watcher host. It has a static office IP and
POSTs to terra-view's heartbeat endpoint plus SFM's Blastware import
endpoint. Both flows are LAN-internal.
- **Sound level meters (NL-43)** sit on the office LAN; SLMM reaches them
via `network_mode: host`.
The **only** thing that needs to cross the office firewall is operator UI
access from outside the office (laptops, phones, working from home). That
makes the external networking layer trivial — see Phase 4.
---
## Pre-requisites
On the Synology side:
- **DSM 7.2+** with **Container Manager** installed (Package Center).
Older "Docker" package works too — same engine, different menu names.
- **x86_64 model** (Plus / Value / XS series). ARM j-series will build but
expect a slower first build.
- **Static LAN IP** reserved for the NAS in the office router's DHCP table.
Devices on the LAN must have a stable target.
- **SSH enabled** — Control Panel → Terminal & SNMP → Enable SSH service.
- **Shared folder** for the stack — e.g. `/volume1/docker/`.
On the home server side:
- Working terra-view / SFM / SLMM stack you want to migrate.
- `rsync` available (it almost certainly is).
You will also need:
- An admin account on the Synology with sudo privileges.
- Network access between the home server and the NAS during the migration
window (or USB-drive shuttle if not).
---
## Phase 1 — Pre-stage on the NAS (no downtime)
Goal: get the NAS booting an empty stack so you can validate the build and
networking *before* touching any production data.
### 1.1 Clone the repos
SSH to the NAS as admin:
```bash
sudo mkdir -p /volume1/docker
cd /volume1/docker
sudo git clone <your-terra-view-remote> terra-view
sudo git clone <your-slmm-remote> slmm
sudo git clone <your-seismo-relay-remote> seismo-relay
cd terra-view
sudo git checkout main # or whichever branch you ship from
```
### 1.2 Build images
```bash
cd /volume1/docker/terra-view
sudo docker compose build
```
First build takes 515 min depending on model.
### 1.3 Boot the empty stack
```bash
sudo docker compose up -d
```
Hit `http://<nas-lan-ip>:1001` (dev profile) or `:8001` (prod profile) from
another office machine. You should see an empty fleet roster. If that
works, the NAS can run the stack — proven before any production data is
at risk.
### 1.4 Stop the NAS stack again
```bash
sudo docker compose stop
```
We're ready for the data migration.
---
## Phase 2 — Data migration (~10 min window)
The terra-view stack is stateful in three places. All three must be moved
together for consistency.
| Service | Data location (home server) |
|------------|----------------------------------------------|
| terra-view | `/home/serversdown/terra-view/data/` |
| SLMM | `/home/serversdown/slmm/data/` |
| SFM | `/home/serversdown/seismo-relay/data/` |
### 2.1 Stop writes on both sides
On the NAS:
```bash
cd /volume1/docker/terra-view
sudo docker compose stop
```
On the home server:
```bash
cd /home/serversdown/terra-view
docker compose stop terra-view slmm sfm
```
### 2.2 rsync the data dirs
From the home server (or anywhere with SSH access to both):
```bash
rsync -avh /home/serversdown/terra-view/data/ admin@<nas-lan-ip>:/volume1/docker/terra-view/data/
rsync -avh /home/serversdown/slmm/data/ admin@<nas-lan-ip>:/volume1/docker/slmm/data/
rsync -avh /home/serversdown/seismo-relay/data/ admin@<nas-lan-ip>:/volume1/docker/seismo-relay/data/
```
### 2.3 Fix ownership on the NAS
Synology admin is usually UID `1026`, GID `100`. Inside containers running
as root, this doesn't matter — but if you've configured `user:` in any
compose file it will. Safe default:
```bash
ssh admin@<nas-lan-ip> "sudo chown -R 1026:100 \
/volume1/docker/terra-view/data \
/volume1/docker/slmm/data \
/volume1/docker/seismo-relay/data"
```
### 2.4 Run any pending migrations
Some earlier feature work added migration scripts that need to run once
per database. After the rsync, before starting the stack, check what's
pending:
```bash
ssh admin@<nas-lan-ip>
cd /volume1/docker/terra-view
ls backend/migrate_*.py
```
Run each one inside the container (after starting it temporarily) or apply
them on the host with the same Python environment. Idempotent migrations
re-run safely.
### 2.5 Start the NAS stack
```bash
ssh admin@<nas-lan-ip> \
"cd /volume1/docker/terra-view && sudo docker compose up -d"
```
### 2.6 Spot-check
- Dashboard loads with real units
- `/sfm` page lists historical events
- A photo loads on a unit detail page
- SFM/HB badge mix on the active table matches what you saw on the home
server
If anything's off, see [Rollback plan](#rollback-plan).
---
## Phase 3 — Repoint the watcher (download2-PC)
The download2-PC is the one client we have to reconfigure. It currently
POSTs to the home server. Two endpoints to change:
1. **terra-view heartbeat URL**
`http://<old-home-ip>:8001/api/series3/heartbeat`
`http://<new-nas-lan-ip>:8001/api/series3/heartbeat`
2. **SFM Blastware import URL**
`http://<old-home-ip>:8200/db/import/blastware_file`
`http://<new-nas-lan-ip>:8200/db/import/blastware_file`
Or, if you want to keep SFM container-internal and not publish 8200 on
the LAN at all, point it through terra-view's existing SFM proxy:
`http://<new-nas-lan-ip>:8001/api/sfm/db/import/blastware_file`
Update the config, restart the watcher service, and confirm the next
heartbeat lands in the NAS DB (check the Recent Call-Ins card on the
dashboard).
> **Tip:** keep the home server running in parallel for 12 days. If you
> forget to repoint something, it'll still flow into the old DB and you
> can resync.
---
## Phase 4 — External access for remote operators
Only the terra-view UI needs to be reachable from outside the office. Two
clean options — pick one.
### Option A — Tailscale (recommended for small teams)
Zero port forwards, zero certs, zero public DNS, zero reverse proxy.
1. Install Tailscale from Synology Package Center, sign in.
2. Install Tailscale on each operator's laptop/phone, sign in to the same
tailnet.
3. Operators access `http://<nas-tailscale-ip>:8001` from anywhere.
That's the whole setup. The office network has no external exposure at
all.
### Option B — Reverse proxy with Let's Encrypt
If you want a `https://terraview.yourdomain.com` URL that any browser can
reach:
#### B.1 Port forward on the office router
```
WAN 443 → <nas-lan-ip>:443
WAN 80 → <nas-lan-ip>:80 (only needed for Let's Encrypt HTTP-01;
skip if you use DNS-01 challenge)
```
Do **not** forward 1001, 8001, 8100, or 8200.
#### B.2 Public DNS
- Free: Synology DDNS (Control Panel → External Access → DDNS) — gives
you `something.synology.me`.
- Better: your own domain with an A record → office WAN IP, or a CNAME →
Synology DDNS hostname (handles dynamic IPs automatically).
#### B.3 Let's Encrypt certificate
Control Panel → Security → Certificate → Add → "Get a certificate from
Let's Encrypt." DSM handles renewal.
#### B.4 Synology reverse proxy
Control Panel → Login Portal → Advanced → Reverse Proxy → Create:
```
Source: Hostname terraview.yourdomain.com
Protocol HTTPS
Port 443
Destination: Hostname localhost
Protocol HTTP
Port 8001
```
Under "Custom Header", add:
| Header | Value |
|---------------------|------------------------------------|
| `X-Forwarded-For` | `$proxy_add_x_forwarded_for` |
| `X-Forwarded-Proto` | `$scheme` |
| `Host` | `$host` |
Tick the WebSocket support checkbox.
#### B.5 DSM firewall
Control Panel → Security → Firewall → enable:
- 443/TCP from `Anywhere` — allow
- 80/TCP from `Anywhere` — allow (cert renewal only)
- Everything else from WAN — deny
- All from LAN — allow
Optional: geo-block to your country if your operators are domestic only.
Big reduction in scanning noise.
---
## Phase 5 — Decommission home server
After 12 weeks of stable NAS operation:
1. Take a final `docker compose down` on the home server.
2. Archive `/home/serversdown/{terra-view,slmm,seismo-relay}/data/` to a
backup volume.
3. Free the home server hardware.
---
## Verification checklist
After Phase 2 (data migration):
- [ ] `http://<nas-lan-ip>:8001/` loads dashboard with real units
- [ ] Recent Alerts, Call-Ins (2 cols), Fleet Summary across the top
- [ ] SFM/HB badge mix on the active table looks sane
- [ ] `/sfm` page lists historical events (the same count as before)
- [ ] A unit detail page loads with photos rendering
- [ ] `/api/recent-event-callins` returns 200 with real data
- [ ] `/api/status-snapshot` returns 200, `sfm_reachable: true`
After Phase 3 (watcher cutover):
- [ ] Next heartbeat from download2-PC lands in NAS DB
- [ ] A new event arrives in `/sfm` page on the NAS within the next
Blastware ACH cycle
- [ ] No errors in `docker logs terra-view-terra-view-1`
After Phase 4 (external access):
- [ ] (Option A) Operator laptop on tailnet can reach
`http://<nas-tailscale-ip>:8001`
- [ ] (Option B) `https://terraview.yourdomain.com` resolves, cert is
valid, dashboard loads
- [ ] (Option B) Office DSM admin (5001) is **not** reachable from outside
---
## Rollback plan
The home server stays alive in parallel through Phases 23 as a safety
net. If anything goes wrong on the NAS:
1. On the home server:
```bash
cd /home/serversdown/terra-view
docker compose up -d
```
2. Point download2-PC back at the home server IP.
3. NAS data isn't lost — it's just sitting idle. Investigate, fix, retry.
The "irreversible" point is when you decommission the home server in
Phase 5. Until then, you can always fall back.
---
## Gotchas
1. **Synology UID/GID quirks.** Synology admin is usually `1026:100`.
Containers running as root inside don't care, but if your compose
files set `user:`, mismatched UIDs cause SQLite "readonly database"
errors. Easiest fix: omit `user:` and let containers run as root.
2. **`network_mode: host` for SLMM.** Required for LAN-direct comms with
sound level meters. On Synology this binds to the NAS's interface —
confirm nothing else on the NAS uses ports 8100 or 21 (FTP).
3. **Auto-start on boot.** Container Manager → Project → Settings →
enable "Auto-restart". Otherwise a DSM update or NAS reboot drops the
stack.
4. **`restart: unless-stopped` in compose.** Verify every service has it.
DSM occasionally restarts Docker during DSM updates — this flag
ensures everything comes back.
5. **Hyper Backup.** Schedule a daily snapshot of
`/volume1/docker/terra-view/data/` to a USB drive or off-site. SQLite
+ small photo dir = trivially small backups. The DB-Management UI's
built-in snapshots are an additional layer but not a replacement.
6. **NAT loopback (Option B only).** If your office router doesn't
support hairpinning, machines INSIDE the office can't reach the NAS
by its public hostname — they have to use the LAN IP. Most modern
routers handle this; some ISP-provided ones don't. Test from a laptop
on the office Wi-Fi.
7. **Let's Encrypt rate limits (Option B only).** 5 issuances per domain
per week. Don't fat-finger DNS or you'll be locked out. Test with the
staging endpoint first if unsure.
8. **`host.docker.internal` resolution.** terra-view's
`SFM_BASE_URL=http://host.docker.internal:8200` relies on Docker's
internal DNS. Works on DSM 7.2+ in bridge mode. If you see "name not
resolved" errors, fall back to explicit container names with a custom
network in compose.
9. **SFM stale rows.** The SFM SQLite has a few rows in `monitor_log`
and `ach_sessions` from earlier Python-ACH experiments. Harmless
to bring over — invisible to terra-view's UI under the
watcher-forward pipeline.
---
## Suggested timeline
For a low-risk migration:
- **Week 1**: Phase 1. Get the NAS booting an empty stack. No production
touch.
- **Week 2, day 1**: Phase 2. Migrate data. 10-min window. Keep home
server alive in parallel.
- **Week 2, day 1**: Phase 3. Repoint download2-PC. Watch heartbeats
land on the NAS for the rest of the day.
- **Week 3**: Phase 4. Add Tailscale or reverse-proxy access for remote
operators.
- **Week 45**: Monitor. Confirm everything's stable. Then Phase 5
(decommission home server).
Splitting "make it work on LAN" from "expose it remotely" means you
debug one thing at a time.
-57
View File
@@ -1,57 +0,0 @@
# ADR 0001 — Device data ownership: modules own raw data, Terra-View owns fleet context
- **Status:** Accepted (SLMM grandfathered as a known exception — see Consequences)
- **Date:** 2026-06-16
- **Deciders:** Brian
- **Applies to:** Terra-View core and all device modules (SFM, SLMM, and future modules)
## Context
Terra-View is a fleet-management / UI layer that talks to specialized **device modules**, each of which speaks one device's protocol (see the architecture note in `CLAUDE.md`). Two modules exist today, and they store their data **differently**:
- **SFM (seismograph / seismo-relay).** Owns its own database **and** waveform store. Terra-View holds **no** seismic event or waveform data — it reads through live, e.g. `GET {SFM_BASE_URL}/db/events` (`backend/routers/activity.py`, `backend/routers/admin_modules.py`). Terra-View renders; SFM persists.
- **SLMM (sound level meters).** A thin device-control shim. The sound **measurement data is stored in Terra-View**`MonitoringSession` + `DataFile` rows in `data/seismo_fleet.db`, and the `.rnh` + Leq `.rnd` files under `data/Projects/{project_id}/{session_id}/` (`backend/routers/project_locations.py:_ingest_file_entries`). SLMM only keeps device config + a live-status cache (`slmm.db`) and a transient download staging area (`data/downloads/{unit_id}/`).
This inconsistency is real, not cosmetic. It raises an obvious question every time we add a feature or a module: *where does this device's data live?* Without a stated rule, the answer drifts per-module, which is exactly how conceptual integrity erodes.
### Why the asymmetry exists (history, not sloppiness)
1. **Path dependence.** seismo-relay pre-existed as a complete data system; Terra-View integrated *with* it. SLMM was built fresh as a control shim, so persistence drifted up into Terra-View.
2. **Coupling.** A seismic event is largely self-contained — Terra-View just tags it to a unit. A Leq interval is only meaningful against an NRL location + baseline + report config, which are **Terra-View concepts**. Sound data has stronger natural gravity toward Terra-View ownership than seismic events do.
## Decision
Adopt one explicit ownership rule for all device data:
> **The device module owns the raw device data (waveforms, events, Leq files, raw telemetry). Terra-View owns the fleet/project/location/session/report context that gives that data meaning.**
Note this is **not** "Terra-View stores nothing" — Terra-View remains the system of record for roster, projects, locations, deployments, history, schedules, and the associations between fleet entities and module-owned data. What it should **not** own is a second copy of raw device telemetry.
**Litmus test for any "where does this live?" call:** *whose question does this data answer?*
- "What did the sensor record?" (raw waveform / Leq rows) → **the module**.
- "Which NRL, which night, versus which baseline?" (context) → **Terra-View**.
### Application
- **New device modules MUST follow the SFM pattern**: the module owns its data and exposes a read API (`/db/*` or equivalent); Terra-View references it and reads through, rather than ingesting a copy.
- **SFM** already conforms. No change.
- **SLMM does not conform** and is explicitly **grandfathered** (see Consequences).
## Consequences
**Positive**
- Consistent module boundaries → lower cognitive load, fewer "which copy is authoritative?" bugs.
- Terra-View stays thin; "add a device type = add a module" stays true (the CLAUDE.md north star).
- Single source of truth for raw data; no silent duplication.
**Negative / costs**
- Realigning **SLMM** to this rule is a non-trivial refactor: move ingest + file storage into SLMM, build a SLMM read API, repoint the report engine and the Data Files UI to read through it, handle the session↔location association across the module boundary, and migrate existing `MonitoringSession`/`DataFile` data. The FTP night-report pipeline currently **assumes Terra-View ownership**.
**SLMM grandfather clause**
- SLMM stays as-is for now. Realignment is a **deliberate future project**, not a background cleanup, and should be triggered by a real signal — e.g. a 3rd device type arriving, or the duplication/coupling actually causing pain. Until then, Terra-View remains the system of record for sound data, and that is an accepted, documented exception rather than an aspiration.
- The current sound data flow (for reference): `NL-43 SD card → (FTP) → SLMM data/downloads/ → (proxy ZIP) → Terra-View ingest → data/Projects/ + seismo_fleet.db`. The 1-second `_Lp_` files are dropped at ingest and never land in Terra-View.
## Related
- `CLAUDE.md` — module architecture ("Terra-View does NOT communicate directly with physical devices").
- FTP night-report pipeline (`feat/ftp-report-pipeline`) — built on the current SLMM/Terra-View-ownership model; a future SLMM realignment would need to repoint it.
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More