utp_service/backend/app/routers/devices.py

279 lines
9.1 KiB
Python

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"})
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)
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.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)
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)