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