import csv import io from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy import select, update as sa_update 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 _touch_object(db: AsyncSession, obj_id: int) -> None: await db.execute( sa_update(Object).where(Object.id == obj_id).values(updated_at=datetime.now(timezone.utc)) ) 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", "ssh_key_id_override"}) device = Device(object_id=obj_id, **data) if body.ssh_password_override: device.ssh_pass_override_enc = crypto.encrypt(body.ssh_password_override) if body.ssh_key_id_override is not None: device.ssh_key_id_override = body.ssh_key_id_override db.add(device) await db.flush() await db.refresh(device) await _touch_object(db, obj_id) # 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) await _touch_object(db, obj_id) 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.get("/{device_id}/credentials") async def get_device_credentials( obj_id: int, device_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): """Return all decrypted passwords for the device: SSH (device override + object level) and connection_params.""" device = await _get_device(db, obj_id, device_id) obj = await _get_object(db, obj_id) ssh_password_device = crypto.decrypt(device.ssh_pass_override_enc) if device.ssh_pass_override_enc else None ssh_password_object = crypto.decrypt(obj.ssh_pass_enc) if obj.ssh_pass_enc else None # connection_params.password may be a Fernet ciphertext — try to decrypt, fall back to raw connection_password: str | None = None raw_conn_pass = (device.connection_params or {}).get("password") if raw_conn_pass: try: connection_password = crypto.decrypt(str(raw_conn_pass)) except Exception: connection_password = str(raw_conn_pass) return { "ssh_password_device": ssh_password_device, "ssh_password_object": ssh_password_object, "connection_password": connection_password, } @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", "ssh_key_id_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 ) if body.ssh_key_id_override is not None: device.ssh_key_id_override = body.ssh_key_id_override if body.ssh_key_id_override != 0 else None await db.flush() await db.refresh(device) await _touch_object(db, obj_id) 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) await _touch_object(db, obj_id) @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) await _touch_object(db, obj_id)