Files
flowdeck/app/routers/automations.py
T
bruno 1706ad1ee9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped
feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
2026-09-30 20:02:57 -04:00

316 lines
12 KiB
Python

"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import (
get_page_context,
get_steps,
press_button,
run_automation,
validate_step,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
TRIGGER_TYPES = ("event", "cron", "button")
def _json_or_dumps(val, default="[]"):
"""Store JSON string columns without double-encoding."""
if val is None:
return default
if isinstance(val, str):
try:
json.loads(val)
return val
except (TypeError, json.JSONDecodeError):
return json.dumps(val)
return json.dumps(val)
def _current_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user if user and user.get("id") else {}
def _validate_payload(body: dict) -> None:
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name required")
trigger_type = body.get("trigger_type", "event")
if trigger_type not in TRIGGER_TYPES:
raise HTTPException(status_code=400, detail="invalid trigger_type")
if trigger_type == "event" and not body.get("event"):
raise HTTPException(status_code=400, detail="event required for event trigger")
if trigger_type == "cron" and not (body.get("cron_expression") or "").strip():
raise HTTPException(status_code=400, detail="cron_expression required for cron trigger")
for key in ("condition_json", "actions_json"):
val = body.get(key, "[]")
try:
if isinstance(val, str):
json.loads(val)
else:
json.dumps(val)
except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
@router.get("/workspace/automations")
async def list_automations(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows]
return {"automations": items}
@router.post("/workspace/automations")
async def create_automation(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
user = _current_user(request)
by = user.get("id") or 1
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO automations
(workspace, name, trigger_type, event, cron_expression, collection_id,
condition_json, actions_json, enabled, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
body.get("workspace", "") or "",
(body.get("name") or "").strip(),
body.get("trigger_type", "event"),
body.get("event", "page.created"),
body.get("cron_expression", "") or "",
body.get("collection_id") or None,
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
int(body.get("enabled", True)),
by,
),
)
conn.commit()
new_id = cur.lastrowid
return {"id": new_id, "status": "created"}
@router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automation not found")
return dict(row)
@router.put("/workspace/automations/{auto_id}")
async def update_automation(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
with get_conn() as conn:
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automation not found")
conn.execute(
"""UPDATE automations SET
name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?,
condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
(body.get("name") or "").strip(),
body.get("trigger_type", "event"),
body.get("event", "page.created"),
body.get("cron_expression", "") or "",
body.get("collection_id") or None,
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
int(body.get("enabled", True)),
auto_id,
),
)
conn.commit()
return {"id": auto_id, "status": "updated"}
@router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit()
return {"id": auto_id, "status": "deleted"}
async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict:
page_id = body.get("page_id") if isinstance(body, dict) else None
collection_id = body.get("collection_id") if isinstance(body, dict) else None
context = {"collection_id": collection_id, "page_id": page_id}
if page_id:
context.update(get_page_context(int(page_id), collection_id or 0))
result = await run_automation(automation_id, trigger_source, context)
result["automation_id"] = automation_id
return result
@router.post("/workspace/automations/{auto_id}/run")
async def run_automation_endpoint(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
return await _execute(auto_id, "manual", body)
@router.post("/api/automations/{auto_id}/run")
async def run_automation_button(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
return await _execute(auto_id, "button", body)
@router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=?
ORDER BY created_at DESC, id DESC LIMIT ?""",
(auto_id, limit),
).fetchall()
return {"runs": [dict(r) for r in rows]}
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
STEP_SECRET_FIELDS = {"webhook_url"}
def _require_session(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
def _get_auto(auto_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
return dict(row) if row else None
def _auto_404():
# NOTE: return (not raise) — the global 404 handler redirects non-/api
# paths to /workspaces, which TestClient follows into a 200.
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Automation not found"}, status_code=404)
def _encrypt_step_config(config: dict) -> dict:
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
from app.services.sso_provisioning import encrypt_secret
cfg = dict(config or {})
for field in STEP_SECRET_FIELDS:
if field in cfg and cfg[field]:
val = str(cfg[field])
if not val.startswith("gAAAAA"):
cfg[field] = encrypt_secret(val)
return cfg
@router.get("/workspace/automations/{auto_id}/steps")
async def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None:
return _auto_404()
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
@router.post("/workspace/automations/{auto_id}/steps")
async def create_step(request: Request, auto_id: int):
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
kind = body.get("kind", "")
config = body.get("config", {}) or {}
validate_step(kind, config)
with get_conn() as conn:
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
(auto_id,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
" VALUES (?,?,?,?)",
(auto_id, kind, int(body.get("position", pos)),
json.dumps(_encrypt_step_config(config))))
conn.commit()
step_id = cur.lastrowid
return {"id": step_id, "status": "created"}
@router.put("/workspace/automations/steps/{step_id}")
async def update_step(request: Request, step_id: int):
_require_session(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
if not row:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Step not found"}, status_code=404)
kind = body.get("kind", row["kind"])
try:
config = body.get("config", json.loads(row["config_json"] or "{}"))
except (TypeError, json.JSONDecodeError):
config = {}
validate_step(kind, config if isinstance(config, dict) else {})
conn.execute(
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
(kind, int(body.get("position", row["position"])),
json.dumps(_encrypt_step_config(config)), step_id))
conn.commit()
return {"id": step_id, "status": "updated"}
@router.delete("/workspace/automations/steps/{step_id}")
async def delete_step(request: Request, step_id: int):
_require_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
conn.commit()
return {"id": step_id, "status": "deleted"}
@router.put("/workspace/automations/{auto_id}/mode")
async def set_trigger_mode(request: Request, auto_id: int):
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
mode = (body.get("mode") or "any").lower()
if mode not in ("any", "all"):
raise HTTPException(status_code=400, detail="mode must be any or all")
with get_conn() as conn:
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
conn.commit()
return {"id": auto_id, "trigger_mode": mode}
@router.post("/api/automations/press-button")
async def press_button_endpoint(request: Request):
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
body = await request.json() if request.headers.get("content-type") else {}
try:
collection_id = int(body.get("collection_id", 0))
row_id = int(body.get("row_id", 0))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
prop_ref = body.get("property", body.get("property_id", ""))
if not prop_ref:
raise HTTPException(status_code=400, detail="property required")
user = _current_user(request)
try:
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
return result