diff --git a/backend/app/routers/device.py b/backend/app/routers/device.py index 27160f2..14ea2c0 100644 --- a/backend/app/routers/device.py +++ b/backend/app/routers/device.py @@ -10,14 +10,23 @@ the RADIUS ``username``. One device touches three tables: 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.exc import IntegrityError from sqlalchemy.orm import Session from ..database import get_db from ..errors import APIError from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup from ..pagination import Page, PageParams -from ..schemas import DeviceCreate, DeviceEdit, DeviceOut +from ..schemas import ( + DeviceCreate, + DeviceEdit, + DeviceImportError, + DeviceImportRequest, + DeviceImportResult, + DeviceOut, +) router = APIRouter(prefix="/device", tags=["device"]) @@ -32,6 +41,20 @@ def _device_exists(db: Session, mac: str) -> bool: return db.execute(stmt).first() is not None +def _stage_device(db: Session, dev: DeviceCreate) -> None: + """Add a device's three rows (radcheck, radusergroup, customers) to the session. + + Does not commit — the caller controls the transaction boundary. + """ + mac = dev.mac_address + db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac)) + db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1)) + db.add(Customer( + username=mac, mac_address=mac, status="paid", + name=dev.name, phone=dev.phone, device_alias=dev.alias, + )) + + @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.""" @@ -78,12 +101,7 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)): if _device_exists(db, mac): raise APIError(status_code=409, detail=f"Device '{mac}' already exists") - 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", - name=payload.name, phone=payload.phone, device_alias=payload.alias, - )) + _stage_device(db, payload) db.commit() return DeviceOut( mac_address=mac, group=payload.group, status="paid", @@ -91,6 +109,70 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)): ) +def _format_validation_error(exc: ValidationError) -> str: + """Turn a pydantic ValidationError into a short, human message.""" + parts = [] + for err in exc.errors(): + loc = ".".join(str(p) for p in err["loc"]) + parts.append(f"{loc}: {err['msg']}" if loc else err["msg"]) + return "; ".join(parts) + + +@router.post("/import", response_model=DeviceImportResult) +def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)): + """Bulk-import devices from parsed CSV rows. + + Every row is validated with the same rules as ``/device/add`` (MAC/phone + normalization, required fields, group-exists, duplicate MAC — both against the + DB and within the file). Errors are collected per row rather than failing the + batch. With ``dry_run`` nothing is written, so the UI can preview and confirm; + otherwise the valid rows are inserted best-effort (each in its own commit). + """ + errors: list[DeviceImportError] = [] + valid: list[tuple[int, DeviceCreate]] = [] + seen_macs: set[str] = set() + + for idx, raw in enumerate(payload.devices, start=1): + try: + dev = DeviceCreate(**raw.model_dump()) + except ValidationError as exc: + errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc))) + continue + + mac = dev.mac_address + if mac in seen_macs: + errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file")) + continue + if not _group_exists(db, dev.group): + errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found")) + continue + if _device_exists(db, mac): + errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists")) + continue + + seen_macs.add(mac) + valid.append((idx, dev)) + + created = 0 + if not payload.dry_run: + for idx, dev in valid: + _stage_device(db, dev) + try: + db.commit() + created += 1 + except IntegrityError: + db.rollback() + errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)")) + + return DeviceImportResult( + total=len(payload.devices), + valid=len(valid), + created=created, + dry_run=payload.dry_run, + errors=sorted(errors, key=lambda e: e.row), + ) + + @router.post("/edit", response_model=DeviceOut) def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)): """Edit a device — any subset of group, status, name, phone, alias.""" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 31265c1..eeb107f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -209,6 +209,40 @@ class DeviceEdit(BaseModel): return self +# ---------- device CSV import ---------- +class DeviceImportRow(BaseModel): + """A single CSV row — lenient so one bad row never 422s the whole batch. + + Each row is re-validated with ``DeviceCreate`` 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 DeviceImportRequest(BaseModel): + devices: list[DeviceImportRow] + dry_run: bool = Field(default=False, description="Validate only; commit nothing") + + +class DeviceImportError(BaseModel): + row: int # 1-based index within the submitted rows + mac: str | None = None + detail: str + + +class DeviceImportResult(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[DeviceImportError] + + # ---------- radusergroup ---------- class UserGroupBase(BaseModel): username: str = Field(max_length=64) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b08bf1..ca095eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,9 +14,11 @@ "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slot": "^1.3.3", "@tailwindcss/vite": "^4.3.3", + "@types/papaparse": "^5.5.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.28.0", + "papaparse": "^5.5.4", "react": "^19.2.8", "react-dom": "^19.2.8", "sonner": "^2.0.7", @@ -1946,12 +1948,20 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -2469,6 +2479,12 @@ } } }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2779,7 +2795,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/use-callback-ref": { diff --git a/frontend/package.json b/frontend/package.json index 681960b..a580a47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,11 @@ "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slot": "^1.3.3", "@tailwindcss/vite": "^4.3.3", + "@types/papaparse": "^5.5.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.28.0", + "papaparse": "^5.5.4", "react": "^19.2.8", "react-dom": "^19.2.8", "sonner": "^2.0.7", diff --git a/frontend/src/components/DeviceImportExport.tsx b/frontend/src/components/DeviceImportExport.tsx new file mode 100644 index 0000000..a19e8d4 --- /dev/null +++ b/frontend/src/components/DeviceImportExport.tsx @@ -0,0 +1,328 @@ +import { useRef, useState } from 'react' +import Papa from 'papaparse' +import { AlertTriangle, ArrowDownUp, Download, FileText, Loader2, Upload } from 'lucide-react' +import { toast } from 'sonner' +import { + api, + ApiError, + type Device, + type DeviceImportResult, + type DeviceImportRow, +} from '@/lib/api' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +const IMPORT_COLUMNS = ['mac_address', 'group', 'name', 'phone', 'alias'] as const +const EXPORT_COLUMNS = [...IMPORT_COLUMNS, 'status'] as const + +const EXAMPLE_CSV = Papa.unparse({ + fields: [...IMPORT_COLUMNS], + data: [ + ['AA-BB-CC-DD-EE-FF', 'customer', 'Ali Hassan', '7712345', 'Living Room TV'], + ['11-22-33-44-55-66', 'staff', 'Sara Ibrahim', '9998887', ''], + ], +}) + +function downloadCsv(filename: string, csv: string) { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function parseCsv(file: File): Promise { + return new Promise((resolve, reject) => { + Papa.parse>(file, { + header: true, + skipEmptyLines: 'greedy', + transformHeader: (h) => h.trim().toLowerCase(), + complete: (res) => { + const rows = res.data.map((r) => { + const pick = (k: string) => (r[k] ?? '').trim() || undefined + return { + mac_address: pick('mac_address'), + group: pick('group'), + name: pick('name'), + phone: pick('phone'), + alias: pick('alias'), + } + }) + resolve(rows) + }, + error: (err) => reject(err), + }) + }) +} + +export function DeviceImportExport({ onImported }: { onImported: () => void }) { + const [open, setOpen] = useState(false) + const [rows, setRows] = useState(null) + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + const [exporting, setExporting] = useState(false) + const fileRef = useRef(null) + + function reset() { + setRows(null) + setPreview(null) + setBusy(false) + if (fileRef.current) fileRef.current.value = '' + } + + function openChange(o: boolean) { + setOpen(o) + if (!o) reset() + } + + async function onFile(file: File) { + setBusy(true) + try { + const parsed = await parseCsv(file) + if (parsed.length === 0) { + toast.error('No rows found in that CSV') + return + } + const result = await api.devices.import(parsed, true) // dry run + setRows(parsed) + setPreview(result) + } catch (err) { + if (!(err instanceof ApiError && err.status === 401)) + toast.error(err instanceof Error ? err.message : 'Failed to read CSV') + } finally { + setBusy(false) + if (fileRef.current) fileRef.current.value = '' + } + } + + async function confirmImport() { + if (!rows) return + setBusy(true) + try { + const res = await api.devices.import(rows, false) + const skipped = res.errors.length + toast.success( + `Imported ${res.created} device${res.created === 1 ? '' : 's'}` + + (skipped ? `, ${skipped} skipped` : ''), + ) + onImported() + openChange(false) + } catch (err) { + if (!(err instanceof ApiError && err.status === 401)) + toast.error(err instanceof Error ? err.message : 'Import failed') + } finally { + setBusy(false) + } + } + + async function exportAll() { + setExporting(true) + try { + const all: Device[] = [] + const limit = 500 + let offset = 0 + for (;;) { + const page = await api.devices.list(limit, offset) + all.push(...page.items) + offset += page.items.length + if (page.items.length === 0 || all.length >= page.total) break + } + const csv = Papa.unparse({ + fields: [...EXPORT_COLUMNS], + data: all.map((d) => [ + d.mac_address, + d.group ?? '', + d.name ?? '', + d.phone ?? '', + d.alias ?? '', + d.status ?? '', + ]), + }) + downloadCsv('devices.csv', csv) + toast.success(`Exported ${all.length} device${all.length === 1 ? '' : 's'}`) + } catch (err) { + if (!(err instanceof ApiError && err.status === 401)) + toast.error(err instanceof Error ? err.message : 'Export failed') + } finally { + setExporting(false) + } + } + + return ( + <> + + + + + + Import / Export devices + + Bulk-add devices from a CSV, or download all devices as a CSV. + + + + {preview ? ( + + ) : ( +
+ {/* Example */} +
+
+
+
Example CSV
+
+ Columns: {IMPORT_COLUMNS.join(', ')}{' '} + — alias optional. +
+
+ +
+
+ + {/* Import */} +
+
+
+
Import from CSV
+
+ You'll see a preview and confirm before anything is saved. +
+
+ +
+ { + const f = e.target.files?.[0] + if (f) onFile(f) + }} + /> +
+ + {/* Export */} +
+
+
+
Export all devices
+
Download every device as a CSV file.
+
+ +
+
+
+ )} +
+
+ + ) +} + +function ImportPreview({ + preview, + busy, + onConfirm, + onBack, +}: { + preview: DeviceImportResult + busy: boolean + onConfirm: () => void + onBack: () => void +}) { + const hasErrors = preview.errors.length > 0 + const nothingValid = preview.valid === 0 + + return ( +
+
+ {preview.total} rows + + {preview.valid} valid + + {hasErrors && ( + + {preview.errors.length} with errors + + )} +
+ + {hasErrors && ( + <> +
+ + These rows will be skipped: +
+
+ + + {preview.errors.map((e) => ( + + + + + + ))} + +
#{e.row}{e.mac || '—'}{e.detail}
+
+ + )} + + {nothingValid ? ( +

+ No valid rows to import. Fix the CSV and try again. +

+ ) : ( +

+ {hasErrors + ? `Add the ${preview.valid} valid row${preview.valid === 1 ? '' : 's'} anyway, or go back and fix the file.` + : `All rows are valid. Import ${preview.valid} device${preview.valid === 1 ? '' : 's'}?`} +

+ )} + + + + + +
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3e7c2e7..8249154 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -108,6 +108,28 @@ export interface DeviceEdit { alias?: string } +export interface DeviceImportRow { + mac_address?: string + group?: string + name?: string + phone?: string + alias?: string +} + +export interface DeviceImportRowError { + row: number + mac: string | null + detail: string +} + +export interface DeviceImportResult { + total: number + valid: number + created: number + dry_run: boolean + errors: DeviceImportRowError[] +} + export interface Vlan { alias: string vlanid: number @@ -135,6 +157,13 @@ export const api = { request('/device/edit', { method: 'POST', body: JSON.stringify(body) }), remove: (mac: string) => request(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }), + // Bulk import. dryRun=true validates only (nothing written) so the UI can + // preview errors and confirm; dryRun=false inserts the valid rows. + import: (devices: DeviceImportRow[], dryRun: boolean) => + request('/device/import', { + method: 'POST', + body: JSON.stringify({ devices, dry_run: dryRun }), + }), }, vlans: { diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index bf49b0b..c232129 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api' import { normalizePhone, phoneError } from '@/lib/phone' import { Button } from '@/components/ui/button' +import { DeviceImportExport } from '@/components/DeviceImportExport' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -76,6 +77,7 @@ export function Devices() { +