style: Updated the overall UI of the provisining pages
This commit is contained in:
@@ -5,14 +5,22 @@ from enum import Enum
|
||||
|
||||
class BoardType(str, Enum):
|
||||
vs = "vs" # Vesper
|
||||
vp = "vp" # Vesper+
|
||||
vx = "vx" # VesperPro
|
||||
vp = "vp" # Vesper Plus
|
||||
vx = "vx" # Vesper Pro
|
||||
cb = "cb" # Chronos
|
||||
cp = "cp" # Chronos Pro
|
||||
am = "am" # Agnus Mini
|
||||
ab = "ab" # Agnus
|
||||
|
||||
|
||||
BOARD_TYPE_LABELS = {
|
||||
"vs": "Vesper",
|
||||
"vp": "Vesper+",
|
||||
"vx": "VesperPro",
|
||||
"vp": "Vesper Plus",
|
||||
"vx": "Vesper Pro",
|
||||
"cb": "Chronos",
|
||||
"cp": "Chronos Pro",
|
||||
"am": "Agnus Mini",
|
||||
"ab": "Agnus",
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +35,11 @@ class MfgStatus(str, Enum):
|
||||
|
||||
class BatchCreate(BaseModel):
|
||||
board_type: BoardType
|
||||
board_version: str = Field(..., pattern=r"^\d{2}$", description="2-digit zero-padded version, e.g. '01'")
|
||||
board_version: str = Field(
|
||||
...,
|
||||
pattern=r"^\d+(\.\d+)*$",
|
||||
description="SemVer-style version string, e.g. '1.0' or legacy '01'",
|
||||
)
|
||||
quantity: int = Field(..., ge=1, le=100)
|
||||
|
||||
|
||||
@@ -49,6 +61,7 @@ class DeviceInventoryItem(BaseModel):
|
||||
created_at: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
device_name: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceInventoryListResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
@@ -13,6 +13,7 @@ from manufacturing.models import (
|
||||
)
|
||||
from manufacturing import service
|
||||
from manufacturing import audit
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
||||
|
||||
@@ -129,6 +130,41 @@ async def assign_device(
|
||||
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,
|
||||
|
||||
@@ -46,6 +46,7 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
||||
created_at=created_str,
|
||||
owner=data.get("owner"),
|
||||
assigned_to=data.get("assigned_to"),
|
||||
device_name=data.get("device_name") or None,
|
||||
)
|
||||
|
||||
|
||||
@@ -217,6 +218,47 @@ def get_stats() -> ManufacturingStats:
|
||||
return ManufacturingStats(counts=counts, recent_activity=recent)
|
||||
|
||||
|
||||
PROTECTED_STATUSES = {"sold", "claimed"}
|
||||
|
||||
|
||||
def delete_device(sn: str, force: bool = False) -> None:
|
||||
"""Delete a device by serial number.
|
||||
|
||||
Raises PermissionError if the device is sold/claimed and force is not set.
|
||||
The frontend uses force=True only after the user confirms by typing the SN.
|
||||
"""
|
||||
db = get_db()
|
||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise NotFoundError("Device")
|
||||
|
||||
data = docs[0].to_dict() or {}
|
||||
status = data.get("mfg_status", "manufactured")
|
||||
|
||||
if status in PROTECTED_STATUSES and not force:
|
||||
raise PermissionError(
|
||||
f"Device {sn} has status '{status}' and cannot be deleted without explicit confirmation."
|
||||
)
|
||||
|
||||
docs[0].reference.delete()
|
||||
|
||||
|
||||
def delete_unprovisioned_devices() -> list[str]:
|
||||
"""Delete all devices with status 'manufactured' (never flashed/provisioned).
|
||||
|
||||
Returns the list of deleted serial numbers.
|
||||
"""
|
||||
db = get_db()
|
||||
docs = list(db.collection(COLLECTION).where("mfg_status", "==", "manufactured").stream())
|
||||
deleted = []
|
||||
for doc in docs:
|
||||
data = doc.to_dict() or {}
|
||||
sn = data.get("serial_number", "")
|
||||
doc.reference.delete()
|
||||
deleted.append(sn)
|
||||
return deleted
|
||||
|
||||
|
||||
def get_firmware_url(sn: str) -> str:
|
||||
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
|
||||
from firmware.service import get_latest
|
||||
|
||||
Reference in New Issue
Block a user