diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e48b7..6b9d9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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. diff --git a/README.md b/README.md index 6a5a3b5..7e2c5ea 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.12.0 +# Terra-View v0.12.1 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 @@ -9,15 +9,20 @@ 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 - **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 +- **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 - **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 - **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 - **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry -- **Data Ingestion**: Accept reports from emitter scripts via REST API +- **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 -- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile +- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab) +- **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 - **Database Management**: Comprehensive backup and restore system - **Manual Snapshots**: Create on-demand backups with descriptions diff --git a/backend/main.py b/backend/main.py index 8247664..7dd8e17 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.12.0" +VERSION = "0.12.1" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": @@ -291,6 +291,14 @@ async def pending_deployments_page(request: Request): 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) async def modems_page(request: Request): """Field modems management dashboard""" diff --git a/backend/routers/pending_deployments.py b/backend/routers/pending_deployments.py index 06cf2e7..7496d74 100644 --- a/backend/routers/pending_deployments.py +++ b/backend/routers/pending_deployments.py @@ -336,6 +336,36 @@ async def promote_pending( 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). @@ -354,6 +384,12 @@ async def promote_pending( 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() diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 83bd19b..8f81abb 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from sqlalchemy import and_, or_ from datetime import datetime +from zoneinfo import ZoneInfo from typing import Optional import uuid import json @@ -34,7 +35,7 @@ from backend.models import ( ScheduledAction, ) from backend.templates_config import templates -from backend.utils.timezone import local_to_utc +from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401 router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) @@ -262,6 +263,84 @@ async def get_project_locations_json( ] +@router.get("/locations-with-assignments") +async def get_locations_with_assignments( + project_id: str, + db: Session = Depends(get_db), + location_type: Optional[str] = Query(None), +): + """ + Locations + their currently-active assignment + current unit + paired modem + in one call. Used by the Unit Swap tool's location picker so a field tech + can see what's deployed where without N+1 round-trips. + + Empty locations come back with assignment/unit/modem all null. + Removed locations are always excluded — you don't swap onto a dead slot. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter( + MonitoringLocation.removed_at == None # noqa: E711 + ) + if location_type: + query = query.filter_by(location_type=location_type) + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() + + results = [] + for loc in locations: + assignment = db.query(UnitAssignment).filter( + and_( + UnitAssignment.location_id == loc.id, + UnitAssignment.assigned_until == None, # noqa: E711 + ) + ).first() + + unit_payload = None + modem_payload = None + if assignment: + unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() + if unit: + unit_payload = { + "id": unit.id, + "device_type": unit.device_type, + "unit_type": unit.unit_type, + "slm_model": unit.slm_model, + "deployed_with_modem_id": unit.deployed_with_modem_id, + } + if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by( + id=unit.deployed_with_modem_id, device_type="modem" + ).first() + if modem: + modem_payload = { + "id": modem.id, + "hardware_model": modem.hardware_model, + "ip_address": modem.ip_address, + "phone_number": modem.phone_number, + "deployed": bool(modem.deployed), + } + + results.append({ + "id": loc.id, + "name": loc.name, + "location_type": loc.location_type, + "description": loc.description, + "address": loc.address, + "coordinates": loc.coordinates, + "assignment": { + "id": assignment.id, + "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None, + "notes": assignment.notes, + } if assignment else None, + "unit": unit_payload, + "modem": modem_payload, + }) + + return results + + @router.post("/locations/create") async def create_location( project_id: str, @@ -645,6 +724,19 @@ async def assign_unit_to_location( ): """ Assign a unit to a monitoring location. + + Accepts form fields: + - unit_id — required + - assigned_at — optional ISO datetime; defaults to now. Set this + when backfilling a historical deployment whose + events landed in the orphan bucket. + - assigned_until — optional ISO datetime; absent = open-ended / + active. + - notes — optional free text + + Refuses only when the *new window would overlap* an existing active + open-ended assignment at the same location. Closed historical windows + that don't overlap are allowed (and required for orphan-event backfill). """ location = db.query(MonitoringLocation).filter_by( id=location_id, @@ -670,23 +762,50 @@ async def assign_unit_to_location( detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", ) - # Check if location already has an active assignment (active = assigned_until IS NULL) - existing_assignment = db.query(UnitAssignment).filter( - and_( - UnitAssignment.location_id == location_id, - UnitAssignment.assigned_until == None, - ) - ).first() + # Parse dates. Naive datetimes from datetime-local inputs are + # interpreted as user-local and converted to UTC for storage; explicit + # tz-aware ISO strings (Z / +00:00) skip the conversion. + def _parse_user_dt(s: str | None, field: str): + if not s: + return None + try: + parsed = datetime.fromisoformat(s) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}") + if parsed.tzinfo is None: + return local_to_utc(parsed) + return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) - if existing_assignment: + assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow() + assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until") + if assigned_until is not None and assigned_until <= assigned_at: raise HTTPException( - status_code=400, - detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.", + status_code=400, detail="assigned_until must be after assigned_at.", ) - # Create new assignment - assigned_until_str = form_data.get("assigned_until") - assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None + # Reject only if the new window overlaps an existing assignment at the + # SAME location. Closed historical windows that sit before the current + # active assignment are fine — that's the backfill case. + new_end_for_overlap = assigned_until or datetime.utcnow() + existing = db.query(UnitAssignment).filter( + UnitAssignment.location_id == location_id + ).all() + for other in existing: + other_start = other.assigned_at + other_end = other.assigned_until or datetime.utcnow() + if assigned_at < other_end and new_end_for_overlap > other_start: + other_window = ( + f"{other.assigned_at:%Y-%m-%d}" + + (f" → {other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present") + ) + raise HTTPException( + status_code=400, + detail=( + f"New window overlaps an existing assignment at this " + f"location ({other.unit_id} {other_window}). Use swap or " + f"edit that record instead." + ), + ) assignment = UnitAssignment( id=str(uuid.uuid4()), @@ -694,8 +813,9 @@ async def assign_unit_to_location( location_id=location_id, project_id=project_id, device_type=unit.device_type, + assigned_at=assigned_at, assigned_until=assigned_until, - status="active", + status="active" if assigned_until is None else "completed", notes=form_data.get("notes"), ) @@ -752,6 +872,20 @@ async def unassign_unit( assignment.status = "completed" assignment.assigned_until = datetime.utcnow() + # Unit is leaving the field — bench it so the heartbeat / polling + # subsystem stops chasing it. Also break the modem pairing both ways. + unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() + if unit: + if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by( + id=unit.deployed_with_modem_id, device_type="modem" + ).first() + if modem and modem.deployed_with_unit_id == unit.id: + modem.deployed_with_unit_id = None + unit.deployed_with_modem_id = None + if unit.deployed: + unit.deployed = False + location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() _record_assignment_history( db, @@ -786,6 +920,11 @@ async def update_assignment( - assigned_until: ISO datetime, or null/"" to mark indefinite (active) - notes: string + Naive datetimes (no tz suffix) are interpreted as the user's + configured timezone and converted to UTC for storage. Send an + explicit "+00:00" / "Z" suffix to skip the conversion (programmatic + callers that already have UTC). + Sets `status` to "active" when assigned_until is cleared, "completed" when it's set in the past. """ @@ -816,12 +955,14 @@ async def update_assignment( ) try: # Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO. - new_assigned_at = datetime.fromisoformat(raw) + parsed = datetime.fromisoformat(raw) except (TypeError, ValueError): raise HTTPException( status_code=400, detail=f"Invalid assigned_at datetime: {raw!r}", ) + # Naive (no tz) → treat as user's local time and store as UTC. + new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) if "assigned_until" in payload: raw = payload["assigned_until"] @@ -829,12 +970,13 @@ async def update_assignment( new_assigned_until = None else: try: - new_assigned_until = datetime.fromisoformat(raw) + parsed = datetime.fromisoformat(raw) except (TypeError, ValueError): raise HTTPException( status_code=400, detail=f"Invalid assigned_until datetime: {raw!r}", ) + new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) if "notes" in payload: raw = payload["notes"] @@ -1192,6 +1334,22 @@ async def swap_unit_on_location( new_value=f"swapped out → {unit_id}", notes=notes, ) + # Clear the outgoing unit's modem pairing so the bidirectional + # deployed_with_modem_id / deployed_with_unit_id back-reference + # doesn't orphan onto the unit that just left the field. + old_unit = db.query(RosterUnit).filter_by(id=current.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 == current.unit_id: + old_modem.deployed_with_unit_id = None + old_unit.deployed_with_modem_id = None + # Bench the outgoing unit — it's no longer in the field, so + # the heartbeat / polling subsystem should stop chasing it. + if old_unit.deployed: + old_unit.deployed = False # Create new assignment new_assignment = UnitAssignment( @@ -1218,12 +1376,28 @@ async def swap_unit_on_location( modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() if not modem: raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found") + # Symmetric cleanup: if this modem still claims a previous partner + # (a different seismograph whose deployed_with_modem_id never got + # cleared in a past swap), break that stale link before re-pairing. + if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id: + prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first() + if prev_partner and prev_partner.deployed_with_modem_id == modem_id: + prev_partner.deployed_with_modem_id = None unit.deployed_with_modem_id = modem_id modem.deployed_with_unit_id = unit_id + # If the modem was on the bench, swapping it into the field puts it + # back in rotation. + if not modem.deployed: + modem.deployed = True else: # Clear modem pairing if not provided unit.deployed_with_modem_id = None + # If the incoming unit was benched, putting it in the field flips it + # back to deployed (so polling / dashboards see it as in rotation). + if not unit.deployed: + unit.deployed = True + db.commit() return JSONResponse({ @@ -1241,23 +1415,32 @@ async def swap_unit_on_location( async def get_available_modems( project_id: str, db: Session = Depends(get_db), + include_benched: bool = Query(False), ): """ - Get all deployed, non-retired modems for the modem assignment dropdown. + Get all non-retired modems for the modem assignment dropdown. + + By default only deployed (in-rotation) modems are returned, preserving + the existing behavior for callers like the location-detail swap modal. + Pass ``include_benched=true`` to also include benched modems + (``RosterUnit.deployed == False``) — useful when picking a modem to + pull off the bench for a field swap. Each row's ``deployed`` flag is + returned so the UI can badge benched candidates. """ - modems = db.query(RosterUnit).filter( - and_( - RosterUnit.device_type == "modem", - RosterUnit.deployed == True, - RosterUnit.retired == False, - ) - ).order_by(RosterUnit.id).all() + filters = [ + RosterUnit.device_type == "modem", + RosterUnit.retired == False, + ] + if not include_benched: + filters.append(RosterUnit.deployed == True) + modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all() return [ { "id": m.id, "hardware_model": m.hardware_model, "ip_address": m.ip_address, + "deployed": bool(m.deployed), } for m in modems ] @@ -1268,22 +1451,31 @@ async def get_available_units( project_id: str, location_type: str = Query(...), db: Session = Depends(get_db), + include_benched: bool = Query(False), ): """ Get list of available units for assignment to a location. Filters by device type matching the location type. + + By default only deployed (in-rotation) units are returned, preserving + the existing location-detail swap-modal behavior. Pass + ``include_benched=true`` to also include benched units + (``RosterUnit.deployed == False``) — exactly the candidates you'd + pull off the bench for a field swap. Each row carries a ``deployed`` + flag so the UI can badge benched picks. """ # Determine required device type required_device_type = "slm" if location_type == "sound" else "seismograph" - # Get all units of the required type that are deployed and not retired - all_units = db.query(RosterUnit).filter( - and_( - RosterUnit.device_type == required_device_type, - RosterUnit.deployed == True, - RosterUnit.retired == False, - ) - ).all() + # Get all units of the required type that aren't retired (and optionally + # exclude benched units). + filters = [ + RosterUnit.device_type == required_device_type, + RosterUnit.retired == False, + ] + if not include_benched: + filters.append(RosterUnit.deployed == True) + all_units = db.query(RosterUnit).filter(and_(*filters)).all() # Filter out units that already have active assignments (active = assigned_until IS NULL) assigned_unit_ids = db.query(UnitAssignment.unit_id).filter( @@ -1297,6 +1489,7 @@ async def get_available_units( "device_type": unit.device_type, "location": unit.address or unit.location, "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type, + "deployed": bool(unit.deployed), } for unit in all_units if unit.id not in assigned_unit_ids diff --git a/backend/services/deployment_timeline.py b/backend/services/deployment_timeline.py index 6690b52..dae011b 100644 --- a/backend/services/deployment_timeline.py +++ b/backend/services/deployment_timeline.py @@ -39,9 +39,35 @@ from backend.services.sfm_events import ( _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 @@ -185,8 +211,8 @@ async def deployment_timeline_for_unit( overlays[a.id] = { "event_count": len(events), "peak_pvs": peak, - "peak_pvs_at": peak_at, - "last_event": last_ev, + "peak_pvs_at": _iso_local(peak_at), + "last_event": _iso_local(last_ev), } # 4. Build entries. Start by emitting assignment rows + gap rows between @@ -202,8 +228,8 @@ async def deployment_timeline_for_unit( entry = { "kind": "assignment", - "starts_at": _iso_utc(a.assigned_at), - "ends_at": _iso_utc(a.assigned_until), + "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, @@ -227,8 +253,8 @@ async def deployment_timeline_for_unit( if gap_seconds >= _MIN_GAP_SECONDS: entries.append({ "kind": "gap", - "starts_at": _iso_utc(gap_start), - "ends_at": _iso_utc(gap_end), + "starts_at": _iso_local(gap_start), + "ends_at": _iso_local(gap_end), "duration_days": round(gap_seconds / 86400, 1), "context": "between assignments", }) @@ -241,7 +267,7 @@ async def deployment_timeline_for_unit( continue entries.append({ "kind": "state_change", - "starts_at": _iso_utc(h.changed_at), + "starts_at": _iso_local(h.changed_at), "ends_at": None, "duration_days": None, "change_type": h.change_type, diff --git a/templates/admin/unit_swap.html b/templates/admin/unit_swap.html new file mode 100644 index 0000000..b7505ad --- /dev/null +++ b/templates/admin/unit_swap.html @@ -0,0 +1,728 @@ +{% extends "base.html" %} + +{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+
+
+

Unit Swap

+

+ Swap a vibration unit (and modem) at a monitoring location. +

+
+ ← Tools +
+ + +
+
+ 1 + Project +
+
+
+ 2 + Location +
+
+
+ 3 + Unit +
+
+
+ 4 + Modem +
+
+
+ 5 + Confirm +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + +
+ + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index e19acb3..b7bba17 100644 --- a/templates/base.html +++ b/templates/base.html @@ -150,6 +150,7 @@ (project tidy, metadata backfill, pair devices). #} {% set _is_tools = ( request.url.path == '/tools' + or request.url.path.startswith('/tools/') or request.url.path == '/pair-devices' or request.url.path == '/settings/developer/project-tidy' or request.url.path == '/settings/developer/metadata-backfill' diff --git a/templates/tools.html b/templates/tools.html index d9649f6..c205332 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -141,6 +141,24 @@ + + +
+
+ + + +
+
+

Unit Swap

+

+ Field-swap a unit (and modem) at a vibration location. Pick project → location → incoming unit → confirm. Optional photo of the new install. +

+
+
+
+
diff --git a/templates/unit_detail.html b/templates/unit_detail.html index c2eb60f..35f7d8f 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -281,11 +281,16 @@
-
+

Deployment Timeline

- +
+ + +
+ + + + +