207 lines
6.7 KiB
Python
207 lines
6.7 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select, distinct
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.dependencies import get_current_user, get_db
|
|
from app.models.device import Device
|
|
from app.models.object import Object
|
|
from app.models.tag import Tag
|
|
from app.models.user import User
|
|
from app.schemas.admin import SheetsSyncRequest
|
|
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult
|
|
from app.services import crypto
|
|
from app.services.device_discovery import discover_via_ssh
|
|
from app.services.object_db import sync_from_object_db
|
|
from app.services.sheets import sync_from_sheets
|
|
|
|
router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
|
|
|
|
|
|
def _apply_credentials(obj: Object, db_password: str | None, ssh_password: str | None) -> None:
|
|
if db_password is not None:
|
|
obj.db_pass_enc = crypto.encrypt(db_password)
|
|
if ssh_password is not None:
|
|
obj.ssh_pass_enc = crypto.encrypt(ssh_password)
|
|
|
|
|
|
async def _load_object(db: AsyncSession, obj_id: int) -> Object:
|
|
result = await db.execute(
|
|
select(Object).options(selectinload(Object.tags)).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
|
|
|
|
|
|
@router.get("/meta", response_model=ObjectMetaResponse)
|
|
async def get_objects_meta(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
"""Return distinct city and client values for autocomplete."""
|
|
cities_result = await db.execute(
|
|
select(distinct(Object.city)).where(Object.city.isnot(None)).order_by(Object.city)
|
|
)
|
|
clients_result = await db.execute(
|
|
select(distinct(Object.client)).where(Object.client.isnot(None)).order_by(Object.client)
|
|
)
|
|
return ObjectMetaResponse(
|
|
cities=[r for (r,) in cities_result.all()],
|
|
clients=[r for (r,) in clients_result.all()],
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[ObjectRead])
|
|
async def list_objects(
|
|
is_active: bool | None = None,
|
|
tag_id: int | None = None,
|
|
city: str | None = None,
|
|
client: str | None = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
query = select(Object).options(selectinload(Object.tags))
|
|
if is_active is not None:
|
|
query = query.where(Object.is_active == is_active)
|
|
if tag_id is not None:
|
|
query = query.where(Object.tags.any(Tag.id == tag_id))
|
|
if city is not None:
|
|
query = query.where(Object.city == city)
|
|
if client is not None:
|
|
query = query.where(Object.client == client)
|
|
query = query.order_by(Object.city, Object.client, Object.name)
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("", response_model=ObjectRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_object(
|
|
body: ObjectCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
data = body.model_dump(exclude={"db_password", "ssh_password", "tag_ids"})
|
|
obj = Object(**data)
|
|
_apply_credentials(obj, body.db_password, body.ssh_password)
|
|
|
|
if body.tag_ids:
|
|
tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids)))
|
|
obj.tags = list(tags_result.scalars().all())
|
|
|
|
db.add(obj)
|
|
await db.flush()
|
|
await db.refresh(obj, ["tags"])
|
|
return obj
|
|
|
|
|
|
@router.get("/{obj_id}", response_model=ObjectRead)
|
|
async def get_object(
|
|
obj_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
return await _load_object(db, obj_id)
|
|
|
|
|
|
@router.patch("/{obj_id}", response_model=ObjectRead)
|
|
async def update_object(
|
|
obj_id: int,
|
|
body: ObjectUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
obj = await _load_object(db, obj_id)
|
|
|
|
data = body.model_dump(exclude_none=True, exclude={"db_password", "ssh_password", "tag_ids"})
|
|
for field, value in data.items():
|
|
setattr(obj, field, value)
|
|
|
|
_apply_credentials(obj, body.db_password, body.ssh_password)
|
|
|
|
if body.tag_ids is not None:
|
|
tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids)))
|
|
obj.tags = list(tags_result.scalars().all())
|
|
|
|
await db.flush()
|
|
await db.refresh(obj, ["tags"])
|
|
return obj
|
|
|
|
|
|
@router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_object(
|
|
obj_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
obj = await _load_object(db, obj_id)
|
|
|
|
# Check for devices (RESTRICT constraint would catch this, but give a clear message)
|
|
count_result = await db.execute(
|
|
select(Device.id).where(Device.object_id == obj_id).limit(1)
|
|
)
|
|
if count_result.scalar_one_or_none() is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Cannot delete object with existing devices. Remove all devices first.",
|
|
)
|
|
await db.delete(obj)
|
|
|
|
|
|
@router.post("/{obj_id}/sync", response_model=SyncResult)
|
|
async def sync_devices(
|
|
obj_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
obj = await _load_object(db, obj_id)
|
|
result = await sync_from_object_db(db, obj)
|
|
return SyncResult(
|
|
created=result.created,
|
|
updated=result.updated,
|
|
skipped=result.skipped,
|
|
errors=result.errors,
|
|
)
|
|
|
|
|
|
@router.post("/{obj_id}/discover", response_model=SyncResult)
|
|
async def discover_devices(
|
|
obj_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
"""SSH into each rack PC, parse caps config, discover network devices."""
|
|
obj = await _load_object(db, obj_id)
|
|
result = await discover_via_ssh(db, obj)
|
|
return SyncResult(
|
|
created=result.created,
|
|
updated=result.updated,
|
|
skipped=result.skipped,
|
|
errors=result.errors,
|
|
)
|
|
|
|
|
|
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
|
|
async def sync_devices_from_sheets(
|
|
obj_id: int,
|
|
body: SheetsSyncRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
await _load_object(db, obj_id)
|
|
try:
|
|
result = await sync_from_sheets(
|
|
db,
|
|
object_id=obj_id,
|
|
spreadsheet_id=body.spreadsheet_id,
|
|
sheet_name=body.sheet_name,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
return SyncResult(
|
|
created=result.created,
|
|
updated=result.updated,
|
|
skipped=result.skipped,
|
|
errors=result.errors,
|
|
)
|