feat(deployments): field-capture endpoint for pending deployments (commit 1)
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>
This commit is contained in:
@@ -644,3 +644,58 @@ class JobReservationUnit(Base):
|
||||
# Location identity
|
||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
||||
|
||||
|
||||
class PendingDeployment(Base):
|
||||
"""
|
||||
Field-captured "I just installed this seismograph" record waiting
|
||||
to be classified into a project + location.
|
||||
|
||||
Lifecycle:
|
||||
1. Operator captures from the /deploy mobile page — photo (EXIF
|
||||
GPS auto-extracted), optional free-text note. Row created
|
||||
with status="awaiting".
|
||||
2. Later, at a desk: operator picks a project + location (existing
|
||||
or new) and "promotes" the row. A real UnitAssignment is
|
||||
created, this row's status flips to "assigned", and
|
||||
resulting_assignment_id points at the new assignment.
|
||||
3. Mistakes / abandoned captures → status="cancelled" with a
|
||||
cancelled_reason for audit.
|
||||
|
||||
Events emitted by the unit before classification are NOT auto-
|
||||
attributed (no UnitAssignment exists yet). They land in the
|
||||
"unattributed" bucket on the unit's events tab. Once the pending
|
||||
deployment is 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 the same
|
||||
"field-install + verify call-home" pattern and are tracked
|
||||
elsewhere.
|
||||
"""
|
||||
__tablename__ = "pending_deployments"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit
|
||||
captured_at = Column(DateTime, nullable=False) # When the photo was taken
|
||||
coordinates = Column(String, nullable=True) # "lat,lon" from photo EXIF
|
||||
operator_note = Column(Text, nullable=True) # Free text — site memo
|
||||
|
||||
# Path under data/photos/{unit_id}/. Just the filename; the unit
|
||||
# context lives in unit_id.
|
||||
photo_filename = Column(String, nullable=True)
|
||||
|
||||
# Lifecycle.
|
||||
# "awaiting" — captured, not yet classified
|
||||
# "assigned" — promoted to a UnitAssignment
|
||||
# "cancelled" — operator marked it as a mistake / abandoned
|
||||
status = Column(String, nullable=False, default="awaiting", index=True)
|
||||
|
||||
promoted_at = Column(DateTime, nullable=True)
|
||||
resulting_assignment_id = Column(String, nullable=True) # FK to UnitAssignment when promoted
|
||||
|
||||
cancelled_at = Column(DateTime, nullable=True)
|
||||
cancelled_reason = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
Reference in New Issue
Block a user