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:
+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-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==='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>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
@@ -388,6 +389,116 @@
|
||||
</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 -->
|
||||
<div x-show="activeSection==='admin-users'">
|
||||
<h2>Users & Roles</h2>
|
||||
@@ -734,6 +845,13 @@ document.addEventListener('alpine:init', function() {
|
||||
// Per-user provider keys (multi-providers)
|
||||
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() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
@@ -1327,8 +1445,106 @@ document.addEventListener('alpine:init', function() {
|
||||
if (r.ok) { this.githubLinked = false; }
|
||||
} 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() {
|
||||
// Refresh parent page after settings changes, then navigate back
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.location.reload();
|
||||
window.close();
|
||||
|
||||
Reference in New Issue
Block a user