refactor code, new db tables, show AP info on UI
build-and-push / build (push) Failing after 34s

This commit is contained in:
2026-08-01 02:44:50 +05:00
parent a6d4fa1f6d
commit db140912f5
13 changed files with 1103 additions and 660 deletions
+2
View File
@@ -7,6 +7,7 @@ from .config import get_settings
from .database import engine
from .errors import APIError, api_error_handler
from .routers import (
client,
customers,
device,
nas,
@@ -69,6 +70,7 @@ for module in (
radgroupreply,
radusergroup,
vlan,
client,
device,
radacct,
radpostauth,
+26 -1
View File
@@ -13,9 +13,34 @@ class Customer(Base):
username: Mapped[str] = mapped_column(String(64), nullable=False)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
status: Mapped[str] = mapped_column(String(10), nullable=False, default="new")
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class RadadminClient(Base):
"""Human-only client metadata (name/phone/alias), keyed by MAC. RADIUS ignores this."""
__tablename__ = "radadmin_clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
device_alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class RadadminDevice(Base):
"""Human alias for a NAS/AP box, keyed by its AP MAC. RADIUS never reads this."""
__tablename__ = "radadmin_devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
+239
View File
@@ -0,0 +1,239 @@
"""Client management — a logical view spanning three tables.
A "client" is identified by its MAC address, which is used verbatim as the RADIUS
``username``. One client touches three tables:
radcheck username = MAC, Cleartext-Password := MAC (auth)
radusergroup username = MAC, groupname = <group> (VLAN/group membership)
customers username = MAC, mac_address = MAC, status (billing/metadata)
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, RadadminClient, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import (
ClientCreate,
ClientEdit,
ClientImportError,
ClientImportRequest,
ClientImportResult,
ClientOut,
)
router = APIRouter(prefix="/client", tags=["client"])
def _group_exists(db: Session, group: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1)
return db.execute(stmt).first() is not None
def _client_exists(db: Session, mac: str) -> bool:
stmt = select(Customer.id).where(Customer.username == mac).limit(1)
return db.execute(stmt).first() is not None
def _stage_client(db: Session, cli: ClientCreate) -> None:
"""Add a client's four rows to the session — radcheck + radusergroup (RADIUS),
customers (status), and radadmin_clients (name/phone/alias metadata).
Does not commit — the caller controls the transaction boundary.
"""
mac = cli.mac_address
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1))
db.add(Customer(username=mac, mac_address=mac, status="paid"))
db.add(RadadminClient(mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias))
@router.get("/", response_model=Page[ClientOut])
def list_clients(page: PageParams = Depends(), db: Session = Depends(get_db)):
"""List clients — MAC, group and status, joined from customers + radusergroup."""
base = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
)
total = db.execute(select(func.count()).select_from(Customer)).scalar_one()
rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all()
items = [
ClientOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al)
for mac, gn, st, nm, ph, al in rows
]
return Page(total=int(total), limit=page.limit, offset=page.offset, items=items)
@router.get("/{mac_address}", response_model=ClientOut)
def get_client(mac_address: str, db: Session = Depends(get_db)):
mac = mac_address.strip().upper().replace(":", "-")
stmt = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
if row is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
return ClientOut(
mac_address=row[0], group=row[1], status=row[2],
name=row[3], phone=row[4], alias=row[5],
)
@router.post("/add", response_model=ClientOut, status_code=201)
def add_client(payload: ClientCreate, db: Session = Depends(get_db)):
"""Register a client: create its radcheck, radusergroup and customer rows."""
mac = payload.mac_address
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first")
if _client_exists(db, mac):
raise APIError(status_code=409, detail=f"Client '{mac}' already exists")
_stage_client(db, payload)
db.commit()
return ClientOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
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=ClientImportResult)
def import_clients(payload: ClientImportRequest, db: Session = Depends(get_db)):
"""Bulk-import clients from parsed CSV rows.
Every row is validated with the same rules as ``/client/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[ClientImportError] = []
valid: list[tuple[int, ClientCreate]] = []
seen_macs: set[str] = set()
for idx, raw in enumerate(payload.devices, start=1):
try:
cli = ClientCreate(**raw.model_dump())
except ValidationError as exc:
errors.append(ClientImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
continue
mac = cli.mac_address
if mac in seen_macs:
errors.append(ClientImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
continue
if not _group_exists(db, cli.group):
errors.append(ClientImportError(row=idx, mac=mac, detail=f"Group '{cli.group}' not found"))
continue
if _client_exists(db, mac):
errors.append(ClientImportError(row=idx, mac=mac, detail=f"Client '{mac}' already exists"))
continue
seen_macs.add(mac)
valid.append((idx, cli))
created = 0
if not payload.dry_run:
for idx, cli in valid:
_stage_client(db, cli)
try:
db.commit()
created += 1
except IntegrityError:
db.rollback()
errors.append(ClientImportError(row=idx, mac=cli.mac_address, detail="Insert failed (integrity error)"))
return ClientImportResult(
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=ClientOut)
def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
"""Edit a client — any subset of group, status, name, phone, alias."""
mac = payload.mac_address
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
if customer is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
if payload.status is not None:
customer.status = payload.status
if payload.name is not None or payload.phone is not None or payload.alias is not None:
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
if meta is None:
meta = RadadminClient(mac_address=mac)
db.add(meta)
if payload.name is not None:
meta.name = payload.name
if payload.phone is not None:
meta.phone = payload.phone
if payload.alias is not None:
meta.alias = payload.alias
db.commit()
group = db.execute(
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
).scalar_one_or_none()
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
return ClientOut(
mac_address=mac, group=group, status=customer.status,
name=meta.name if meta else None,
phone=meta.phone if meta else None,
alias=meta.alias if meta else None,
)
@router.delete("/{mac_address}", status_code=204)
def delete_client(mac_address: str, db: Session = Depends(get_db)):
"""Remove a client from radcheck, radusergroup and customers."""
mac = mac_address.strip().upper().replace(":", "-")
if not _client_exists(db, mac):
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
db.execute(delete(RadadminClient).where(RadadminClient.mac_address == mac))
db.commit()
+75 -200
View File
@@ -1,221 +1,96 @@
"""Device management — a logical view spanning three tables.
"""Device management — the NAS/AP boxes seen in accounting.
A "device" is a client identified by its MAC address, which is used verbatim as
the RADIUS ``username``. One device touches three tables:
radcheck username = MAC, Cleartext-Password := MAC (auth)
radusergroup username = MAC, groupname = <group> (VLAN/group membership)
customers username = MAC, mac_address = MAC, status (billing/metadata)
This router hides that fan-out behind mac_address + group + status.
Unlike *clients* (customer MACs), a "device" here is a piece of network gear
identified by its **AP MAC** — the part before ':' in ``radacct.calledstationid``
(`<AP-MAC>:<SSID>`). Rows are grouped by that MAC, so one physical AP is a single
row even when it broadcasts several SSIDs (all SSIDs, and the NAS IP(s) it reports
from, are aggregated). An editable ``alias`` lives in the standalone
``radadmin_devices`` table, keyed by AP MAC — FreeRADIUS never reads it.
"""
from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select
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,
DeviceImportError,
DeviceImportRequest,
DeviceImportResult,
DeviceOut,
)
from ..models import RadadminDevice, RadAcct
from ..schemas import DeviceAliasUpdate, DeviceOut
router = APIRouter(prefix="/device", tags=["device"])
def _group_exists(db: Session, group: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1)
return db.execute(stmt).first() is not None
def _norm_mac(mac: str) -> str:
"""Canonical AP MAC — uppercase, hyphen-separated (matches client MAC form)."""
return mac.strip().upper().replace(":", "-")
def _device_exists(db: Session, mac: str) -> bool:
stmt = select(Customer.id).where(Customer.username == mac).limit(1)
return db.execute(stmt).first() is not None
def _split_called(called: str | None) -> tuple[str | None, str | None]:
"""radacct.calledstationid is ``<AP-MAC>:<SSID>`` — split into (mac, ssid).
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.
The MAC is normalized to uppercase-hyphen; the SSID is left verbatim.
"""
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,
))
if not called:
return None, None
if ":" in called:
mac, ssid = called.split(":", 1)
return (_norm_mac(mac) or None), (ssid or None)
return _norm_mac(called) or None, None
@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."""
base = select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
Customer.name, Customer.phone, Customer.device_alias,
).outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
total = db.execute(select(func.count()).select_from(Customer)).scalar_one()
rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all()
items = [
DeviceOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al)
for mac, gn, st, nm, ph, al in rows
def _aliases(db: Session) -> dict[str, str | None]:
rows = db.execute(select(RadadminDevice.mac_address, RadadminDevice.alias)).all()
return {mac: alias for mac, alias in rows}
def _devices(db: Session) -> dict[str, dict]:
"""Aggregate radacct into {ap_mac: {ips, ssids}} over distinct station pairs."""
pairs = db.execute(
select(RadAcct.nasipaddress, RadAcct.calledstationid).distinct()
).all()
devices: dict[str, dict] = {}
for ip, called in pairs:
mac, ssid = _split_called(called)
if not mac:
continue
d = devices.setdefault(mac, {"ips": set(), "ssids": set()})
if ip:
d["ips"].add(ip)
if ssid:
d["ssids"].add(ssid)
return devices
def _device_out(ap_mac: str, agg: dict | None, alias: str | None) -> DeviceOut:
agg = agg or {"ips": set(), "ssids": set()}
return DeviceOut(
ap_mac=ap_mac,
nasipaddress=", ".join(sorted(agg["ips"])) or None,
ssids=sorted(agg["ssids"]),
alias=alias,
)
@router.get("/", response_model=list[DeviceOut])
def list_devices(db: Session = Depends(get_db)):
"""List NAS/AP devices — one row per AP MAC, SSIDs and NAS IPs aggregated."""
devices = _devices(db)
aliases = _aliases(db)
return [
_device_out(mac, devices[mac], aliases.get(mac))
for mac in sorted(devices)
]
return Page(total=int(total), limit=page.limit, offset=page.offset, items=items)
@router.get("/{mac_address}", response_model=DeviceOut)
def get_device(mac_address: str, db: Session = Depends(get_db)):
mac = mac_address.strip().upper().replace(":", "-")
stmt = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
Customer.name, Customer.phone, Customer.device_alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
if row is None:
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
return DeviceOut(
mac_address=row[0], group=row[1], status=row[2],
name=row[3], phone=row[4], alias=row[5],
)
@router.post("/add", response_model=DeviceOut, status_code=201)
def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
"""Register a device: create its radcheck, radusergroup and customer rows."""
mac = payload.mac_address
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first")
if _device_exists(db, mac):
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
_stage_device(db, payload)
db.commit()
return DeviceOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
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."""
mac = payload.mac_address
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
if customer is None:
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
if payload.status is not None:
customer.status = payload.status
if payload.name is not None:
customer.name = payload.name
if payload.phone is not None:
customer.phone = payload.phone
if payload.alias is not None:
customer.device_alias = payload.alias
db.commit()
group = db.execute(
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
@router.put("/{ap_mac}", response_model=DeviceOut)
def set_alias(ap_mac: str, payload: DeviceAliasUpdate, db: Session = Depends(get_db)):
"""Set (or clear) the alias for an AP MAC — upserts into radadmin_devices."""
mac = _norm_mac(ap_mac)
row = db.execute(
select(RadadminDevice).where(RadadminDevice.mac_address == mac)
).scalar_one_or_none()
return DeviceOut(
mac_address=mac, group=group, status=customer.status,
name=customer.name, phone=customer.phone, alias=customer.device_alias,
)
@router.delete("/{mac_address}", status_code=204)
def delete_device(mac_address: str, db: Session = Depends(get_db)):
"""Remove a device from radcheck, radusergroup and customers."""
mac = mac_address.strip().upper().replace(":", "-")
if not _device_exists(db, mac):
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
if row is None:
db.add(RadadminDevice(mac_address=mac, alias=payload.alias))
else:
row.alias = payload.alias
db.commit()
return _device_out(mac, _devices(db).get(mac), payload.alias)
+27 -19
View File
@@ -10,13 +10,12 @@ class ORMModel(BaseModel):
# ---------- customers ----------
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
# only the RADIUS-adjacent identity + billing status.
class CustomerBase(BaseModel):
username: str = Field(max_length=64)
mac_address: str = Field(max_length=17)
status: Literal["new", "paid", "unpaid"] = "new"
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
device_alias: str | None = Field(default=None, max_length=64)
class CustomerCreate(CustomerBase):
@@ -27,9 +26,6 @@ class CustomerUpdate(BaseModel):
username: str | None = Field(default=None, max_length=64)
mac_address: str | None = Field(default=None, max_length=17)
status: Literal["new", "paid", "unpaid"] | None = None
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
device_alias: str | None = Field(default=None, max_length=64)
class CustomerOut(ORMModel, CustomerBase):
@@ -130,7 +126,7 @@ class VlanEdit(BaseModel):
alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)")
# ---------- devices (logical view over radcheck + radusergroup + customers) ----------
# ---------- clients (logical view over radcheck + radusergroup + customers) ----------
_MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$"
@@ -157,16 +153,16 @@ def _normalize_phone(raw: str) -> str:
raise ValueError(_PHONE_ERROR)
class DeviceOut(BaseModel):
class ClientOut(BaseModel):
mac_address: str
group: str | None = None # radusergroup.groupname
status: str | None = None # customers.status
name: str | None = None # customers.name (human metadata)
phone: str | None = None # customers.phone (human metadata)
alias: str | None = None # customers.device_alias (human metadata)
alias: str | None = None # radadmin_clients.alias (human metadata)
class DeviceCreate(BaseModel):
class ClientCreate(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=128, description="Customer name (metadata, ignored by RADIUS)")
@@ -184,7 +180,7 @@ class DeviceCreate(BaseModel):
return _normalize_phone(v)
class DeviceEdit(BaseModel):
class ClientEdit(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str | None = Field(default=None, max_length=64)
status: Literal["new", "paid", "unpaid"] | None = None
@@ -209,11 +205,11 @@ class DeviceEdit(BaseModel):
return self
# ---------- device CSV import ----------
class DeviceImportRow(BaseModel):
# ---------- client CSV import ----------
class ClientImportRow(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
Each row is re-validated with ``ClientCreate`` inside the router so failures
are collected per-row instead of rejecting the entire request.
"""
@@ -224,23 +220,35 @@ class DeviceImportRow(BaseModel):
alias: str | None = None
class DeviceImportRequest(BaseModel):
devices: list[DeviceImportRow]
class ClientImportRequest(BaseModel):
devices: list[ClientImportRow]
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
class DeviceImportError(BaseModel):
class ClientImportError(BaseModel):
row: int # 1-based index within the submitted rows
mac: str | None = None
detail: str
class DeviceImportResult(BaseModel):
class ClientImportResult(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]
errors: list[ClientImportError]
# ---------- devices (NAS/AP boxes seen in radacct, aliased via radadmin_devices) ----------
class DeviceOut(BaseModel):
ap_mac: str # calledstationid before ':' — the device key
nasipaddress: str | None = None # NAS IP(s) this AP reports from (comma-joined)
ssids: list[str] = [] # every SSID seen for this AP MAC
alias: str | None = None # radadmin_devices.alias (human label)
class DeviceAliasUpdate(BaseModel):
alias: str | None = Field(default=None, max_length=64)
# ---------- radusergroup ----------