237 lines
7.6 KiB
Python
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.get("/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)
|