Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
"""Vessel inventory CRUD with onboard device checklist."""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.deps import require_readonly, require_support
|
|
from app.core.crypto import decrypt_secret
|
|
from app.database import get_db
|
|
from app.inventory_catalog import catalog_as_dicts
|
|
from app.models.inventory import Device, Vessel
|
|
from app.models.user import User
|
|
from app.schemas import CatalogEntryOut, DeviceOut, VesselCreate, VesselDetail, VesselOut, VesselUpdate
|
|
from app.services.vessel_devices import build_device, device_to_out, validate_selections
|
|
|
|
router = APIRouter(prefix="/api/vessels", tags=["vessels"])
|
|
|
|
|
|
@router.get("/catalog", response_model=list[CatalogEntryOut])
|
|
async def list_device_catalog(_: User = Depends(require_readonly)):
|
|
"""Predefined device/VM slots with default ports for vessel setup."""
|
|
return catalog_as_dicts()
|
|
|
|
|
|
@router.get("", response_model=list[VesselDetail])
|
|
async def list_vessels(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
|
|
res = await db.execute(
|
|
select(Vessel).options(selectinload(Vessel.devices)).order_by(Vessel.name)
|
|
)
|
|
vessels = res.scalars().all()
|
|
out: list[VesselDetail] = []
|
|
for vessel in vessels:
|
|
detail = VesselDetail.model_validate(vessel)
|
|
detail.devices = [device_to_out(d) for d in vessel.devices]
|
|
out.append(detail)
|
|
return out
|
|
|
|
|
|
@router.get("/{vessel_id}", response_model=VesselDetail)
|
|
async def get_vessel(
|
|
vessel_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
|
):
|
|
res = await db.execute(
|
|
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel_id)
|
|
)
|
|
vessel = res.scalar_one_or_none()
|
|
if not vessel:
|
|
raise HTTPException(status_code=404, detail="Vessel not found")
|
|
detail = VesselDetail.model_validate(vessel)
|
|
detail.devices = [device_to_out(d) for d in vessel.devices]
|
|
return detail
|
|
|
|
|
|
@router.post("", response_model=VesselDetail, status_code=status.HTTP_201_CREATED)
|
|
async def create_vessel(
|
|
body: VesselCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(require_support),
|
|
):
|
|
try:
|
|
pairs = validate_selections(body.devices)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
if not body.public_ip:
|
|
needs_ip = any(not s.address for s, _ in pairs)
|
|
if needs_ip:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Vessel public IP is required when devices use the shared endpoint",
|
|
)
|
|
|
|
vessel = Vessel(
|
|
name=body.name,
|
|
site=body.site,
|
|
public_ip=body.public_ip,
|
|
notes=body.notes,
|
|
)
|
|
db.add(vessel)
|
|
await db.flush()
|
|
|
|
for sel, entry in pairs:
|
|
db.add(build_device(vessel, sel, entry))
|
|
|
|
await db.commit()
|
|
await db.refresh(vessel)
|
|
res = await db.execute(
|
|
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel.id)
|
|
)
|
|
saved = res.scalar_one()
|
|
detail = VesselDetail.model_validate(saved)
|
|
detail.devices = [device_to_out(d) for d in saved.devices]
|
|
return detail
|
|
|
|
|
|
@router.patch("/{vessel_id}", response_model=VesselDetail)
|
|
async def update_vessel(
|
|
vessel_id: int,
|
|
body: VesselUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(require_support),
|
|
):
|
|
res = await db.execute(
|
|
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel_id)
|
|
)
|
|
vessel = res.scalar_one_or_none()
|
|
if not vessel:
|
|
raise HTTPException(status_code=404, detail="Vessel not found")
|
|
|
|
for field in ("name", "site", "public_ip", "notes"):
|
|
val = getattr(body, field)
|
|
if val is not None:
|
|
setattr(vessel, field, val)
|
|
|
|
if body.devices is not None:
|
|
try:
|
|
pairs = validate_selections(body.devices)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
if not vessel.public_ip and any(not s.address for s, _ in pairs):
|
|
raise HTTPException(status_code=400, detail="Set vessel public IP or per-device addresses")
|
|
|
|
# Replace onboard devices from the new checklist
|
|
old_by_key = {d.catalog_key: d for d in vessel.devices if d.catalog_key}
|
|
for d in list(vessel.devices):
|
|
await db.delete(d)
|
|
await db.flush()
|
|
for sel, entry in pairs:
|
|
existing_plain = None
|
|
if not sel.secret and sel.catalog_key in old_by_key:
|
|
enc = old_by_key[sel.catalog_key].secret_enc
|
|
if enc:
|
|
existing_plain = decrypt_secret(enc)
|
|
db.add(build_device(vessel, sel, entry, existing_secret_plain=existing_plain))
|
|
|
|
await db.commit()
|
|
res = await db.execute(
|
|
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel.id)
|
|
)
|
|
saved = res.scalar_one()
|
|
detail = VesselDetail.model_validate(saved)
|
|
detail.devices = [device_to_out(d) for d in saved.devices]
|
|
return detail
|
|
|
|
|
|
@router.delete("/{vessel_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_vessel(
|
|
vessel_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(require_support),
|
|
):
|
|
res = await db.execute(select(Vessel).where(Vessel.id == vessel_id))
|
|
vessel = res.scalar_one_or_none()
|
|
if not vessel:
|
|
raise HTTPException(status_code=404, detail="Vessel not found")
|
|
await db.delete(vessel)
|
|
await db.commit()
|