diff --git a/backend/main.py b/backend/main.py index 8247664..0e9a739 100644 --- a/backend/main.py +++ b/backend/main.py @@ -291,6 +291,14 @@ async def pending_deployments_page(request: Request): return templates.TemplateResponse("admin/pending_deployments.html", {"request": request}) +@app.get("/tools/unit-swap", response_class=HTMLResponse) +async def unit_swap_page(request: Request): + """Mobile-first wizard for swapping a vibration unit (and optionally its + modem) at a monitoring location. Pick project → location → incoming + unit → modem decision → confirm → optional photo of the new install.""" + return templates.TemplateResponse("admin/unit_swap.html", {"request": request}) + + @app.get("/modems", response_class=HTMLResponse) async def modems_page(request: Request): """Field modems management dashboard""" diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 83bd19b..3088741 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -262,6 +262,83 @@ async def get_project_locations_json( ] +@router.get("/locations-with-assignments") +async def get_locations_with_assignments( + project_id: str, + db: Session = Depends(get_db), + location_type: Optional[str] = Query(None), +): + """ + Locations + their currently-active assignment + current unit + paired modem + in one call. Used by the Unit Swap tool's location picker so a field tech + can see what's deployed where without N+1 round-trips. + + Empty locations come back with assignment/unit/modem all null. + Removed locations are always excluded — you don't swap onto a dead slot. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter( + MonitoringLocation.removed_at == None # noqa: E711 + ) + if location_type: + query = query.filter_by(location_type=location_type) + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() + + results = [] + for loc in locations: + assignment = db.query(UnitAssignment).filter( + and_( + UnitAssignment.location_id == loc.id, + UnitAssignment.assigned_until == None, # noqa: E711 + ) + ).first() + + unit_payload = None + modem_payload = None + if assignment: + unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() + if unit: + unit_payload = { + "id": unit.id, + "device_type": unit.device_type, + "unit_type": unit.unit_type, + "slm_model": unit.slm_model, + "deployed_with_modem_id": unit.deployed_with_modem_id, + } + if unit.deployed_with_modem_id: + modem = db.query(RosterUnit).filter_by( + id=unit.deployed_with_modem_id, device_type="modem" + ).first() + if modem: + modem_payload = { + "id": modem.id, + "hardware_model": modem.hardware_model, + "ip_address": modem.ip_address, + "phone_number": modem.phone_number, + } + + results.append({ + "id": loc.id, + "name": loc.name, + "location_type": loc.location_type, + "description": loc.description, + "address": loc.address, + "coordinates": loc.coordinates, + "assignment": { + "id": assignment.id, + "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None, + "notes": assignment.notes, + } if assignment else None, + "unit": unit_payload, + "modem": modem_payload, + }) + + return results + + @router.post("/locations/create") async def create_location( project_id: str, @@ -1192,6 +1269,17 @@ async def swap_unit_on_location( new_value=f"swapped out → {unit_id}", notes=notes, ) + # Clear the outgoing unit's modem pairing so the bidirectional + # deployed_with_modem_id / deployed_with_unit_id back-reference + # doesn't orphan onto the unit that just left the field. + old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first() + if old_unit and old_unit.deployed_with_modem_id: + old_modem = db.query(RosterUnit).filter_by( + id=old_unit.deployed_with_modem_id, device_type="modem" + ).first() + if old_modem and old_modem.deployed_with_unit_id == current.unit_id: + old_modem.deployed_with_unit_id = None + old_unit.deployed_with_modem_id = None # Create new assignment new_assignment = UnitAssignment( diff --git a/templates/admin/unit_swap.html b/templates/admin/unit_swap.html new file mode 100644 index 0000000..fc85159 --- /dev/null +++ b/templates/admin/unit_swap.html @@ -0,0 +1,703 @@ +{% extends "base.html" %} + +{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+ Swap a vibration unit (and modem) at a monitoring location. +
+Project
+—
+Which location?
+ +Swapping at
+—
++ Out: — +
+Incoming
+—
+Modem?
+ +—
+Add a photo of the new install?
+Optional. EXIF GPS will populate the unit's coordinates.
+ ++ Field-swap a unit (and modem) at a vibration location. Pick project → location → incoming unit → confirm. Optional photo of the new install. +
+