feat(locations): soft-remove monitoring locations without destroying history
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 <details>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user