utp_service/backend/app/routers/devices.py
2026-04-07 01:05:59 +03:00

237 lines
7.6 KiB
Python

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)