add more device fields

This commit is contained in:
2026-07-31 21:25:41 +05:00
parent eccda2b26d
commit 2c2d569a70
4 changed files with 74 additions and 23 deletions
+19 -11
View File
@@ -105,24 +105,32 @@ and `Tunnel-Private-Group-Id=<vlanid>`.
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). MAC input is normalized to uppercase,
hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted.
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.
The `customers` table also carries **human-only metadata that RADIUS never reads**:
`name`, `phone`, and `device_alias`. These are collected at device creation.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``{total,limit,offset,items:[{mac_address,group,status}]}` |
| 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"}` |
| Edit a device | `POST /device/edit` body `{"mac_address":"...","group":"...","status":"..."}` |
| 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) |
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).
- **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`.
- **Edit** accepts `group` and/or `status` — supply either or both (at least one
required). 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.
- **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`.
## Examples
@@ -159,11 +167,11 @@ curl http://10.0.1.235:8000/vlan/55 \
curl http://10.0.1.235:8000/device/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Add a device (MAC + existing group)
# Add a device (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/device/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"}' -s | jq
-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 one field: groupname and/or status)
curl http://10.0.1.235:8000/device/edit \
+3
View File
@@ -13,6 +13,9 @@ 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")
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)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
+35 -10
View File
@@ -35,12 +35,16 @@ def _device_exists(db: Session, mac: str) -> bool:
@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).outerjoin(
RadUserGroup, RadUserGroup.username == Customer.username
)
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) for mac, gn, st in rows]
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)
@@ -48,14 +52,20 @@ def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
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)
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])
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)
@@ -70,14 +80,20 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
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"))
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")
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's group and/or status. Any single field may be supplied."""
"""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:
@@ -92,13 +108,22 @@ def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
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)
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)
+17 -2
View File
@@ -13,6 +13,9 @@ 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):
@@ -23,6 +26,9 @@ 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):
@@ -136,11 +142,17 @@ class DeviceOut(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)
class DeviceCreate(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)")
phone: str = Field(min_length=1, max_length=32, description="Phone number (metadata, ignored by RADIUS)")
alias: str | None = Field(default=None, max_length=64, description="Optional device alias (metadata)")
@field_validator("mac_address")
@classmethod
@@ -152,6 +164,9 @@ class DeviceEdit(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
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
alias: str | None = Field(default=None, max_length=64)
@field_validator("mac_address")
@classmethod
@@ -160,8 +175,8 @@ class DeviceEdit(BaseModel):
@model_validator(mode="after")
def _at_least_one(self):
if self.group is None and self.status is None:
raise ValueError("provide at least one of: group, status")
if all(v is None for v in (self.group, self.status, self.name, self.phone, self.alias)):
raise ValueError("provide at least one of: group, status, name, phone, alias")
return self