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
+110 -38
View File
@@ -4,6 +4,17 @@ A FastAPI service exposing RESTful CRUD over the FreeRADIUS MySQL/MariaDB schema
(`customers`, `radcheck`, `radreply`, `radgroupreply`/vlans, `radusergroup`, `nas`,
plus read-only `radacct`, `radpostauth`, `nasreload`).
Two endpoints are **logical views** layered over that schema rather than raw tables:
- **Clients** (`/client`) — customer devices keyed by MAC, spanning `radcheck` +
`radusergroup` + `customers` (status), with human metadata (name/phone/alias) in
the standalone **`radadmin_clients`** table.
- **Devices** (`/device`) — the NAS/AP boxes seen in `radacct`, keyed by **AP MAC**
(from `calledstationid`), with a human alias in the standalone
**`radadmin_devices`** table.
The two `radadmin_*` tables hold only human labels — RADIUS never reads them.
## Stack
- **FastAPI** + **Uvicorn** (ASGI)
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver
@@ -57,8 +68,9 @@ Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open.
## UI integration guide
For a management portal the two primary resources are **Devices** (`/device`) and
**VLANs** (`/vlan`). Everything else is lower-level raw-table access.
For a management portal the primary resources are **Clients** (`/client`),
**Devices** (`/device`), and **VLANs** (`/vlan`). Everything else is lower-level
raw-table access.
**Base URL (staging):** `http://10.0.1.235:8000`
**Every request:** header `X-API-Key: <API_KEY>` (except `/health`).
@@ -66,7 +78,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an
### Response shapes
`GET /device/`**paginated** envelope:
`GET /client/`**paginated** envelope:
```json
{
@@ -80,13 +92,20 @@ For a management portal the two primary resources are **Devices** (`/device`) an
}
```
`GET /device/{mac}`, `POST /device/add`, `POST /device/edit` — a single device object:
`GET /client/{mac}`, `POST /client/add`, `POST /client/edit` — a single client object:
```json
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
```
`GET /device/`**plain array** of NAS devices (not paginated):
```json
[ { "ap_mac": "3C-52-A1-93-06-FC", "nasipaddress": "192.168.1.102",
"ssids": ["VSARLINK", "Guest"], "alias": "Lobby AP" } ]
```
`GET /vlan/`**plain array** (not paginated):
```json
@@ -102,7 +121,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an
Application errors (`400`, `401`, `404`, `409`) return a **string** detail:
```json
{ "detail": "Device 'AA-BB-CC-DD-EE-11' already exists" }
{ "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" }
```
Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail:
@@ -116,12 +135,15 @@ each entry's `msg` (and `loc`) for field-level messages.
### Typical portal flows
- **Provision a device:** `POST /device/add` with `mac_address`, `group` (an
- **Provision a client:** `POST /client/add` with `mac_address`, `group` (an
existing VLAN alias), `name`, `phone`, optional `alias`. Handle `400` (group
missing), `409` (MAC exists), `422` (bad MAC / missing name·phone).
- **Change plan state:** `POST /device/edit` `{mac_address, status}`.
- **Move to another VLAN:** `POST /device/edit` `{mac_address, group}`.
- **Change plan state:** `POST /client/edit` `{mac_address, status}`.
- **Move to another VLAN:** `POST /client/edit` `{mac_address, group}`.
- **Bulk import clients:** `POST /client/import` `{devices:[...], dry_run}` — see
the Clients section.
- **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`).
- **Label a NAS device:** `GET /device/` to list them, `PUT /device/{ip}` `{alias}`.
## Endpoints
@@ -141,7 +163,8 @@ per-resource filters:
| Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
| Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
| **VLANs** | `/vlan` | see below | — |
| **Devices** | `/device` | see below | — |
| **Clients** | `/client` | see below | — |
| **Devices** | `/device` | GET (list), PUT (alias) | — |
| User↔group | `/radusergroup` | GET, POST, PUT, DELETE | `username`, `groupname` |
| Accounting | `/radacct` | GET (read-only) | `username`, `nasipaddress`, `active` |
| Post-auth log | `/radpostauth` | GET (read-only) | `username`, `reply` |
@@ -169,37 +192,65 @@ and `Tunnel-Private-Group-Id=<vlanid>`.
- Renaming updates `radgroupreply.groupname` only; if you also map users to groups
in `radusergroup`, update those separately.
### Devices — `/device`
### Clients — `/client`
A device is a client identified by its MAC address (used as the RADIUS `username`).
One device spans three tables: `radcheck` (MAC = password), `radusergroup` (group
membership), and `customers` (status + human metadata). MAC input is normalized to
uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted.
A client is identified by its MAC address (used as the RADIUS `username`). One
client spans four tables: `radcheck` (MAC = password), `radusergroup` (group
membership), `customers` (status), and `radadmin_clients` (human metadata). MAC
input is normalized to uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and
lowercase are accepted.
The `customers` table also carries **human-only metadata that RADIUS never reads**:
`name`, `phone`, and `device_alias`. These are collected at device creation.
The **`radadmin_clients`** table carries **human-only metadata that RADIUS never
reads** — `name`, `phone`, and `alias`, keyed by `mac_address`. These are collected
at client creation.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` |
| Get one device | `GET /device/{mac_address}` |
| Add a device | `POST /device/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` |
| Edit a device | `POST /device/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` |
| Delete a device | `DELETE /device/{mac_address}` (removes all 3 rows) |
| List clients | `GET /client/``{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` |
| Get one client | `GET /client/{mac_address}` |
| Add a client | `POST /client/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` |
| Edit a client | `POST /client/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` |
| Import clients | `POST /client/import` body `{"devices":[{...}], "dry_run":true}` (bulk CSV import) |
| Delete a client | `DELETE /client/{mac_address}` (removes all rows) |
On **add**, `name` and `phone` are **required**; `alias` is **optional**. They are
stored in `customers.name`, `customers.phone`, `customers.device_alias` and returned
on every device response (`alias` mirrors the `device_alias` column).
stored in `radadmin_clients` (`name`, `phone`, `alias`) and returned on every client
response.
- **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a
`radusergroup` row (`priority 1`), and a `customers` row (`status = paid`). The
`group` must already exist in `radgroupreply` or you get `400`.
`radusergroup` row (`priority 1`), a `customers` row (`status = paid`), and a
`radadmin_clients` row (name/phone/alias). The `group` must already exist in
`radgroupreply` or you get `400`.
- **Edit** accepts any subset of `group`, `status`, `name`, `phone`, `alias` (at
least one required); omitted fields are left unchanged. A new `group` must exist
in `radgroupreply` (`400` otherwise). `status` must be one of `new` / `paid` /
`unpaid`. In the DB the group is stored in the `radusergroup.groupname` column and
`alias` in `customers.device_alias`.
- Adding a device whose MAC already exists → `409`.
`unpaid`. In the DB the group is stored in the `radusergroup.groupname` column,
`status` in `customers.status`, and `name`/`phone`/`alias` in `radadmin_clients`.
- Adding a client whose MAC already exists → `409`.
- **Import** validates each row with the same rules as `add`; with `dry_run:true`
nothing is written and it returns `{total, valid, created:0, dry_run, errors:[{row,
mac, detail}]}` for a preview. With `dry_run:false` the valid rows are inserted
best-effort (invalid rows reported, not fatal). The JSON field is `devices` (rows).
### Devices — `/device`
A device here is a **NAS/AP box**, keyed by its **AP MAC** — not a customer MAC
(those are Clients). The list is derived from `radacct.calledstationid`
(`<AP-MAC>:<SSID>`): rows are grouped by the AP MAC, so one physical AP is a single
entry even when it broadcasts several SSIDs. Each entry aggregates every `ssid` seen
and the NAS IP(s) it reported from. An editable human `alias` is stored in the
standalone **`radadmin_devices`** table (keyed by AP MAC), which FreeRADIUS never reads.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``[{ap_mac, nasipaddress, ssids:[...], alias}]` |
| Set/clear alias | `PUT /device/{ap_mac}` body `{"alias":"Lobby AP"}` (`alias:null` clears) |
- List is a **plain array** (not paginated), ordered by AP MAC.
- `nasipaddress` is the NAS IP(s) the AP reports from (comma-joined if more than one);
`ssids` is the full list of SSIDs seen for that AP MAC.
- `PUT` upserts the `radadmin_devices` row for that AP MAC — no row need pre-exist;
the MAC is normalized to uppercase-hyphen form.
## Examples
@@ -231,27 +282,44 @@ curl http://10.0.1.235:8000/vlan/55 \
-X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# List devices
curl http://10.0.1.235:8000/device/ \
# List clients
curl http://10.0.1.235:8000/client/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Add a device (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/device/add \
# Add a client (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/client/add \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}' -s | jq
# Edit a device (any subset of group/status/name/phone/alias)
curl http://10.0.1.235:8000/device/edit \
# Edit a client (any subset of group/status/name/phone/alias)
curl http://10.0.1.235:8000/client/edit \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"mac_address":"14-99-3E-74-CB-7F","status":"unpaid","alias":"Living Room TV"}' -s | jq
# Delete a device
curl http://10.0.1.235:8000/device/14-99-3E-74-CB-7F \
# Bulk-import clients (dry-run — preview only, nothing written)
curl http://10.0.1.235:8000/client/import \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"dry_run":true,"devices":[{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali","phone":"7712345"}]}' -s | jq
# Delete a client
curl http://10.0.1.235:8000/client/14-99-3E-74-CB-7F \
-X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# List NAS devices (IPs seen in radacct, with alias/AP-MAC/SSID)
curl http://10.0.1.235:8000/device/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Set a device alias by AP MAC (PUT; send {"alias":null} to clear)
curl http://10.0.1.235:8000/device/3C-52-A1-93-06-FC \
-X PUT \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"alias":"Lobby AP"}' -s | jq
# Create a customer
curl http://10.0.1.235:8000/customers \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
@@ -282,7 +350,7 @@ curl http://10.0.1.235:8000/health -s | jq
| 204 | Deleted (no content) |
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
| 401 | Missing/invalid `X-API-Key` |
| 404 | Row / device / VLAN not found |
| 404 | Row / client / device / VLAN not found |
| 409 | Duplicate / integrity conflict |
| 422 | Request body failed validation |
@@ -297,7 +365,11 @@ app/
errors.py APIError + JSON handler
crud.py generic list/get/create/update/delete helpers
pagination.py Page envelope + limit/offset dependency
models.py SQLAlchemy models (one per table)
models.py SQLAlchemy models (incl. radadmin_clients, radadmin_devices)
schemas.py Pydantic request/response models
routers/ one module per resource
client.py Clients — MAC view over radcheck+radusergroup+customers
device.py Devices — AP MACs from radacct + radadmin_devices alias
vlan.py VLANs — view over radgroupreply
... customers, nas, radcheck/reply, radgroup*, radacct, ...
```
+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 ----------