import re from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class ORMModel(BaseModel): model_config = ConfigDict(from_attributes=True) # ---------- auth: login / session / current user ---------- class LoginRequest(BaseModel): username: str = Field(max_length=64) password: str = Field(min_length=1, max_length=256) class LoginResponse(BaseModel): token: str username: str is_admin: bool must_change_password: bool class CurrentUser(BaseModel): id: int username: str is_admin: bool must_change_password: bool class ChangePasswordRequest(BaseModel): # Optional: a forced first-login change skips it (the user just authenticated # with their current password to reach that screen). A normal self-service # change from the account page still requires it. current_password: str | None = Field(default=None, max_length=256) new_password: str = Field(min_length=6, max_length=256) # ---------- admin users (radadmin_admins) ---------- class AdminOut(ORMModel): id: int username: str is_admin: bool must_change_password: bool created_at: datetime | None = None class AdminCreate(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=6, max_length=256) is_admin: bool = False force_password_change: bool = True # require a reset on first login class ResetPasswordRequest(BaseModel): new_password: str = Field(min_length=6, max_length=256) force_password_change: bool = True # make the user reset again on next login # ---------- API keys (radadmin_api_keys) ---------- class ApiKeyOut(ORMModel): id: int name: str key_prefix: str created_by: str | None = None created_at: datetime | None = None last_used_at: datetime | None = None revoked: bool class ApiKeyCreate(BaseModel): name: str = Field(min_length=1, max_length=64) class ApiKeyCreated(ApiKeyOut): key: str # the raw key, shown only once at creation # ---------- activity log (radadmin_logs) ---------- class LogOut(ORMModel): id: int username: str | None = None action: str detail: str | None = None ip_address: str | None = None created_at: datetime | None = None # ---------- customers ---------- # Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds # only the RADIUS-adjacent identity + billing status. class CustomerBase(BaseModel): username: str = Field(max_length=64) mac_address: str = Field(max_length=17) status: Literal["new", "paid", "unpaid"] = "new" class CustomerCreate(CustomerBase): pass 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 class CustomerOut(ORMModel, CustomerBase): id: int created_at: datetime | None = None # ---------- nas ---------- class NasBase(BaseModel): nasname: str = Field(max_length=128) shortname: str | None = Field(default=None, max_length=32) type: str | None = Field(default="other", max_length=30) ports: int | None = None secret: str = Field(default="secret", max_length=60) server: str | None = Field(default=None, max_length=64) community: str | None = Field(default=None, max_length=50) description: str | None = Field(default="RADIUS Client", max_length=200) class NasCreate(NasBase): pass class NasUpdate(BaseModel): nasname: str | None = Field(default=None, max_length=128) shortname: str | None = Field(default=None, max_length=32) type: str | None = Field(default=None, max_length=30) ports: int | None = None secret: str | None = Field(default=None, max_length=60) server: str | None = Field(default=None, max_length=64) community: str | None = Field(default=None, max_length=50) description: str | None = Field(default=None, max_length=200) class NasOut(ORMModel, NasBase): id: int # ---------- attribute pair tables (radcheck / radreply) ---------- class UserAttrBase(BaseModel): username: str = Field(max_length=64) attribute: str = Field(max_length=64) op: str = Field(max_length=2) value: str = Field(max_length=253) class UserAttrCreate(UserAttrBase): pass class UserAttrUpdate(BaseModel): username: str | None = Field(default=None, max_length=64) attribute: str | None = Field(default=None, max_length=64) op: str | None = Field(default=None, max_length=2) value: str | None = Field(default=None, max_length=253) class UserAttrOut(ORMModel, UserAttrBase): id: int # ---------- group attribute tables (radgroupcheck / radgroupreply / vlans) ---------- class GroupAttrBase(BaseModel): groupname: str = Field(max_length=64) attribute: str = Field(max_length=64) op: str = Field(max_length=2) value: str = Field(max_length=253) class GroupAttrCreate(GroupAttrBase): pass class GroupAttrUpdate(BaseModel): groupname: str | None = Field(default=None, max_length=64) attribute: str | None = Field(default=None, max_length=64) op: str | None = Field(default=None, max_length=2) value: str | None = Field(default=None, max_length=253) class GroupAttrOut(ORMModel, GroupAttrBase): id: int # ---------- vlans (logical view over radgroupreply) ---------- class VlanOut(BaseModel): alias: str # radgroupreply.groupname vlanid: int # value of the Tunnel-Private-Group-Id attribute class VlanCreate(BaseModel): vlanid: int = Field(ge=1, le=4094, description="802.1Q VLAN ID") alias: str = Field(min_length=1, max_length=64, description="Group name for this VLAN") class VlanEdit(BaseModel): vlanid: int = Field(ge=1, le=4094, description="VLAN ID identifying the group to rename") alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)") # ---------- clients (logical view over radcheck + radusergroup + customers) ---------- _MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$" def _normalize_mac(mac: str) -> str: """Canonicalize a MAC to uppercase, hyphen-separated (AA-BB-CC-DD-EE-FF).""" return mac.strip().upper().replace(":", "-") _PHONE_ERROR = "Enter a valid phone number" def _normalize_phone(raw: str) -> str: """Validate a Maldivian mobile number and return the 7-digit local form. Rules: 7 digits, starts with 9 or 7. A 960/+960 country code is stripped ONLY when the number is 10 digits (960 + 7) — a bare 7-digit number like 9601234 is a valid local number and is never stripped. """ digits = re.sub(r"\D", "", raw or "") if len(digits) == 10 and digits.startswith("960"): digits = digits[3:] if len(digits) == 7 and digits[0] in ("7", "9"): return digits raise ValueError(_PHONE_ERROR) class ClientOut(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 # radadmin_clients.alias (human metadata) class ClientCreate(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="7-digit mobile (9/7…), 960/+960 optional") alias: str | None = Field(default=None, max_length=64, description="Optional device alias (metadata)") @field_validator("mac_address") @classmethod def _norm(cls, v: str) -> str: return _normalize_mac(v) @field_validator("phone") @classmethod def _norm_phone(cls, v: str) -> str: return _normalize_phone(v) class ClientEdit(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 def _norm(cls, v: str) -> str: return _normalize_mac(v) @field_validator("phone") @classmethod def _norm_phone(cls, v: str | None) -> str | None: return None if v is None else _normalize_phone(v) @model_validator(mode="after") def _at_least_one(self): 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 # ---------- client CSV import ---------- class ClientImportRow(BaseModel): """A single CSV row — lenient so one bad row never 422s the whole batch. Each row is re-validated with ``ClientCreate`` inside the router so failures are collected per-row instead of rejecting the entire request. """ mac_address: str | None = None group: str | None = None name: str | None = None phone: str | None = None alias: str | None = None class ClientImportRequest(BaseModel): devices: list[ClientImportRow] dry_run: bool = Field(default=False, description="Validate only; commit nothing") class ClientImportError(BaseModel): row: int # 1-based index within the submitted rows mac: str | None = None detail: str class ClientImportResult(BaseModel): total: int # rows submitted valid: int # rows that passed validation created: int # rows actually inserted (0 on dry_run) dry_run: bool errors: list[ClientImportError] # ---------- devices (NAS/AP boxes seen in radacct, aliased via radadmin_devices) ---------- class DeviceOut(BaseModel): ap_mac: str # calledstationid before ':' — the device key nasipaddress: str | None = None # NAS IP(s) this AP reports from (comma-joined) ssids: list[str] = [] # every SSID seen for this AP MAC alias: str | None = None # radadmin_devices.alias (human label) class DeviceAliasUpdate(BaseModel): alias: str | None = Field(default=None, max_length=64) # ---------- radusergroup ---------- class UserGroupBase(BaseModel): username: str = Field(max_length=64) groupname: str = Field(max_length=64) priority: int = 1 class UserGroupCreate(UserGroupBase): pass class UserGroupUpdate(BaseModel): username: str | None = Field(default=None, max_length=64) groupname: str | None = Field(default=None, max_length=64) priority: int | None = None class UserGroupOut(ORMModel, UserGroupBase): id: int # ---------- read-only: radacct ---------- class RadAcctOut(ORMModel): radacctid: int acctsessionid: str acctuniqueid: str username: str realm: str | None = None nasipaddress: str nasportid: str | None = None nasporttype: str | None = None acctstarttime: datetime | None = None acctupdatetime: datetime | None = None acctstoptime: datetime | None = None acctinterval: int | None = None acctsessiontime: int | None = None acctauthentic: str | None = None connectinfo_start: str | None = None connectinfo_stop: str | None = None acctinputoctets: int | None = None acctoutputoctets: int | None = None calledstationid: str callingstationid: str acctterminatecause: str servicetype: str | None = None framedprotocol: str | None = None framedipaddress: str class_: str | None = Field(default=None, alias="class") # ---------- read-only: radpostauth ---------- class RadPostAuthOut(ORMModel): id: int username: str reply: str authdate: datetime | None = None class_: str | None = Field(default=None, alias="class") # ---------- read-only: nasreload ---------- class NasReloadOut(ORMModel): nasipaddress: str reloadtime: datetime