From d5a0163852d23315d6973e58e74be8f00bd615ce Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 14 May 2026 22:22:40 +0000 Subject: [PATCH] feat(locations): soft-remove monitoring locations without destroying history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client drops a location from scope mid-project (e.g. the office half of a museum+office monitoring job), operators couldn't previously mark it as no-longer-active without either deleting it (which would orphan historical events) or leaving it in the active list looking deployable. Now there's a proper middle ground. Data model - MonitoringLocation gets two new nullable columns: - removed_at — NULL means active; set means soft-removed - removal_reason — optional operator note Migration: backend/migrate_add_location_removed.py (idempotent) Endpoints - POST /api/projects/{p}/locations/{l}/remove Body: { effective_date?: ISO-datetime, reason?: str } Side effects (cascade): 1. Closes active UnitAssignment rows at this location (assigned_until = effective_date, status = "completed") 2. Cancels pending ScheduledActions at this location 3. Marks location.removed_at = effective_date Returns counts of assignments closed + actions cancelled. - POST /api/projects/{p}/locations/{l}/restore Clears removed_at + removal_reason. Does NOT auto-reopen assignments — operator creates new ones if resuming monitoring. Active-surface filters - locations-json defaults to active-only; pass include_removed=true for historical / reporting views. Schedule modal dropdowns now exclude removed locations automatically. - Metadata-backfill fuzzy matcher excludes removed locations from proposed targets (don't want backfill creating new assignments at decommissioned locations). - Vibration-summary per_location rollup includes removed locations (so historical event totals stay accurate) but tags each with removed_at so the UI can show a badge. UI - Project detail page's Monitoring Locations section now splits into: Active locations (full card with Assign / Edit / Remove / Delete) Removed locations (collapsed
, greyed cards, Restore button, shows removal date + reason) - New per-card "Remove" button → opens confirmation modal explaining the cascade, with optional effective-date (defaults to now, backdateable) and reason fields. - Unit detail's SFM Events attribution cell shows a small "removed" badge next to historical attributions whose location is no longer active. Same pattern in vibration_summary's top-locations list. - Soft-removal indicator surfaced through the events_for_unit attribution payload as location_removed_at. Co-Authored-By: Claude Opus 4.7 --- backend/migrate_add_location_removed.py | 63 ++++++ backend/models.py | 8 + backend/routers/metadata_backfill.py | 3 + backend/routers/project_locations.py | 214 ++++++++++++++++-- backend/services/sfm_events.py | 23 +- .../partials/projects/location_list.html | 104 +++++++-- .../partials/projects/vibration_summary.html | 4 + templates/projects/detail.html | 143 ++++++++++++ templates/unit_detail.html | 7 +- 9 files changed, 531 insertions(+), 38 deletions(-) create mode 100644 backend/migrate_add_location_removed.py 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/models.py b/backend/models.py index 5ab7761..8e17197 100644 --- a/backend/models.py +++ b/backend/models.py @@ -235,6 +235,14 @@ 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) + 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..f086020 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() ) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 733fd38..7fda2eb 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: @@ -152,10 +153,14 @@ async def get_project_locations( locations = query.order_by(MonitoringLocation.name).all() - # Enrich with assignment info - locations_data = [] + # 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 +177,23 @@ 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.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 +202,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 +221,21 @@ async def get_project_locations_json( if location_type: query = query.filter_by(location_type=location_type) + if not include_removed: + query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711 + locations = query.order_by(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 ] @@ -335,6 +356,165 @@ async def delete_location( return {"success": True, "message": "Location deleted successfully"} +@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 # ============================================================================ 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/partials/projects/location_list.html b/templates/partials/projects/location_list.html index 9c8219a..f274373 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -1,7 +1,21 @@ - -{% if locations %} + + +{% if not active_locations and not removed_locations %} +
+ + + +

No locations added yet

+
+{% else %} + +{# ─── Active locations ─── #} +{% if active_locations %}
- {% for item in locations %} + {% for item in active_locations %}
@@ -24,11 +38,13 @@
{% if item.assignment %} - {% else %} - {% endif %} @@ -37,7 +53,14 @@ class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"> Edit - +
@@ -54,11 +77,66 @@
{% endfor %}
-{% else %} -
- - - -

No locations added yet

-
+{% 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 %} +
+ +
+ +
+
+ +
+ Historical sessions: {{ item.session_count }} +
+
+ {% endfor %} +
+
+{% endif %} + {% endif %} diff --git a/templates/partials/projects/vibration_summary.html b/templates/partials/projects/vibration_summary.html index 4197af9..ce32f8a 100644 --- a/templates/partials/projects/vibration_summary.html +++ b/templates/partials/projects/vibration_summary.html @@ -60,6 +60,10 @@ class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors"> 📍 {{ loc.location_name }} + {% if loc.removed_at %} + removed + {% endif %} {{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 70c3dc5..4f75550 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -778,6 +778,61 @@
+ + +