v0.11.0 #50
@@ -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.")
|
||||||
@@ -235,6 +235,14 @@ class MonitoringLocation(Base):
|
|||||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||||
location_metadata = Column(Text, nullable=True)
|
location_metadata = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Soft-removal: NULL means active. When set, the location is hidden from
|
||||||
|
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
|
||||||
|
# vibration summary) but historical events generated before this time
|
||||||
|
# still attribute to it. Mirrors the closed-state pattern used by
|
||||||
|
# UnitAssignment.assigned_until.
|
||||||
|
removed_at = Column(DateTime, nullable=True)
|
||||||
|
removal_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,9 @@ def locations_search(
|
|||||||
db.query(MonitoringLocation)
|
db.query(MonitoringLocation)
|
||||||
.filter(MonitoringLocation.project_id == project_id)
|
.filter(MonitoringLocation.project_id == project_id)
|
||||||
.filter(MonitoringLocation.location_type == "vibration")
|
.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()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from backend.models import (
|
|||||||
MonitoringSession,
|
MonitoringSession,
|
||||||
DataFile,
|
DataFile,
|
||||||
UnitHistory,
|
UnitHistory,
|
||||||
|
ScheduledAction,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.utils.timezone import local_to_utc
|
from backend.utils.timezone import local_to_utc
|
||||||
@@ -138,7 +139,7 @@ async def get_project_locations(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all monitoring locations for a project.
|
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()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
@@ -152,10 +153,14 @@ async def get_project_locations(
|
|||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
locations = query.order_by(MonitoringLocation.name).all()
|
||||||
|
|
||||||
# Enrich with assignment info
|
# Enrich with assignment info, splitting active vs removed.
|
||||||
locations_data = []
|
active_data: list = []
|
||||||
|
removed_data: list = []
|
||||||
for location in locations:
|
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(
|
assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location.id,
|
UnitAssignment.location_id == location.id,
|
||||||
@@ -172,17 +177,23 @@ async def get_project_locations(
|
|||||||
location_id=location.id
|
location_id=location.id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
locations_data.append({
|
item = {
|
||||||
"location": location,
|
"location": location,
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
"session_count": session_count,
|
"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", {
|
return templates.TemplateResponse("partials/projects/location_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project": project,
|
"project": project,
|
||||||
"locations": locations_data,
|
"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,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
location_type: Optional[str] = Query(None),
|
location_type: Optional[str] = Query(None),
|
||||||
|
include_removed: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all monitoring locations for a project as JSON.
|
Get all monitoring locations for a project as JSON.
|
||||||
Used by the schedule modal to populate location dropdown.
|
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()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
@@ -205,16 +221,21 @@ async def get_project_locations_json(
|
|||||||
if location_type:
|
if location_type:
|
||||||
query = query.filter_by(location_type=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()
|
locations = query.order_by(MonitoringLocation.name).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": loc.id,
|
"id": loc.id,
|
||||||
"name": loc.name,
|
"name": loc.name,
|
||||||
"location_type": loc.location_type,
|
"location_type": loc.location_type,
|
||||||
"description": loc.description,
|
"description": loc.description,
|
||||||
"address": loc.address,
|
"address": loc.address,
|
||||||
"coordinates": loc.coordinates,
|
"coordinates": loc.coordinates,
|
||||||
|
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||||
|
"removal_reason": loc.removal_reason,
|
||||||
}
|
}
|
||||||
for loc in locations
|
for loc in locations
|
||||||
]
|
]
|
||||||
@@ -335,6 +356,165 @@ async def delete_location(
|
|||||||
return {"success": True, "message": "Location deleted successfully"}
|
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
|
# Unit Assignments
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -318,13 +318,18 @@ async def events_for_unit(
|
|||||||
loc = loc_map.get(a.location_id)
|
loc = loc_map.get(a.location_id)
|
||||||
proj = proj_map.get(a.project_id)
|
proj = proj_map.get(a.project_id)
|
||||||
return {
|
return {
|
||||||
"assignment_id": a.id,
|
"assignment_id": a.id,
|
||||||
"location_id": a.location_id,
|
"location_id": a.location_id,
|
||||||
"location_name": loc.name if loc else None,
|
"location_name": loc.name if loc else None,
|
||||||
"project_id": a.project_id,
|
# Soft-removal indicator so the UI can render a "(removed)"
|
||||||
"project_name": proj.name if proj else None,
|
# badge next to historical attributions whose location is no
|
||||||
"assigned_at": _iso_utc(a.assigned_at),
|
# longer actively monitored.
|
||||||
"assigned_until": _iso_utc(a.assigned_until),
|
"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.
|
# 2. Fetch all events for this serial in one shot.
|
||||||
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
|
|||||||
"event_count": ec,
|
"event_count": ec,
|
||||||
"peak_pvs": ev_peak,
|
"peak_pvs": ev_peak,
|
||||||
"last_event": ev_last,
|
"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)
|
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
<!-- Project Locations List -->
|
<!-- Project Locations List — split into Active + Removed sections.
|
||||||
{% if locations %}
|
Active locations get the full card with Assign/Edit/Delete/Remove
|
||||||
|
actions. Removed locations get a greyed-out card with a
|
||||||
|
Removed-on date, optional reason, and a Restore button. -->
|
||||||
|
|
||||||
|
{% if not active_locations and not removed_locations %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No locations added yet</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ─── Active locations ─── #}
|
||||||
|
{% if active_locations %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{% for item in locations %}
|
{% for item in active_locations %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -24,11 +38,13 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if item.assignment %}
|
{% if item.assignment %}
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
<button onclick="unassignUnit('{{ item.assignment.id }}')"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
Unassign
|
Unassign
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% 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">
|
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
<button onclick="openRemoveLocationModal('{{ item.location.id }}', {{ item.location.name | tojson }})"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100"
|
||||||
|
title="Mark as no longer actively monitored — preserves historical events">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteLocation('{{ item.location.id }}')"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||||
|
title="Permanently delete — only available if there's no history">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,11 +77,66 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{# ─── Removed locations (collapsed by default) ─── #}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
{% if removed_locations %}
|
||||||
</svg>
|
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
|
||||||
<p>No locations added yet</p>
|
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
|
||||||
</div>
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
Removed locations
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
|
||||||
|
</span>
|
||||||
|
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="space-y-3 mt-3">
|
||||||
|
{% for item in removed_locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
|
||||||
|
Removed
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if item.location.removal_reason %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.description %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.address %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="restoreLocation('{{ item.location.id }}', {{ item.location.name | tojson }})"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
|
||||||
|
title="Restore to active monitoring">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
<span>Historical sessions: {{ item.session_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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">
|
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
📍 {{ loc.location_name }}
|
📍 {{ loc.location_name }}
|
||||||
|
{% if loc.removed_at %}
|
||||||
|
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
|
||||||
|
title="Location no longer actively monitored — events shown are historical">removed</span>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
||||||
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
||||||
|
|||||||
@@ -778,6 +778,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove Location Confirmation Modal —
|
||||||
|
Soft-removal: preserves historical events, closes active assignments,
|
||||||
|
cancels pending scheduled actions. Distinct from Delete (which is
|
||||||
|
permanent and only allowed when there's no history). -->
|
||||||
|
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white">…</span> as no longer actively monitored.
|
||||||
|
</p>
|
||||||
|
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
|
||||||
|
<li>Closes any active unit assignment at this location</li>
|
||||||
|
<li>Cancels pending scheduled actions at this location</li>
|
||||||
|
<li>Historical events stay attributed (visible in reports + event lists)</li>
|
||||||
|
<li>Can be restored later if needed</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<input type="hidden" id="remove-location-id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
|
||||||
|
<input type="datetime-local" id="remove-location-effective"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
|
||||||
|
<input type="text" id="remove-location-reason" maxlength="200"
|
||||||
|
placeholder="e.g. client dropped from scope"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="closeRemoveLocationModal()"
|
||||||
|
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmRemoveLocation()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Project Confirmation Modal -->
|
<!-- Delete Project Confirmation Modal -->
|
||||||
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
@@ -1213,6 +1268,94 @@ async function deleteLocation(locationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remove / Restore location ────────────────────────────────────────
|
||||||
|
// Soft-removal: marks a location as no longer actively monitored without
|
||||||
|
// destroying it. Historical events stay attributed; active assignments
|
||||||
|
// are auto-closed and pending scheduled actions are auto-cancelled.
|
||||||
|
|
||||||
|
function openRemoveLocationModal(locationId, locationName) {
|
||||||
|
document.getElementById('remove-location-id').value = locationId;
|
||||||
|
document.getElementById('remove-location-name').textContent = locationName;
|
||||||
|
document.getElementById('remove-location-reason').value = '';
|
||||||
|
// Default effective_date to "now" in local datetime-input format.
|
||||||
|
const now = new Date();
|
||||||
|
const tzOffsetMin = now.getTimezoneOffset();
|
||||||
|
const local = new Date(now.getTime() - tzOffsetMin * 60000);
|
||||||
|
document.getElementById('remove-location-effective').value =
|
||||||
|
local.toISOString().slice(0, 16);
|
||||||
|
document.getElementById('remove-location-error').classList.add('hidden');
|
||||||
|
document.getElementById('remove-location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRemoveLocationModal() {
|
||||||
|
document.getElementById('remove-location-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveLocation() {
|
||||||
|
const locationId = document.getElementById('remove-location-id').value;
|
||||||
|
const reason = document.getElementById('remove-location-reason').value.trim();
|
||||||
|
const effective = document.getElementById('remove-location-effective').value;
|
||||||
|
const errBox = document.getElementById('remove-location-error');
|
||||||
|
errBox.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/locations/${locationId}/remove`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
reason: reason || null,
|
||||||
|
effective_date: effective || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to remove location');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
closeRemoveLocationModal();
|
||||||
|
refreshLocationLists();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
// Lightweight feedback — the UI refresh already shows the location
|
||||||
|
// moving to the Removed section, but a toast confirms the cascade.
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
const bits = [];
|
||||||
|
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
|
||||||
|
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
|
||||||
|
const tail = bits.length ? ` (${bits.join(', ')})` : '';
|
||||||
|
showToast(`Location removed${tail}`, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errBox.textContent = err.message || 'Failed to remove location.';
|
||||||
|
errBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreLocation(locationId, locationName) {
|
||||||
|
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/locations/${locationId}/restore`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to restore location');
|
||||||
|
}
|
||||||
|
refreshLocationLists();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(`"${locationName}" restored to active`, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to restore location.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assign modal functions
|
// Assign modal functions
|
||||||
function openAssignModal(locationId, locationType) {
|
function openAssignModal(locationId, locationType) {
|
||||||
const safeType = locationType || 'sound';
|
const safeType = locationType || 'sound';
|
||||||
|
|||||||
@@ -2286,12 +2286,17 @@ function _ueAttrCell(ev) {
|
|||||||
if (a) {
|
if (a) {
|
||||||
const projLabel = _ueEsc(a.project_name || '—');
|
const projLabel = _ueEsc(a.project_name || '—');
|
||||||
const locLabel = _ueEsc(a.location_name || '—');
|
const locLabel = _ueEsc(a.location_name || '—');
|
||||||
|
// If the attributed location has since been soft-removed, badge
|
||||||
|
// it so operators see at a glance this is historical attribution.
|
||||||
|
const removedBadge = a.location_removed_at
|
||||||
|
? '<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold" title="Location no longer actively monitored">removed</span>'
|
||||||
|
: '';
|
||||||
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||||
onclick="event.stopPropagation()"
|
onclick="event.stopPropagation()"
|
||||||
class="text-seismo-orange hover:text-seismo-navy"
|
class="text-seismo-orange hover:text-seismo-navy"
|
||||||
title="${projLabel} → ${locLabel}">
|
title="${projLabel} → ${locLabel}">
|
||||||
📍 ${locLabel}
|
📍 ${locLabel}
|
||||||
</a>
|
</a>${removedBadge}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||||
}
|
}
|
||||||
const n = ev.nearest_assignment;
|
const n = ev.nearest_assignment;
|
||||||
|
|||||||
Reference in New Issue
Block a user