merge v0.12.0 #51
@@ -112,6 +112,9 @@ app.include_router(admin_modules.router)
|
|||||||
from backend.routers import deployment_history
|
from backend.routers import deployment_history
|
||||||
app.include_router(deployment_history.router)
|
app.include_router(deployment_history.router)
|
||||||
|
|
||||||
|
from backend.routers import pending_deployments
|
||||||
|
app.include_router(pending_deployments.router)
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -644,3 +644,58 @@ class JobReservationUnit(Base):
|
|||||||
# Location identity
|
# Location identity
|
||||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
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)
|
||||||
|
|||||||
@@ -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": "<uuid>",
|
||||||
|
"notes": "<optional>"
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Create a new location under (existing or new) project:
|
||||||
|
{
|
||||||
|
"project_id": "<existing>" | null, # null means create new
|
||||||
|
"project_name": "<required if project_id is null>",
|
||||||
|
"project_type_id": "<existing project_type id, e.g. 'vibration_monitoring'>",
|
||||||
|
# required if creating new project
|
||||||
|
"location_name": "<required>",
|
||||||
|
"use_captured_coords": true | false, # default true — write the
|
||||||
|
# pending's coordinates onto
|
||||||
|
# the new location
|
||||||
|
"notes": "<optional>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user