feat(automations): moteur de regles if-this-then-that (v5.1.0 roadmap, bump v5.2.0)
Moteur d'automatisation complet : declencheurs (evenement / cron / bouton),
conditions combinables (eq, neq, contains, is_empty, is_not_empty, changed)
et actions (webhook, set_property, create_page, notify).
- Migration v5 : tables `automations` + `automation_runs` (avec index).
- Service `app/services/automations.py` : fire_event, cron_due (`*/N`,
minute fixe, @hourly/@daily), scheduler de fond dans le lifespan.
- Router `app/routers/automations.py` : CRUD `/workspace/automations`,
historique des executions, run manuel + run bouton `/api/automations/{id}/run`
(exempt CSRF, comme `/api/agent`).
- Hooks d'evenements dans collections.py / board.py / workspace.py
(collection.* et page.* : created/updated/deleted/moved).
- Bloc `button` dans l'editeur (menu slash) : declenche une regle au clic,
picker d'automation inline, serialisation automations_id/name.
- Panneau Automations dans le Settings (creer/editer/activer/desactiver/
lancer/supprimer + historique), collection scope + JSON conditions/actions.
- 11 tests `tests/test_automations.py` ; suite complete pytest 289 verte.
- Version bump 5.2.0 (VERSION, app/main.py, CHANGELOG).
This commit is contained in:
+21
-1
@@ -1,4 +1,24 @@
|
|||||||
# Changelog — FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v5.2.0 (2026-09-07) - Automations : un moteur de règles (if-this-then-that)
|
||||||
|
|
||||||
|
> Nouveau moteur de règles : brancher des actions sur des événements de pages
|
||||||
|
> (création, mise à jour, suppression, déplacement), sur un planning cron ou
|
||||||
|
> sur un bouton cliquable directement dans l'éditeur.
|
||||||
|
|
||||||
|
- **Moteur de règles** : trigger (événement / cron / bouton), conditions
|
||||||
|
combinables (eq, neq, contains, is_empty, is_not_empty, changed) et actions
|
||||||
|
(webhook, changer une propriété, créer une page, notification).
|
||||||
|
- **Événements couverts** : collection créée/modifiée/supprimée, page
|
||||||
|
créée/modifiée/supprimée/déplacée (API et Canvas).
|
||||||
|
- **Déclencheur cron** : `*/N * * * *`, minute fixe, `@hourly`, `@daily`, avec
|
||||||
|
un planificateur en arrière-plan qui exécute les règles dues chaque minute.
|
||||||
|
- **Bouton d'automation** : nouveau bloc de type `button` dans l'éditeur
|
||||||
|
(menu slash) qui déclenche une règle au clic.
|
||||||
|
- **Interface** : panneau Automations dans les paramètres (créer, modifier,
|
||||||
|
activer/désactiver, lancer, supprimer, historique des exécutions).
|
||||||
|
- **Historique des exécutions** : chaque déclenchement est tracé (statut,
|
||||||
|
détail, page/collection concernée).
|
||||||
|
|
||||||
## v5.1.11 (2026-09-07) — Éditeur : collage intelligent (découpe en blocs)
|
## v5.1.11 (2026-09-07) — Éditeur : collage intelligent (découpe en blocs)
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -16,6 +16,7 @@ from app.middleware.csrf import CSRFMiddleware
|
|||||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent, search
|
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent, search
|
||||||
from app.routers.notifications import router as notifications_router
|
from app.routers.notifications import router as notifications_router
|
||||||
|
from app.routers.automations import router as automations_router
|
||||||
from app.routers.collaboration import router as collaboration_router
|
from app.routers.collaboration import router as collaboration_router
|
||||||
from app.routers.gitea import router as gitea_router
|
from app.routers.gitea import router as gitea_router
|
||||||
from app.routers.github_routes import router as github_router
|
from app.routers.github_routes import router as github_router
|
||||||
@@ -47,20 +48,29 @@ async def lifespan(_app: FastAPI):
|
|||||||
from app.routers.agent import agent_scheduler
|
from app.routers.agent import agent_scheduler
|
||||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||||
|
|
||||||
logger.info("FlowDeck v4.10.0 started on port %d", settings.app_port)
|
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||||
|
from app.services.automations import automation_scheduler
|
||||||
|
automation_task = asyncio.create_task(automation_scheduler())
|
||||||
|
|
||||||
|
logger.info("FlowDeck v5.2.0 started on port %d", settings.app_port)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
scheduler_task.cancel()
|
scheduler_task.cancel()
|
||||||
|
automation_task.cancel()
|
||||||
try:
|
try:
|
||||||
await scheduler_task
|
await scheduler_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
await automation_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="5.1.11",
|
version="5.2.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -90,6 +100,7 @@ app.include_router(sharing.router)
|
|||||||
app.include_router(sidebar_config.router)
|
app.include_router(sidebar_config.router)
|
||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
|
app.include_router(automations_router)
|
||||||
app.include_router(collaboration_router)
|
app.include_router(collaboration_router)
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent"}
|
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations"}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||||
|
|||||||
@@ -172,6 +172,58 @@ def _migration_fts5(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(5, "automations (v5.1.0 rules engine)")
|
||||||
|
def _migration_automations(conn: sqlite3.Connection) -> None:
|
||||||
|
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
|
||||||
|
condition + action) and clickable buttons that trigger actions.
|
||||||
|
|
||||||
|
``automations`` — the rules (event/cron/button trigger, optional
|
||||||
|
condition JSON, actions JSON, run counters).
|
||||||
|
``automation_runs`` — execution history for auditing and the Settings UI.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS automations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
|
||||||
|
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
|
||||||
|
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
|
||||||
|
collection_id INTEGER, -- optional scope (event triggers)
|
||||||
|
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
|
||||||
|
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_by INTEGER,
|
||||||
|
last_run_at TIMESTAMP,
|
||||||
|
run_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||||
|
trigger_source TEXT NOT NULL DEFAULT 'event',
|
||||||
|
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
collection_id INTEGER,
|
||||||
|
page_id INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register(4, "database templates (icon) + property validation")
|
@register(4, "database templates (icon) + property validation")
|
||||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.services.automations import run_automation, get_page_context
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
@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]}
|
||||||
+11
-1
@@ -10,8 +10,9 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.gitea_client import gitea
|
from app.services.gitea_client import gitea
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
|
||||||
from app.routers.dashboard import _get_app_version
|
from app.routers.dashboard import _get_app_version
|
||||||
|
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||||
|
from app.services.automations import fire_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["board"], prefix="/board")
|
router = APIRouter(tags=["board"], prefix="/board")
|
||||||
@@ -836,6 +837,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
page_id = cur.lastrowid
|
page_id = cur.lastrowid
|
||||||
|
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||||
|
"workspace": ws_key, "parent_id": parent_id})
|
||||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("create_page failed: %s", e)
|
logger.error("create_page failed: %s", e)
|
||||||
@@ -866,6 +869,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
|||||||
if content_format:
|
if content_format:
|
||||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||||
|
"content_format": content_format or "markdown"})
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -886,6 +891,8 @@ async def save_page_blocks(request: Request, page_id: int):
|
|||||||
(blocks_json, page_id),
|
(blocks_json, page_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||||
|
"content_format": "blocks"})
|
||||||
return {"status": "ok", "id": page_id}
|
return {"status": "ok", "id": page_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -932,6 +939,8 @@ async def move_page(request: Request, page_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||||
|
"parent_id": new_parent_id})
|
||||||
return {"status": "ok", "id": page_id}
|
return {"status": "ok", "id": page_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -945,6 +954,7 @@ async def delete_page(request: Request, page_id: int):
|
|||||||
import datetime
|
import datetime
|
||||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.db import get_conn
|
|||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.services.property_types import validate_property_rule
|
from app.services.property_types import validate_property_rule
|
||||||
from app.services.db_templates import materialize_properties
|
from app.services.db_templates import materialize_properties
|
||||||
|
from app.services.automations import fire_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["collections"], prefix="/db")
|
router = APIRouter(tags=["collections"], prefix="/db")
|
||||||
@@ -162,6 +163,7 @@ async def create_collection_api(request: Request):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
|
||||||
return {"id": collection_id, "name": name, "status": "created"}
|
return {"id": collection_id, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +202,7 @@ async def update_collection_api(request: Request, collection_id: int):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
|
||||||
return {"id": collection_id, "status": "updated"}
|
return {"id": collection_id, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +219,8 @@ async def delete_collection_api(request: Request, collection_id: int):
|
|||||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
await fire_event("collection.deleted", {"collection_id": collection_id,
|
||||||
|
"name": existing["name"] if existing else ""})
|
||||||
return {"id": collection_id, "status": "deleted"}
|
return {"id": collection_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@@ -276,6 +281,13 @@ async def update_page_api(request: Request, page_id: int):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
await fire_event("page.updated", {
|
||||||
|
"page_id": page_id,
|
||||||
|
"collection_id": existing["collection_id"],
|
||||||
|
"title": title,
|
||||||
|
"icon": icon,
|
||||||
|
"properties": props,
|
||||||
|
})
|
||||||
return {"id": page_id, "status": "updated"}
|
return {"id": page_id, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
@@ -292,6 +304,11 @@ async def delete_page_api(request: Request, page_id: int):
|
|||||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
await fire_event("page.deleted", {
|
||||||
|
"page_id": page_id,
|
||||||
|
"collection_id": existing["collection_id"],
|
||||||
|
"title": existing["title"],
|
||||||
|
})
|
||||||
return {"id": page_id, "status": "deleted"}
|
return {"id": page_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@@ -754,8 +771,16 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
|||||||
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
new_id = cur.lastrowid
|
||||||
|
|
||||||
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
|
await fire_event("page.created", {
|
||||||
|
"page_id": new_id,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"parent_id": page_id,
|
||||||
|
"title": title,
|
||||||
|
"properties": body.get("properties", {}),
|
||||||
|
})
|
||||||
|
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||||
@@ -1897,4 +1922,11 @@ async def create_page_api(request: Request, collection_id: int):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
page_id = cur.lastrowid
|
page_id = cur.lastrowid
|
||||||
|
|
||||||
|
await fire_event("page.created", {
|
||||||
|
"page_id": page_id,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"title": title,
|
||||||
|
"icon": icon,
|
||||||
|
"properties": property_values,
|
||||||
|
})
|
||||||
return {"id": page_id, "title": title, "status": "created"}
|
return {"id": page_id, "title": title, "status": "created"}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
|
|||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
|
from app.services.automations import fire_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||||
@@ -289,6 +290,12 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
|||||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
await fire_event("page.created", {
|
||||||
|
"page_id": cur.lastrowid,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"title": body.get("title", "New Page"),
|
||||||
|
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||||
|
})
|
||||||
return {"id": cur.lastrowid, "status": "created"}
|
return {"id": cur.lastrowid, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""FlowDeck — Automations engine (v5.1.0).
|
||||||
|
|
||||||
|
Implements the "if-this-then-that" rule engine: automations match an event (or a
|
||||||
|
cron schedule, or a clickable button), optionally guard on a condition, then run
|
||||||
|
a list of actions.
|
||||||
|
|
||||||
|
Condition clauses (``condition_json``), all combined with AND:
|
||||||
|
{"property": "Status", "op": "eq", "value": "Done"}
|
||||||
|
{"property": "Priority", "op": "not_contains", "value": "Low"}
|
||||||
|
{"property": "Assignee", "op": "is_empty"}
|
||||||
|
{"property": "Estimate", "op": "changed"} (only event triggers)
|
||||||
|
Flags:
|
||||||
|
op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed}
|
||||||
|
|
||||||
|
Actions (``actions_json``), executed sequentially:
|
||||||
|
{"type": "webhook", "url": "...", "secret": "..."}
|
||||||
|
{"type": "set_property", "property": "Status", "value": "Done"}
|
||||||
|
{"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}}
|
||||||
|
{"type": "notify", "message": "Automation fired"}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services import notifications
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"}
|
||||||
|
# Events that fire on collection pages (payload carries a `properties` dict).
|
||||||
|
PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
def _prop_value(props: dict, key) -> tuple[bool, object]:
|
||||||
|
"""Resolve a property value by id or name. Returns ``(found, value)``.
|
||||||
|
|
||||||
|
``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API).
|
||||||
|
"""
|
||||||
|
if props is None:
|
||||||
|
return False, None
|
||||||
|
if key is None:
|
||||||
|
return True, None
|
||||||
|
skey = str(key)
|
||||||
|
if skey in props:
|
||||||
|
return True, props[skey]
|
||||||
|
if isinstance(key, int) and str(key) in props:
|
||||||
|
return True, props[str(key)]
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool:
|
||||||
|
"""Evaluate a single condition clause against page property values."""
|
||||||
|
op = clause.get("op", "eq")
|
||||||
|
if op not in COND_OPS:
|
||||||
|
return False
|
||||||
|
if op == "changed":
|
||||||
|
key = clause.get("property")
|
||||||
|
if before_props is None:
|
||||||
|
return False
|
||||||
|
found_before, before_val = _prop_value(before_props, key)
|
||||||
|
found_after, after_val = _prop_value(props, key)
|
||||||
|
return found_before and found_after and before_val != after_val
|
||||||
|
|
||||||
|
found, val = _prop_value(props, clause.get("property"))
|
||||||
|
|
||||||
|
if op == "is_empty":
|
||||||
|
if not found:
|
||||||
|
return True
|
||||||
|
return val is None or str(val).strip() == ""
|
||||||
|
if op == "is_not_empty":
|
||||||
|
return found and val is not None and str(val).strip() != ""
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return False
|
||||||
|
want = clause.get("value")
|
||||||
|
if op == "eq":
|
||||||
|
return _norm(val) == _norm(want)
|
||||||
|
if op == "neq":
|
||||||
|
return _norm(val) != _norm(want)
|
||||||
|
if op == "contains":
|
||||||
|
return _norm(want) in _norm(val) if _norm(val) else False
|
||||||
|
if op == "not_contains":
|
||||||
|
return _norm(want) not in _norm(val) if _norm(val) else True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(v) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(v, (list, dict)):
|
||||||
|
return json.dumps(v)
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool:
|
||||||
|
"""Evaluate the stored condition list (AND of all clauses). Empty list → True."""
|
||||||
|
try:
|
||||||
|
clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or [])
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
clauses = []
|
||||||
|
for clause in clauses or []:
|
||||||
|
if not match_condition_props(props, before_props, clause):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_context(page_id: int, collection_id: int) -> dict:
|
||||||
|
"""Load a collection page's property values for condition evaluation."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?",
|
||||||
|
(page_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"page_id": page_id, "collection_id": collection_id,
|
||||||
|
"title": "", "properties": {}, "icon": "file"}
|
||||||
|
try:
|
||||||
|
props = json.loads(row["property_values_json"])
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
props = {}
|
||||||
|
return {"page_id": page_id, "collection_id": collection_id,
|
||||||
|
"title": row["title"], "icon": row["icon"], "properties": props}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_run(automation_id: int, trigger_source: str, status: str, detail: str,
|
||||||
|
collection_id: int | None = None, page_id: int | None = None) -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO automation_runs
|
||||||
|
(automation_id, trigger_source, status, detail, collection_id, page_id)
|
||||||
|
VALUES (?,?,?,?,?,?)""",
|
||||||
|
(automation_id, trigger_source, status, detail, collection_id, page_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(automation_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
|
||||||
|
"""Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return True, value
|
||||||
|
replaced = value
|
||||||
|
for key in props:
|
||||||
|
if f"[[" + str(key) + "]]" in replaced:
|
||||||
|
replaced = replaced.replace(f"[[" + str(key) + "]]", str(props[key]))
|
||||||
|
if "{{title}}" in replaced:
|
||||||
|
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
|
||||||
|
if "{{id}}" in replaced:
|
||||||
|
replaced = replaced.replace("{{id}}", str(props.get("page_id", "")))
|
||||||
|
return True, replaced
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||||
|
"""Execute a single action. Returns a human summary. Raises on failure."""
|
||||||
|
atype = action.get("type")
|
||||||
|
|
||||||
|
if atype == "webhook":
|
||||||
|
url = action.get("url", "").strip()
|
||||||
|
if not url:
|
||||||
|
raise ValueError("webhook action requires a url")
|
||||||
|
secret = action.get("secret", "")
|
||||||
|
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||||
|
if secret:
|
||||||
|
headers["X-FlowDeck-Secret"] = secret
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(url, json=context, headers=headers)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||||
|
return f"webhook → {url} ({resp.status_code})"
|
||||||
|
|
||||||
|
if atype == "set_property":
|
||||||
|
prop = action.get("property")
|
||||||
|
value = action.get("value")
|
||||||
|
page_id = context.get("page_id")
|
||||||
|
if not prop or not page_id:
|
||||||
|
raise ValueError("set_property requires property + page context")
|
||||||
|
_, resolved = _maybe_convert_prediction(value, context)
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT property_values_json, collection_id FROM collection_pages WHERE id=?",
|
||||||
|
(page_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"page {page_id} not found")
|
||||||
|
try:
|
||||||
|
props = json.loads(row["property_values_json"])
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
props = {}
|
||||||
|
props[prop] = resolved
|
||||||
|
from app.routers.collections import _validate_page_properties
|
||||||
|
_validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(json.dumps(props), page_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return f"set property {prop} = {resolved}"
|
||||||
|
|
||||||
|
if atype == "create_page":
|
||||||
|
coll_id = action.get("collection_id")
|
||||||
|
title = action.get("title", "Automation page")
|
||||||
|
properties = action.get("properties", {}) or {}
|
||||||
|
if not coll_id:
|
||||||
|
raise ValueError("create_page requires a collection_id")
|
||||||
|
_, resolved_title = _maybe_convert_prediction(title, context)
|
||||||
|
resolved_props = {}
|
||||||
|
for k, v in properties.items():
|
||||||
|
_, pv = _maybe_convert_prediction(v, context)
|
||||||
|
resolved_props[k] = pv
|
||||||
|
with get_conn() as conn:
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||||
|
(coll_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||||
|
(coll_id, resolved_title, max_pos, json.dumps(resolved_props)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return f"created page {cur.lastrowid} in collection {coll_id}"
|
||||||
|
|
||||||
|
if atype == "notify":
|
||||||
|
message = action.get("message", "Automation fired")
|
||||||
|
user_id = action.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
user_id = context.get("created_by") or 1
|
||||||
|
_, resolved = _maybe_convert_prediction(message, context)
|
||||||
|
notifications.create_notification(
|
||||||
|
user_id=user_id,
|
||||||
|
actor_id=context.get("created_by") or 1,
|
||||||
|
ntype="page",
|
||||||
|
title=context.get("automation_name", "Automation"),
|
||||||
|
message=resolved,
|
||||||
|
resource_type="collection_page" if context.get("page_id") else "page",
|
||||||
|
resource_id=context.get("page_id") or context.get("collection_id") or 0,
|
||||||
|
url=context.get("url", ""),
|
||||||
|
)
|
||||||
|
return f"notified user {user_id}"
|
||||||
|
|
||||||
|
raise ValueError(f"unknown action type: {atype!r}")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict:
|
||||||
|
"""Load, condition-check and execute an automation. Records a run row."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"status": "skipped", "detail": "automation not found"}
|
||||||
|
auto = dict(row)
|
||||||
|
|
||||||
|
if not auto["enabled"]:
|
||||||
|
return {"status": "skipped", "detail": "automation disabled"}
|
||||||
|
|
||||||
|
props = context.get("properties")
|
||||||
|
before = context.get("before_properties")
|
||||||
|
if not evaluate_conditions(auto["condition_json"], props, before):
|
||||||
|
_save_run(automation_id, trigger_source, "skipped", "condition not met",
|
||||||
|
context.get("collection_id"), context.get("page_id"))
|
||||||
|
return {"status": "skipped", "detail": "condition not met"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
actions = json.loads(auto["actions_json"]) if auto["actions_json"] else []
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
ctx = dict(context)
|
||||||
|
ctx["automation_name"] = auto["name"]
|
||||||
|
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
for action in actions or []:
|
||||||
|
results.append(await _run_action(action, ctx, trigger_source))
|
||||||
|
detail = "; ".join(results)
|
||||||
|
_save_run(automation_id, trigger_source, "fired", detail,
|
||||||
|
ctx.get("collection_id"), ctx.get("page_id"))
|
||||||
|
return {"status": "fired", "detail": detail}
|
||||||
|
except Exception as exc: # noqa: BLE001 — record every failure in history
|
||||||
|
logger.warning("Automation %s failed: %s", automation_id, exc)
|
||||||
|
_save_run(automation_id, trigger_source, "error", str(exc),
|
||||||
|
ctx.get("collection_id"), ctx.get("page_id"))
|
||||||
|
return {"status": "error", "detail": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def fire_event(event: str, payload: dict):
|
||||||
|
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||||
|
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
|
||||||
|
try:
|
||||||
|
from app.services.webhook_outbound import fire_event as fire_webhooks
|
||||||
|
await fire_webhooks(event, payload)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Webhook dispatch failed for %s", event)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT * FROM automations
|
||||||
|
WHERE trigger_type='event' AND event=? AND enabled=1""",
|
||||||
|
(event,),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
auto = dict(row)
|
||||||
|
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
|
||||||
|
continue
|
||||||
|
context = dict(payload)
|
||||||
|
context["event"] = event
|
||||||
|
await run_automation(auto["id"], "event", context)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
|
||||||
|
|
||||||
|
_SUPPORTED_CRON = {
|
||||||
|
"*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30,
|
||||||
|
"*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool:
|
||||||
|
"""True when a ``*/N`-style or fixed-minute cron expression is due.
|
||||||
|
|
||||||
|
Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact
|
||||||
|
``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work.
|
||||||
|
"""
|
||||||
|
expr = (expression or "").strip().lower()
|
||||||
|
if not expr:
|
||||||
|
return False
|
||||||
|
now = now or datetime.utcnow()
|
||||||
|
minute = now.minute
|
||||||
|
fields = expr.split()
|
||||||
|
|
||||||
|
if expr in ("@hourly", "hourly"):
|
||||||
|
if last_run_at is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60)
|
||||||
|
|
||||||
|
if expr in ("@daily", "daily"):
|
||||||
|
if last_run_at is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
return (now - last.replace(tzinfo=None)) >= timedelta(hours=24)
|
||||||
|
|
||||||
|
# "*/N * * * *" → every N minutes
|
||||||
|
if fields and fields[0].startswith("*/"):
|
||||||
|
val = fields[0][2:]
|
||||||
|
if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values():
|
||||||
|
return False
|
||||||
|
n = int(val)
|
||||||
|
if last_run_at is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n)
|
||||||
|
|
||||||
|
# "H * * * *" → at a fixed minute of each hour
|
||||||
|
if len(fields) == 5 and fields[0].isdigit():
|
||||||
|
return int(fields[0]) == minute
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def automation_scheduler():
|
||||||
|
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
auto = dict(row)
|
||||||
|
try:
|
||||||
|
if cron_due(auto["cron_expression"], auto["last_run_at"]):
|
||||||
|
context = {
|
||||||
|
"collection_id": auto["collection_id"] or 0,
|
||||||
|
"page_id": None,
|
||||||
|
"properties": None,
|
||||||
|
}
|
||||||
|
await run_automation(auto["id"], "cron", context)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("Cron automation %s errored", auto["id"])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("automation_scheduler iteration failed")
|
||||||
|
await asyncio.sleep(60)
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
||||||
{id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'},
|
{id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'},
|
||||||
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||||
{id:'table',name:'Table',icon:'⊞'},
|
{id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'},
|
||||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}];
|
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}];
|
||||||
|
|
||||||
@@ -358,6 +358,7 @@
|
|||||||
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
||||||
if(b.type==='table')return renderTableBlock(b,idx);
|
if(b.type==='table')return renderTableBlock(b,idx);
|
||||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
||||||
|
if(b.type==='button')return renderAutomationButton(b,idx);
|
||||||
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||||
if(b.type==='table_of_contents')return renderTOC();
|
if(b.type==='table_of_contents')return renderTOC();
|
||||||
if(b.type==='math')return `<div class="block-wrapper"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
if(b.type==='math')return `<div class="block-wrapper"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||||
@@ -414,6 +415,18 @@
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAutomationButton(b,idx){
|
||||||
|
var label=(b.content&&b.content.trim())?b.content:((b.automation_name||'Run automation'));
|
||||||
|
var bound=b.automation_id>0;
|
||||||
|
var badge=bound?'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">⚡ '+esc(b.automation_name||'')+'</span>':'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">Choose…</span>';
|
||||||
|
var actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||||
|
return `<div class="block-wrapper">
|
||||||
|
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||||
|
${actions}
|
||||||
|
<div class="block-content"><button type="button" class="fd-automation-btn" onclick="E.runAutomationButton('${b.id}')">⚡ ${esc(label)}${badge}</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderColumnsBlock(b){
|
function renderColumnsBlock(b){
|
||||||
var cols=(b.children&&b.children.length)?b.children:[{id:genId(),type:'paragraph',content:''}];
|
var cols=(b.children&&b.children.length)?b.children:[{id:genId(),type:'paragraph',content:''}];
|
||||||
var cells=cols.map(function(c){
|
var cells=cols.map(function(c){
|
||||||
@@ -742,14 +755,14 @@
|
|||||||
}
|
}
|
||||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
||||||
},
|
},
|
||||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];return b;},
|
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
|
||||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='button')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
||||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
||||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents')continue;
|
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
|
||||||
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
|
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
|
||||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
|
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
|
||||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
|
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
|
||||||
@@ -772,7 +785,7 @@
|
|||||||
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
||||||
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
|
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
|
||||||
},
|
},
|
||||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||||
@@ -1164,7 +1177,7 @@ applyAIBlocks(text){
|
|||||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||||
const block=this.blocks[idx];this.sync();block.content='';
|
const block=this.blocks[idx];this.sync();block.content='';
|
||||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}}
|
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}}
|
||||||
this.render();this.dirty=true;this.autoSave();
|
this.render();this.dirty=true;this.autoSave();
|
||||||
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
||||||
else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);}
|
else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);}
|
||||||
@@ -1379,12 +1392,56 @@ applyAIBlocks(text){
|
|||||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||||
this.saving=true;
|
this.saving=true;
|
||||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||||
if(cb)cb();
|
if(cb)cb();
|
||||||
}).catch(()=>{this.saving=false;});
|
}).catch(()=>{this.saving=false;});
|
||||||
},
|
},
|
||||||
|
runAutomationButton(bid){
|
||||||
|
const idx=this.getIdx(bid);if(idx<0)return;
|
||||||
|
const b=this.blocks[idx];
|
||||||
|
if(!b.automation_id){this.pickAutomation(bid);return;}
|
||||||
|
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
const self=this;
|
||||||
|
this.showToast('⚡ '+ (b.automation_name||'Automation') + '…');
|
||||||
|
fetch('/api/automations/'+b.automation_id+'/run',{method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||||
|
body:JSON.stringify({page_id:this.pid})})
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(d=>{
|
||||||
|
if(d.status==='fired'){self.showToast('✅ Automation exécutée');}
|
||||||
|
else if(d.status==='skipped'){self.showToast('⏭️ Condition non remplie (skippée)');}
|
||||||
|
else{self.showToast((d.detail||'Erreur')+'','error');}
|
||||||
|
})
|
||||||
|
.catch(()=>{self.showToast('Échec de l\u2019exécution','error');});
|
||||||
|
},
|
||||||
|
pickAutomation(bid){
|
||||||
|
const self=this;
|
||||||
|
fetch('/workspace/automations',{credentials:'same-origin'})
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(d=>{
|
||||||
|
const list=d.automations||[];
|
||||||
|
if(!list.length){self.showToast('Aucune automation — créez-en dans Paramètres → Automations','error');return;}
|
||||||
|
const ov=document.createElement('div');ov.id='fdAutomationOverlay';
|
||||||
|
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;';
|
||||||
|
const box=document.createElement('div');box.style.cssText='background:var(--bg-secondary,#1f1f1f);border:1px solid var(--border,#333);border-radius:12px;padding:20px;width:420px;max-width:92vw;max-height:80vh;overflow:auto;';
|
||||||
|
box.innerHTML='<div style="font-weight:600;font-size:14px;margin-bottom:12px;">Choisir une automation</div>'+
|
||||||
|
list.filter(a=>a.enabled).map(function(a){
|
||||||
|
return `<button type="button" data-id="${a.id}" style="display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:9px 10px;background:var(--bg-hover,transparent);border:1px solid var(--border,#333);color:var(--text-primary);border-radius:8px;margin-bottom:8px;cursor:pointer;"><span>⚡</span><span style="flex:1;font-size:13px;">${esc(a.name||'Automation')}</span><span style="font-size:11px;color:var(--text-tertiary);">${esc(a.trigger_type||'event')}</span></button>`;
|
||||||
|
}).join('')+
|
||||||
|
'<button type="button" data-cancel="1" style="width:100%;text-align:center;padding:8px;border:none;background:transparent;color:var(--text-secondary);cursor:pointer;font-size:12px;">Annuler</button>';
|
||||||
|
ov.appendChild(box);
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
box.addEventListener('click',function(ev){
|
||||||
|
const btn=ev.target.closest('[data-id]');if(btn){
|
||||||
|
const idx=self.getIdx(bid);if(idx>=0){self.blocks[idx].automation_id=parseInt(btn.getAttribute('data-id'),10);const a=list.find(function(x){return x.id===parseInt(btn.getAttribute('data-id'),10);});self.blocks[idx].automation_name=a?a.name:'';self.dirty=true;self.autoSave();self.render();}
|
||||||
|
ov.remove();
|
||||||
|
}else if(ev.target.closest('[data-cancel]')){ov.remove();}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(()=>{self.showToast('Erreur de chargement','error');});
|
||||||
|
},
|
||||||
md2b(md){const bl=[];const lines=String(md).split('\n');let i=0;for(;i<lines.length;i++){const raw=lines[i];const t=raw.trim();if(!t)continue;
|
md2b(md){const bl=[];const lines=String(md).split('\n');let i=0;for(;i<lines.length;i++){const raw=lines[i];const t=raw.trim();if(!t)continue;
|
||||||
if(t.startsWith('```')){const lang=t.substring(3).trim()||'Plain Text';let code='';i++;for(;i<lines.length;i++){if(lines[i].trim()==='```')break;code+=(code?'\n':'')+lines[i];}bl.push(this.mkB('code',code,{language:lang}));continue;}
|
if(t.startsWith('```')){const lang=t.substring(3).trim()||'Plain Text';let code='';i++;for(;i<lines.length;i++){if(lines[i].trim()==='```')break;code+=(code?'\n':'')+lines[i];}bl.push(this.mkB('code',code,{language:lang}));continue;}
|
||||||
if(_tblIsData(t)&&i+1<lines.length&&_tblIsSep(lines[i+1])){const header=_tblSplitLine(t);const rows=[header];let j=i+2;while(j<lines.length&&_tblIsData(lines[j])){rows.push(_tblSplitLine(lines[j]));j++;}let w=0;rows.forEach(function(r){if(r.length>w)w=r.length;});if(w<1)w=1;const rows2=rows.map(function(r){const a=r.slice();while(a.length<w)a.push('');return a;});bl.push(this.mkB('table','',{rows:rows2}));i=j-1;continue;}
|
if(_tblIsData(t)&&i+1<lines.length&&_tblIsSep(lines[i+1])){const header=_tblSplitLine(t);const rows=[header];let j=i+2;while(j<lines.length&&_tblIsData(lines[j])){rows.push(_tblSplitLine(lines[j]));j++;}let w=0;rows.forEach(function(r){if(r.length>w)w=r.length;});if(w<1)w=1;const rows2=rows.map(function(r){const a=r.slice();while(a.length<w)a.push('');return a;});bl.push(this.mkB('table','',{rows:rows2}));i=j-1;continue;}
|
||||||
|
|||||||
+217
-1
@@ -115,6 +115,7 @@
|
|||||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||||
<div class="settings-nav-header">Features</div>
|
<div class="settings-nav-header">Features</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||||
<!-- Admin nav: only visible to admins -->
|
<!-- Admin nav: only visible to admins -->
|
||||||
<template x-if="userIsAdmin">
|
<template x-if="userIsAdmin">
|
||||||
@@ -388,6 +389,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Automations -->
|
||||||
|
<div x-show="activeSection==='automations'">
|
||||||
|
<h2>Automations</h2>
|
||||||
|
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
||||||
|
|
||||||
|
<!-- New / Edit form -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3 x-text="editAutomationId ? 'Modifier' : 'Nouvelle automation'"></h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Nom</div>
|
||||||
|
<input class="settings-input" style="width:100%;" type="text" x-model="af.name" placeholder="Ex : Notifier le statut Done">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-wrap:wrap;gap:10px;">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Déclencheur</div>
|
||||||
|
<select class="settings-input" style="min-width:180px;" x-model="af.trigger_type" @change="af.event='page.created'">
|
||||||
|
<option value="event">Événement (base de données)</option>
|
||||||
|
<option value="cron">Planifié (cron)</option>
|
||||||
|
<option value="button">Bouton cliquable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="af.trigger_type==='event'">
|
||||||
|
<div class="setting-label">Événement</div>
|
||||||
|
<select class="settings-input" style="min-width:200px;" x-model="af.event">
|
||||||
|
<option value="page.created">Page créée</option>
|
||||||
|
<option value="page.updated">Page modifiée</option>
|
||||||
|
<option value="page.deleted">Page supprimée</option>
|
||||||
|
<option value="collection.created">Base créée</option>
|
||||||
|
<option value="collection.updated">Base modifiée</option>
|
||||||
|
<option value="collection.deleted">Base supprimée</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="af.trigger_type==='cron'">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Expression cron</div>
|
||||||
|
<input class="settings-input" style="min-width:180px;" type="text" x-model="af.cron_expression" placeholder="*/15 * * * * | @hourly | @daily">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="af.trigger_type==='event'">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Base (optionnel)</div>
|
||||||
|
<select class="settings-input" style="min-width:180px;" x-model="af.collection_id">
|
||||||
|
<option value="">Toutes les bases</option>
|
||||||
|
<template x-for="c in globalCollections" :key="c.id">
|
||||||
|
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||||
|
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||||
|
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||||
|
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||||
|
<div class="setting-label">Actions (JSON)</div>
|
||||||
|
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||||
|
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||||
|
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||||
|
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Mes automatisations <span style="font-size:11px;color:var(--text-tertiary);" x-text="automations.length + ' règle(s)'"></span></h3>
|
||||||
|
<div x-show="!automations.length" class="setting-desc" style="padding:8px 0;">Aucune automation pour l'instant.</div>
|
||||||
|
<template x-for="a in automations" :key="a.id">
|
||||||
|
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span x-text="'⚡ '+a.name"></span>
|
||||||
|
<span x-show="!a.enabled" style="font-size:10px;background:#666;color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">OFF</span>
|
||||||
|
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'('+a.trigger_type+' · '+(a.event||a.cron_expression||'')+')'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-desc" style="font-size:11px;">
|
||||||
|
<span x-text="a.run_count + ' exécution(s)'"></span>
|
||||||
|
<span x-show="a.last_run_at" x-text="' · dernière : '+a.last_run_at"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="gap:6px;">
|
||||||
|
<button class="btn-sm" @click="runAutomation(a.id)">▶ Lancer</button>
|
||||||
|
<button class="btn-sm" @click="toggleAutomation(a.id, a.enabled)"><span x-text="a.enabled ? 'Désactiver' : 'Activer'"></span></button>
|
||||||
|
<button class="btn-sm" @click="editAutomation(a)">✎</button>
|
||||||
|
<button class="btn-sm" style="color:var(--danger);" @click="deleteAutomation(a.id)">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Runs history -->
|
||||||
|
<div class="setting-group" x-show="autoRuns.length">
|
||||||
|
<h3>Historique d'exécutions</h3>
|
||||||
|
<template x-for="r in autoRuns" :key="r.id">
|
||||||
|
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<span style="font-size:12px;" x-text="r.created_at"></span>
|
||||||
|
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'['+r.trigger_source+']'"></span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:11px;" x-text="r.detail" :style="r.status==='error' ? 'color:var(--danger);' : 'color:var(--text-secondary);'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin: Users & Roles -->
|
<!-- Admin: Users & Roles -->
|
||||||
<div x-show="activeSection==='admin-users'">
|
<div x-show="activeSection==='admin-users'">
|
||||||
<h2>Users & Roles</h2>
|
<h2>Users & Roles</h2>
|
||||||
@@ -734,6 +845,13 @@ document.addEventListener('alpine:init', function() {
|
|||||||
// Per-user provider keys (multi-providers)
|
// Per-user provider keys (multi-providers)
|
||||||
keyForms: {},
|
keyForms: {},
|
||||||
|
|
||||||
|
// Automations (v5.1.0)
|
||||||
|
automations: [],
|
||||||
|
autoRuns: [],
|
||||||
|
editAutomationId: null,
|
||||||
|
globalCollections: [],
|
||||||
|
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadTags();
|
await this.loadTags();
|
||||||
await this.loadGiteaStatus();
|
await this.loadGiteaStatus();
|
||||||
@@ -1327,8 +1445,106 @@ document.addEventListener('alpine:init', function() {
|
|||||||
if (r.ok) { this.githubLinked = false; }
|
if (r.ok) { this.githubLinked = false; }
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
},
|
},
|
||||||
|
async loadAutomations() {
|
||||||
|
try { await this.loadCollectionsForForm(); } catch(e) {}
|
||||||
|
try {
|
||||||
|
var r = await fetch('/workspace/automations', {credentials:'same-origin'});
|
||||||
|
var d = await r.json();
|
||||||
|
this.automations = d.automations || [];
|
||||||
|
} catch(e) { this.automations = []; }
|
||||||
|
},
|
||||||
|
async loadCollectionsForForm() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/db/api', {credentials:'same-origin'});
|
||||||
|
var d = await r.json();
|
||||||
|
this.globalCollections = d.collections || [];
|
||||||
|
} catch(e) { this.globalCollections = []; }
|
||||||
|
},
|
||||||
|
async saveAutomation() {
|
||||||
|
if (!this.af.name.trim()) { window.showToast && window.showToast('Nom requis', 'error'); return; }
|
||||||
|
try { JSON.parse(this.af.condition_json || '[]'); } catch(e) { window.showToast && window.showToast('Conditions JSON invalide', 'error'); return; }
|
||||||
|
try { JSON.parse(this.af.actions_json || '[]'); } catch(e) { window.showToast && window.showToast('Actions JSON invalide', 'error'); return; }
|
||||||
|
var payload = {
|
||||||
|
name: this.af.name,
|
||||||
|
trigger_type: this.af.trigger_type,
|
||||||
|
event: this.af.event,
|
||||||
|
cron_expression: this.af.cron_expression,
|
||||||
|
collection_id: this.af.collection_id ? parseInt(this.af.collection_id, 10) : null,
|
||||||
|
condition_json: this.af.condition_json,
|
||||||
|
actions_json: this.af.actions_json,
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
var url = '/workspace/automations' + (this.editAutomationId ? '/' + this.editAutomationId : '');
|
||||||
|
var method = this.editAutomationId ? 'PUT' : 'POST';
|
||||||
|
try {
|
||||||
|
var r = await fetch(url, { method: method, credentials:'same-origin',
|
||||||
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: JSON.stringify(payload) });
|
||||||
|
var d = await r.json();
|
||||||
|
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Échec', 'error'); return; }
|
||||||
|
window.showToast && window.showToast(this.editAutomationId ? 'Automation mise à jour' : 'Automation créée');
|
||||||
|
this.cancelEditAutomation();
|
||||||
|
await this.loadAutomations();
|
||||||
|
} catch(e) { window.showToast && window.showToast('Erreur réseau', 'error'); }
|
||||||
|
},
|
||||||
|
editAutomation(a) {
|
||||||
|
this.editAutomationId = a.id;
|
||||||
|
this.af = {
|
||||||
|
name: a.name || '',
|
||||||
|
trigger_type: a.trigger_type || 'event',
|
||||||
|
event: a.event || 'page.created',
|
||||||
|
cron_expression: a.cron_expression || '*/15 * * * *',
|
||||||
|
collection_id: a.collection_id ? String(a.collection_id) : '',
|
||||||
|
condition_json: a.condition_json || '[]',
|
||||||
|
actions_json: a.actions_json || '[]'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cancelEditAutomation() {
|
||||||
|
this.editAutomationId = null;
|
||||||
|
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
|
||||||
|
},
|
||||||
|
async toggleAutomation(id, currentlyEnabled) {
|
||||||
|
try {
|
||||||
|
var row = this.automations.find(function(x){ return x.id === id; });
|
||||||
|
if (!row) return;
|
||||||
|
var payload = {
|
||||||
|
name: row.name,
|
||||||
|
trigger_type: row.trigger_type || 'event',
|
||||||
|
event: row.event || 'page.created',
|
||||||
|
cron_expression: row.cron_expression || '',
|
||||||
|
collection_id: row.collection_id || null,
|
||||||
|
condition_json: row.condition_json || '[]',
|
||||||
|
actions_json: row.actions_json || '[]',
|
||||||
|
enabled: !currentlyEnabled
|
||||||
|
};
|
||||||
|
var r = await fetch('/workspace/automations/' + id, { method:'PUT', credentials:'same-origin',
|
||||||
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||||
|
body: JSON.stringify(payload) });
|
||||||
|
if (r.ok) await this.loadAutomations();
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
async runAutomation(id) {
|
||||||
|
try {
|
||||||
|
this.autoRuns = [];
|
||||||
|
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: 'running', detail: '…' });
|
||||||
|
var r = await fetch('/workspace/automations/' + id + '/run', { method:'POST', credentials:'same-origin',
|
||||||
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
|
||||||
|
var d = await r.json();
|
||||||
|
this.autoRuns = [];
|
||||||
|
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: d.status || 'fired', detail: d.detail || '' });
|
||||||
|
window.showToast && window.showToast('Résultat : ' + (d.status || '') + ' — ' + (d.detail || ''));
|
||||||
|
await this.loadAutomations();
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
async deleteAutomation(id) {
|
||||||
|
if (!confirm('Supprimer cette automation ?')) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/workspace/automations/' + id, { method:'DELETE', credentials:'same-origin',
|
||||||
|
headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
||||||
|
if (r.ok) await this.loadAutomations();
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
closeSettings() {
|
closeSettings() {
|
||||||
// Refresh parent page after settings changes, then navigate back
|
|
||||||
if (window.opener && !window.opener.closed) {
|
if (window.opener && !window.opener.closed) {
|
||||||
window.opener.location.reload();
|
window.opener.location.reload();
|
||||||
window.close();
|
window.close();
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""FlowDeck — v5.1.0 automations: rules engine (trigger + condition + action).
|
||||||
|
|
||||||
|
Covers migration v5, the automations CRUD API, the if-this-then-that engine
|
||||||
|
(condition matching + action execution on collection-page events), manual/button
|
||||||
|
runs, and the cron due logic.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
db_path = db_file.name
|
||||||
|
db_file.close()
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||||
|
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||||
|
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||||
|
from app.config import settings
|
||||||
|
settings.database_url = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.db import init_db, get_conn
|
||||||
|
init_db()
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
tc = TestClient(app)
|
||||||
|
yield tc
|
||||||
|
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_collection(client, name="Autos DB"):
|
||||||
|
r = client.post("/db/api", json={"name": name, "schema": [
|
||||||
|
{"id": 1, "name": "Status", "prop_type": "select"},
|
||||||
|
{"id": 2, "name": "Priority", "prop_type": "text"},
|
||||||
|
]})
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_page(client, coll_id, title="Page", props=None):
|
||||||
|
r = client.post(f"/db/{coll_id}/pages/api", json={"title": title, "properties": props or {}})
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Migration v5 ──
|
||||||
|
|
||||||
|
def test_automations_tables_created_by_migration(client):
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
tables = {r["name"] for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "automations" in tables
|
||||||
|
assert "automation_runs" in tables
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
||||||
|
assert {"trigger_type", "event", "condition_json", "actions_json", "enabled"} <= cols
|
||||||
|
runs = {r[1] for r in conn.execute("PRAGMA table_info(automation_runs)").fetchall()}
|
||||||
|
assert {"automation_id", "status", "detail", "trigger_source"} <= runs
|
||||||
|
|
||||||
|
|
||||||
|
# ── Condition matching (service) ──
|
||||||
|
|
||||||
|
def test_condition_matching_service():
|
||||||
|
from app.services.automations import evaluate_conditions
|
||||||
|
|
||||||
|
assert evaluate_conditions("[]", {"Status": "Done"}) is True
|
||||||
|
assert evaluate_conditions('[{"property":"Status","op":"eq","value":"Done"}]', {"Status": "Done"}) is True
|
||||||
|
assert evaluate_conditions('[{"property":"Status","op":"eq","value":"Done"}]', {"Status": "Todo"}) is False
|
||||||
|
assert evaluate_conditions('[{"property":"Priority","op":"is_empty"}]', {"Status": "Done"}) is True
|
||||||
|
assert evaluate_conditions('[{"property":"Priority","op":"is_not_empty"}]', {"Priority": "High"}) is True
|
||||||
|
assert evaluate_conditions('[{"property":"Title","op":"contains","value":"urgent"}]', {"Title": "very urgent"}) is True
|
||||||
|
# AND semantics
|
||||||
|
assert evaluate_conditions(
|
||||||
|
'[{"property":"Status","op":"eq","value":"Done"},{"property":"Priority","op":"neq","value":"Low"}]',
|
||||||
|
{"Status": "Done", "Priority": "High"},
|
||||||
|
) is True
|
||||||
|
assert evaluate_conditions(
|
||||||
|
'[{"property":"Status","op":"eq","value":"Done"},{"property":"Priority","op":"neq","value":"Low"}]',
|
||||||
|
{"Status": "Done", "Priority": "Low"},
|
||||||
|
) is False
|
||||||
|
# changed only valid with before_props
|
||||||
|
assert evaluate_conditions('[{"property":"Status","op":"changed"}]', {"Status": "Done"},
|
||||||
|
{"Status": "Todo"}) is True
|
||||||
|
assert evaluate_conditions('[{"property":"Status","op":"changed"}]', {"Status": "Done"},
|
||||||
|
{"Status": "Done"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD API ──
|
||||||
|
|
||||||
|
def test_automation_crud(client):
|
||||||
|
# create
|
||||||
|
r = client.post("/workspace/automations", json={
|
||||||
|
"name": "Notify when done",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.updated",
|
||||||
|
"collection_id": None,
|
||||||
|
"condition_json": '[{"property":"Status","op":"eq","value":"Done"}]',
|
||||||
|
"actions_json": '[{"type":"notify","message":"Reacted to Done"}]',
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
aid = r.json()["id"]
|
||||||
|
|
||||||
|
# list
|
||||||
|
d = client.get("/workspace/automations").json()
|
||||||
|
assert len(d["automations"]) == 1
|
||||||
|
assert d["automations"][0]["name"] == "Notify when done"
|
||||||
|
|
||||||
|
# get
|
||||||
|
assert client.get(f"/workspace/automations/{aid}").json()["id"] == aid
|
||||||
|
|
||||||
|
# update
|
||||||
|
r = client.put(f"/workspace/automations/{aid}", json={
|
||||||
|
"name": "Renamed",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.created",
|
||||||
|
"collection_id": None,
|
||||||
|
"condition_json": "[]",
|
||||||
|
"actions_json": "[]",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.get(f"/workspace/automations/{aid}").json()["name"] == "Renamed"
|
||||||
|
|
||||||
|
# validation: name required
|
||||||
|
r = client.post("/workspace/automations", json={"name": " "})
|
||||||
|
assert r.status_code == 400
|
||||||
|
# validation: bad actions JSON
|
||||||
|
r = client.post("/workspace/automations", json={
|
||||||
|
"name": "Bad", "trigger_type": "event", "event": "page.created",
|
||||||
|
"actions_json": "not json",
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
# delete
|
||||||
|
assert client.delete(f"/workspace/automations/{aid}").status_code == 200
|
||||||
|
# After delete: the app's HTML 404 handler redirects non-API GETs to /workspaces.
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT id FROM automations WHERE id=?", (aid,)).fetchone()
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_automation_scope_collection_filter(client):
|
||||||
|
c1 = _make_collection(client, "C1")
|
||||||
|
c2 = _make_collection(client, "C2")
|
||||||
|
only_c1 = client.post("/workspace/automations", json={
|
||||||
|
"name": "Only C1",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.created",
|
||||||
|
"collection_id": c1,
|
||||||
|
"condition_json": "[]",
|
||||||
|
"actions_json": '[{"type":"notify","message":"page in C1"}]',
|
||||||
|
}).json()["id"]
|
||||||
|
|
||||||
|
p_c1 = _make_page(client, c1, props={"Status": "Todo"})
|
||||||
|
p_c2 = _make_page(client, c2, props={"Status": "Todo"})
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
runs_c1 = conn.execute(
|
||||||
|
"SELECT * FROM automation_runs WHERE automation_id=? AND status='fired'",
|
||||||
|
(only_c1,),
|
||||||
|
).fetchall()
|
||||||
|
# Only page in C1 should have triggered the automation.
|
||||||
|
assert len(runs_c1) == 1
|
||||||
|
assert runs_c1[0]["page_id"] == p_c1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event engine: fire on collection-page events ──
|
||||||
|
|
||||||
|
def test_event_trigger_set_property_action(client):
|
||||||
|
coll = _make_collection(client)
|
||||||
|
pid = _make_page(client, coll, props={"Status": "Todo"})
|
||||||
|
|
||||||
|
client.post("/workspace/automations", json={
|
||||||
|
"name": "Mark archived",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.updated",
|
||||||
|
"collection_id": coll,
|
||||||
|
"condition_json": '[{"property":"Status","op":"eq","value":"Archived"}]',
|
||||||
|
"actions_json": '[{"type":"set_property","property":"Priority","value":"Low"}]',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert client.put(f"/db/pages/{pid}/api", json={
|
||||||
|
"properties": {"Status": "Archived", "Priority": "High"},
|
||||||
|
}).status_code == 200
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT property_values_json FROM collection_pages WHERE id=?", (pid,)
|
||||||
|
).fetchone()
|
||||||
|
props = __import__("json").loads(row["property_values_json"])
|
||||||
|
assert props["Priority"] == "Low" # action overrode the value
|
||||||
|
assert props["Status"] == "Archived" # original value preserved
|
||||||
|
|
||||||
|
# The action should not re-fire (DB-level write, not via API)
|
||||||
|
with get_conn() as conn:
|
||||||
|
runs = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS c FROM automation_runs WHERE status='fired'"
|
||||||
|
).fetchone()["c"]
|
||||||
|
assert runs == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_trigger_skipped_when_condition_fails(client):
|
||||||
|
coll = _make_collection(client)
|
||||||
|
pid = _make_page(client, coll, props={"Status": "Todo"})
|
||||||
|
client.post("/workspace/automations", json={
|
||||||
|
"name": "Skip unless Done",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.updated",
|
||||||
|
"collection_id": coll,
|
||||||
|
"condition_json": '[{"property":"Status","op":"eq","value":"Done"}]',
|
||||||
|
"actions_json": '[{"type":"notify","message":"done!"}]',
|
||||||
|
})
|
||||||
|
client.put(f"/db/pages/{pid}/api", json={"properties": {"Status": "Todo"}})
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status FROM automation_runs WHERE automation_id=(SELECT id FROM automations LIMIT 1)"
|
||||||
|
).fetchone()
|
||||||
|
assert row["status"] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_deleted_fires_event(client):
|
||||||
|
coll = _make_collection(client)
|
||||||
|
pid = _make_page(client, coll)
|
||||||
|
client.post("/workspace/automations", json={
|
||||||
|
"name": "On delete",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.deleted",
|
||||||
|
"collection_id": None,
|
||||||
|
"condition_json": "[]",
|
||||||
|
"actions_json": '[{"type":"notify","message":"page deleted"}]',
|
||||||
|
})
|
||||||
|
assert client.delete(f"/db/pages/{pid}/api").status_code == 200
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
runs = conn.execute("SELECT * FROM automation_runs WHERE status='fired'").fetchall()
|
||||||
|
assert len(runs) == 1
|
||||||
|
assert runs[0]["trigger_source"] == "event"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_page_action(client):
|
||||||
|
coll = _make_collection(client)
|
||||||
|
target = _make_collection(client, "Target")
|
||||||
|
_make_page(client, coll, props={"Status": "Go"})
|
||||||
|
client.post("/workspace/automations", json={
|
||||||
|
"name": "Spawn a page",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.updated",
|
||||||
|
"collection_id": coll,
|
||||||
|
"condition_json": '[{"property":"Status","op":"contains","value":"Go"}]',
|
||||||
|
"actions_json": json_str([{"type": "create_page", "collection_id": target,
|
||||||
|
"title": "Generated from {{title}}",
|
||||||
|
"properties": {"Status": "Todo"}}]),
|
||||||
|
})
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
src = conn.execute(
|
||||||
|
"SELECT id FROM collection_pages WHERE collection_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(coll,),
|
||||||
|
).fetchone()["id"]
|
||||||
|
client.put(f"/db/pages/{src}/api", json={"properties": {"Status": "Go"}})
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT title FROM collection_pages WHERE collection_id=? ORDER BY id DESC",
|
||||||
|
(target,),
|
||||||
|
).fetchall()
|
||||||
|
assert any("Generated from" in (r["title"] or "") for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Manual / button run ──
|
||||||
|
|
||||||
|
def test_button_run_endpoint(client):
|
||||||
|
coll = _make_collection(client)
|
||||||
|
pid = _make_page(client, coll, props={"Status": "Todo"})
|
||||||
|
auto_id = client.post("/workspace/automations", json={
|
||||||
|
"name": "Button action",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.updated",
|
||||||
|
"collection_id": None,
|
||||||
|
"condition_json": "[]",
|
||||||
|
"actions_json": '[{"type":"set_property","property":"Status","value":"Clicked"}]',
|
||||||
|
}).json()["id"]
|
||||||
|
|
||||||
|
r = client.post(f"/api/automations/{auto_id}/run", json={"page_id": pid})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "fired"
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
props = __import__("json").loads(conn.execute(
|
||||||
|
"SELECT property_values_json FROM collection_pages WHERE id=?", (pid,)
|
||||||
|
).fetchone()["property_values_json"])
|
||||||
|
assert props["Status"] == "Clicked"
|
||||||
|
|
||||||
|
# history recorded
|
||||||
|
d = client.get(f"/workspace/automations/{auto_id}/runs").json()
|
||||||
|
assert len(d["runs"]) == 1
|
||||||
|
assert d["runs"][0]["trigger_source"] == "button"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_run_skipped_for_disabled(client):
|
||||||
|
auto_id = client.post("/workspace/automations", json={
|
||||||
|
"name": "Disabled",
|
||||||
|
"trigger_type": "event",
|
||||||
|
"event": "page.created",
|
||||||
|
"collection_id": None,
|
||||||
|
"condition_json": "[]",
|
||||||
|
"actions_json": "[]",
|
||||||
|
"enabled": False,
|
||||||
|
}).json()["id"]
|
||||||
|
r = client.post(f"/workspace/automations/{auto_id}/run", json={})
|
||||||
|
assert r.json()["status"] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cron due logic ──
|
||||||
|
|
||||||
|
def test_cron_due():
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.services.automations import cron_due
|
||||||
|
|
||||||
|
now = datetime(2026, 9, 7, 10, 15, 0)
|
||||||
|
# every 15 minutes: due when >= 15 minutes elapsed, otherwise not
|
||||||
|
last = (now - timedelta(minutes=15)).isoformat()
|
||||||
|
assert cron_due("*/15 * * * *", last, now) is True
|
||||||
|
last = (now - timedelta(minutes=10)).isoformat()
|
||||||
|
assert cron_due("*/15 * * * *", last, now) is False
|
||||||
|
# no last run → always due
|
||||||
|
assert cron_due("*/30 * * * *", None, now) is True
|
||||||
|
# fixed minute
|
||||||
|
assert cron_due("15 * * * *", None, now) is True
|
||||||
|
assert cron_due("45 * * * *", None, now) is False
|
||||||
|
# @hourly / @daily
|
||||||
|
assert cron_due("@hourly", None, now) is True
|
||||||
|
assert cron_due("@daily", (now - timedelta(hours=25)).isoformat(), now) is True
|
||||||
|
assert cron_due("@daily", (now - timedelta(hours=2)).isoformat(), now) is False
|
||||||
|
assert cron_due(" ", None, now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def json_str(obj):
|
||||||
|
import json
|
||||||
|
return json.dumps(obj)
|
||||||
Reference in New Issue
Block a user