The legacy RosterUnit.deployed flag drives heartbeat polling and
benched-vs-deployed roster filters. Three workflows ended an
assignment without flipping it, so the outgoing unit kept being
polled and showed up as "deployed" forever:
- swap endpoint (POST /locations/{loc}/swap)
- unassign endpoint (POST /assignments/{aid}/unassign)
- promote-pending endpoint (POST /deployments/pending/{id}/promote)
All three now: close the previous active assignment, break the
outgoing unit's modem pairing (both directions), and set
`deployed = False` on the outgoing unit. Unassign and swap also
clear the modem's back-reference.
The promote-pending path additionally handles the case where the
target location already has an active assignment — that previously
silently created two active assignments at the same location. Now
the old one is closed (assigned_until = pending capture time, status
= completed), the old unit is benched and unpaired, and an
"assignment_swapped" history row is written. Incoming unit gets
`deployed = True` if it was on the bench.
Verified live: triggered a swap via the existing endpoint and saw
the outgoing unit flip True → False while the incoming flipped
False → True. Test mutations rolled back.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UI for the pending-deployment workflow (commits 2 + 3 from the plan,
landed together since commit 1 already shipped the full backend).
New surfaces
- /deploy — mobile-first 3-step wizard. Pick unit → take photo (uses
<input capture="environment"> so it opens the phone camera) → add
optional note + submit. EXIF GPS auto-extracted on the server.
Success page shows the captured coords + links to either "Deploy
another" or "View pending hopper." Whole flow is meant to take
under 90 seconds on site.
- /tools/pending-deployments — the hopper. Filter pills: Awaiting /
Assigned / Cancelled. Each card shows photo thumbnail, unit serial
link, captured-at timestamp, coordinates, operator note, and
status-appropriate actions.
- Classify modal on the hopper: two modes — "Assign to existing
location" (project + location pickers, scoped to vibration_monitoring)
or "Create new location" (with new-or-existing project, plus a
"use captured coords" checkbox that writes the pending row's coords
onto the new location). Calls /pending/{id}/promote on submit.
- Cancel button uses prompt() for the optional reason → POSTs to
/pending/{id}/cancel.
Backend additions
- GET /api/deployments/seismograph-picker — JSON list of non-retired
seismograph units for the /deploy unit picker. Annotates each unit
with has_pending so the picker can flag units that already have a
pending capture waiting.
Discovery
- New "Field Deploy" + "Pending Deployments" cards on /tools.
- Dashboard banner: auto-shows when there are awaiting captures,
polled every 30s. Hides when count drops to 0. Click → /tools/
pending-deployments.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Field-install workflow needs to be fast: arrive on site, snap a photo
of the seismograph in place, leave. Project / location classification
happens later at a desk. This adds the data model + capture endpoint
for that workflow.
Data model
- New PendingDeployment table. Lifecycle: awaiting → assigned (when
promoted to a real UnitAssignment) or → cancelled (operator's
mistake). Photos are filesystem files under data/photos/{unit_id}/
with the filename stored on the row.
- Migration: backend/migrate_add_pending_deployments.py (idempotent).
Endpoints
- POST /api/deployments/capture — multipart upload (unit_id, photo,
optional note). Refuses non-seismographs. Extracts EXIF GPS
(cribbing extract_exif_data from routers/photos.py) and stores
the captured "lat,lon" on the row. Saves the photo under
data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>.
Returns the new pending_deployment_id + extracted coords + photo
URL for the client to render confirmation.
- GET /api/deployments/pending — list by status (default awaiting)
- GET /api/deployments/pending/{id} — single row detail
- POST /api/deployments/pending/{id}/promote — classify → create
UnitAssignment. Body accepts two shapes: assign-to-existing-location
OR create-new-location (with new-or-existing project). Sets
status=assigned, resulting_assignment_id, promoted_at.
- POST /api/deployments/pending/{id}/cancel — abandon with optional reason.
All four routes write UnitHistory audit rows
(pending_deployment_captured / _promoted / _cancelled).
Events from a unit with an unclassified pending deployment land in the
unit's "Unattributed" events bucket as usual. Once promoted, the new
UnitAssignment's window retroactively attributes them — same mechanism
the metadata-backfill tool uses.
Seismograph-only for v1. SLM deployments don't follow this pattern
and are tracked elsewhere. Capture refuses non-seismograph unit_ids
with HTTP 400.
UI (commits 2 + 3) lands next.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>