add more device fields
This commit is contained in:
@@ -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`).
|
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
|
One device spans three tables: `radcheck` (MAC = password), `radusergroup` (group
|
||||||
membership), and `customers` (status). MAC input is normalized to uppercase,
|
membership), and `customers` (status + human metadata). MAC input is normalized to
|
||||||
hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted.
|
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 |
|
| 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}` |
|
| Get one device | `GET /device/{mac_address}` |
|
||||||
| Add a device | `POST /device/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff"}` |
|
| 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":"..."}` |
|
| 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) |
|
| 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
|
- **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a
|
||||||
`radusergroup` row (`priority 1`), and a `customers` row (`status = paid`). The
|
`radusergroup` row (`priority 1`), and a `customers` row (`status = paid`). The
|
||||||
`group` must already exist in `radgroupreply` or you get `400`.
|
`group` must already exist in `radgroupreply` or you get `400`.
|
||||||
- **Edit** accepts `group` and/or `status` — supply either or both (at least one
|
- **Edit** accepts any subset of `group`, `status`, `name`, `phone`, `alias` (at
|
||||||
required). A new `group` must exist in `radgroupreply` (`400` otherwise).
|
least one required); omitted fields are left unchanged. A new `group` must exist
|
||||||
`status` must be one of `new` / `paid` / `unpaid`. In the DB the group is stored
|
in `radgroupreply` (`400` otherwise). `status` must be one of `new` / `paid` /
|
||||||
in the `radusergroup.groupname` column.
|
`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`.
|
- Adding a device whose MAC already exists → `409`.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -159,11 +167,11 @@ curl http://10.0.1.235:8000/vlan/55 \
|
|||||||
curl http://10.0.1.235:8000/device/ \
|
curl http://10.0.1.235:8000/device/ \
|
||||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
-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 \
|
curl http://10.0.1.235:8000/device/add \
|
||||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||||
-H "Content-Type: application/json" \
|
-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)
|
# Edit a device (any one field: groupname and/or status)
|
||||||
curl http://10.0.1.235:8000/device/edit \
|
curl http://10.0.1.235:8000/device/edit \
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class Customer(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
|
mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(10), nullable=False, default="new")
|
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(
|
created_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||||
)
|
)
|
||||||
|
|||||||
+35
-10
@@ -35,12 +35,16 @@ def _device_exists(db: Session, mac: str) -> bool:
|
|||||||
@router.get("/", response_model=Page[DeviceOut])
|
@router.get("/", response_model=Page[DeviceOut])
|
||||||
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
|
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
|
||||||
"""List devices — MAC, group and status, joined from customers + radusergroup."""
|
"""List devices — MAC, group and status, joined from customers + radusergroup."""
|
||||||
base = select(Customer.mac_address, RadUserGroup.groupname, Customer.status).outerjoin(
|
base = select(
|
||||||
RadUserGroup, RadUserGroup.username == Customer.username
|
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()
|
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()
|
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)
|
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)):
|
def get_device(mac_address: str, db: Session = Depends(get_db)):
|
||||||
mac = mac_address.strip().upper().replace(":", "-")
|
mac = mac_address.strip().upper().replace(":", "-")
|
||||||
stmt = (
|
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)
|
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
|
||||||
.where(Customer.username == mac)
|
.where(Customer.username == mac)
|
||||||
)
|
)
|
||||||
row = db.execute(stmt).first()
|
row = db.execute(stmt).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
|
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)
|
@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(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
||||||
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
|
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()
|
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)
|
@router.post("/edit", response_model=DeviceOut)
|
||||||
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
|
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
|
mac = payload.mac_address
|
||||||
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
|
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
|
||||||
if customer is 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:
|
if payload.status is not None:
|
||||||
customer.status = payload.status
|
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()
|
db.commit()
|
||||||
|
|
||||||
group = db.execute(
|
group = db.execute(
|
||||||
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
|
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
|
||||||
).scalar_one_or_none()
|
).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)
|
@router.delete("/{mac_address}", status_code=204)
|
||||||
|
|||||||
+17
-2
@@ -13,6 +13,9 @@ class CustomerBase(BaseModel):
|
|||||||
username: str = Field(max_length=64)
|
username: str = Field(max_length=64)
|
||||||
mac_address: str = Field(max_length=17)
|
mac_address: str = Field(max_length=17)
|
||||||
status: Literal["new", "paid", "unpaid"] = "new"
|
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):
|
class CustomerCreate(CustomerBase):
|
||||||
@@ -23,6 +26,9 @@ class CustomerUpdate(BaseModel):
|
|||||||
username: str | None = Field(default=None, max_length=64)
|
username: str | None = Field(default=None, max_length=64)
|
||||||
mac_address: str | None = Field(default=None, max_length=17)
|
mac_address: str | None = Field(default=None, max_length=17)
|
||||||
status: Literal["new", "paid", "unpaid"] | None = None
|
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):
|
class CustomerOut(ORMModel, CustomerBase):
|
||||||
@@ -136,11 +142,17 @@ class DeviceOut(BaseModel):
|
|||||||
mac_address: str
|
mac_address: str
|
||||||
group: str | None = None # radusergroup.groupname
|
group: str | None = None # radusergroup.groupname
|
||||||
status: str | None = None # customers.status
|
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):
|
class DeviceCreate(BaseModel):
|
||||||
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
|
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
|
||||||
group: str = Field(min_length=1, max_length=64)
|
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")
|
@field_validator("mac_address")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -152,6 +164,9 @@ class DeviceEdit(BaseModel):
|
|||||||
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
|
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
|
||||||
group: str | None = Field(default=None, max_length=64)
|
group: str | None = Field(default=None, max_length=64)
|
||||||
status: Literal["new", "paid", "unpaid"] | None = None
|
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")
|
@field_validator("mac_address")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -160,8 +175,8 @@ class DeviceEdit(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _at_least_one(self):
|
def _at_least_one(self):
|
||||||
if self.group is None and self.status is None:
|
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")
|
raise ValueError("provide at least one of: group, status, name, phone, alias")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user