import/export devices
build-and-push / build (push) Failing after 17s

This commit is contained in:
2026-08-01 01:47:42 +05:00
parent 6fe7c69adb
commit a6d4fa1f6d
7 changed files with 501 additions and 9 deletions
+89 -7
View File
@@ -10,14 +10,23 @@ the RADIUS ``username``. One device touches three tables:
This router hides that fan-out behind mac_address + group + status. This router hides that fan-out behind mac_address + group + status.
""" """
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..errors import APIError from ..errors import APIError
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams 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"]) 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 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]) @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."""
@@ -78,12 +101,7 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
if _device_exists(db, mac): if _device_exists(db, mac):
raise APIError(status_code=409, detail=f"Device '{mac}' already exists") raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac)) _stage_device(db, payload)
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,
))
db.commit() db.commit()
return DeviceOut( return DeviceOut(
mac_address=mac, group=payload.group, status="paid", 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) @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 — any subset of group, status, name, phone, alias.""" """Edit a device — any subset of group, status, name, phone, alias."""
+34
View File
@@ -209,6 +209,40 @@ class DeviceEdit(BaseModel):
return self 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 ---------- # ---------- radusergroup ----------
class UserGroupBase(BaseModel): class UserGroupBase(BaseModel):
username: str = Field(max_length=64) username: str = Field(max_length=64)
+17 -2
View File
@@ -14,9 +14,11 @@
"@radix-ui/react-select": "^2.3.7", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/papaparse": "^5.5.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"papaparse": "^5.5.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -1946,12 +1948,20 @@
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "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": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2779,7 +2795,6 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/use-callback-ref": { "node_modules/use-callback-ref": {
+2
View File
@@ -16,9 +16,11 @@
"@radix-ui/react-select": "^2.3.7", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/papaparse": "^5.5.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"papaparse": "^5.5.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -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<DeviceImportRow[]> {
return new Promise((resolve, reject) => {
Papa.parse<Record<string, string>>(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<DeviceImportRow[] | null>(null)
const [preview, setPreview] = useState<DeviceImportResult | null>(null)
const [busy, setBusy] = useState(false)
const [exporting, setExporting] = useState(false)
const fileRef = useRef<HTMLInputElement>(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 (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<ArrowDownUp className="h-4 w-4" /> Import / Export
</Button>
<Dialog open={open} onOpenChange={openChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import / Export devices</DialogTitle>
<DialogDescription>
Bulk-add devices from a CSV, or download all devices as a CSV.
</DialogDescription>
</DialogHeader>
{preview ? (
<ImportPreview
preview={preview}
busy={busy}
onConfirm={confirmImport}
onBack={reset}
/>
) : (
<div className="space-y-4">
{/* Example */}
<section className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm">
<div className="font-medium">Example CSV</div>
<div className="text-muted-foreground">
Columns: <span className="font-mono text-xs">{IMPORT_COLUMNS.join(', ')}</span>{' '}
<span className="font-mono text-xs">alias</span> optional.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => downloadCsv('devices-template.csv', EXAMPLE_CSV)}
>
<FileText className="h-4 w-4" /> Download
</Button>
</div>
</section>
{/* Import */}
<section className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm">
<div className="font-medium">Import from CSV</div>
<div className="text-muted-foreground">
You'll see a preview and confirm before anything is saved.
</div>
</div>
<Button size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Choose file
</Button>
</div>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onFile(f)
}}
/>
</section>
{/* Export */}
<section className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm">
<div className="font-medium">Export all devices</div>
<div className="text-muted-foreground">Download every device as a CSV file.</div>
</div>
<Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export
</Button>
</div>
</section>
</div>
)}
</DialogContent>
</Dialog>
</>
)
}
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 (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-sm">
<span className="rounded-md bg-muted px-2 py-1">{preview.total} rows</span>
<span className="rounded-md bg-emerald-500/10 px-2 py-1 text-emerald-600 dark:text-emerald-400">
{preview.valid} valid
</span>
{hasErrors && (
<span className="rounded-md bg-destructive/10 px-2 py-1 text-destructive">
{preview.errors.length} with errors
</span>
)}
</div>
{hasErrors && (
<>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="h-4 w-4 text-destructive" />
These rows will be skipped:
</div>
<div className="max-h-56 overflow-y-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-y">
{preview.errors.map((e) => (
<tr key={`${e.row}-${e.mac ?? ''}`} className="align-top">
<td className="w-14 px-3 py-2 text-muted-foreground">#{e.row}</td>
<td className="px-3 py-2 font-mono text-xs">{e.mac || ''}</td>
<td className="px-3 py-2 text-destructive">{e.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{nothingValid ? (
<p className="text-sm text-muted-foreground">
No valid rows to import. Fix the CSV and try again.
</p>
) : (
<p className="text-sm text-muted-foreground">
{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'}?`}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onBack} disabled={busy}>
{hasErrors ? 'Cancel & retry' : 'Back'}
</Button>
<Button onClick={onConfirm} disabled={busy || nothingValid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{hasErrors ? `Add anyway (${preview.valid})` : `Import ${preview.valid}`}
</Button>
</DialogFooter>
</div>
)
}
+29
View File
@@ -108,6 +108,28 @@ export interface DeviceEdit {
alias?: string 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 { export interface Vlan {
alias: string alias: string
vlanid: number vlanid: number
@@ -135,6 +157,13 @@ export const api = {
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }), request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
remove: (mac: string) => remove: (mac: string) =>
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }), request<void>(`/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<DeviceImportResult>('/device/import', {
method: 'POST',
body: JSON.stringify({ devices, dry_run: dryRun }),
}),
}, },
vlans: { vlans: {
+2
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api' import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone' import { normalizePhone, phoneError } from '@/lib/phone'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DeviceImportExport } from '@/components/DeviceImportExport'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -76,6 +77,7 @@ export function Devices() {
<Button variant="outline" size="icon" onClick={load} title="Refresh"> <Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} /> <RefreshCw className={loading ? 'animate-spin' : ''} />
</Button> </Button>
<DeviceImportExport onImported={load} />
<Button onClick={() => setAddOpen(true)}> <Button onClick={() => setAddOpen(true)}>
<Plus /> Add device <Plus /> Add device
</Button> </Button>