diff --git a/backend/README.md b/backend/README.md index c89fa09..415e9fe 100644 --- a/backend/README.md +++ b/backend/README.md @@ -219,7 +219,7 @@ 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 | — | -| **Clients** | `/client` | see below | — | +| **Clients** | `/client` | see below | `search`, `status` | | **Devices** | `/device` | GET (list), PUT (alias) | — | | User↔group | `/radusergroup` | GET, POST, PUT, DELETE | `username`, `groupname` | | Accounting | `/radacct` | GET (read-only) | `username`, `nasipaddress`, `active` | @@ -263,6 +263,7 @@ at client creation. | Action | Request | |------------------|-----------------------------------------------------| | List clients | `GET /client/` → `{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` | +| Search / filter | `GET /client/?search=&status=` | | 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?}` | @@ -283,6 +284,11 @@ response. `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`. +- **List** accepts two optional filters (both applied before pagination, so + `total` reflects the filtered set): `search` is a case-insensitive substring + matched across `mac_address`, `name`, `phone`, and `alias`; `status` is an exact + match on `new` / `paid` / `unpaid`. Combine them freely, e.g. + `GET /client/?search=ali&status=unpaid`. - **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 diff --git a/backend/app/routers/client.py b/backend/app/routers/client.py index 23c5e53..6b8ea46 100644 --- a/backend/app/routers/client.py +++ b/backend/app/routers/client.py @@ -11,7 +11,7 @@ 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 import delete, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -55,8 +55,18 @@ def _stage_client(db: Session, cli: ClientCreate) -> None: @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.""" +def list_clients( + page: PageParams = Depends(), + search: str | None = None, + status: str | None = None, + db: Session = Depends(get_db), +): + """List clients — MAC, group and status, joined from customers + radusergroup. + + Optional filters: + - ``search`` case-insensitive substring match across MAC, name, phone, alias. + - ``status`` exact match on billing status (new/paid/unpaid). + """ base = ( select( Customer.mac_address, RadUserGroup.groupname, Customer.status, @@ -65,7 +75,28 @@ def list_clients(page: PageParams = Depends(), db: Session = Depends(get_db)): .outerjoin(RadUserGroup, RadUserGroup.username == Customer.username) .outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username) ) - total = db.execute(select(func.count()).select_from(Customer)).scalar_one() + # Count over the same joins so filtered totals drive pagination correctly. + count_stmt = ( + select(func.count()) + .select_from(Customer) + .outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username) + ) + + if search: + term = f"%{search.strip()}%" + cond = or_( + Customer.mac_address.ilike(term), + RadadminClient.name.ilike(term), + RadadminClient.phone.ilike(term), + RadadminClient.alias.ilike(term), + ) + base = base.where(cond) + count_stmt = count_stmt.where(cond) + if status: + base = base.where(Customer.status == status) + count_stmt = count_stmt.where(Customer.status == status) + + total = db.execute(count_stmt).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)