mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(automations): static_inputs on triggers + vertical-slice api/services
This commit is contained in:
parent
84d99f19a2
commit
27ab367a13
27 changed files with 915 additions and 356 deletions
|
|
@ -5,8 +5,12 @@ from __future__ import annotations
|
|||
from fastapi import APIRouter
|
||||
|
||||
from .automation import router as automation_router
|
||||
from .run import router as run_router
|
||||
from .trigger import router as trigger_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(automation_router)
|
||||
router.include_router(trigger_router)
|
||||
router.include_router(run_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -1,23 +1,80 @@
|
|||
"""Routes for the ``Automation`` resource."""
|
||||
"""HTTP routes for the ``Automation`` resource."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from app.automations.api.schemas import RunDispatched
|
||||
from app.automations.schemas.api import (
|
||||
AutomationCreate,
|
||||
AutomationDetail,
|
||||
AutomationList,
|
||||
AutomationSummary,
|
||||
AutomationUpdate,
|
||||
)
|
||||
from app.automations.services import AutomationService, get_automation_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/automations/{automation_id}/run", response_model=RunDispatched)
|
||||
async def run_automation_now(
|
||||
automation_id: int,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
@router.post(
|
||||
"/automations",
|
||||
response_model=AutomationDetail,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_automation(
|
||||
payload: AutomationCreate,
|
||||
service: AutomationService = Depends(get_automation_service),
|
||||
) -> RunDispatched:
|
||||
"""Fire a manual run."""
|
||||
run = await service.run_now(automation_id=automation_id, payload=payload)
|
||||
return RunDispatched(run_id=run.id, status=run.status)
|
||||
) -> AutomationDetail:
|
||||
"""Create an automation, optionally with initial triggers (atomic)."""
|
||||
automation = await service.create(payload)
|
||||
return AutomationDetail.model_validate(automation)
|
||||
|
||||
|
||||
@router.get("/automations", response_model=AutomationList)
|
||||
async def list_automations(
|
||||
search_space_id: int = Query(...),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
service: AutomationService = Depends(get_automation_service),
|
||||
) -> AutomationList:
|
||||
"""List automations in a search space."""
|
||||
items, total = await service.list(
|
||||
search_space_id=search_space_id, limit=limit, offset=offset
|
||||
)
|
||||
return AutomationList(
|
||||
items=[AutomationSummary.model_validate(a) for a in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/automations/{automation_id}", response_model=AutomationDetail)
|
||||
async def get_automation(
|
||||
automation_id: int,
|
||||
service: AutomationService = Depends(get_automation_service),
|
||||
) -> AutomationDetail:
|
||||
"""Get one automation with its definition and triggers."""
|
||||
automation = await service.get(automation_id)
|
||||
return AutomationDetail.model_validate(automation)
|
||||
|
||||
|
||||
@router.patch("/automations/{automation_id}", response_model=AutomationDetail)
|
||||
async def update_automation(
|
||||
automation_id: int,
|
||||
patch: AutomationUpdate,
|
||||
service: AutomationService = Depends(get_automation_service),
|
||||
) -> AutomationDetail:
|
||||
"""Partially update an automation. Triggers are managed separately."""
|
||||
automation = await service.update(automation_id, patch)
|
||||
return AutomationDetail.model_validate(automation)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/automations/{automation_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_automation(
|
||||
automation_id: int,
|
||||
service: AutomationService = Depends(get_automation_service),
|
||||
) -> None:
|
||||
"""Delete an automation; triggers and runs are removed by FK cascade."""
|
||||
await service.delete(automation_id)
|
||||
|
|
|
|||
71
surfsense_backend/app/automations/api/run.py
Normal file
71
surfsense_backend/app/automations/api/run.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""HTTP routes for automation runs (dispatch + history)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, status
|
||||
|
||||
from app.automations.schemas.api import (
|
||||
RunDetail,
|
||||
RunDispatched,
|
||||
RunList,
|
||||
RunSummary,
|
||||
)
|
||||
from app.automations.services import RunService, get_run_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/automations/{automation_id}/run",
|
||||
response_model=RunDispatched,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def run_automation_now(
|
||||
automation_id: int,
|
||||
inputs: dict[str, Any] | None = Body(default=None),
|
||||
service: RunService = Depends(get_run_service),
|
||||
) -> RunDispatched:
|
||||
"""Fire a manual run.
|
||||
|
||||
``inputs`` is the runtime payload supplied by the caller; it is merged with
|
||||
the manual trigger's ``static_inputs`` (static wins) and validated against
|
||||
the automation's input schema.
|
||||
"""
|
||||
run = await service.dispatch_manual(automation_id=automation_id, runtime_inputs=inputs)
|
||||
return RunDispatched(run_id=run.id, status=run.status)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/automations/{automation_id}/runs",
|
||||
response_model=RunList,
|
||||
)
|
||||
async def list_runs(
|
||||
automation_id: int,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
service: RunService = Depends(get_run_service),
|
||||
) -> RunList:
|
||||
"""List run history for an automation, newest first."""
|
||||
items, total = await service.list(
|
||||
automation_id=automation_id, limit=limit, offset=offset
|
||||
)
|
||||
return RunList(
|
||||
items=[RunSummary.model_validate(r) for r in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/automations/{automation_id}/runs/{run_id}",
|
||||
response_model=RunDetail,
|
||||
)
|
||||
async def get_run(
|
||||
automation_id: int,
|
||||
run_id: int,
|
||||
service: RunService = Depends(get_run_service),
|
||||
) -> RunDetail:
|
||||
"""Get the full record of a single run, including step results and artifacts."""
|
||||
run = await service.get(automation_id=automation_id, run_id=run_id)
|
||||
return RunDetail.model_validate(run)
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"""Request/response schemas for the automations HTTP layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .automation import (
|
||||
AutomationCreate,
|
||||
AutomationDetail,
|
||||
AutomationList,
|
||||
AutomationSummary,
|
||||
AutomationUpdate,
|
||||
)
|
||||
from .run import RunDetail, RunDispatched, RunList, RunSummary
|
||||
from .trigger import TriggerCreate, TriggerDetail, TriggerUpdate
|
||||
|
||||
__all__ = [
|
||||
"AutomationCreate",
|
||||
"AutomationDetail",
|
||||
"AutomationList",
|
||||
"AutomationSummary",
|
||||
"AutomationUpdate",
|
||||
"RunDetail",
|
||||
"RunDispatched",
|
||||
"RunList",
|
||||
"RunSummary",
|
||||
"TriggerCreate",
|
||||
"TriggerDetail",
|
||||
"TriggerUpdate",
|
||||
]
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""Request/response schemas for the ``Automation`` resource."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.automations.persistence.enums.automation_status import AutomationStatus
|
||||
from app.automations.schemas.definition import AutomationDefinition
|
||||
|
||||
from .trigger import TriggerCreate, TriggerDetail
|
||||
|
||||
|
||||
class AutomationCreate(BaseModel):
|
||||
"""Create an automation, optionally with initial triggers (atomic)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
search_space_id: int
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
definition: AutomationDefinition
|
||||
triggers: list[TriggerCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AutomationUpdate(BaseModel):
|
||||
"""Partial update of an automation. Triggers are managed separately."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
status: AutomationStatus | None = None
|
||||
definition: AutomationDefinition | None = None
|
||||
|
||||
|
||||
class AutomationSummary(BaseModel):
|
||||
"""Lightweight automation view for list endpoints."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
search_space_id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
status: AutomationStatus
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AutomationDetail(AutomationSummary):
|
||||
"""Full automation view including definition and attached triggers."""
|
||||
|
||||
definition: AutomationDefinition
|
||||
triggers: list[TriggerDetail] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AutomationList(BaseModel):
|
||||
"""Paginated list of automations."""
|
||||
|
||||
items: list[AutomationSummary]
|
||||
total: int
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
"""Response schemas for run sub-resources and run dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.automations.persistence.enums.run_status import RunStatus
|
||||
|
||||
|
||||
class RunSummary(BaseModel):
|
||||
"""Lightweight run view for list endpoints."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
automation_id: int
|
||||
trigger_id: int | None = None
|
||||
status: RunStatus
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunDetail(RunSummary):
|
||||
"""Full run view including snapshot, results and artifacts."""
|
||||
|
||||
definition_snapshot: dict[str, Any]
|
||||
trigger_payload: dict[str, Any] | None = None
|
||||
resolved_inputs: dict[str, Any]
|
||||
step_results: list[dict[str, Any]]
|
||||
output: dict[str, Any] | None = None
|
||||
artifacts: list[dict[str, Any]]
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RunList(BaseModel):
|
||||
"""Paginated list of runs."""
|
||||
|
||||
items: list[RunSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class RunDispatched(BaseModel):
|
||||
"""Response of a successful run dispatch."""
|
||||
|
||||
run_id: int
|
||||
status: RunStatus
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"""Request/response schemas for trigger sub-resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.automations.persistence.enums.trigger_type import TriggerType
|
||||
|
||||
|
||||
class TriggerCreate(BaseModel):
|
||||
"""Attach a trigger to an automation."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: TriggerType
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TriggerUpdate(BaseModel):
|
||||
"""Partial update of an existing trigger."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool | None = None
|
||||
params: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TriggerDetail(BaseModel):
|
||||
"""Trigger as returned to clients."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
type: TriggerType
|
||||
params: dict[str, Any]
|
||||
enabled: bool
|
||||
last_fired_at: datetime | None = None
|
||||
next_fire_at: datetime | None = None
|
||||
created_at: datetime
|
||||
55
surfsense_backend/app/automations/api/trigger.py
Normal file
55
surfsense_backend/app/automations/api/trigger.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""HTTP routes for triggers attached to an automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from app.automations.schemas.api import TriggerCreate, TriggerDetail, TriggerUpdate
|
||||
from app.automations.services import TriggerService, get_trigger_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/automations/{automation_id}/triggers",
|
||||
response_model=TriggerDetail,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_trigger(
|
||||
automation_id: int,
|
||||
payload: TriggerCreate,
|
||||
service: TriggerService = Depends(get_trigger_service),
|
||||
) -> TriggerDetail:
|
||||
"""Attach a new trigger to an automation."""
|
||||
trigger = await service.add(automation_id=automation_id, payload=payload)
|
||||
return TriggerDetail.model_validate(trigger)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/automations/{automation_id}/triggers/{trigger_id}",
|
||||
response_model=TriggerDetail,
|
||||
)
|
||||
async def update_trigger(
|
||||
automation_id: int,
|
||||
trigger_id: int,
|
||||
patch: TriggerUpdate,
|
||||
service: TriggerService = Depends(get_trigger_service),
|
||||
) -> TriggerDetail:
|
||||
"""Toggle ``enabled`` or replace ``params``. Trigger type is immutable."""
|
||||
trigger = await service.update(
|
||||
automation_id=automation_id, trigger_id=trigger_id, patch=patch
|
||||
)
|
||||
return TriggerDetail.model_validate(trigger)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/automations/{automation_id}/triggers/{trigger_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def remove_trigger(
|
||||
automation_id: int,
|
||||
trigger_id: int,
|
||||
service: TriggerService = Depends(get_trigger_service),
|
||||
) -> None:
|
||||
"""Detach a trigger from an automation."""
|
||||
await service.remove(automation_id=automation_id, trigger_id=trigger_id)
|
||||
Loading…
Add table
Add a link
Reference in a new issue