546 lines
17 KiB
Python
546 lines
17 KiB
Python
"""API routes for the Automation plugin — /api/v1/automation.
|
|
|
|
Endpoints: automation definitions CRUD, execute, dry-run, runs, versions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.db import get_db, set_tenant_context
|
|
from app.deps import get_current_user, require_permission
|
|
from app.plugins.builtins.automation.models import (
|
|
AutomationDefinition,
|
|
AutomationRun,
|
|
AutomationVersion,
|
|
)
|
|
from app.plugins.builtins.automation.schemas import (
|
|
AutomationDefinitionCreate,
|
|
AutomationDefinitionListResponse,
|
|
AutomationDefinitionResponse,
|
|
AutomationDefinitionUpdate,
|
|
AutomationRunListResponse,
|
|
AutomationRunResponse,
|
|
AutomationSettingsResponse,
|
|
AutomationSettingsUpdate,
|
|
AutomationVersionListResponse,
|
|
AutomationVersionResponse,
|
|
MiniAppCreate,
|
|
MiniAppResponse,
|
|
)
|
|
from app.plugins.builtins.automation.services import (
|
|
AutomationService,
|
|
RunLogService,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/automation", tags=["automation"])
|
|
|
|
|
|
# ─── Helper Functions ───
|
|
|
|
|
|
def _automation_to_response(a: AutomationDefinition) -> AutomationDefinitionResponse:
|
|
"""Convert AutomationDefinition model to response schema."""
|
|
return AutomationDefinitionResponse(
|
|
id=str(a.id),
|
|
name=a.name,
|
|
description=a.description or "",
|
|
trigger_type=a.trigger_type,
|
|
trigger_config=a.trigger_config or {},
|
|
conditions=a.conditions or [],
|
|
actions=a.actions or [],
|
|
is_active=a.is_active,
|
|
dry_run=a.dry_run,
|
|
created_by=str(a.created_by) if a.created_by else None,
|
|
created_at=a.created_at.isoformat() if a.created_at else None,
|
|
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
|
)
|
|
|
|
|
|
def _run_to_response(r: AutomationRun) -> AutomationRunResponse:
|
|
"""Convert AutomationRun model to response schema."""
|
|
return AutomationRunResponse(
|
|
id=str(r.id),
|
|
automation_id=str(r.automation_id),
|
|
status=r.status,
|
|
started_at=r.started_at.isoformat() if r.started_at else "",
|
|
completed_at=r.completed_at.isoformat() if r.completed_at else None,
|
|
duration_seconds=r.duration_seconds,
|
|
result=r.result,
|
|
error=r.error,
|
|
trigger_type=r.trigger_type,
|
|
trigger_data=r.trigger_data or {},
|
|
dry_run=r.dry_run,
|
|
created_at=r.created_at.isoformat() if r.created_at else None,
|
|
)
|
|
|
|
|
|
def _version_to_response(v: AutomationVersion) -> AutomationVersionResponse:
|
|
"""Convert AutomationVersion model to response schema."""
|
|
return AutomationVersionResponse(
|
|
id=str(v.id),
|
|
automation_id=str(v.automation_id),
|
|
version_number=v.version_number,
|
|
snapshot=v.snapshot or {},
|
|
changed_by=str(v.changed_by) if v.changed_by else None,
|
|
created_at=v.created_at.isoformat() if v.created_at else None,
|
|
)
|
|
|
|
|
|
# ─── CRUD Endpoints ───
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
dependencies=[Depends(require_permission("automation:read"))],
|
|
response_model=AutomationDefinitionListResponse,
|
|
)
|
|
async def list_automations(
|
|
trigger_type: str | None = Query(None),
|
|
is_active: bool | None = Query(None),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List automation definitions with optional filters."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
items, total = await AutomationService.list(
|
|
db, tenant_id, trigger_type=trigger_type, is_active=is_active,
|
|
limit=limit, offset=offset,
|
|
)
|
|
return AutomationDefinitionListResponse(
|
|
items=[_automation_to_response(a) for a in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/",
|
|
dependencies=[Depends(require_permission("automation:write"))],
|
|
response_model=AutomationDefinitionResponse,
|
|
status_code=201,
|
|
)
|
|
async def create_automation(
|
|
data: AutomationDefinitionCreate,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new automation definition."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
automation = await AutomationService.create(
|
|
db, tenant_id, data.model_dump(), user_id=user_id
|
|
)
|
|
return _automation_to_response(automation)
|
|
|
|
|
|
# ─── Recent Runs ───
|
|
|
|
|
|
@router.get(
|
|
"/runs/recent",
|
|
dependencies=[Depends(require_permission("automation:read"))],
|
|
response_model=AutomationRunListResponse,
|
|
)
|
|
async def list_recent_automation_runs(
|
|
limit: int = Query(20, ge=1, le=100),
|
|
status: str | None = Query(None),
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List recent automation runs across all automations (for dashboard)."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
items, total = await RunLogService.list_automation_runs(
|
|
db, tenant_id, automation_id=None, status=status, limit=limit, offset=0
|
|
)
|
|
return AutomationRunListResponse(
|
|
items=[_run_to_response(r) for r in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
# ─── MiniApps ───
|
|
|
|
|
|
@router.get(
|
|
"/miniapps",
|
|
dependencies=[Depends(require_permission("automation:read"))],
|
|
response_model=list[MiniAppResponse],
|
|
)
|
|
async def list_miniapps(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""List custom MiniApps from plugin config."""
|
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
|
registry = MiniAppRegistry()
|
|
return registry.list_apps()
|
|
|
|
|
|
@router.post(
|
|
"/miniapps",
|
|
dependencies=[Depends(require_permission("automation:write"))],
|
|
response_model=MiniAppResponse,
|
|
status_code=201,
|
|
)
|
|
async def create_miniapp(
|
|
data: MiniAppCreate,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""Create a custom MiniApp definition."""
|
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
|
registry = MiniAppRegistry()
|
|
registry.register(
|
|
app_id=data.app_id,
|
|
name=data.name,
|
|
icon=data.icon,
|
|
description=data.description,
|
|
plugin_name="automation",
|
|
render_schema=data.render_schema,
|
|
)
|
|
return MiniAppResponse(
|
|
app_id=data.app_id,
|
|
name=data.name,
|
|
icon=data.icon,
|
|
description=data.description,
|
|
plugin_name="automation",
|
|
render_schema=data.render_schema,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/miniapps/{app_id}",
|
|
dependencies=[Depends(require_permission("automation:delete"))],
|
|
)
|
|
async def delete_miniapp(
|
|
app_id: str,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""Delete a custom MiniApp definition."""
|
|
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
|
registry = MiniAppRegistry()
|
|
registry.unregister(app_id)
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ─── Settings ───
|
|
|
|
|
|
@router.get(
|
|
"/settings",
|
|
dependencies=[Depends(require_permission("automation:configure"))],
|
|
response_model=AutomationSettingsResponse,
|
|
)
|
|
async def get_automation_settings(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""Get automation default settings."""
|
|
# Return default settings (could be stored in plugin config in the future)
|
|
return AutomationSettingsResponse(
|
|
default_llm_model="ollama/deepseek-v4-flash",
|
|
heartbeat_default_interval=300,
|
|
max_concurrent_agents=5,
|
|
log_level="INFO",
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/settings",
|
|
dependencies=[Depends(require_permission("automation:configure"))],
|
|
response_model=AutomationSettingsResponse,
|
|
)
|
|
async def update_automation_settings(
|
|
data: AutomationSettingsUpdate,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""Update automation settings (stored in plugin config)."""
|
|
# TODO: Persist settings to plugin config table
|
|
# For now, return the updated values as a preview
|
|
settings = AutomationSettingsResponse(
|
|
default_llm_model=data.default_llm_model or "ollama/deepseek-v4-flash",
|
|
heartbeat_default_interval=data.heartbeat_default_interval or 300,
|
|
max_concurrent_agents=data.max_concurrent_agents or 5,
|
|
log_level=data.log_level or "INFO",
|
|
)
|
|
return settings
|
|
@router.get(
|
|
"/{automation_id}",
|
|
dependencies=[Depends(require_permission("automation:read"))],
|
|
response_model=AutomationDefinitionResponse,
|
|
)
|
|
async def get_automation(
|
|
automation_id: str,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get a single automation definition by ID."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
|
if automation is None:
|
|
raise HTTPException(status_code=404, detail="Automation not found")
|
|
return _automation_to_response(automation)
|
|
|
|
|
|
@router.patch(
|
|
"/{automation_id}",
|
|
dependencies=[Depends(require_permission("automation:write"))],
|
|
response_model=AutomationDefinitionResponse,
|
|
)
|
|
async def update_automation(
|
|
automation_id: str,
|
|
data: AutomationDefinitionUpdate,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update an existing automation definition."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
automation = await AutomationService.update(
|
|
db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id
|
|
)
|
|
if automation is None:
|
|
raise HTTPException(status_code=404, detail="Automation not found")
|
|
return _automation_to_response(automation)
|
|
|
|
|
|
@router.delete(
|
|
"/{automation_id}",
|
|
dependencies=[Depends(require_permission("automation:delete"))],
|
|
)
|
|
async def delete_automation(
|
|
automation_id: str,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete an automation definition."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
success = await AutomationService.delete(db, tenant_id, aid)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Automation not found")
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ─── Execute / Dry-Run ───
|
|
|
|
|
|
@router.post(
|
|
"/{automation_id}/execute",
|
|
dependencies=[Depends(require_permission("automation:execute"))],
|
|
)
|
|
async def execute_automation(
|
|
automation_id: str,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Execute an automation definition."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
|
if automation is None:
|
|
raise HTTPException(status_code=404, detail="Automation not found")
|
|
if not automation.is_active:
|
|
raise HTTPException(status_code=400, detail="Automation is not active")
|
|
|
|
# Create run log entry
|
|
run = AutomationRun(
|
|
tenant_id=tenant_id,
|
|
automation_id=aid,
|
|
status="running",
|
|
started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
|
trigger_type="manual",
|
|
trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"]))},
|
|
dry_run=False,
|
|
)
|
|
db.add(run)
|
|
await db.flush()
|
|
|
|
# TODO: Execute automation actions asynchronously
|
|
run.status = "completed"
|
|
run.completed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
|
|
run.duration_seconds = 0.0
|
|
run.result = "Executed successfully (stub)"
|
|
await db.flush()
|
|
|
|
return {"status": "ok", "run_id": str(run.id)}
|
|
|
|
|
|
@router.post(
|
|
"/{automation_id}/dry-run",
|
|
dependencies=[Depends(require_permission("automation:execute"))],
|
|
)
|
|
async def dry_run_automation(
|
|
automation_id: str,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Dry-run an automation definition (validate without executing)."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
automation = await AutomationService.get_by_id(db, tenant_id, aid)
|
|
if automation is None:
|
|
raise HTTPException(status_code=404, detail="Automation not found")
|
|
|
|
# Execute dry-run via execution engine
|
|
from app.plugins.builtins.automation.execution_engine import run_automation as execute_automation_engine
|
|
|
|
result = await execute_automation_engine(
|
|
ctx={},
|
|
automation_id=str(aid),
|
|
trigger_type="manual",
|
|
trigger_data={
|
|
"triggered_by": str(uuid.UUID(current_user["user_id"])),
|
|
"dry_run": True,
|
|
},
|
|
)
|
|
|
|
# Create dry-run log entry
|
|
run = AutomationRun(
|
|
tenant_id=tenant_id,
|
|
automation_id=aid,
|
|
status=result.get("status", "dry_run"),
|
|
started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
|
completed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
|
|
duration_seconds=0.0,
|
|
trigger_type="manual",
|
|
trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"])), "dry_run": True},
|
|
dry_run=True,
|
|
result=str(result),
|
|
)
|
|
db.add(run)
|
|
await db.flush()
|
|
|
|
return {
|
|
"status": "ok",
|
|
"run_id": str(run.id),
|
|
"automation": _automation_to_response(automation),
|
|
"validation": {
|
|
"valid": True,
|
|
"conditions_count": len(automation.conditions or []),
|
|
"actions_count": len(automation.actions or []),
|
|
},
|
|
"dry_run_result": result,
|
|
}
|
|
|
|
|
|
# ─── Runs ───
|
|
|
|
|
|
@router.get(
|
|
"/{automation_id}/runs",
|
|
dependencies=[Depends(require_permission("automation:read"))],
|
|
response_model=AutomationRunListResponse,
|
|
)
|
|
async def list_automation_runs(
|
|
automation_id: str,
|
|
status: str | None = Query(None),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List run logs for an automation definition."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
items, total = await RunLogService.list_automation_runs(
|
|
db, tenant_id, automation_id=aid, status=status, limit=limit, offset=offset
|
|
)
|
|
return AutomationRunListResponse(
|
|
items=[_run_to_response(r) for r in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
# ─── Versions ───
|
|
|
|
|
|
@router.get(
|
|
"/{automation_id}/versions",
|
|
dependencies=[Depends(require_permission("automation:read"))],
|
|
response_model=AutomationVersionListResponse,
|
|
)
|
|
async def list_automation_versions(
|
|
automation_id: str,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List version history for an automation definition."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid automation ID")
|
|
|
|
items, total = await AutomationService.get_versions(
|
|
db, tenant_id, aid, limit=limit, offset=offset
|
|
)
|
|
return AutomationVersionListResponse(
|
|
items=[_version_to_response(v) for v in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{automation_id}/versions/{version_id}/restore",
|
|
dependencies=[Depends(require_permission("automation:write"))],
|
|
response_model=AutomationDefinitionResponse,
|
|
)
|
|
async def restore_automation_version(
|
|
automation_id: str,
|
|
version_id: str,
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Restore an automation definition from a specific version."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
try:
|
|
aid = uuid.UUID(automation_id)
|
|
vid = uuid.UUID(version_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="Invalid ID")
|
|
|
|
automation = await AutomationService.restore_version(
|
|
db, tenant_id, aid, vid, user_id=user_id
|
|
)
|
|
if automation is None:
|
|
raise HTTPException(status_code=404, detail="Automation or version not found")
|
|
return _automation_to_response(automation)
|
|
|
|
|