Initial commit: Agentic OS troubleshooting platform
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>
This commit is contained in:
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
37
backend/app/api/auth.py
Normal file
37
backend/app/api/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Authentication endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas import Token, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.email == form.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password"
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
|
||||
token = create_access_token(subject=user.email, role=user.role.value)
|
||||
return Token(access_token=token, role=user.role, email=user.email)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
54
backend/app/api/balance.py
Normal file
54
backend/app/api/balance.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""API balance / consumption endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import require_readonly, require_support
|
||||
from app.database import get_db
|
||||
from app.models.balance import BalanceSnapshot
|
||||
from app.models.user import User
|
||||
from app.schemas import BalanceOut
|
||||
from app.services import balance as balance_service
|
||||
|
||||
router = APIRouter(prefix="/api/balance", tags=["balance"])
|
||||
|
||||
|
||||
async def _latest_snapshot(db: AsyncSession, provider: str) -> BalanceSnapshot | None:
|
||||
"""Prefer the newest successful snapshot; fall back to latest error row."""
|
||||
res = await db.execute(
|
||||
select(BalanceSnapshot)
|
||||
.where(BalanceSnapshot.provider == provider)
|
||||
.where(BalanceSnapshot.error.is_(None))
|
||||
.where(BalanceSnapshot.remaining.is_not(None))
|
||||
.order_by(BalanceSnapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
snap = res.scalar_one_or_none()
|
||||
if snap:
|
||||
return snap
|
||||
res = await db.execute(
|
||||
select(BalanceSnapshot)
|
||||
.where(BalanceSnapshot.provider == provider)
|
||||
.order_by(BalanceSnapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return res.scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("", response_model=list[BalanceOut])
|
||||
async def latest_balances(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
|
||||
"""Latest snapshot per provider."""
|
||||
out: list[BalanceSnapshot] = []
|
||||
for provider in ("openrouter", "deepseek"):
|
||||
snap = await _latest_snapshot(db, provider)
|
||||
if snap:
|
||||
out.append(snap)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=list[BalanceOut])
|
||||
async def refresh_balances(db: AsyncSession = Depends(get_db), _: User = Depends(require_support)):
|
||||
await balance_service.poll_once()
|
||||
return await latest_balances(db) # type: ignore[arg-type]
|
||||
43
backend/app/api/devices.py
Normal file
43
backend/app/api/devices.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Onboard device updates (credentials / port tweaks)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.crypto import encrypt_secret
|
||||
from app.core.deps import require_readonly, require_support
|
||||
from app.database import get_db
|
||||
from app.models.inventory import Device
|
||||
from app.models.user import User
|
||||
from app.schemas import DeviceOut, DeviceUpdate
|
||||
from app.services.vessel_devices import device_to_out
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[DeviceOut])
|
||||
async def list_devices(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
|
||||
res = await db.execute(select(Device).order_by(Device.vessel_id, Device.name))
|
||||
return [device_to_out(d) for d in res.scalars().all()]
|
||||
|
||||
|
||||
@router.patch("/{device_id}", response_model=DeviceOut)
|
||||
async def update_device(
|
||||
device_id: int,
|
||||
body: DeviceUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_support),
|
||||
):
|
||||
res = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = res.scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "secret" in data:
|
||||
device.secret_enc = encrypt_secret(data.pop("secret"))
|
||||
for k, v in data.items():
|
||||
setattr(device, k, v)
|
||||
await db.commit()
|
||||
await db.refresh(device)
|
||||
return device_to_out(device)
|
||||
85
backend/app/api/llm.py
Normal file
85
backend/app/api/llm.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""LLM routing configuration API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.deps import require_admin, require_readonly
|
||||
from app.models.user import User
|
||||
from app.schemas import LlmRoutingConfigIn, LlmRoutingConfigOut, ProviderModelsOut
|
||||
from app.services.model_catalog import catalog_by_tier, get_entry
|
||||
from app.services.model_config import (
|
||||
LlmRoutingConfig,
|
||||
available_backends,
|
||||
get_routing_config,
|
||||
save_routing_config,
|
||||
)
|
||||
from app.services.model_discovery import list_all_provider_models
|
||||
|
||||
router = APIRouter(prefix="/api/llm", tags=["llm"])
|
||||
|
||||
ROUTING_STRATEGY = (
|
||||
"Local Ollama first for triage and reasoning. On escalation, cloud providers are tried "
|
||||
"in order: Gemini API → DeepSeek → OpenRouter. Within each cost tier (economy, premium), "
|
||||
"each provider is attempted before moving to the next tier."
|
||||
)
|
||||
|
||||
_MODEL_ID_FIELDS = (
|
||||
"local_model_id",
|
||||
"economy_gemini_id",
|
||||
"economy_deepseek_id",
|
||||
"economy_openrouter_id",
|
||||
"premium_gemini_id",
|
||||
"premium_deepseek_id",
|
||||
"premium_openrouter_id",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/routing", response_model=LlmRoutingConfigOut)
|
||||
async def get_llm_routing(_: User = Depends(require_readonly)):
|
||||
cfg = await get_routing_config()
|
||||
return LlmRoutingConfigOut(
|
||||
config=cfg.to_dict(),
|
||||
catalog=catalog_by_tier(),
|
||||
available_backends=available_backends(),
|
||||
strategy=ROUTING_STRATEGY,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/models/available", response_model=ProviderModelsOut)
|
||||
async def get_available_models(_: User = Depends(require_readonly)):
|
||||
"""Live model lists from Ollama, Gemini, DeepSeek, and OpenRouter APIs."""
|
||||
data = await list_all_provider_models()
|
||||
return ProviderModelsOut(providers=data["providers"], routing_order=data["routing_order"])
|
||||
|
||||
|
||||
@router.put("/routing", response_model=LlmRoutingConfigOut)
|
||||
async def update_llm_routing(body: LlmRoutingConfigIn, _: User = Depends(require_admin)):
|
||||
current = await get_routing_config()
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
|
||||
for field in (
|
||||
*_MODEL_ID_FIELDS,
|
||||
"auto_escalate",
|
||||
"max_tier",
|
||||
):
|
||||
if field in data and data[field] is not None:
|
||||
setattr(current, field, data[field])
|
||||
|
||||
if body.cloud_provider_order is not None:
|
||||
current.cloud_provider_order = body.cloud_provider_order
|
||||
|
||||
for fid in _MODEL_ID_FIELDS:
|
||||
val = getattr(current, fid)
|
||||
if val and not get_entry(val):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown model id: {val}")
|
||||
|
||||
if current.max_tier not in ("local", "economy", "premium"):
|
||||
raise HTTPException(status_code=400, detail="max_tier must be local, economy, or premium")
|
||||
|
||||
await save_routing_config(current)
|
||||
return LlmRoutingConfigOut(
|
||||
config=current.to_dict(),
|
||||
catalog=catalog_by_tier(),
|
||||
available_backends=available_backends(),
|
||||
strategy=ROUTING_STRATEGY,
|
||||
)
|
||||
117
backend/app/api/mcp.py
Normal file
117
backend/app/api/mcp.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""MCP server status panel and maintenance."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import require_admin, require_readonly
|
||||
from app.database import get_db
|
||||
from app.mcp_manager.commands import enqueue_mcp_command, enqueue_mcp_command_async
|
||||
from app.mcp_manager.manager import MCP_STATUS_KEY
|
||||
from app.models.enums import TaskStatus
|
||||
from app.models.task import Task
|
||||
from app.models.user import User
|
||||
from app.schemas import McpCommandResult, McpDevelopRequest, McpDevelopResult, McpServerOut
|
||||
from app.services.audit import record_audit
|
||||
from app.services.events import get_redis, publish_event
|
||||
|
||||
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||
|
||||
|
||||
@router.get("/servers", response_model=list[McpServerOut])
|
||||
async def list_mcp_servers(_: User = Depends(require_readonly)):
|
||||
raw = await get_redis().get(MCP_STATUS_KEY)
|
||||
if not raw:
|
||||
return []
|
||||
data = json.loads(raw)
|
||||
return [McpServerOut(**s) for s in data]
|
||||
|
||||
|
||||
@router.post("/reload-all", response_model=McpCommandResult)
|
||||
async def reload_all_mcps(_: User = Depends(require_admin)):
|
||||
result = await enqueue_mcp_command("reload_all")
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("error", "Reload failed"))
|
||||
return McpCommandResult(**result)
|
||||
|
||||
|
||||
@router.post("/servers/{name}/reload", response_model=McpCommandResult)
|
||||
async def reload_mcp_server(name: str, _: User = Depends(require_admin)):
|
||||
result = await enqueue_mcp_command("reload", server=name)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("error", "Reload failed"))
|
||||
return McpCommandResult(**result)
|
||||
|
||||
|
||||
@router.post("/servers/{name}/upgrade", response_model=McpCommandResult)
|
||||
async def upgrade_mcp_server(name: str, _: User = Depends(require_admin)):
|
||||
"""Git pull (or re-sync local copy) and restart the MCP server."""
|
||||
result = await enqueue_mcp_command("upgrade", server=name)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("error", "Upgrade failed"))
|
||||
return McpCommandResult(**result)
|
||||
|
||||
|
||||
@router.post("/servers/{name}/develop", response_model=McpDevelopResult)
|
||||
async def develop_mcp_server(
|
||||
name: str,
|
||||
body: McpDevelopRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Research and patch an MCP server; progress is streamed on a monitor task."""
|
||||
task_id = body.task_id
|
||||
if task_id:
|
||||
res = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task.status = TaskStatus.running
|
||||
task.error = None
|
||||
await db.commit()
|
||||
else:
|
||||
task = Task(
|
||||
title=f"MCP develop: {name}",
|
||||
issue=body.issue.strip(),
|
||||
details={"kind": "mcp_develop", "mcp_server": name, "tool": body.tool},
|
||||
status=TaskStatus.running,
|
||||
created_by_id=user.id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
task_id = task.id
|
||||
|
||||
await publish_event(
|
||||
task_id,
|
||||
"log",
|
||||
f"MCP develop queued for {name}",
|
||||
{"server": name, "kind": "mcp_develop"},
|
||||
persist=True,
|
||||
)
|
||||
await enqueue_mcp_command_async(
|
||||
"develop",
|
||||
server=name,
|
||||
issue=body.issue.strip(),
|
||||
tool=body.tool,
|
||||
task_id=task_id,
|
||||
push=body.push,
|
||||
title=f"MCP develop: {name}",
|
||||
)
|
||||
await record_audit(
|
||||
db,
|
||||
user,
|
||||
"mcp.develop",
|
||||
"mcp",
|
||||
task_id,
|
||||
{"server": name, "issue": body.issue[:500]},
|
||||
)
|
||||
|
||||
return McpDevelopResult(
|
||||
ok=True,
|
||||
task_id=task_id,
|
||||
summary=f"Development started — open task #{task_id} to watch live progress.",
|
||||
)
|
||||
69
backend/app/api/rules.py
Normal file
69
backend/app/api/rules.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Editable troubleshooting rules API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.deps import require_admin, require_readonly
|
||||
from app.models.user import User
|
||||
from app.schemas import TroubleshootingRulesOut, TroubleshootingRulesUpdate
|
||||
from app.services.audit import record_audit
|
||||
from app.services.troubleshooting_rules import (
|
||||
load_rules,
|
||||
match_rule,
|
||||
read_rules_raw,
|
||||
save_rules_raw,
|
||||
)
|
||||
from app.database import get_db
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/rules", tags=["rules"])
|
||||
|
||||
|
||||
@router.get("/troubleshooting", response_model=TroubleshootingRulesOut)
|
||||
async def get_troubleshooting_rules(_: User = Depends(require_readonly)):
|
||||
data = load_rules() # mtime-cached; reloads only when the file changes
|
||||
return TroubleshootingRulesOut(
|
||||
yaml=read_rules_raw(),
|
||||
parsed=data,
|
||||
path_hint="rules/troubleshooting.yaml",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/troubleshooting", response_model=TroubleshootingRulesOut)
|
||||
async def update_troubleshooting_rules(
|
||||
body: TroubleshootingRulesUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
parsed = save_rules_raw(body.yaml)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"Invalid rules: {exc}") from exc
|
||||
|
||||
await record_audit(
|
||||
db,
|
||||
user,
|
||||
"rules.troubleshooting.update",
|
||||
"rules",
|
||||
0,
|
||||
{"bytes": len(body.yaml)},
|
||||
)
|
||||
return TroubleshootingRulesOut(
|
||||
yaml=read_rules_raw(),
|
||||
parsed=parsed,
|
||||
path_hint="rules/troubleshooting.yaml",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/troubleshooting/preview")
|
||||
async def preview_rule_match(
|
||||
body: dict,
|
||||
_: User = Depends(require_readonly),
|
||||
):
|
||||
"""Preview which rule and devices match a title + issue (for the Rules UI)."""
|
||||
title = str(body.get("title") or "")
|
||||
issue = str(body.get("issue") or "")
|
||||
matched = match_rule(title, issue)
|
||||
return {"matched": matched.as_dict() if matched else None}
|
||||
457
backend/app/api/tasks.py
Normal file
457
backend/app/api/tasks.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""Task creation, listing, approvals, and live event streaming."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, 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.security import decode_access_token
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.models.enums import ApprovalStatus, TaskStatus
|
||||
from app.models.task import ApprovalRequest, Task
|
||||
from app.models.user import User
|
||||
from app.schemas import (
|
||||
ApprovalDecision,
|
||||
ApprovalOut,
|
||||
TaskCreate,
|
||||
TaskContinue,
|
||||
TaskDetail,
|
||||
TaskEscalateLlm,
|
||||
TaskEventOut,
|
||||
TaskOverviewOut,
|
||||
TaskOut,
|
||||
TaskPreviewIn,
|
||||
TaskPreviewOut,
|
||||
)
|
||||
from app.services.audit import record_audit
|
||||
from app.services.events import enqueue_task, publish_event, subscribe_events
|
||||
from app.inventory_catalog import catalog_by_key
|
||||
from app.models.inventory import Vessel
|
||||
from app.services.task_present import (
|
||||
task_to_detail,
|
||||
task_to_out,
|
||||
tasks_to_out_list,
|
||||
tasks_to_overview_list,
|
||||
vessel_name_map,
|
||||
)
|
||||
from app.services.task_runtime import CANCELLABLE_STATUSES, clear_task_cancel, request_task_cancel
|
||||
from app.services.troubleshooting_rules import select_devices_for_issue
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
EVENT_REPLAY_LIMIT = 400
|
||||
|
||||
CHECK_LABELS = {
|
||||
"proxmox": ["VM and LXC status", "Guest configuration", "Host health"],
|
||||
"pfsense": ["System status", "Interfaces", "Gateways", "Firewall/NAT when requested"],
|
||||
"docker_vm": ["Container status", "Host resources"],
|
||||
"asterisk_geneseasx": ["Asterisk health", "SIP registrations", "Channels"],
|
||||
"asterisk_satbox": ["Asterisk health", "SIP registrations"],
|
||||
"fortigate": ["Firewall status"],
|
||||
"fortiswitch": ["Switch status"],
|
||||
}
|
||||
|
||||
|
||||
def _preview_device_dict(device) -> dict:
|
||||
key = device.catalog_key or device.device_type.value
|
||||
entry = catalog_by_key().get(device.catalog_key or "")
|
||||
return {
|
||||
"id": device.id,
|
||||
"name": device.name,
|
||||
"catalog_key": key,
|
||||
"device_type": device.device_type.value,
|
||||
"label": entry.label if entry else key.replace("_", " ").title(),
|
||||
}
|
||||
|
||||
|
||||
def _checks_for_device(key: str | None) -> list[str]:
|
||||
return CHECK_LABELS.get(key or "", ["Health and status checks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_readonly),
|
||||
):
|
||||
limit = max(1, min(limit, 500))
|
||||
offset = max(0, offset)
|
||||
res = await db.execute(
|
||||
select(Task).order_by(Task.id.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
return await tasks_to_out_list(db, list(res.scalars().all()))
|
||||
|
||||
|
||||
@router.get("/overview", response_model=list[TaskOverviewOut])
|
||||
async def task_overview(
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_readonly),
|
||||
):
|
||||
"""Compact task data for dashboard cards. No raw diagnostic payloads."""
|
||||
limit = max(1, min(limit, 200))
|
||||
res = await db.execute(select(Task).order_by(Task.id.desc()).limit(limit))
|
||||
return await tasks_to_overview_list(db, list(res.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/preview", response_model=TaskPreviewOut)
|
||||
async def preview_task_scope(
|
||||
body: TaskPreviewIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_readonly),
|
||||
):
|
||||
"""Preview the devices/check groups that task creation will select."""
|
||||
res = await db.execute(
|
||||
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == body.vessel_id)
|
||||
)
|
||||
vessel = res.scalar_one_or_none()
|
||||
if not vessel:
|
||||
raise HTTPException(status_code=404, detail="Vessel not found")
|
||||
device_dicts = [_preview_device_dict(d) for d in vessel.devices]
|
||||
selected, rule = select_devices_for_issue(device_dicts, body.issue, title=body.title)
|
||||
warning = None
|
||||
if not selected:
|
||||
warning = "No onboard device matched this issue. Mention a device type or ask for a full health check."
|
||||
return TaskPreviewOut(
|
||||
vessel_id=vessel.id,
|
||||
vessel_name=vessel.name,
|
||||
devices=[
|
||||
{
|
||||
"catalog_key": d.get("catalog_key") or d.get("device_type") or "device",
|
||||
"name": d.get("name") or d.get("catalog_key") or "device",
|
||||
"label": d.get("label") or (d.get("catalog_key") or "device").replace("_", " ").title(),
|
||||
"checks": _checks_for_device(d.get("catalog_key")),
|
||||
}
|
||||
for d in selected
|
||||
],
|
||||
rule_id=rule.id if rule else None,
|
||||
rule_name=rule.name if rule else None,
|
||||
severity=rule.severity if rule else None,
|
||||
hints=rule.hints if rule else [],
|
||||
warning=warning,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
body: TaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_support),
|
||||
):
|
||||
if not body.vessel_id:
|
||||
raise HTTPException(status_code=400, detail="vessel_id is required — pick a vessel; the agent selects devices from the issue")
|
||||
task = Task(
|
||||
title=body.title,
|
||||
issue=body.issue,
|
||||
details=body.details,
|
||||
device_id=None,
|
||||
vessel_id=body.vessel_id,
|
||||
created_by_id=user.id,
|
||||
status=TaskStatus.queued,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
await enqueue_task(task.id)
|
||||
await publish_event(task.id, "log", "Task queued.", persist=True)
|
||||
await record_audit(db, user, "task.create", "task", task.id, {"title": task.title})
|
||||
vmap = await vessel_name_map(db, {task.vessel_id} if task.vessel_id else set())
|
||||
return task_to_out(task, vessel_name=vmap.get(task.vessel_id) if task.vessel_id else None)
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel", response_model=TaskOut)
|
||||
async def cancel_task(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_support),
|
||||
):
|
||||
"""Stop a queued or running task. In-flight work is interrupted at the next checkpoint."""
|
||||
res = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status.value not in CANCELLABLE_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot stop a task with status '{task.status.value}'",
|
||||
)
|
||||
|
||||
await request_task_cancel(task_id)
|
||||
task.status = TaskStatus.cancelled
|
||||
task.error = None
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
await publish_event(
|
||||
task_id,
|
||||
"cancelled",
|
||||
f"Task stopped by {user.email}.",
|
||||
{"requested_by": user.email},
|
||||
persist=True,
|
||||
)
|
||||
await record_audit(db, user, "task.cancel", "task", task.id)
|
||||
vmap = await vessel_name_map(db, {task.vessel_id} if task.vessel_id else set())
|
||||
return task_to_out(task, vessel_name=vmap.get(task.vessel_id) if task.vessel_id else None)
|
||||
|
||||
|
||||
@router.post("/{task_id}/retry", response_model=TaskOut)
|
||||
async def retry_task(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_support),
|
||||
):
|
||||
res = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status in (TaskStatus.running, TaskStatus.queued):
|
||||
raise HTTPException(status_code=400, detail="Task is already running")
|
||||
await clear_task_cancel(task_id)
|
||||
task.status = TaskStatus.queued
|
||||
task.error = None
|
||||
task.report = None
|
||||
task.details = {}
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
await enqueue_task(task.id)
|
||||
await publish_event(task_id, "log", "Task re-queued from scratch (run history cleared).", persist=True)
|
||||
await record_audit(db, user, "task.retry", "task", task.id)
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/continue", response_model=TaskOut)
|
||||
async def continue_task(
|
||||
task_id: int,
|
||||
body: TaskContinue,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_support),
|
||||
):
|
||||
"""Run another investigation pass on the same task with optional follow-up instructions."""
|
||||
res = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status in (TaskStatus.running, TaskStatus.queued):
|
||||
raise HTTPException(status_code=400, detail="Task is already running")
|
||||
if not body.follow_up.strip():
|
||||
raise HTTPException(status_code=400, detail="follow_up is required — describe what to check next")
|
||||
|
||||
await clear_task_cancel(task_id)
|
||||
details = dict(task.details or {})
|
||||
details["_continue"] = {
|
||||
"follow_up": body.follow_up.strip(),
|
||||
"prior_report": task.report,
|
||||
}
|
||||
task.details = details
|
||||
task.status = TaskStatus.queued
|
||||
task.error = None
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
await enqueue_task(task.id)
|
||||
await publish_event(
|
||||
task_id,
|
||||
"log",
|
||||
f"Task queued for follow-up run: {body.follow_up.strip()[:200]}",
|
||||
persist=True,
|
||||
)
|
||||
await record_audit(db, user, "task.continue", "task", task.id, {"follow_up": body.follow_up[:500]})
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/escalate-llm", response_model=TaskOut)
|
||||
async def escalate_task_llm(
|
||||
task_id: int,
|
||||
body: TaskEscalateLlm,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_support),
|
||||
):
|
||||
"""Request cloud API for reasoning — skips remaining local LLM tiers when possible."""
|
||||
from app.services.task_runtime import set_task_min_tier
|
||||
|
||||
res = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status != TaskStatus.running:
|
||||
raise HTTPException(status_code=400, detail="Task is not running")
|
||||
|
||||
await set_task_min_tier(task_id, body.tier)
|
||||
label = "Economy" if body.tier == "economy" else "Premium"
|
||||
await publish_event(
|
||||
task_id,
|
||||
"log",
|
||||
f"{label} cloud API requested by {user.email} — will skip local LLM for remaining reasoning steps.",
|
||||
{"min_tier": body.tier, "requested_by": user.email},
|
||||
persist=True,
|
||||
)
|
||||
await record_audit(db, user, "task.escalate_llm", "task", task.id, {"tier": body.tier})
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskDetail)
|
||||
async def get_task(
|
||||
task_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
||||
):
|
||||
res = await db.execute(
|
||||
select(Task).options(selectinload(Task.events)).where(Task.id == task_id)
|
||||
)
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
vmap = await vessel_name_map(db, {task.vessel_id} if task.vessel_id else set())
|
||||
return task_to_detail(task, vessel_name=vmap.get(task.vessel_id) if task.vessel_id else None)
|
||||
|
||||
|
||||
@router.get("/{task_id}/events", response_model=list[TaskEventOut])
|
||||
async def get_task_events(
|
||||
task_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
||||
):
|
||||
res = await db.execute(
|
||||
select(Task).options(selectinload(Task.events)).where(Task.id == task_id)
|
||||
)
|
||||
task = res.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task.events
|
||||
|
||||
|
||||
@router.get("/{task_id}/approvals", response_model=list[ApprovalOut])
|
||||
async def get_approvals(
|
||||
task_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
||||
):
|
||||
res = await db.execute(select(ApprovalRequest).where(ApprovalRequest.task_id == task_id))
|
||||
return res.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{task_id}/approvals/{approval_id}", response_model=ApprovalOut)
|
||||
async def decide_approval(
|
||||
task_id: int,
|
||||
approval_id: int,
|
||||
body: ApprovalDecision,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_support),
|
||||
):
|
||||
res = await db.execute(
|
||||
select(ApprovalRequest).where(
|
||||
ApprovalRequest.id == approval_id, ApprovalRequest.task_id == task_id
|
||||
)
|
||||
)
|
||||
approval = res.scalar_one_or_none()
|
||||
if not approval:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
from datetime import datetime, timezone
|
||||
|
||||
approval.status = ApprovalStatus.approved if body.approve else ApprovalStatus.rejected
|
||||
approval.decided_by_id = user.id
|
||||
approval.decided_at = datetime.now(timezone.utc)
|
||||
|
||||
if body.approve and approval.tool_name == "mcp_git_push" and approval.tool_args:
|
||||
from app.services import mcp_workspace
|
||||
|
||||
args = approval.tool_args
|
||||
push = await mcp_workspace.commit_and_push(
|
||||
args.get("server", ""),
|
||||
args.get("commit_message", "agentic-os: MCP update"),
|
||||
args.get("files") or [],
|
||||
)
|
||||
if not push.get("ok"):
|
||||
if push.get("runtime_only"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
push.get("error")
|
||||
or "Changes exist only in the running MCP — no git repo to push."
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=push.get("error", "Git push failed"),
|
||||
)
|
||||
note = push.get("note") or ""
|
||||
msg = f"MCP pushed to Gitea: {args.get('server')}"
|
||||
if push.get("commit"):
|
||||
msg += f" ({push.get('commit')})"
|
||||
elif note:
|
||||
msg += f" — {note}"
|
||||
await publish_event(
|
||||
task_id,
|
||||
"mcp_dev",
|
||||
msg,
|
||||
push,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(approval)
|
||||
await record_audit(
|
||||
db, user, "approval.decide", "approval", approval.id, {"approve": body.approve}
|
||||
)
|
||||
await publish_event(
|
||||
task_id,
|
||||
"approval",
|
||||
f"Approval {approval.id} {'approved' if body.approve else 'rejected'} by {user.email}",
|
||||
{"approval_id": approval.id, "approve": body.approve},
|
||||
)
|
||||
|
||||
# If no more pending approvals, settle task status
|
||||
pend = await db.execute(
|
||||
select(ApprovalRequest).where(
|
||||
ApprovalRequest.task_id == task_id, ApprovalRequest.status == ApprovalStatus.pending
|
||||
)
|
||||
)
|
||||
if pend.scalars().first() is None:
|
||||
tres = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = tres.scalar_one_or_none()
|
||||
if task and task.status == TaskStatus.waiting_approval:
|
||||
task.status = TaskStatus.succeeded
|
||||
await db.commit()
|
||||
return approval
|
||||
|
||||
|
||||
@router.websocket("/{task_id}/stream")
|
||||
async def stream_task(websocket: WebSocket, task_id: int):
|
||||
"""Live event stream. Authenticate via ?token=<jwt> query param."""
|
||||
token = websocket.query_params.get("token")
|
||||
payload = decode_access_token(token) if token else None
|
||||
if not payload:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
# Replay the most recent persisted events first (bounded), oldest-first.
|
||||
async with SessionLocal() as db:
|
||||
from app.models.task import TaskEvent
|
||||
|
||||
res = await db.execute(
|
||||
select(TaskEvent)
|
||||
.where(TaskEvent.task_id == task_id)
|
||||
.order_by(TaskEvent.id.desc())
|
||||
.limit(EVENT_REPLAY_LIMIT)
|
||||
)
|
||||
recent = list(reversed(res.scalars().all()))
|
||||
for ev in recent:
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"kind": ev.kind,
|
||||
"message": ev.message,
|
||||
"payload": ev.payload,
|
||||
"ts": ev.created_at.isoformat(),
|
||||
"replay": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async for event in subscribe_events(task_id):
|
||||
await websocket.send_text(json.dumps(event))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
85
backend/app/api/users.py
Normal file
85
backend/app/api/users.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""User management (admin only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import require_admin
|
||||
from app.core.security import hash_password
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas import UserCreate, UserOut, UserUpdate
|
||||
from app.services.audit import record_audit
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
|
||||
res = await db.execute(select(User).order_by(User.id))
|
||||
return res.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
exists = await db.execute(select(User).where(User.email == body.email))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Email already registered")
|
||||
user = User(
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
role=body.role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await record_audit(db, admin, "user.create", "user", user.id, {"email": user.email, "role": user.role.value})
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
res = await db.execute(select(User).where(User.id == user_id))
|
||||
user = res.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if body.full_name is not None:
|
||||
user.full_name = body.full_name
|
||||
if body.role is not None:
|
||||
user.role = body.role
|
||||
if body.is_active is not None:
|
||||
user.is_active = body.is_active
|
||||
if body.password:
|
||||
user.hashed_password = hash_password(body.password)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await record_audit(db, admin, "user.update", "user", user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
res = await db.execute(select(User).where(User.id == user_id))
|
||||
user = res.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
await record_audit(db, admin, "user.delete", "user", user_id)
|
||||
160
backend/app/api/vessels.py
Normal file
160
backend/app/api/vessels.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user