import csv import io from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import Response as FastAPIResponse, StreamingResponse 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 camera as camera_service 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.get("/{device_id}/camera/snapshot") async def camera_snapshot( obj_id: int, device_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): """Proxy a JPEG snapshot from a camera (channel 1 main stream).""" device = await _get_device(db, obj_id, device_id) if device.category != "camera": raise HTTPException(status_code=400, detail="Device is not a camera") obj = await _get_object(db, obj_id) try: image_bytes, content_type = await camera_service.isapi_snapshot(device, obj) except RuntimeError as exc: raise HTTPException(status_code=502, detail=str(exc)) return FastAPIResponse(content=image_bytes, media_type=content_type) @router.get("/{device_id}/camera/stream") async def camera_stream( obj_id: int, device_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): """Proxy MJPEG stream from a camera.""" device = await _get_device(db, obj_id, device_id) if device.category != "camera": raise HTTPException(status_code=400, detail="Device is not a camera") obj = await _get_object(db, obj_id) try: generator = camera_service.mjpeg_stream(device, obj) except RuntimeError as exc: raise HTTPException(status_code=502, detail=str(exc)) return StreamingResponse( generator, media_type="multipart/x-mixed-replace; boundary=--myboundary", headers={"Cache-Control": "no-cache"}, ) @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)