import csv import io from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.models.device import DEVICE_CATEGORIES, DEVICE_ROLES, Device from app.models.object import Object from app.models.user import User from app.schemas.device import ( DeviceCreate, DeviceImportPreview, DeviceImportResult, DeviceImportRow, DeviceRead, DeviceUpdate, ) from app.services import crypto router = APIRouter(prefix="/api/v1/objects/{obj_id}/devices", tags=["devices"]) async def _get_object(db: AsyncSession, obj_id: int) -> Object: result = await db.execute(select(Object).where(Object.id == obj_id)) obj = result.scalar_one_or_none() if obj is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found") return obj async def _get_device(db: AsyncSession, obj_id: int, device_id: int) -> Device: result = await db.execute( select(Device).where(Device.id == device_id, Device.object_id == obj_id) ) device = result.scalar_one_or_none() if device is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") return device @router.get("", response_model=list[DeviceRead]) async def list_devices( obj_id: int, category: str | None = None, role: str | None = None, status: str | None = Query(None), is_active: bool | None = None, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): await _get_object(db, obj_id) query = select(Device).where(Device.object_id == obj_id) if category: query = query.where(Device.category == category) if role: query = query.where(Device.role == role) if status: query = query.where(Device.status == status) if is_active is not None: query = query.where(Device.is_active == is_active) query = query.order_by(Device.ip) result = await db.execute(query) return result.scalars().all() @router.post("", response_model=DeviceRead, status_code=201) async def create_device( obj_id: int, body: DeviceCreate, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): await _get_object(db, obj_id) data = body.model_dump(exclude={"ssh_password_override"}) device = Device(object_id=obj_id, **data) if body.ssh_password_override: device.ssh_pass_override_enc = crypto.encrypt(body.ssh_password_override) db.add(device) await db.flush() await db.refresh(device) return device @router.post("/import/preview", response_model=DeviceImportPreview) async def preview_csv_import( obj_id: int, file: Annotated[UploadFile, File()], db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): await _get_object(db, obj_id) return await _parse_csv_import(db, obj_id, file, dry_run=True) @router.post("/import", response_model=DeviceImportResult, status_code=201) async def import_csv( obj_id: int, file: Annotated[UploadFile, File()], db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): await _get_object(db, obj_id) preview = await _parse_csv_import(db, obj_id, file, dry_run=False) return DeviceImportResult( created=len(preview.would_create), updated=len(preview.would_update), errors=preview.errors, ) async def _parse_csv_import( db: AsyncSession, obj_id: int, file: UploadFile, dry_run: bool ) -> DeviceImportPreview: content = await file.read() text = content.decode("utf-8-sig") # handle BOM reader = csv.DictReader(io.StringIO(text)) would_create: list[DeviceImportRow] = [] would_update: list[dict] = [] errors: list[dict] = [] for row_num, row in enumerate(reader, start=2): ip = (row.get("ip") or "").strip() if not ip: errors.append({"row": row_num, "error": "IP address is required"}) continue hostname = (row.get("hostname") or "").strip() or None category = (row.get("category") or "other").strip().lower() role = (row.get("role") or "other").strip().lower() model = (row.get("model") or "").strip() or None mac = (row.get("mac") or "").strip() or None notes = (row.get("notes") or "").strip() or None if category not in DEVICE_CATEGORIES: category = "other" if role not in DEVICE_ROLES: role = "other" existing_result = await db.execute( select(Device).where(Device.object_id == obj_id, Device.ip == ip) ) existing = existing_result.scalar_one_or_none() if existing is not None: changes: dict = {} if hostname and existing.hostname != hostname: changes["hostname"] = {"from": existing.hostname, "to": hostname} if existing.category != category: changes["category"] = {"from": existing.category, "to": category} if model and existing.model != model: changes["model"] = {"from": existing.model, "to": model} would_update.append({"ip": ip, "id": existing.id, "changes": changes}) if not dry_run and changes: if "hostname" in changes: existing.hostname = hostname if "category" in changes: existing.category = category if "model" in changes: existing.model = model existing.source = "csv_import" else: row_data = DeviceImportRow( ip=ip, hostname=hostname, category=category, role=role, model=model, mac=mac, notes=notes ) would_create.append(row_data) if not dry_run: db.add(Device( object_id=obj_id, ip=ip, hostname=hostname, category=category, role=role, model=model, mac=mac, notes=notes, source="csv_import", )) if not dry_run: await db.flush() return DeviceImportPreview(would_create=would_create, would_update=would_update, errors=errors) @router.get("/{device_id}", response_model=DeviceRead) async def get_device( obj_id: int, device_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): return await _get_device(db, obj_id, device_id) @router.patch("/{device_id}", response_model=DeviceRead) async def update_device( obj_id: int, device_id: int, body: DeviceUpdate, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): device = await _get_device(db, obj_id, device_id) data = body.model_dump(exclude_none=True, exclude={"ssh_password_override"}) for field, value in data.items(): setattr(device, field, value) if body.ssh_password_override is not None: device.ssh_pass_override_enc = ( crypto.encrypt(body.ssh_password_override) if body.ssh_password_override else None ) await db.flush() await db.refresh(device) return device @router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_device( obj_id: int, device_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): device = await _get_device(db, obj_id, device_id) await db.delete(device)