"""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()