diff --git a/README.md b/README.md index ca05bd6..6e48ee5 100644 --- a/README.md +++ b/README.md @@ -105,24 +105,32 @@ and `Tunnel-Private-Group-Id=`. 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 \ diff --git a/app/models.py b/app/models.py index 5b36f68..e7bb36f 100644 --- a/app/models.py +++ b/app/models.py @@ -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() ) diff --git a/app/routers/device.py b/app/routers/device.py index 51e25c5..27160f2 100644 --- a/app/routers/device.py +++ b/app/routers/device.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index 898e15d..b803d57 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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