import/export devices
build-and-push / build (push) Failing after 17s

This commit is contained in:
2026-08-01 01:47:42 +05:00
parent 6fe7c69adb
commit a6d4fa1f6d
7 changed files with 501 additions and 9 deletions
+89 -7
View File
@@ -10,14 +10,23 @@ the RADIUS ``username``. One device touches three tables:
This router hides that fan-out behind mac_address + group + status.
"""
from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import DeviceCreate, DeviceEdit, DeviceOut
from ..schemas import (
DeviceCreate,
DeviceEdit,
DeviceImportError,
DeviceImportRequest,
DeviceImportResult,
DeviceOut,
)
router = APIRouter(prefix="/device", tags=["device"])
@@ -32,6 +41,20 @@ def _device_exists(db: Session, mac: str) -> bool:
return db.execute(stmt).first() is not None
def _stage_device(db: Session, dev: DeviceCreate) -> None:
"""Add a device's three rows (radcheck, radusergroup, customers) to the session.
Does not commit — the caller controls the transaction boundary.
"""
mac = dev.mac_address
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1))
db.add(Customer(
username=mac, mac_address=mac, status="paid",
name=dev.name, phone=dev.phone, device_alias=dev.alias,
))
@router.get("/", response_model=Page[DeviceOut])
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
"""List devices — MAC, group and status, joined from customers + radusergroup."""
@@ -78,12 +101,7 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
if _device_exists(db, mac):
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
db.add(Customer(
username=mac, mac_address=mac, status="paid",
name=payload.name, phone=payload.phone, device_alias=payload.alias,
))
_stage_device(db, payload)
db.commit()
return DeviceOut(
mac_address=mac, group=payload.group, status="paid",
@@ -91,6 +109,70 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
)
def _format_validation_error(exc: ValidationError) -> str:
"""Turn a pydantic ValidationError into a short, human message."""
parts = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
return "; ".join(parts)
@router.post("/import", response_model=DeviceImportResult)
def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)):
"""Bulk-import devices from parsed CSV rows.
Every row is validated with the same rules as ``/device/add`` (MAC/phone
normalization, required fields, group-exists, duplicate MAC — both against the
DB and within the file). Errors are collected per row rather than failing the
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
otherwise the valid rows are inserted best-effort (each in its own commit).
"""
errors: list[DeviceImportError] = []
valid: list[tuple[int, DeviceCreate]] = []
seen_macs: set[str] = set()
for idx, raw in enumerate(payload.devices, start=1):
try:
dev = DeviceCreate(**raw.model_dump())
except ValidationError as exc:
errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
continue
mac = dev.mac_address
if mac in seen_macs:
errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
continue
if not _group_exists(db, dev.group):
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found"))
continue
if _device_exists(db, mac):
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists"))
continue
seen_macs.add(mac)
valid.append((idx, dev))
created = 0
if not payload.dry_run:
for idx, dev in valid:
_stage_device(db, dev)
try:
db.commit()
created += 1
except IntegrityError:
db.rollback()
errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)"))
return DeviceImportResult(
total=len(payload.devices),
valid=len(valid),
created=created,
dry_run=payload.dry_run,
errors=sorted(errors, key=lambda e: e.row),
)
@router.post("/edit", response_model=DeviceOut)
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
"""Edit a device — any subset of group, status, name, phone, alias."""
+34
View File
@@ -209,6 +209,40 @@ class DeviceEdit(BaseModel):
return self
# ---------- device CSV import ----------
class DeviceImportRow(BaseModel):
"""A single CSV row — lenient so one bad row never 422s the whole batch.
Each row is re-validated with ``DeviceCreate`` inside the router so failures
are collected per-row instead of rejecting the entire request.
"""
mac_address: str | None = None
group: str | None = None
name: str | None = None
phone: str | None = None
alias: str | None = None
class DeviceImportRequest(BaseModel):
devices: list[DeviceImportRow]
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
class DeviceImportError(BaseModel):
row: int # 1-based index within the submitted rows
mac: str | None = None
detail: str
class DeviceImportResult(BaseModel):
total: int # rows submitted
valid: int # rows that passed validation
created: int # rows actually inserted (0 on dry_run)
dry_run: bool
errors: list[DeviceImportError]
# ---------- radusergroup ----------
class UserGroupBase(BaseModel):
username: str = Field(max_length=64)