From e05f2189c41ad1326e96eaa605f39ee328ed9477 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 16 May 2026 03:40:24 +0000 Subject: [PATCH] feat(deployments): field-capture endpoint for pending deployments (commit 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_.. 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 --- backend/main.py | 3 + backend/migrate_add_pending_deployments.py | 75 ++++ backend/models.py | 55 +++ backend/routers/pending_deployments.py | 408 +++++++++++++++++++++ 4 files changed, 541 insertions(+) create mode 100644 backend/migrate_add_pending_deployments.py create mode 100644 backend/routers/pending_deployments.py diff --git a/backend/main.py b/backend/main.py index aca1b1f..7accdab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -112,6 +112,9 @@ app.include_router(admin_modules.router) from backend.routers import deployment_history app.include_router(deployment_history.router) +from backend.routers import pending_deployments +app.include_router(pending_deployments.router) + # Projects system routers app.include_router(projects.router) app.include_router(project_locations.router) diff --git a/backend/migrate_add_pending_deployments.py b/backend/migrate_add_pending_deployments.py new file mode 100644 index 0000000..d4a767e --- /dev/null +++ b/backend/migrate_add_pending_deployments.py @@ -0,0 +1,75 @@ +""" +Migration: add `pending_deployments` table. + +Stores "I just installed this seismograph" captures from the field. +A pending deployment is the prospective form of a UnitAssignment — +captured at install time (photo + coords + maybe a free-text note), +classified later (project + location chosen at a desk). + +Once classified, a real UnitAssignment is created, the pending row's +status flips to "assigned", and resulting_assignment_id points at the +new assignment for audit. + +Idempotent — safe to re-run. Non-destructive — adds only. + +Run with: + docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py +""" + +import os +import sqlite3 + +DB_PATH = "./data/seismo_fleet.db" + + +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() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS pending_deployments ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL, + captured_at DATETIME NOT NULL, + coordinates TEXT, + operator_note TEXT, + photo_filename TEXT, + status TEXT NOT NULL DEFAULT 'awaiting', + promoted_at DATETIME, + resulting_assignment_id TEXT, + cancelled_at DATETIME, + cancelled_reason TEXT, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + updated_at DATETIME NOT NULL DEFAULT (datetime('now')) + ) + """) + print(" Table 'pending_deployments' ready.") + + # Indexes — operators will query by status (hopper list) and by + # unit_id (per-unit detail page → "is there a pending capture?"). + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_pending_deployments_status + ON pending_deployments (status) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_pending_deployments_unit_id + ON pending_deployments (unit_id) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_pending_deployments_captured_at + ON pending_deployments (captured_at) + """) + print(" Indexes ready.") + + conn.commit() + conn.close() + + +if __name__ == "__main__": + print("Running migration: add pending_deployments table") + migrate_database() + print("Done.") diff --git a/backend/models.py b/backend/models.py index 380656b..3ba1e11 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/pending_deployments.py b/backend/routers/pending_deployments.py new file mode 100644 index 0000000..06ae3c8 --- /dev/null +++ b/backend/routers/pending_deployments.py @@ -0,0 +1,408 @@ +""" +Pending deployments — field-captured "I just installed this seismograph" +records waiting to be classified into a project + location. + +Routes: + POST /api/deployments/capture — capture a new pending deployment + GET /api/deployments/pending — list awaiting captures + GET /api/deployments/pending/{id} — single capture detail + POST /api/deployments/pending/{id}/promote — classify → create UnitAssignment + POST /api/deployments/pending/{id}/cancel — abandon + +See backend/models.py PendingDeployment docstring for the full lifecycle. + +Seismograph-only for v1; capture refuses if unit_id is anything else. +""" + +from __future__ import annotations + +import shutil +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import ( + PendingDeployment, + RosterUnit, + Project, + MonitoringLocation, + UnitAssignment, + UnitHistory, +) +from backend.routers.photos import extract_exif_data + +router = APIRouter(prefix="/api/deployments", tags=["pending-deployments"]) + +PHOTOS_BASE_DIR = Path("data/photos") +_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"} + + +def _record_history( + db: Session, + unit_id: str, + change_type: str, + *, + old_value: Optional[str] = None, + new_value: Optional[str] = None, + notes: Optional[str] = None, + source: str = "manual", +) -> None: + """Mirror of project_locations._record_assignment_history — kept local + so this router doesn't depend on a project_locations import cycle.""" + db.add(UnitHistory( + unit_id=unit_id, + change_type=change_type, + field_name="pending_deployment", + old_value=old_value, + new_value=new_value, + changed_at=datetime.utcnow(), + source=source, + notes=notes, + )) + + +@router.post("/capture") +async def capture_deployment( + unit_id: str = Form(...), + operator_note: str = Form(""), + captured_at_iso: str = Form(""), + photo: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Field-capture endpoint. + + Multipart form: + unit_id — seismograph being deployed + operator_note — optional free-text site memo + captured_at_iso — optional override of the capture timestamp + (default: photo's EXIF DateTimeOriginal, or now) + photo — install photo (EXIF GPS extracted if present) + + Refuses if unit_id isn't a seismograph (SLM deployments don't follow + the same field-install pattern). + """ + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail=f"Unit {unit_id!r} not found.") + if unit.device_type != "seismograph": + raise HTTPException( + status_code=400, + detail=f"Pending deployments are for seismographs only " + f"(this unit is {unit.device_type}).", + ) + + # Validate + save the photo. + file_ext = Path(photo.filename or "photo.jpg").suffix.lower() + if file_ext not in _ALLOWED_PHOTO_EXTS: + raise HTTPException( + status_code=400, + detail=f"Invalid photo type {file_ext!r}. Allowed: {sorted(_ALLOWED_PHOTO_EXTS)}", + ) + + unit_photo_dir = PHOTOS_BASE_DIR / unit_id + unit_photo_dir.mkdir(parents=True, exist_ok=True) + + capture_id = str(uuid.uuid4()) + ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"install_{ts_str}_{capture_id[:8]}{file_ext}" + file_path = unit_photo_dir / filename + + try: + with open(file_path, "wb") as buf: + shutil.copyfileobj(photo.file, buf) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save photo: {e}") + + # Extract EXIF — best-effort. No EXIF / no GPS is fine; operator + # can fill coordinates manually later in the promote step. + metadata = extract_exif_data(file_path) + coords = metadata.get("coordinates") # "lat,lon" or None + photo_ts = metadata.get("timestamp") # datetime or None + + if captured_at_iso: + try: + captured_at = datetime.fromisoformat(captured_at_iso) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid captured_at_iso: {captured_at_iso!r}") + elif photo_ts: + captured_at = photo_ts + else: + captured_at = datetime.utcnow() + + pd = PendingDeployment( + id = capture_id, + unit_id = unit_id, + captured_at = captured_at, + coordinates = coords, + operator_note = (operator_note or "").strip() or None, + photo_filename = filename, + status = "awaiting", + ) + db.add(pd) + + _record_history( + db, unit_id=unit_id, + change_type="pending_deployment_captured", + new_value=f"awaiting classification @ {captured_at:%Y-%m-%d %H:%M}" + + (f" • {coords}" if coords else ""), + notes=(operator_note or None), + ) + + db.commit() + db.refresh(pd) + + return JSONResponse({ + "success": True, + "pending_deployment": _to_dict(pd, unit=unit), + "photo_url": f"/api/unit/{unit_id}/photo/{filename}", + "extracted_coords": coords, + "extracted_timestamp": photo_ts.isoformat() if photo_ts else None, + }) + + +@router.get("/pending") +def list_pending( + status: str = "awaiting", + db: Session = Depends(get_db), +): + """List pending deployments by status (default: awaiting classification).""" + rows = ( + db.query(PendingDeployment) + .filter_by(status=status) + .order_by(PendingDeployment.captured_at.desc()) + .all() + ) + # Bulk-resolve unit references in one query (avoid N+1). + unit_ids = {r.unit_id for r in rows} + units = {u.id: u for u in db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()} \ + if unit_ids else {} + return { + "count": len(rows), + "status": status, + "pending_deployments": [_to_dict(r, unit=units.get(r.unit_id)) for r in rows], + } + + +@router.get("/pending/{pending_id}") +def get_pending(pending_id: str, db: Session = Depends(get_db)): + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first() + return _to_dict(pd, unit=unit, detail=True) + + +@router.post("/pending/{pending_id}/promote") +async def promote_pending( + pending_id: str, + request: Request, + db: Session = Depends(get_db), +): + """Classify a pending deployment → create a UnitAssignment. + + Body JSON — one of two shapes: + + 1. Assign to existing location: + { + "location_id": "", + "notes": "" + } + + 2. Create a new location under (existing or new) project: + { + "project_id": "" | null, # null means create new + "project_name": "", + "project_type_id": "", + # required if creating new project + "location_name": "", + "use_captured_coords": true | false, # default true — write the + # pending's coordinates onto + # the new location + "notes": "" + } + + Status flips to "assigned"; resulting_assignment_id is populated. + """ + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + if pd.status != "awaiting": + raise HTTPException( + status_code=400, + detail=f"Pending deployment is {pd.status!r}, not awaiting — already classified?", + ) + + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body.") + + notes = (payload.get("notes") or "").strip() or None + + # Resolve / create the location. + location_id = payload.get("location_id") + if location_id: + location = db.query(MonitoringLocation).filter_by(id=location_id).first() + if not location: + raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.") + project_id = location.project_id + else: + # Create-new path. Need a project (existing or new). + project_id = payload.get("project_id") + if not project_id: + project_name = (payload.get("project_name") or "").strip() + project_type_id = (payload.get("project_type_id") or "").strip() + if not project_name: + raise HTTPException( + status_code=400, + detail="Either project_id, or project_name + project_type_id, required.", + ) + if not project_type_id: + raise HTTPException( + status_code=400, + detail="project_type_id required when creating a new project.", + ) + new_project = Project( + id=str(uuid.uuid4()), + name=project_name, + project_type_id=project_type_id, + status="active", + ) + db.add(new_project) + db.flush() + project_id = new_project.id + + loc_name = (payload.get("location_name") or "").strip() + if not loc_name: + raise HTTPException(status_code=400, detail="location_name required.") + use_coords = payload.get("use_captured_coords", True) + location = MonitoringLocation( + id=str(uuid.uuid4()), + project_id=project_id, + location_type="vibration", # seismographs only + name=loc_name, + coordinates=(pd.coordinates if use_coords else None), + ) + db.add(location) + db.flush() + + # Create the assignment. assigned_at = pending capture time (so + # events emitted after the install are correctly attributed back + # to this location). + assignment = UnitAssignment( + id=str(uuid.uuid4()), + unit_id=pd.unit_id, + location_id=location.id, + project_id=project_id, + device_type="seismograph", + assigned_at=pd.captured_at, + assigned_until=None, + status="active", + notes=notes, + source="manual", + ) + db.add(assignment) + db.flush() + + # Promote the pending row. + pd.status = "assigned" + pd.promoted_at = datetime.utcnow() + pd.resulting_assignment_id = assignment.id + pd.updated_at = datetime.utcnow() + + _record_history( + db, unit_id=pd.unit_id, + change_type="pending_deployment_promoted", + old_value="awaiting", + new_value=f"{location.name} (assignment {assignment.id[:8]})", + notes=notes, + ) + + db.commit() + db.refresh(pd) + db.refresh(assignment) + + return { + "success": True, + "assignment_id": assignment.id, + "location_id": location.id, + "project_id": project_id, + "promoted_at": pd.promoted_at.isoformat(), + } + + +@router.post("/pending/{pending_id}/cancel") +async def cancel_pending( + pending_id: str, + request: Request, + db: Session = Depends(get_db), +): + """Mark a pending deployment as cancelled (operator captured by mistake).""" + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + if pd.status != "awaiting": + raise HTTPException( + status_code=400, + detail=f"Pending deployment is {pd.status!r}, not awaiting.", + ) + + try: + payload = await request.json() + except Exception: + payload = {} + reason = (payload.get("reason") or "").strip() or None + + pd.status = "cancelled" + pd.cancelled_at = datetime.utcnow() + pd.cancelled_reason = reason + pd.updated_at = datetime.utcnow() + + _record_history( + db, unit_id=pd.unit_id, + change_type="pending_deployment_cancelled", + old_value="awaiting", + new_value="cancelled", + notes=reason, + ) + + db.commit() + return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict: + out = { + "id": pd.id, + "unit_id": pd.unit_id, + "captured_at": pd.captured_at.isoformat() if pd.captured_at else None, + "coordinates": pd.coordinates, + "operator_note": pd.operator_note, + "photo_filename": pd.photo_filename, + "photo_url": f"/api/unit/{pd.unit_id}/photo/{pd.photo_filename}" + if pd.photo_filename else None, + "status": pd.status, + "created_at": pd.created_at.isoformat() if pd.created_at else None, + } + if pd.status == "assigned": + out["promoted_at"] = pd.promoted_at.isoformat() if pd.promoted_at else None + out["resulting_assignment_id"] = pd.resulting_assignment_id + if pd.status == "cancelled": + out["cancelled_at"] = pd.cancelled_at.isoformat() if pd.cancelled_at else None + out["cancelled_reason"] = pd.cancelled_reason + + if unit: + out["unit"] = { + "id": unit.id, + "device_type": unit.device_type, + "note": unit.note, + "deployed": unit.deployed, + } + return out