restructure: move backend into backend/

This commit is contained in:
2026-07-31 23:53:50 +05:00
parent 9074e55a13
commit 79bc04aaa7
29 changed files with 0 additions and 0 deletions
View File
+72
View File
@@ -0,0 +1,72 @@
"""Router factories for the AVP-style tables (radcheck/radreply/radgroup*).
They share the same shape — an id plus (username|groupname, attribute, op, value).
Two factories cover the two owner shapes: user-owned (radcheck, radreply) and
group-owned (radgroupcheck, radgroupreply).
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import Base, get_db
from ..pagination import Page, PageParams
def build_user_attr_router(*, model, prefix, tag, out_schema, create_schema, update_schema) -> APIRouter:
router = APIRouter(prefix=prefix, tags=[tag])
@router.get("", response_model=Page[out_schema])
def list_items(
page: PageParams = Depends(),
username: str | None = None,
attribute: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "attribute": attribute}
total = crud.count_rows(db, model, filters)
rows = crud.list_rows(db, model, limit=page.limit, offset=page.offset,
filters=filters, order_by=model.id.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
_register_item_routes(router, model, out_schema, create_schema, update_schema)
return router
def build_group_attr_router(*, model, prefix, tag, out_schema, create_schema, update_schema) -> APIRouter:
router = APIRouter(prefix=prefix, tags=[tag])
@router.get("", response_model=Page[out_schema])
def list_items(
page: PageParams = Depends(),
groupname: str | None = None,
attribute: str | None = None,
db: Session = Depends(get_db),
):
filters = {"groupname": groupname, "attribute": attribute}
total = crud.count_rows(db, model, filters)
rows = crud.list_rows(db, model, limit=page.limit, offset=page.offset,
filters=filters, order_by=model.id.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
_register_item_routes(router, model, out_schema, create_schema, update_schema)
return router
def _register_item_routes(router: APIRouter, model: type[Base], out_schema, create_schema, update_schema) -> None:
@router.get("/{item_id}", response_model=out_schema)
def get_item(item_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, model, item_id)
@router.post("", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)):
return crud.create_row(db, model, payload.model_dump())
@router.put("/{item_id}", response_model=out_schema)
def update_item(item_id: int, payload: update_schema, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, model, item_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{item_id}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, model, item_id)
crud.delete_row(db, obj)
+47
View File
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import Customer
from ..pagination import Page, PageParams
from ..schemas import CustomerCreate, CustomerOut, CustomerUpdate
router = APIRouter(prefix="/customers", tags=["customers"])
@router.get("", response_model=Page[CustomerOut])
def list_customers(
page: PageParams = Depends(),
username: str | None = None,
mac_address: str | None = None,
status: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "mac_address": mac_address, "status": status}
total = crud.count_rows(db, Customer, filters)
rows = crud.list_rows(db, Customer, limit=page.limit, offset=page.offset, filters=filters,
order_by=Customer.id.desc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, Customer, customer_id)
@router.post("", response_model=CustomerOut, status_code=201)
def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)):
return crud.create_row(db, Customer, payload.model_dump())
@router.put("/{customer_id}", response_model=CustomerOut)
def update_customer(customer_id: int, payload: CustomerUpdate, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Customer, customer_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{customer_id}", status_code=204)
def delete_customer(customer_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Customer, customer_id)
crud.delete_row(db, obj)
+139
View File
@@ -0,0 +1,139 @@
"""Device management — a logical view spanning three tables.
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.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import DeviceCreate, DeviceEdit, DeviceOut
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 _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
@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
]
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")
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
db.add(Customer(
username=mac, mac_address=mac, status="paid",
name=payload.name, phone=payload.phone, device_alias=payload.alias,
))
db.commit()
return DeviceOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
@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)
).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))
db.commit()
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import Nas
from ..pagination import Page, PageParams
from ..schemas import NasCreate, NasOut, NasUpdate
router = APIRouter(prefix="/nas", tags=["nas"])
@router.get("", response_model=Page[NasOut])
def list_nas(
page: PageParams = Depends(),
nasname: str | None = None,
shortname: str | None = None,
db: Session = Depends(get_db),
):
filters = {"nasname": nasname, "shortname": shortname}
total = crud.count_rows(db, Nas, filters)
rows = crud.list_rows(db, Nas, limit=page.limit, offset=page.offset, filters=filters,
order_by=Nas.id.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{nas_id}", response_model=NasOut)
def get_nas(nas_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, Nas, nas_id)
@router.post("", response_model=NasOut, status_code=201)
def create_nas(payload: NasCreate, db: Session = Depends(get_db)):
return crud.create_row(db, Nas, payload.model_dump())
@router.put("/{nas_id}", response_model=NasOut)
def update_nas(nas_id: int, payload: NasUpdate, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Nas, nas_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{nas_id}", status_code=204)
def delete_nas(nas_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Nas, nas_id)
crud.delete_row(db, obj)
+24
View File
@@ -0,0 +1,24 @@
"""nasreload — last reload time per NAS. Read-only."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import NasReload
from ..pagination import Page, PageParams
from ..schemas import NasReloadOut
router = APIRouter(prefix="/nasreload", tags=["nasreload (read-only)"])
@router.get("", response_model=Page[NasReloadOut])
def list_nasreload(page: PageParams = Depends(), db: Session = Depends(get_db)):
total = crud.count_rows(db, NasReload)
rows = crud.list_rows(db, NasReload, limit=page.limit, offset=page.offset,
order_by=NasReload.reloadtime.desc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{nasipaddress}", response_model=NasReloadOut)
def get_nasreload(nasipaddress: str, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, NasReload, nasipaddress, pk_field="nasipaddress")
+41
View File
@@ -0,0 +1,41 @@
"""radacct — accounting data. Read-only: FreeRADIUS owns writes to this table."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import RadAcct
from ..pagination import Page, PageParams
from ..schemas import RadAcctOut
router = APIRouter(prefix="/radacct", tags=["radacct (read-only)"])
@router.get("", response_model=Page[RadAcctOut])
def list_acct(
page: PageParams = Depends(),
username: str | None = None,
nasipaddress: str | None = None,
active: bool | None = Query(default=None, description="true = sessions with no stop time"),
db: Session = Depends(get_db),
):
filters = {"username": username, "nasipaddress": nasipaddress}
total = crud.count_rows(db, RadAcct, filters)
stmt = select(RadAcct)
for field, value in filters.items():
if value is not None:
stmt = stmt.where(getattr(RadAcct, field) == value)
if active is True:
stmt = stmt.where(RadAcct.acctstoptime.is_(None))
elif active is False:
stmt = stmt.where(RadAcct.acctstoptime.is_not(None))
stmt = stmt.order_by(RadAcct.acctstarttime.desc()).limit(page.limit).offset(page.offset)
rows = list(db.execute(stmt).scalars().all())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{radacctid}", response_model=RadAcctOut)
def get_acct(radacctid: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, RadAcct, radacctid, pk_field="radacctid")
+12
View File
@@ -0,0 +1,12 @@
from ..models import RadCheck
from ..schemas import UserAttrCreate, UserAttrOut, UserAttrUpdate
from ._attr_factory import build_user_attr_router
router = build_user_attr_router(
model=RadCheck,
prefix="/radcheck",
tag="radcheck",
out_schema=UserAttrOut,
create_schema=UserAttrCreate,
update_schema=UserAttrUpdate,
)
+12
View File
@@ -0,0 +1,12 @@
from ..models import RadGroupCheck
from ..schemas import GroupAttrCreate, GroupAttrOut, GroupAttrUpdate
from ._attr_factory import build_group_attr_router
router = build_group_attr_router(
model=RadGroupCheck,
prefix="/radgroupcheck",
tag="radgroupcheck",
out_schema=GroupAttrOut,
create_schema=GroupAttrCreate,
update_schema=GroupAttrUpdate,
)
+17
View File
@@ -0,0 +1,17 @@
"""radgroupreply — raw group reply attributes (all attributes, not just VLAN).
The VLAN-specific abstraction lives in ``routers/vlan.py`` (``/vlan``); this router
stays as generic CRUD over the raw table for any other reply attributes.
"""
from ..models import RadGroupReply
from ..schemas import GroupAttrCreate, GroupAttrOut, GroupAttrUpdate
from ._attr_factory import build_group_attr_router
router = build_group_attr_router(
model=RadGroupReply,
prefix="/radgroupreply",
tag="radgroupreply",
out_schema=GroupAttrOut,
create_schema=GroupAttrCreate,
update_schema=GroupAttrUpdate,
)
+30
View File
@@ -0,0 +1,30 @@
"""radpostauth — authentication log. Read-only."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import RadPostAuth
from ..pagination import Page, PageParams
from ..schemas import RadPostAuthOut
router = APIRouter(prefix="/radpostauth", tags=["radpostauth (read-only)"])
@router.get("", response_model=Page[RadPostAuthOut])
def list_postauth(
page: PageParams = Depends(),
username: str | None = None,
reply: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "reply": reply}
total = crud.count_rows(db, RadPostAuth, filters)
rows = crud.list_rows(db, RadPostAuth, limit=page.limit, offset=page.offset,
filters=filters, order_by=RadPostAuth.id.desc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{item_id}", response_model=RadPostAuthOut)
def get_postauth(item_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, RadPostAuth, item_id)
+12
View File
@@ -0,0 +1,12 @@
from ..models import RadReply
from ..schemas import UserAttrCreate, UserAttrOut, UserAttrUpdate
from ._attr_factory import build_user_attr_router
router = build_user_attr_router(
model=RadReply,
prefix="/radreply",
tag="radreply",
out_schema=UserAttrOut,
create_schema=UserAttrCreate,
update_schema=UserAttrUpdate,
)
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import UserGroupCreate, UserGroupOut, UserGroupUpdate
router = APIRouter(prefix="/radusergroup", tags=["radusergroup"])
@router.get("", response_model=Page[UserGroupOut])
def list_usergroups(
page: PageParams = Depends(),
username: str | None = None,
groupname: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "groupname": groupname}
total = crud.count_rows(db, RadUserGroup, filters)
rows = crud.list_rows(db, RadUserGroup, limit=page.limit, offset=page.offset,
filters=filters, order_by=RadUserGroup.priority.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{item_id}", response_model=UserGroupOut)
def get_usergroup(item_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, RadUserGroup, item_id)
@router.post("", response_model=UserGroupOut, status_code=201)
def create_usergroup(payload: UserGroupCreate, db: Session = Depends(get_db)):
return crud.create_row(db, RadUserGroup, payload.model_dump())
@router.put("/{item_id}", response_model=UserGroupOut)
def update_usergroup(item_id: int, payload: UserGroupUpdate, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, RadUserGroup, item_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{item_id}", status_code=204)
def delete_usergroup(item_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, RadUserGroup, item_id)
crud.delete_row(db, obj)
+112
View File
@@ -0,0 +1,112 @@
"""VLAN management — a logical view over the ``radgroupreply`` table.
A single "VLAN" is stored as three reply rows sharing one ``groupname`` (the alias):
groupname | attribute | op | value
----------+--------------------------+----+----------
staff | Tunnel-Type | = | VLAN
staff | Tunnel-Medium-Type | = | IEEE-802
staff | Tunnel-Private-Group-Id | = | 55 <- the VLAN ID
This router hides that shape behind alias + vlanid.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import RadGroupReply
from ..schemas import VlanCreate, VlanEdit, VlanOut
router = APIRouter(prefix="/vlan", tags=["vlan"])
ID_ATTR = "Tunnel-Private-Group-Id"
OP = "="
# (attribute, value) pairs written for every VLAN. `None` value = the VLAN ID.
VLAN_ROWS: list[tuple[str, str | None]] = [
("Tunnel-Type", "VLAN"),
("Tunnel-Medium-Type", "IEEE-802"),
(ID_ATTR, None),
]
def _groupnames_for_vlanid(db: Session, vlanid: int) -> list[str]:
stmt = select(RadGroupReply.groupname).where(
RadGroupReply.attribute == ID_ATTR,
RadGroupReply.value == str(vlanid),
)
return list(db.execute(stmt).scalars().all())
def _alias_exists(db: Session, alias: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == alias).limit(1)
return db.execute(stmt).first() is not None
@router.get("/", response_model=list[VlanOut])
def list_vlans(db: Session = Depends(get_db)):
"""List VLANs — one entry per group, showing only alias + VLAN ID."""
stmt = (
select(RadGroupReply.groupname, RadGroupReply.value)
.where(RadGroupReply.attribute == ID_ATTR)
.order_by(RadGroupReply.value.asc())
)
rows = db.execute(stmt).all()
return [VlanOut(alias=gn, vlanid=int(val)) for gn, val in rows]
@router.post("/add", response_model=VlanOut, status_code=201)
def add_vlan(payload: VlanCreate, db: Session = Depends(get_db)):
"""Create a VLAN — inserts the 3 radgroupreply rows atomically."""
# reject duplicate VLAN ID or duplicate alias
if _groupnames_for_vlanid(db, payload.vlanid):
raise APIError(status_code=409, detail=f"VLAN ID {payload.vlanid} already exists")
if _alias_exists(db, payload.alias):
raise APIError(status_code=409, detail=f"Alias '{payload.alias}' already exists")
for attribute, value in VLAN_ROWS:
db.add(RadGroupReply(
groupname=payload.alias,
attribute=attribute,
op=OP,
value=str(payload.vlanid) if value is None else value,
))
db.commit()
return VlanOut(alias=payload.alias, vlanid=payload.vlanid)
@router.post("/edit", response_model=VlanOut)
def edit_vlan_alias(payload: VlanEdit, db: Session = Depends(get_db)):
"""Rename a VLAN's alias (groupname), identified by its VLAN ID."""
groupnames = _groupnames_for_vlanid(db, payload.vlanid)
if not groupnames:
raise APIError(status_code=404, detail=f"VLAN ID {payload.vlanid} not found")
current = groupnames[0]
if payload.alias == current:
return VlanOut(alias=current, vlanid=payload.vlanid)
# new alias must not collide with a different group
if _alias_exists(db, payload.alias):
raise APIError(status_code=409, detail=f"Alias '{payload.alias}' already exists")
db.execute(
update(RadGroupReply)
.where(RadGroupReply.groupname == current)
.values(groupname=payload.alias)
)
db.commit()
return VlanOut(alias=payload.alias, vlanid=payload.vlanid)
@router.delete("/{vlanid}", status_code=204)
def delete_vlan(vlanid: int, db: Session = Depends(get_db)):
"""Delete a VLAN — removes every radgroupreply row for the matching group(s)."""
groupnames = _groupnames_for_vlanid(db, vlanid)
if not groupnames:
raise APIError(status_code=404, detail=f"VLAN ID {vlanid} not found")
db.execute(delete(RadGroupReply).where(RadGroupReply.groupname.in_(groupnames)))
db.commit()