from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File from fastapi.responses import Response from fastapi.responses import RedirectResponse from typing import Optional from pydantic import BaseModel from auth.models import TokenPayload from auth.dependencies import require_permission from manufacturing.models import ( BatchCreate, BatchResponse, DeviceInventoryItem, DeviceInventoryListResponse, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, ) from manufacturing import service from manufacturing import audit from shared.exceptions import NotFoundError from shared.firebase import get_db as get_firestore class LifecycleEntryPatch(BaseModel): index: int date: Optional[str] = None note: Optional[str] = None class LifecycleEntryCreate(BaseModel): status_id: str date: Optional[str] = None note: Optional[str] = None VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"} VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"} # Bespoke UIDs are dynamic — we allow any non-empty slug that doesn't clash with # a standard hw_type name. The flash-asset upload endpoint checks this below. router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) @router.get("/stats", response_model=ManufacturingStats) def get_stats( _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): return service.get_stats() @router.get("/audit-log") async def get_audit_log( limit: int = Query(20, ge=1, le=100), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): entries = await audit.get_recent(limit=limit) return {"entries": entries} @router.post("/batch", response_model=BatchResponse, status_code=201) async def create_batch( body: BatchCreate, user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): result = service.create_batch(body) await audit.log_action( admin_user=user.email, action="batch_created", detail={ "batch_id": result.batch_id, "board_type": result.board_type, "board_version": result.board_version, "quantity": len(result.serial_numbers), }, ) return result @router.get("/devices", response_model=DeviceInventoryListResponse) def list_devices( status: Optional[str] = Query(None), hw_type: Optional[str] = Query(None), search: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): items = service.list_devices( status=status, hw_type=hw_type, search=search, limit=limit, offset=offset, ) return DeviceInventoryListResponse(devices=items, total=len(items)) @router.get("/devices/{sn}", response_model=DeviceInventoryItem) def get_device( sn: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): return service.get_device_by_sn(sn) @router.get("/customers/search") def search_customers( q: str = Query(""), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Search CRM customers by name, email, phone, organization, or tags.""" results = service.search_customers(q) return {"results": results} @router.get("/customers/{customer_id}") def get_customer( customer_id: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Get a single CRM customer by ID.""" db = get_firestore() doc = db.collection("crm_customers").document(customer_id).get() if not doc.exists: raise HTTPException(status_code=404, detail="Customer not found") data = doc.to_dict() or {} loc = data.get("location") or {} city = loc.get("city") if isinstance(loc, dict) else None return { "id": doc.id, "name": data.get("name") or "", "surname": data.get("surname") or "", "email": data.get("email") or "", "organization": data.get("organization") or "", "phone": data.get("phone") or "", "city": city or "", } @router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem) async def update_status( sn: str, body: DeviceStatusUpdate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): # Guard: claimed requires at least one user in user_list # (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually) if body.status.value == "claimed": db = get_firestore() docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if docs: data = docs[0].to_dict() or {} user_list = data.get("user_list", []) or [] if not user_list and not getattr(body, "force_claimed", False): raise HTTPException( status_code=400, detail="Cannot set status to 'claimed': device has no users in user_list. " "Assign a user first, then set to Claimed.", ) # Guard: sold requires a customer assigned if body.status.value == "sold": db = get_firestore() docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if docs: data = docs[0].to_dict() or {} if not data.get("customer_id"): raise HTTPException( status_code=400, detail="Cannot set status to 'sold' without an assigned customer. " "Use the 'Assign to Customer' action first.", ) result = service.update_device_status(sn, body, set_by=user.email) await audit.log_action( admin_user=user.email, action="status_updated", serial_number=sn, detail={"status": body.status.value, "note": body.note}, ) return result @router.patch("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem) async def patch_lifecycle_entry( sn: str, body: LifecycleEntryPatch, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): """Edit the date and/or note of a lifecycle history entry by index.""" db = get_firestore() docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if not docs: raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference data = docs[0].to_dict() or {} history = data.get("lifecycle_history") or [] if body.index < 0 or body.index >= len(history): raise HTTPException(status_code=400, detail="Invalid lifecycle entry index") if body.date is not None: history[body.index]["date"] = body.date if body.note is not None: history[body.index]["note"] = body.note doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item return _doc_to_inventory_item(doc_ref.get()) @router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201) async def create_lifecycle_entry( sn: str, body: LifecycleEntryCreate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): """Create a lifecycle history entry for a step that has no entry yet (on-the-fly).""" from datetime import datetime, timezone db = get_firestore() docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if not docs: raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference data = docs[0].to_dict() or {} history = data.get("lifecycle_history") or [] new_entry = { "status_id": body.status_id, "date": body.date or datetime.now(timezone.utc).isoformat(), "note": body.note, "set_by": user.email, } history.append(new_entry) doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item return _doc_to_inventory_item(doc_ref.get()) @router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem) async def delete_lifecycle_entry( sn: str, index: int, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): """Delete a lifecycle history entry by index. Cannot delete the entry for the current status.""" db = get_firestore() docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if not docs: raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference data = docs[0].to_dict() or {} history = data.get("lifecycle_history") or [] if index < 0 or index >= len(history): raise HTTPException(status_code=400, detail="Invalid lifecycle entry index") current_status = data.get("mfg_status", "") if history[index].get("status_id") == current_status: raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.") history.pop(index) doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item return _doc_to_inventory_item(doc_ref.get()) @router.get("/devices/{sn}/nvs.bin") async def download_nvs( sn: str, hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"), hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"), user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override) await audit.log_action( admin_user=user.email, action="device_flashed", serial_number=sn, ) return Response( content=binary, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'}, ) @router.post("/devices/{sn}/assign", response_model=DeviceInventoryItem) async def assign_device( sn: str, body: DeviceAssign, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): try: result = service.assign_device(sn, body) except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) await audit.log_action( admin_user=user.email, action="device_assigned", serial_number=sn, detail={"customer_id": body.customer_id}, ) return result @router.delete("/devices/{sn}", status_code=204) async def delete_device( sn: str, force: bool = Query(False, description="Required to delete sold/claimed devices"), user: TokenPayload = Depends(require_permission("manufacturing", "delete")), ): """Delete a device. Sold/claimed devices require force=true.""" try: service.delete_device(sn, force=force) except NotFoundError: raise HTTPException(status_code=404, detail="Device not found") except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) await audit.log_action( admin_user=user.email, action="device_deleted", serial_number=sn, detail={"force": force}, ) @router.delete("/devices", status_code=200) async def delete_unprovisioned( user: TokenPayload = Depends(require_permission("manufacturing", "delete")), ): """Delete all devices with status 'manufactured' (never provisioned).""" deleted = service.delete_unprovisioned_devices() await audit.log_action( admin_user=user.email, action="bulk_delete_unprovisioned", detail={"count": len(deleted), "serial_numbers": deleted}, ) return {"deleted": deleted, "count": len(deleted)} @router.get("/devices/{sn}/firmware.bin") def redirect_firmware( sn: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Redirect to the latest stable firmware binary for this device's hw_type. Resolves to GET /api/firmware/{hw_type}/stable/{version}/firmware.bin. """ url = service.get_firmware_url(sn) return RedirectResponse(url=url, status_code=302) # ───────────────────────────────────────────────────────────────────────────── # Flash assets — bootloader.bin and partitions.bin per hw_type # These are the binaries that must be flashed at fixed addresses during full # provisioning (0x1000 bootloader, 0x8000 partition table). # They are NOT flashed during OTA updates — only during initial provisioning. # Upload once per hw_type after each PlatformIO build that changes the layout. # ───────────────────────────────────────────────────────────────────────────── @router.post("/flash-assets/{hw_type}/{asset}", status_code=204) async def upload_flash_asset( hw_type: str, asset: str, file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): """Upload a bootloader.bin or partitions.bin for a given hw_type. These are build artifacts from PlatformIO (.pio/build/{env}/bootloader.bin and .pio/build/{env}/partitions.bin). Upload them once per hw_type after each PlatformIO build that changes the partition layout. """ # hw_type can be a standard board type OR a bespoke UID (any non-empty slug) if not hw_type or len(hw_type) > 128: raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.") if asset not in VALID_FLASH_ASSETS: raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}") data = await file.read() service.save_flash_asset(hw_type, asset, data) @router.get("/devices/{sn}/bootloader.bin") def download_bootloader( sn: str, hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Return the bootloader.bin for this device's hw_type (flashed at 0x1000).""" item = service.get_device_by_sn(sn) hw_type = hw_type_override or item.hw_type try: data = service.get_flash_asset(hw_type, "bootloader.bin") except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) return Response( content=data, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'}, ) @router.get("/devices/{sn}/partitions.bin") def download_partitions( sn: str, hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Return the partitions.bin for this device's hw_type (flashed at 0x8000).""" item = service.get_device_by_sn(sn) hw_type = hw_type_override or item.hw_type try: data = service.get_flash_asset(hw_type, "partitions.bin") except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) return Response( content=data, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'}, )