import csv import io from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy import select from sqlalchemy.orm import selectinload 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) .options(selectinload(Device.rack)) .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) devices = result.scalars().all() return [DeviceRead.from_orm_with_rack(d) for d in devices] @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) # reload with rack relationship result = await db.execute( select(Device).options(selectinload(Device.rack)).where(Device.id == device.id) ) return DeviceRead.from_orm_with_rack(result.scalar_one()) @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), ): result = await db.execute( select(Device) .options(selectinload(Device.rack)) .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 DeviceRead.from_orm_with_rack(device) @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("", status_code=status.HTTP_204_NO_CONTENT) async def delete_all_devices( obj_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): await _get_object(db, obj_id) result = await db.execute(select(Device).where(Device.object_id == obj_id)) for device in result.scalars().all(): await db.delete(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)