From 44ab4d842721ffd56a1bbca81e80ffc8b9d865a5 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 18 May 2026 01:47:31 +0000 Subject: [PATCH 1/6] feat: test version of unit swap tool. --- backend/main.py | 8 + backend/routers/project_locations.py | 88 ++++ templates/admin/unit_swap.html | 703 +++++++++++++++++++++++++++ templates/base.html | 1 + templates/tools.html | 18 + 5 files changed, 818 insertions(+) create mode 100644 templates/admin/unit_swap.html diff --git a/backend/main.py b/backend/main.py index 8247664..0e9a739 100644 --- a/backend/main.py +++ b/backend/main.py @@ -291,6 +291,14 @@ async def pending_deployments_page(request: Request): return templates.TemplateResponse("admin/pending_deployments.html", {"request": request}) +@app.get("/tools/unit-swap", response_class=HTMLResponse) +async def unit_swap_page(request: Request): + """Mobile-first wizard for swapping a vibration unit (and optionally its + modem) at a monitoring location. Pick project → location → incoming + unit → modem decision → confirm → optional photo of the new install.""" + return templates.TemplateResponse("admin/unit_swap.html", {"request": request}) + + @app.get("/modems", response_class=HTMLResponse) async def modems_page(request: Request): """Field modems management dashboard""" diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 83bd19b..3088741 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -262,6 +262,83 @@ async def get_project_locations_json( ] +@router.get("/locations-with-assignments") +async def get_locations_with_assignments( + project_id: str, + db: Session = Depends(get_db), + location_type: Optional[str] = Query(None), +): + """ + Locations + their currently-active assignment + current unit + paired modem + in one call. Used by the Unit Swap tool's location picker so a field tech + can see what's deployed where without N+1 round-trips. + + Empty locations come back with assignment/unit/modem all null. + Removed locations are always excluded — you don't swap onto a dead slot. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter( + MonitoringLocation.removed_at == None # noqa: E711 + ) + if location_type: + query = query.filter_by(location_type=location_type) + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() + + results = [] + for loc in locations: + assignment = db.query(UnitAssignment).filter( + and_( + UnitAssignment.location_id == loc.id, + UnitAssignment.assigned_until == None, # noqa: E711 + ) + ).first() + + unit_payload = None + modem_payload = None + if assignment: + unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() + if unit: + unit_payload = { + "id": unit.id, + "device_type": unit.device_type, + "unit_type": unit.unit_type, + "slm_model": unit.slm_model, + "deployed_with_modem_id": unit.deployed_with_modem_id, + } + if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by( + id=unit.deployed_with_modem_id, device_type="modem" + ).first() + if modem: + modem_payload = { + "id": modem.id, + "hardware_model": modem.hardware_model, + "ip_address": modem.ip_address, + "phone_number": modem.phone_number, + } + + results.append({ + "id": loc.id, + "name": loc.name, + "location_type": loc.location_type, + "description": loc.description, + "address": loc.address, + "coordinates": loc.coordinates, + "assignment": { + "id": assignment.id, + "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None, + "notes": assignment.notes, + } if assignment else None, + "unit": unit_payload, + "modem": modem_payload, + }) + + return results + + @router.post("/locations/create") async def create_location( project_id: str, @@ -1192,6 +1269,17 @@ async def swap_unit_on_location( new_value=f"swapped out → {unit_id}", notes=notes, ) + # Clear the outgoing unit's modem pairing so the bidirectional + # deployed_with_modem_id / deployed_with_unit_id back-reference + # doesn't orphan onto the unit that just left the field. + old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first() + if old_unit and old_unit.deployed_with_modem_id: + old_modem = db.query(RosterUnit).filter_by( + id=old_unit.deployed_with_modem_id, device_type="modem" + ).first() + if old_modem and old_modem.deployed_with_unit_id == current.unit_id: + old_modem.deployed_with_unit_id = None + old_unit.deployed_with_modem_id = None # Create new assignment new_assignment = UnitAssignment( diff --git a/templates/admin/unit_swap.html b/templates/admin/unit_swap.html new file mode 100644 index 0000000..fc85159 --- /dev/null +++ b/templates/admin/unit_swap.html @@ -0,0 +1,703 @@ +{% extends "base.html" %} + +{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+
+
+

Unit Swap

+

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

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

Unit Swap

+

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

+
+
+
+
From 6d37bd759e5773411c3a59af0a3155c92256d736 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 18 May 2026 04:46:23 +0000 Subject: [PATCH 2/6] feat(unit-swap): show benched candidates and clean stale modem pairings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `available-units` and `available-modems` now accept `include_benched=true` to also return units/modems with `deployed=False`. Default is False so the existing location-detail swap modal is unchanged. Each row carries a `deployed` boolean for badge rendering. The Unit Swap wizard fetches with the flag enabled — exactly the candidates a field tech pulls off the shelf. The /swap endpoint now flips the incoming unit (and modem) back to `deployed=True` when they came in benched, keeping the legacy roster flag consistent with the active-assignment signal. Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), break that stale back-reference before re-pairing. `locations-with-assignments` includes `modem.deployed` so the wizard can badge the current modem in the location card, the "Keep current modem" choice, the picker rows, and the review screen. Co-Authored-By: Claude Opus 4.7 --- backend/routers/project_locations.py | 68 +++++++++++++++++++++------- templates/admin/unit_swap.html | 57 ++++++++++++++++------- 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 3088741..1aa5e85 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -318,6 +318,7 @@ async def get_locations_with_assignments( "hardware_model": modem.hardware_model, "ip_address": modem.ip_address, "phone_number": modem.phone_number, + "deployed": bool(modem.deployed), } results.append({ @@ -1306,12 +1307,28 @@ async def swap_unit_on_location( modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() if not modem: raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found") + # Symmetric cleanup: if this modem still claims a previous partner + # (a different seismograph whose deployed_with_modem_id never got + # cleared in a past swap), break that stale link before re-pairing. + if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id: + prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first() + if prev_partner and prev_partner.deployed_with_modem_id == modem_id: + prev_partner.deployed_with_modem_id = None unit.deployed_with_modem_id = modem_id modem.deployed_with_unit_id = unit_id + # If the modem was on the bench, swapping it into the field puts it + # back in rotation. + if not modem.deployed: + modem.deployed = True else: # Clear modem pairing if not provided unit.deployed_with_modem_id = None + # If the incoming unit was benched, putting it in the field flips it + # back to deployed (so polling / dashboards see it as in rotation). + if not unit.deployed: + unit.deployed = True + db.commit() return JSONResponse({ @@ -1329,23 +1346,32 @@ async def swap_unit_on_location( async def get_available_modems( project_id: str, db: Session = Depends(get_db), + include_benched: bool = Query(False), ): """ - Get all deployed, non-retired modems for the modem assignment dropdown. + Get all non-retired modems for the modem assignment dropdown. + + By default only deployed (in-rotation) modems are returned, preserving + the existing behavior for callers like the location-detail swap modal. + Pass ``include_benched=true`` to also include benched modems + (``RosterUnit.deployed == False``) — useful when picking a modem to + pull off the bench for a field swap. Each row's ``deployed`` flag is + returned so the UI can badge benched candidates. """ - modems = db.query(RosterUnit).filter( - and_( - RosterUnit.device_type == "modem", - RosterUnit.deployed == True, - RosterUnit.retired == False, - ) - ).order_by(RosterUnit.id).all() + filters = [ + RosterUnit.device_type == "modem", + RosterUnit.retired == False, + ] + if not include_benched: + filters.append(RosterUnit.deployed == True) + modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all() return [ { "id": m.id, "hardware_model": m.hardware_model, "ip_address": m.ip_address, + "deployed": bool(m.deployed), } for m in modems ] @@ -1356,22 +1382,31 @@ async def get_available_units( project_id: str, location_type: str = Query(...), db: Session = Depends(get_db), + include_benched: bool = Query(False), ): """ Get list of available units for assignment to a location. Filters by device type matching the location type. + + By default only deployed (in-rotation) units are returned, preserving + the existing location-detail swap-modal behavior. Pass + ``include_benched=true`` to also include benched units + (``RosterUnit.deployed == False``) — exactly the candidates you'd + pull off the bench for a field swap. Each row carries a ``deployed`` + flag so the UI can badge benched picks. """ # Determine required device type required_device_type = "slm" if location_type == "sound" else "seismograph" - # Get all units of the required type that are deployed and not retired - all_units = db.query(RosterUnit).filter( - and_( - RosterUnit.device_type == required_device_type, - RosterUnit.deployed == True, - RosterUnit.retired == False, - ) - ).all() + # Get all units of the required type that aren't retired (and optionally + # exclude benched units). + filters = [ + RosterUnit.device_type == required_device_type, + RosterUnit.retired == False, + ] + if not include_benched: + filters.append(RosterUnit.deployed == True) + all_units = db.query(RosterUnit).filter(and_(*filters)).all() # Filter out units that already have active assignments (active = assigned_until IS NULL) assigned_unit_ids = db.query(UnitAssignment.unit_id).filter( @@ -1385,6 +1420,7 @@ async def get_available_units( "device_type": unit.device_type, "location": unit.address or unit.location, "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type, + "deployed": bool(unit.deployed), } for unit in all_units if unit.id not in assigned_unit_ids diff --git a/templates/admin/unit_swap.html b/templates/admin/unit_swap.html index fc85159..b7505ad 100644 --- a/templates/admin/unit_swap.html +++ b/templates/admin/unit_swap.html @@ -232,6 +232,12 @@ function _esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function _badge(deployed) { + return deployed + ? 'deployed' + : 'benched'; +} + function swapGoToStep(n) { _swap.step = n; for (let i = 1; i <= 5; i++) { @@ -339,7 +345,10 @@ function _swapRenderLocations(locations) { ? `
${_esc(unit.id)}${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}
` : `
Empty — first assign
`; const modemLine = modem - ? `
+ modem ${_esc(modem.id)}
` + ? `
+ + modem ${_esc(modem.id)} + ${_badge(modem.deployed)} +
` : ''; // Pass index for cleaner attribute escaping return ``; @@ -428,11 +441,14 @@ function _swapInitModemStep() { const question = document.getElementById('swap-modem-question'); if (current) { - question.textContent = `Modem currently at this location: ${current.id}`; + question.textContent = 'Modem at this location'; choices.innerHTML = ` `; @@ -553,16 +573,21 @@ function swapAfterModem() { document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)'; document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id; - const oldModem = _swap.location.modem ? _swap.location.modem.id : '(none)'; - document.getElementById('swap-review-old-modem').textContent = oldModem; - - let newModem = '(none)'; - if (_swap.modem_action === 'keep' && _swap.location.modem) { - newModem = _swap.location.modem.id + ' (kept)'; - } else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) { - newModem = _swap.new_modem.id; + const oldModemEl = document.getElementById('swap-review-old-modem'); + if (_swap.location.modem) { + oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`; + } else { + oldModemEl.textContent = '(none)'; + } + + const newModemEl = document.getElementById('swap-review-new-modem'); + if (_swap.modem_action === 'keep' && _swap.location.modem) { + newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} (kept) ${_badge(_swap.location.modem.deployed)}`; + } else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) { + newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`; + } else { + newModemEl.textContent = '(none)'; } - document.getElementById('swap-review-new-modem').textContent = newModem; swapGoToStep(5); } From 472c25372ddf9f6434baeb8c8a989ce1e8c9f53d Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 18 May 2026 06:32:11 +0000 Subject: [PATCH 3/6] feat(unit-detail): editable deployment timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each assignment row in the timeline now gets an inline edit (pencil) that opens a modal with `assigned_at`, `assigned_until`, and notes. Save calls the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) calls the existing `DELETE`. Open-ended checkbox clears `assigned_until` and the endpoint flips status back to "active". Adds an "+ Add deployment record" button at the top of the timeline for backfilling historical windows when orphan events sit outside any assignment. Modal: project → location → assigned_at → assigned_until (optional open-ended) → notes. Backend: the `/locations/{loc}/assign` endpoint now accepts an `assigned_at` form field and a closed-window assignment. The previous blanket "location already has an active assignment" check is replaced with same-location overlap detection — closed historical windows that don't overlap an existing assignment are accepted (which is exactly the backfill case). After any save/delete the timeline reloads and the SFM-events list re-fetches so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window. Co-Authored-By: Claude Opus 4.7 --- backend/routers/project_locations.py | 80 ++++-- templates/unit_detail.html | 392 ++++++++++++++++++++++++++- 2 files changed, 451 insertions(+), 21 deletions(-) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 1aa5e85..3382dfc 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -723,6 +723,19 @@ async def assign_unit_to_location( ): """ Assign a unit to a monitoring location. + + Accepts form fields: + - unit_id — required + - assigned_at — optional ISO datetime; defaults to now. Set this + when backfilling a historical deployment whose + events landed in the orphan bucket. + - assigned_until — optional ISO datetime; absent = open-ended / + active. + - notes — optional free text + + Refuses only when the *new window would overlap* an existing active + open-ended assignment at the same location. Closed historical windows + that don't overlap are allowed (and required for orphan-event backfill). """ location = db.query(MonitoringLocation).filter_by( id=location_id, @@ -748,23 +761,55 @@ async def assign_unit_to_location( detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", ) - # Check if location already has an active assignment (active = assigned_until IS NULL) - existing_assignment = db.query(UnitAssignment).filter( - and_( - UnitAssignment.location_id == location_id, - UnitAssignment.assigned_until == None, - ) - ).first() - - if existing_assignment: - raise HTTPException( - status_code=400, - detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.", - ) - - # Create new assignment + # Parse dates. + assigned_at_str = form_data.get("assigned_at") assigned_until_str = form_data.get("assigned_until") - assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None + try: + assigned_at = ( + datetime.fromisoformat(assigned_at_str) + if assigned_at_str else datetime.utcnow() + ) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, detail=f"Invalid assigned_at: {assigned_at_str!r}" + ) + try: + assigned_until = ( + datetime.fromisoformat(assigned_until_str) + if assigned_until_str else None + ) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, detail=f"Invalid assigned_until: {assigned_until_str!r}" + ) + if assigned_until is not None and assigned_until <= assigned_at: + raise HTTPException( + status_code=400, detail="assigned_until must be after assigned_at.", + ) + + # Reject only if the new window overlaps an existing assignment at the + # SAME location. Closed historical windows that sit before the current + # active assignment are fine — that's the backfill case. + new_end_for_overlap = assigned_until or datetime.utcnow() + existing = db.query(UnitAssignment).filter( + UnitAssignment.location_id == location_id + ).all() + for other in existing: + other_start = other.assigned_at + other_end = other.assigned_until or datetime.utcnow() + if assigned_at < other_end and new_end_for_overlap > other_start: + other_window = ( + f"{other.assigned_at:%Y-%m-%d}" + + (f" → {other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present") + ) + raise HTTPException( + status_code=400, + detail=( + f"New window overlaps an existing assignment at this " + f"location ({other.unit_id} {other_window}). Use swap or " + f"edit that record instead." + ), + ) assignment = UnitAssignment( id=str(uuid.uuid4()), @@ -772,8 +817,9 @@ async def assign_unit_to_location( location_id=location_id, project_id=project_id, device_type=unit.device_type, + assigned_at=assigned_at, assigned_until=assigned_until, - status="active", + status="active" if assigned_until is None else "completed", notes=form_data.get("notes"), ) diff --git a/templates/unit_detail.html b/templates/unit_detail.html index c2eb60f..35f7d8f 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -281,11 +281,16 @@
-
+

Deployment Timeline

- +
+ + +
+ + + + +