@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user