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.
"""
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."""
+34
View File
@@ -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)
+17 -2
View File
@@ -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": {
+2
View File
@@ -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",
@@ -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
}
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>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
remove: (mac: string) =>
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: {
+2
View File
@@ -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() {
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
<DeviceImportExport onImported={load} />
<Button onClick={() => setAddOpen(true)}>
<Plus /> Add device
</Button>