""" 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