diff --git a/CHANGELOG.md b/CHANGELOG.md index a71d3cf..6c9aa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ 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.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 200–800 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. diff --git a/README.md b/README.md index 9ab2bc2..4a74e14 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.10.0 +# Terra-View v0.11.0 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 @@ -496,6 +496,19 @@ docker compose down -v ## Release Highlights +### 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. @@ -612,9 +625,11 @@ MIT ## Version -**Current: 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) +**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15) -Previous: 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06) +Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14) + +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) diff --git a/backend/main.py b/backend/main.py index c7d39d7..f88e18c 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.10.0" +VERSION = "0.11.0" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": diff --git a/backend/migrate_add_location_removed.py b/backend/migrate_add_location_removed.py new file mode 100644 index 0000000..7c7fee7 --- /dev/null +++ b/backend/migrate_add_location_removed.py @@ -0,0 +1,63 @@ +""" +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.") diff --git a/backend/migrate_add_location_sort_order.py b/backend/migrate_add_location_sort_order.py new file mode 100644 index 0000000..cc5c2c1 --- /dev/null +++ b/backend/migrate_add_location_sort_order.py @@ -0,0 +1,76 @@ +""" +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.") diff --git a/backend/models.py b/backend/models.py index 5ab7761..380656b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -235,6 +235,20 @@ class MonitoringLocation(Base): # For vibration: {"ground_type": "bedrock", "depth": "10m"} 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) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/routers/metadata_backfill.py b/backend/routers/metadata_backfill.py index 3e002a1..34e0d2d 100644 --- a/backend/routers/metadata_backfill.py +++ b/backend/routers/metadata_backfill.py @@ -361,6 +361,9 @@ def locations_search( 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() ) @@ -373,7 +376,11 @@ def locations_search( if q_norm in l_norm: scored.append((l, 1.0)) continue - score = svc.similarity(q_norm, l_norm) + # 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)) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 733fd38..83bd19b 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -31,6 +31,7 @@ from backend.models import ( MonitoringSession, DataFile, UnitHistory, + ScheduledAction, ) from backend.templates_config import templates from backend.utils.timezone import local_to_utc @@ -138,7 +139,7 @@ async def get_project_locations( ): """ Get all monitoring locations for a project. - Returns HTML partial with location list. + Returns HTML partial with location list, split into active + removed. """ project = db.query(Project).filter_by(id=project_id).first() if not project: @@ -150,12 +151,35 @@ async def get_project_locations( if location_type: query = query.filter_by(location_type=location_type) - locations = query.order_by(MonitoringLocation.name).all() + # Order by operator-set sort_order, then name as a stable tie-breaker. + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() - # Enrich with assignment info - locations_data = [] + # For vibration locations, fan out event counts via SFM concurrently + # so the card layout can show "{N} events" instead of "Sessions: 0" + # (sessions don't really exist for the watcher-forward pipeline). + # Sound locations skip this and keep showing session counts. + event_counts: dict[str, int] = {} + vibration_locations = [l for l in locations if l.location_type == "vibration"] + if vibration_locations: + import asyncio + from backend.services.sfm_events import events_for_location + results = await asyncio.gather( + *(events_for_location(db, l.id, limit=1) for l in vibration_locations), + return_exceptions=True, + ) + for loc, res in zip(vibration_locations, results): + if isinstance(res, Exception): + continue # leave event_counts[loc.id] unset → template falls back + event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0 + + # Enrich with assignment info, splitting active vs removed. + active_data: list = [] + removed_data: list = [] for location in locations: - # Get active assignment (active = assigned_until IS NULL) + # Get active assignment (active = assigned_until IS NULL). For + # removed locations this will normally be None because the + # /remove cascade closes them, but check anyway for resilience + # against legacy data. assignment = db.query(UnitAssignment).filter( and_( UnitAssignment.location_id == location.id, @@ -172,17 +196,25 @@ async def get_project_locations( location_id=location.id ).count() - locations_data.append({ - "location": location, - "assignment": assignment, + item = { + "location": location, + "assignment": assignment, "assigned_unit": assigned_unit, "session_count": session_count, - }) + } + if location.id in event_counts: + item["event_count"] = event_counts[location.id] + if location.removed_at is None: + active_data.append(item) + else: + removed_data.append(item) return templates.TemplateResponse("partials/projects/location_list.html", { - "request": request, - "project": project, - "locations": locations_data, + "request": request, + "project": project, + "locations": active_data, # back-compat alias + "active_locations": active_data, + "removed_locations": removed_data, }) @@ -191,10 +223,15 @@ async def get_project_locations_json( project_id: str, db: Session = Depends(get_db), location_type: Optional[str] = Query(None), + include_removed: bool = Query(False), ): """ Get all monitoring locations for a project as JSON. Used by the schedule modal to populate location dropdown. + + Removed locations are filtered out by default (you can't schedule + a new action at a removed location). Pass `include_removed=true` + to get them too — useful for historical / reporting views. """ project = db.query(Project).filter_by(id=project_id).first() if not project: @@ -205,16 +242,21 @@ async def get_project_locations_json( if location_type: query = query.filter_by(location_type=location_type) - locations = query.order_by(MonitoringLocation.name).all() + if not include_removed: + query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711 + + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() return [ { - "id": loc.id, - "name": loc.name, - "location_type": loc.location_type, - "description": loc.description, - "address": loc.address, - "coordinates": loc.coordinates, + "id": loc.id, + "name": loc.name, + "location_type": loc.location_type, + "description": loc.description, + "address": loc.address, + "coordinates": loc.coordinates, + "removed_at": loc.removed_at.isoformat() if loc.removed_at else None, + "removal_reason": loc.removal_reason, } for loc in locations ] @@ -235,6 +277,13 @@ async def create_location( form_data = await request.form() + # Compute next sort_order so new locations land at the END of the + # project's list rather than getting interleaved alphabetically. + from sqlalchemy import func + max_sort = db.query(func.max(MonitoringLocation.sort_order))\ + .filter_by(project_id=project_id).scalar() + next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0 + location = MonitoringLocation( id=str(uuid.uuid4()), project_id=project_id, @@ -244,6 +293,7 @@ async def create_location( coordinates=form_data.get("coordinates"), address=form_data.get("address"), location_metadata=form_data.get("location_metadata"), # JSON string + sort_order=next_sort_order, ) db.add(location) @@ -335,6 +385,216 @@ async def delete_location( return {"success": True, "message": "Location deleted successfully"} +@router.post("/locations/reorder") +async def reorder_locations( + project_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Persist a new sort order for a project's monitoring locations. + + Body JSON: { "location_ids": [uuid, uuid, ...] } + The list MUST contain location ids in the desired display order. + Locations not included in the list keep their current sort_order + (useful for the "active locations only — leave removed alone" + drag-and-drop UX). + + Updates `sort_order` to the index of each id in the list. Ties + between included and excluded locations fall back to the existing + sort_order. + """ + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + ids = payload.get("location_ids") or [] + if not isinstance(ids, list) or len(ids) == 0: + raise HTTPException(status_code=400, detail="location_ids must be a non-empty list") + + # Fetch only the locations being reordered and validate ownership. + locations = db.query(MonitoringLocation).filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.id.in_(ids), + ).all() + + found_ids = {l.id for l in locations} + missing = [i for i in ids if i not in found_ids] + if missing: + raise HTTPException( + status_code=404, + detail=f"Some locations not found in this project: {missing[:3]}…", + ) + + # Apply 0-indexed sort_order matching the operator's chosen order. + by_id = {l.id: l for l in locations} + for idx, loc_id in enumerate(ids): + by_id[loc_id].sort_order = idx + + db.commit() + return {"success": True, "reordered": len(ids)} + + +@router.post("/locations/{location_id}/remove") +async def remove_location( + project_id: str, + location_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Soft-remove a monitoring location — mark it as no longer actively + monitored without destroying it. + + Use case: a client drops a location from scope mid-project, but the + historical events recorded there should remain attributed. Deleting + would orphan those events; this preserves them. + + Cascading side-effects: + 1. All active UnitAssignment rows at this location are closed + (assigned_until = effective_date, status = "completed"). + Units become available for other deployments. + 2. All pending ScheduledAction rows at this location are cancelled + (execution_status = "cancelled"). + 3. Historical events stay attributed (attribution is window-based; + events with timestamp < effective_date still match the + now-closed assignment windows). + + Accepts JSON body: + - effective_date: ISO datetime (optional, defaults to now) + - reason: operator note (optional) + """ + location = db.query(MonitoringLocation).filter_by( + id=location_id, + project_id=project_id, + ).first() + + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + if location.removed_at is not None: + raise HTTPException( + status_code=400, + detail=f"Location is already removed (as of {location.removed_at.isoformat()}).", + ) + + # Body is optional — POST with no body is fine and means "remove now, + # no reason given." + try: + payload = await request.json() + except Exception: + payload = {} + + # Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or + # full ISO. Defaults to now if absent/empty. + raw_eff = payload.get("effective_date") + if raw_eff: + try: + effective_date = datetime.fromisoformat(raw_eff) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail=f"Invalid effective_date: {raw_eff!r}", + ) + else: + effective_date = datetime.utcnow() + + reason = (payload.get("reason") or "").strip() or None + + # 1. Close active assignments at this location. + active_assignments = db.query(UnitAssignment).filter( + and_( + UnitAssignment.location_id == location_id, + UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL + ) + ).all() + + for a in active_assignments: + a.status = "completed" + a.assigned_until = effective_date + _record_assignment_history( + db, + unit_id=a.unit_id, + change_type="assignment_ended", + old_value=location.name, + new_value="location removed", + notes=f"Location '{location.name}' marked as removed" + + (f" — {reason}" if reason else ""), + ) + + # 2. Cancel pending scheduled actions at this location. + pending_actions = db.query(ScheduledAction).filter( + and_( + ScheduledAction.location_id == location_id, + ScheduledAction.execution_status == "pending", + ScheduledAction.scheduled_time >= effective_date, + ) + ).all() + + for sa in pending_actions: + sa.execution_status = "cancelled" + sa.error_message = ( + f"Cancelled: location '{location.name}' marked as removed" + + (f" — {reason}" if reason else "") + ) + + # 3. Mark the location itself as removed. + location.removed_at = effective_date + location.removal_reason = reason + location.updated_at = datetime.utcnow() + + db.commit() + + return { + "success": True, + "message": f"Location '{location.name}' marked as removed", + "effective_date": effective_date.isoformat(), + "assignments_closed": len(active_assignments), + "actions_cancelled": len(pending_actions), + } + + +@router.post("/locations/{location_id}/restore") +async def restore_location( + project_id: str, + location_id: str, + db: Session = Depends(get_db), +): + """ + Restore a previously-removed monitoring location to active. + + Clears `removed_at` and `removal_reason`. Does NOT automatically + re-open the assignments or scheduled actions that were closed when + the location was removed — those stay closed and the operator can + create new ones if they want to resume monitoring. + """ + location = db.query(MonitoringLocation).filter_by( + id=location_id, + project_id=project_id, + ).first() + + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + if location.removed_at is None: + raise HTTPException( + status_code=400, + detail="Location is already active.", + ) + + location.removed_at = None + location.removal_reason = None + location.updated_at = datetime.utcnow() + + db.commit() + + return { + "success": True, + "message": f"Location '{location.name}' restored to active", + } + + # ============================================================================ # Unit Assignments # ============================================================================ @@ -650,6 +910,228 @@ async def update_assignment( } +@router.delete("/assignments/{assignment_id}") +async def delete_assignment( + project_id: str, + assignment_id: str, + db: Session = Depends(get_db), +): + """ + Hard-delete an assignment record. + + Use case: operator clicked Assign by mistake (or 8 times in a row) and + wants the bogus records gone — not just closed with an `assigned_until` + timestamp. The standard close-via-unassign path is for legitimate + deployments that ended; this is for mis-clicks that never actually + happened. + + Safety: + - Refuses if any MonitoringSession exists for the same (unit, location) + within this assignment's window — that suggests the deployment was + real, and the operator should use unassign instead. + - Refuses if the assignment is the ONLY active assignment for a unit + currently shown as deployed AND a recording session is in progress. + + Audit: + - Records UnitHistory `assignment_deleted` so the unit's deployment + timeline shows the deletion happened (even though the row itself + is gone). + """ + assignment = db.query(UnitAssignment).filter_by( + id=assignment_id, + project_id=project_id, + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Safety: is there a real recording history for this (unit, location) + # within the assignment's time window? If so, this isn't a mis-click — + # the operator should close it via unassign, not delete it. + window_start = assignment.assigned_at + window_end = assignment.assigned_until or datetime.utcnow() + real_sessions = db.query(MonitoringSession).filter( + and_( + MonitoringSession.location_id == assignment.location_id, + MonitoringSession.unit_id == assignment.unit_id, + MonitoringSession.started_at >= window_start, + MonitoringSession.started_at <= window_end, + ) + ).count() + + if real_sessions > 0: + raise HTTPException( + status_code=400, + detail=( + f"Cannot delete this assignment — {real_sessions} monitoring " + f"session(s) were recorded under it. Use Unassign to close " + f"the window instead, which preserves the audit trail." + ), + ) + + # Resolve location name for audit log before deletion. + location = db.query(MonitoringLocation).filter_by( + id=assignment.location_id + ).first() + location_label = location.name if location else assignment.location_id + + _record_assignment_history( + db, + unit_id=assignment.unit_id, + change_type="assignment_deleted", + old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → " + f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})", + new_value="deleted", + notes=( + "Assignment row removed — created in error or accidental duplicate." + ), + ) + + db.delete(assignment) + db.commit() + + return { + "success": True, + "message": "Assignment deleted.", + } + + +@router.post("/assignments/merge") +async def merge_assignments( + project_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Merge multiple consecutive UnitAssignment rows for the same (unit, location) + into a single record spanning their combined window. + + Use case: a unit's deployment timeline shows 3 stacked rows for the + same location because the assignment was closed-and-reopened (e.g. via + location remove + restore) or because metadata-backfill auto-created + a retroactive window adjacent to a manual one. Operator sees three + rows but they represent one continuous deployment. + + Body JSON: + { "assignment_ids": ["", "", ...] } + + Validation: + - All assignments must belong to project_id + - All must share the same unit_id AND location_id + - At least 2 ids must be provided + + Merge rules: + - Keeps the EARLIEST-starting assignment as the surviving row + - assigned_at = min(assigned_at across all) + - assigned_until = max(assigned_until), or NULL if any input was active + - status = "active" if any input was active, else "completed" + - source = source of the earliest record (preserves original ingest provenance) + - notes = earliest's notes + "Merged N records ()" + - Other records are DELETED + - One UnitHistory `assignment_merged` row is written for audit + + No tolerance check — the operator is asking for the merge, so we trust + the intent. The UI can pre-filter to only offer "consecutive" merges + if it wants. + """ + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + ids = payload.get("assignment_ids") or [] + if not isinstance(ids, list) or len(ids) < 2: + raise HTTPException( + status_code=400, + detail="Need at least 2 assignment_ids to merge.", + ) + + assignments = ( + db.query(UnitAssignment) + .filter(UnitAssignment.project_id == project_id) + .filter(UnitAssignment.id.in_(ids)) + .all() + ) + + if len(assignments) != len(set(ids)): + raise HTTPException( + status_code=404, + detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).", + ) + + unit_ids = {a.unit_id for a in assignments} + loc_ids = {a.location_id for a in assignments} + if len(unit_ids) > 1 or len(loc_ids) > 1: + raise HTTPException( + status_code=400, + detail="Can only merge assignments that share the same unit and location.", + ) + + # Order chronologically. + assignments.sort(key=lambda a: a.assigned_at) + earliest = assignments[0] + others = assignments[1:] + + # Compute merged window. + any_active = any(a.assigned_until is None for a in assignments) + if any_active: + merged_until = None + else: + merged_until = max(a.assigned_until for a in assignments) + + # Build a brief audit-style note describing what got merged. + bits = [] + for a in assignments: + win = ( + f"{a.assigned_at:%Y-%m-%d}" + f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}" + ) + bits.append(f"{win} [{a.source}]") + merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits) + new_notes = (earliest.notes + " • " + merge_note_suffix) if earliest.notes else merge_note_suffix + + # Resolve names for the audit log before mutating. + location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first() + location_label = location.name if location else earliest.location_id + + # Mutate the survivor. + earliest.assigned_at = min(a.assigned_at for a in assignments) + earliest.assigned_until = merged_until + earliest.status = "active" if any_active else "completed" + earliest.notes = new_notes + + # Delete the rest. + deleted_ids = [a.id for a in others] + for a in others: + db.delete(a) + + _record_assignment_history( + db, + unit_id=earliest.unit_id, + change_type="assignment_merged", + old_value=f"{len(assignments)} rows at {location_label}", + new_value=( + f"1 row {earliest.assigned_at:%Y-%m-%d}" + f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}" + ), + notes=merge_note_suffix, + ) + + db.commit() + db.refresh(earliest) + + return { + "success": True, + "message": f"Merged {len(assignments)} assignments into one.", + "kept_id": earliest.id, + "deleted_ids": deleted_ids, + "merged_window": { + "assigned_at": earliest.assigned_at.isoformat(), + "assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None, + }, + } + + @router.post("/locations/{location_id}/swap") async def swap_unit_on_location( project_id: str, diff --git a/backend/services/deployment_timeline.py b/backend/services/deployment_timeline.py index 21fa8af..6690b52 100644 --- a/backend/services/deployment_timeline.py +++ b/backend/services/deployment_timeline.py @@ -46,6 +46,13 @@ log = logging.getLogger("backend.services.deployment_timeline") # 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 @@ -245,12 +252,41 @@ async def deployment_timeline_for_unit( "history_notes": h.notes, }) - # 6. Sort newest first. Active assignments (no end) sort by start time, + # 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, + "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, } diff --git a/backend/services/metadata_backfill.py b/backend/services/metadata_backfill.py index 303328a..30e234c 100644 --- a/backend/services/metadata_backfill.py +++ b/backend/services/metadata_backfill.py @@ -162,6 +162,11 @@ def similarity(a: str, b: str) -> float: too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the strings don't exact-match. This guardrails the 'one common word inside a longer phrase' false positive. + + USE FOR: project names (where typos like '1-80' vs 'I-80' should + still match). For location names use `location_similarity()` — + WRatio is too lenient on the shared boilerplate vocabulary in + location strings ('Area', 'Loc', 'Bridge', 'Dam', etc.). """ if not a or not b: return 0.0 @@ -172,6 +177,50 @@ def similarity(a: str, b: str) -> float: return rapidfuzz.fuzz.WRatio(a, b) / 100.0 +# Multi-digit penalty applied when two location names have completely +# disjoint multi-digit numeric tokens (e.g. "87 Jenks" vs "68 Jenks"). +# Single-digit numbers ("Loc 1", "Area 2") are often shared coincidentally, +# but address-style multi-digit numbers are strong identifiers — if they +# differ, the locations are usually different physical places. +_LOCATION_DIGIT_MISMATCH_PENALTY = 0.30 + + +def location_similarity(a: str, b: str) -> float: + """Stricter similarity score for location-name matching. + + Location names share so much boilerplate vocabulary ('Area', 'Loc', + 'Bridge', 'Dam') that rapidfuzz.WRatio inflates obvious mismatches. + Example: 'Area 2 - Brookville Dam - Loc 2 East' vs 'Area 1 - Loc 1 - + 87 Jenks' scores 85.5 via WRatio despite being unrelated locations. + + This scorer uses `token_set_ratio` as the base (sensitive to actual + word overlap, not just substring containment). It then applies a + multi-digit penalty: if both strings contain 2+-digit numbers and + none overlap, subtract 0.30. Catches the "same project, different + address-style identifier" case ('87 Jenks' vs '68 Jenks') that pure + token-set scoring still rates above 0.90. + + Single-digit numbers ('Loc 1', 'Area 2') are excluded from the + penalty because they're often shared boilerplate ("Loc 1" in every + project) rather than discriminating identifiers. + """ + if not a or not b: + return 0.0 + if a == b: + return 1.0 + if min(len(a), len(b)) < _MIN_FUZZY_LEN: + return 0.0 + + base = rapidfuzz.fuzz.token_set_ratio(a, b) / 100.0 + + multidigits_a = set(re.findall(r"\d{2,}", a)) + multidigits_b = set(re.findall(r"\d{2,}", b)) + if multidigits_a and multidigits_b and not (multidigits_a & multidigits_b): + base = max(0.0, base - _LOCATION_DIGIT_MISMATCH_PENALTY) + + return base + + # ── Cluster + Suggestion dataclasses ─────────────────────────────────────────── @@ -572,15 +621,24 @@ async def _scan_clusters( def _find_best_match( candidate_norm: str, candidates: list[tuple[str, str]], # (id, normalised_name) + *, + kind: str = "project", # "project" | "location" ) -> tuple[Optional[str], Optional[float], str]: """Return (best_id, best_score, classification). classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"} + + The `kind` parameter selects the scorer. Project matching uses + rapidfuzz.WRatio (lenient — catches typos like '1-80' vs 'I-80'). + Location matching uses `location_similarity` (stricter — catches + boilerplate-shared-but-actually-different strings like 'Loc 2 - 68 + Jenks' vs 'Loc 1 - 87 Jenks'). """ if not candidate_norm or not candidates: return None, None, "no_match" - scored = [(cid, similarity(candidate_norm, cnorm)) for cid, cnorm in candidates] + scorer = location_similarity if kind == "location" else similarity + scored = [(cid, scorer(candidate_norm, cnorm)) for cid, cnorm in candidates] scored.sort(key=lambda x: x[1], reverse=True) best_id, best_score = scored[0] @@ -725,7 +783,7 @@ def _build_suggestion(db: Session, cluster: Cluster) -> Suggestion: ) location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs] if cluster.location_norm: - loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates) + loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates, kind="location") else: loc_id, loc_score, loc_match = None, None, "create_new" else: diff --git a/backend/services/sfm_events.py b/backend/services/sfm_events.py index b866818..5fd46dd 100644 --- a/backend/services/sfm_events.py +++ b/backend/services/sfm_events.py @@ -318,13 +318,18 @@ async def events_for_unit( 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, - "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), + "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. @@ -515,6 +520,10 @@ async def vibration_summary_for_project( "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) diff --git a/templates/admin/metadata_backfill.html b/templates/admin/metadata_backfill.html index 84459cd..41bd18e 100644 --- a/templates/admin/metadata_backfill.html +++ b/templates/admin/metadata_backfill.html @@ -275,7 +275,13 @@ async function _fetchTypeahead(input, fieldKind) { return; } + // Args go in data-* attributes (not inline onclick) to avoid the quote + // collision when location names contain characters JSON.stringify quotes + // (e.g. anything with spaces/punctuation — basically every real name). + // _esc() escapes for HTML attribute context (entities for <>&"), then + // the browser decodes them when reading the dataset value. dropdown.innerHTML = items.map((it, idx) => { + const cid = _esc(input.dataset.clusterId); if (it.kind === 'match') { const m = it.payload; const scoreBadge = m.score >= 0.99 @@ -291,16 +297,24 @@ async function _fetchTypeahead(input, fieldKind) { } const metaLine = meta.length ? `
${meta.join(' · ')}
` : ''; return ``; } return ``; @@ -308,6 +322,19 @@ async function _fetchTypeahead(input, fieldKind) { dropdown.classList.remove('hidden'); } +// Trampoline — reads the click target's data-* attributes and forwards +// to onTypeaheadPick. Keeps the inline onclick attribute free of any +// string interpolation that could collide with HTML quoting. +function _typeaheadPickFromButton(btn) { + onTypeaheadPick( + null, + btn.dataset.cid, + btn.dataset.fieldKind, + btn.dataset.entityId || '', + btn.dataset.entityName || '' + ); +} + function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) { // entityId is empty string for "create new", or a UUID for matched existing. const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`); diff --git a/templates/partials/projects/location_list.html b/templates/partials/projects/location_list.html index 9c8219a..d5ccecb 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -1,64 +1,302 @@ - -{% if locations %} -
- {% for item in locations %} -
-
-
- - {% if item.location.description %} -

{{ item.location.description }}

- {% endif %} - {% if item.location.address %} -

{{ item.location.address }}

- {% endif %} - {% if item.location.coordinates %} -

{{ item.location.coordinates }}

- {% endif %} -
+ + +{% if not active_locations and not removed_locations %}

No locations added yet

+{% else %} + +{# ─── Active locations (draggable) ─── #} +{% if active_locations %} +
+ {% for item in active_locations %} +
+ +
+ +
+
+ + + +
+
+ + {{ item.location.name }} + + {% if item.location.description %} +

{{ item.location.description }}

+ {% endif %} + {% if item.location.address %} +

{{ item.location.address }}

+ {% endif %} + {% if item.location.coordinates %} +

{{ item.location.coordinates }}

+ {% endif %} + +
+ {% if item.event_count is defined and item.location.location_type == 'vibration' %} + {{ "{:,}".format(item.event_count) }} event{{ '' if item.event_count == 1 else 's' }} + {% else %} + Sessions: {{ item.session_count }} + {% endif %} + {% if item.assignment and item.assigned_unit %} + Assigned: {{ item.assigned_unit.id }} + {% else %} + No active assignment + {% endif %} +
+
+
+ + +
+ {% if not item.assignment %} + + + {% endif %} + + +
+ + + +
+
+
+
+ {% endfor %} +
{% endif %} + +{# ─── Removed locations (collapsed by default) ─── #} +{% if removed_locations %} +
+ + + + + + Removed locations + {{ removed_locations | length }} + +

Historical only — events stay attributed, but no new assignments or schedules can be created here.

+
+ +
+ {% for item in removed_locations %} +
+
+
+
+ + {{ item.location.name }} + + + Removed + + + {{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }} + +
+ {% if item.location.removal_reason %} +

"{{ item.location.removal_reason }}"

+ {% endif %} + {% if item.location.description %} +

{{ item.location.description }}

+ {% endif %} + {% if item.location.address %} +

{{ item.location.address }}

+ {% endif %} +
+ +
+ +
+
+ +
+ {% if item.event_count is defined and item.location.location_type == 'vibration' %} + {{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }} + {% else %} + Historical sessions: {{ item.session_count }} + {% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% endif %} + + + diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index aaa3eea..5b859e2 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -78,22 +78,128 @@
-
-

Upcoming Actions

- {% if upcoming_actions %} -
- {% for action in upcoming_actions %} -
-

{{ action.action_type }}

-

{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}

- {% if action.description %} -

{{ action.description }}

- {% endif %} -
- {% endfor %} -
- {% else %} -

No scheduled actions.

- {% endif %} + +
+ + +
+ +
+ + diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index 6aeff79..9e49749 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -87,9 +87,14 @@
- +