This commit is contained in:
@@ -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
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ----------
|
||||
|
||||
Reference in New Issue
Block a user