feat(vwb-v3): Architecture Thin Client fonctionnelle
API = Source de vérité unique (SQLite + Flask) - Backend: API v3 avec session, workflow, capture, execute - Frontend: Vanilla TypeScript, pas de state local - Contrats stricts pour les actions RPA - Drag & drop pour réorganiser les étapes - Insertion d'étapes entre deux existantes - Bibliothèque de captures (sessionStorage) - Exécution avec coordonnées statiques (pyautogui) Fonctionne mais fragile (coordonnées fixes, pas de détection visuelle) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
18
visual_workflow_builder/backend/api_v3/__init__.py
Normal file
18
visual_workflow_builder/backend/api_v3/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
API v3 - RPA Vision Thin Client
|
||||
Source de vérité unique, pas de logique frontend
|
||||
|
||||
Auteur: Dom, Alice, Kiro - 23 janvier 2026
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
api_v3_bp = Blueprint('api_v3', __name__, url_prefix='/api/v3')
|
||||
|
||||
# Import des routes après la création du blueprint pour éviter les imports circulaires
|
||||
from . import session
|
||||
from . import workflow
|
||||
from . import capture
|
||||
from . import execute
|
||||
|
||||
__all__ = ['api_v3_bp']
|
||||
318
visual_workflow_builder/backend/api_v3/capture.py
Normal file
318
visual_workflow_builder/backend/api_v3/capture.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
API v3 - Capture et Sélection Visuelle
|
||||
Gestion des captures d'écran et création d'ancres visuelles
|
||||
|
||||
POST /api/v3/capture/screen → Capture écran
|
||||
POST /api/v3/capture/select → Crée ancre depuis sélection
|
||||
GET /api/v3/anchor/{id}/image → Image de l'ancre
|
||||
"""
|
||||
|
||||
from flask import jsonify, request, send_file
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import os
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Step, VisualAnchor, get_session_state
|
||||
|
||||
|
||||
# Dossier pour stocker les images d'ancres
|
||||
ANCHORS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'anchors')
|
||||
os.makedirs(ANCHORS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/screen', methods=['POST'])
|
||||
def capture_screen():
|
||||
"""
|
||||
Capture l'écran actuel.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"capture": {
|
||||
"screenshot_base64": "...",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"timestamp": "..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
import pyautogui
|
||||
|
||||
# Capture écran
|
||||
screenshot = pyautogui.screenshot()
|
||||
width, height = screenshot.size
|
||||
|
||||
# Convertir en base64
|
||||
buffer = BytesIO()
|
||||
screenshot.save(buffer, format='PNG')
|
||||
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# Stocker dans la session
|
||||
session = get_session_state()
|
||||
session.last_capture = {
|
||||
'screenshot_base64': screenshot_base64,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
print(f"📸 [API v3] Capture écran: {width}x{height}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'capture': session.last_capture
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/select', methods=['POST'])
|
||||
def select_anchor():
|
||||
"""
|
||||
Crée une ancre visuelle à partir d'une sélection utilisateur.
|
||||
|
||||
Request:
|
||||
{
|
||||
"step_id": "step_123", // Étape à laquelle associer l'ancre
|
||||
"bbox": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"width": 50,
|
||||
"height": 30
|
||||
},
|
||||
"description": "Bouton Valider", // Optionnel
|
||||
"screenshot_base64": "..." // Optionnel - si fourni, utilise cette capture au lieu de session.last_capture
|
||||
}
|
||||
|
||||
L'image est extraite de la capture fournie ou de la dernière capture (session.last_capture).
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... },
|
||||
"step": { ... },
|
||||
"anchor": { ... }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
step_id = data.get('step_id')
|
||||
bbox = data.get('bbox')
|
||||
description = data.get('description', '')
|
||||
screenshot_base64 = data.get('screenshot_base64') # Capture optionnelle directe
|
||||
|
||||
if not step_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "step_id requis"
|
||||
}), 400
|
||||
|
||||
if not bbox:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "bbox requis"
|
||||
}), 400
|
||||
|
||||
# Vérifier que l'étape existe
|
||||
step = Step.query.get(step_id)
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
# Récupérer la capture (fournie ou depuis la session)
|
||||
session = get_session_state()
|
||||
|
||||
if screenshot_base64:
|
||||
# Utiliser la capture fournie directement
|
||||
print(f"📸 [API v3] Utilisation de la capture fournie dans la requête")
|
||||
elif session.last_capture:
|
||||
screenshot_base64 = session.last_capture['screenshot_base64']
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune capture disponible. Fournissez screenshot_base64 ou appelez /capture/screen d'abord."
|
||||
}), 400
|
||||
|
||||
# Décoder l'image
|
||||
img_data = base64.b64decode(screenshot_base64)
|
||||
img = Image.open(BytesIO(img_data))
|
||||
|
||||
# Extraire la zone sélectionnée
|
||||
x = int(bbox.get('x', 0))
|
||||
y = int(bbox.get('y', 0))
|
||||
w = int(bbox.get('width', 100))
|
||||
h = int(bbox.get('height', 100))
|
||||
|
||||
# Valider les coordonnées
|
||||
x = max(0, min(x, img.width - 1))
|
||||
y = max(0, min(y, img.height - 1))
|
||||
w = max(10, min(w, img.width - x))
|
||||
h = max(10, min(h, img.height - y))
|
||||
|
||||
# Créer l'ancre
|
||||
anchor_id = generate_id('anchor')
|
||||
|
||||
# Sauvegarder l'image complète de référence
|
||||
image_path = os.path.join(ANCHORS_DIR, f"{anchor_id}_full.png")
|
||||
img.save(image_path, 'PNG')
|
||||
|
||||
# Créer et sauvegarder la miniature (zone sélectionnée)
|
||||
thumbnail = img.crop((x, y, x + w, y + h))
|
||||
thumbnail_path = os.path.join(ANCHORS_DIR, f"{anchor_id}_thumb.png")
|
||||
thumbnail.save(thumbnail_path, 'PNG')
|
||||
|
||||
# Créer l'enregistrement en base
|
||||
# Utiliser les dimensions de l'image décodée (pas de session.last_capture qui peut être None)
|
||||
anchor = VisualAnchor(
|
||||
id=anchor_id,
|
||||
image_path=image_path,
|
||||
thumbnail_path=thumbnail_path,
|
||||
bbox_x=x,
|
||||
bbox_y=y,
|
||||
bbox_width=w,
|
||||
bbox_height=h,
|
||||
screen_width=img.width,
|
||||
screen_height=img.height,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.session.add(anchor)
|
||||
|
||||
# Associer l'ancre à l'étape
|
||||
step.anchor_id = anchor_id
|
||||
|
||||
# Mettre à jour les paramètres de l'étape avec l'ancre
|
||||
params = step.parameters or {}
|
||||
params['visual_anchor'] = {
|
||||
'anchor_id': anchor_id,
|
||||
'bounding_box': {'x': x, 'y': y, 'width': w, 'height': h}
|
||||
}
|
||||
step.parameters = params
|
||||
|
||||
db.session.commit()
|
||||
|
||||
from db.models import Workflow
|
||||
workflow = Workflow.query.get(step.workflow_id)
|
||||
|
||||
print(f"✅ [API v3] Ancre créée: {anchor_id} pour étape {step_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'step': step.to_dict(),
|
||||
'anchor': anchor.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/anchor/<anchor_id>/image', methods=['GET'])
|
||||
def get_anchor_image(anchor_id: str):
|
||||
"""Retourne l'image complète de l'ancre"""
|
||||
try:
|
||||
anchor = VisualAnchor.query.get(anchor_id)
|
||||
if not anchor or not anchor.image_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{anchor_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(anchor.image_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Fichier image non trouvé"
|
||||
}), 404
|
||||
|
||||
return send_file(anchor.image_path, mimetype='image/png')
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/anchor/<anchor_id>/thumbnail', methods=['GET'])
|
||||
def get_anchor_thumbnail(anchor_id: str):
|
||||
"""Retourne la miniature de l'ancre (zone sélectionnée)"""
|
||||
try:
|
||||
anchor = VisualAnchor.query.get(anchor_id)
|
||||
if not anchor or not anchor.thumbnail_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{anchor_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(anchor.thumbnail_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Fichier miniature non trouvé"
|
||||
}), 404
|
||||
|
||||
return send_file(anchor.thumbnail_path, mimetype='image/png')
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/anchor/<anchor_id>/base64', methods=['GET'])
|
||||
def get_anchor_base64(anchor_id: str):
|
||||
"""Retourne l'image de l'ancre en base64 (pour exécution)"""
|
||||
try:
|
||||
anchor = VisualAnchor.query.get(anchor_id)
|
||||
if not anchor or not anchor.image_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{anchor_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(anchor.image_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Fichier image non trouvé"
|
||||
}), 404
|
||||
|
||||
with open(anchor.image_path, 'rb') as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'anchor_id': anchor_id,
|
||||
'image_base64': image_base64,
|
||||
'bounding_box': {
|
||||
'x': anchor.bbox_x,
|
||||
'y': anchor.bbox_y,
|
||||
'width': anchor.bbox_width,
|
||||
'height': anchor.bbox_height
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
504
visual_workflow_builder/backend/api_v3/execute.py
Normal file
504
visual_workflow_builder/backend/api_v3/execute.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
API v3 - Exécution de Workflows
|
||||
Gestion de l'exécution avec validation de contrats
|
||||
|
||||
POST /api/v3/execute/start → Lance l'exécution
|
||||
POST /api/v3/execute/pause → Met en pause
|
||||
POST /api/v3/execute/resume → Reprend
|
||||
POST /api/v3/execute/stop → Arrête
|
||||
GET /api/v3/execute/status → État actuel
|
||||
"""
|
||||
|
||||
from flask import jsonify, request
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import threading
|
||||
import time
|
||||
import base64
|
||||
import os
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Workflow, Step, Execution, ExecutionStep, VisualAnchor, get_session_state
|
||||
from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
|
||||
|
||||
|
||||
# État de l'exécution en cours (en mémoire)
|
||||
_execution_state = {
|
||||
'is_running': False,
|
||||
'is_paused': False,
|
||||
'should_stop': False,
|
||||
'current_execution_id': None,
|
||||
'thread': None
|
||||
}
|
||||
|
||||
|
||||
def execute_workflow_thread(execution_id: str, workflow_id: str, app):
|
||||
"""
|
||||
Thread d'exécution du workflow.
|
||||
Exécute chaque étape séquentiellement avec validation de contrat.
|
||||
"""
|
||||
global _execution_state
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
execution = Execution.query.get(execution_id)
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
|
||||
if not execution or not workflow:
|
||||
print(f"❌ [Execute] Workflow ou exécution non trouvé")
|
||||
return
|
||||
|
||||
steps = workflow.steps.order_by(Step.order).all()
|
||||
execution.total_steps = len(steps)
|
||||
execution.status = 'running'
|
||||
execution.started_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
print(f"🚀 [Execute] Démarrage workflow {workflow_id}: {len(steps)} étapes")
|
||||
|
||||
for index, step in enumerate(steps):
|
||||
# Vérifier si arrêt demandé
|
||||
if _execution_state['should_stop']:
|
||||
print(f"⛔ [Execute] Arrêt demandé")
|
||||
execution.status = 'cancelled'
|
||||
break
|
||||
|
||||
# Attendre si en pause
|
||||
while _execution_state['is_paused'] and not _execution_state['should_stop']:
|
||||
time.sleep(0.1)
|
||||
|
||||
if _execution_state['should_stop']:
|
||||
execution.status = 'cancelled'
|
||||
break
|
||||
|
||||
# Mettre à jour la progression
|
||||
execution.current_step_index = index
|
||||
db.session.commit()
|
||||
|
||||
# Créer l'enregistrement de résultat
|
||||
step_result = ExecutionStep(
|
||||
execution_id=execution_id,
|
||||
step_id=step.id,
|
||||
status='running',
|
||||
started_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(step_result)
|
||||
db.session.commit()
|
||||
|
||||
print(f"📋 [Execute] Étape {index + 1}/{len(steps)}: {step.action_type} (id={step.id})")
|
||||
|
||||
try:
|
||||
# === VALIDATION CONTRAT STRICT ===
|
||||
params = step.parameters or {}
|
||||
|
||||
# Si l'étape a une ancre, charger ses données
|
||||
if step.anchor_id:
|
||||
anchor = VisualAnchor.query.get(step.anchor_id)
|
||||
if anchor:
|
||||
# Charger l'image base64 depuis le fichier
|
||||
if anchor.image_path and os.path.exists(anchor.image_path):
|
||||
with open(anchor.image_path, 'rb') as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
else:
|
||||
image_base64 = None
|
||||
|
||||
params['visual_anchor'] = {
|
||||
'anchor_id': anchor.id,
|
||||
'screenshot': image_base64,
|
||||
'bounding_box': {
|
||||
'x': anchor.bbox_x,
|
||||
'y': anchor.bbox_y,
|
||||
'width': anchor.bbox_width,
|
||||
'height': anchor.bbox_height
|
||||
},
|
||||
'metadata': {
|
||||
'screen_resolution': {
|
||||
'width': anchor.screen_width,
|
||||
'height': anchor.screen_height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Valider le contrat
|
||||
try:
|
||||
enforce_action_contract(step.action_type, params)
|
||||
except ContractValidationError as e:
|
||||
print(f"🚫 [Execute] CONTRAT VIOLÉ pour étape {step.id}: {e}")
|
||||
step_result.status = 'error'
|
||||
step_result.error_message = f"Contrat violé: {str(e)}"
|
||||
step_result.ended_at = datetime.utcnow()
|
||||
execution.failed_steps += 1
|
||||
db.session.commit()
|
||||
|
||||
# Arrêter sur violation de contrat
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Contrat violé à l'étape {index + 1}: {str(e)}"
|
||||
break
|
||||
|
||||
# === EXÉCUTION DE L'ACTION ===
|
||||
result = execute_action(step.action_type, params)
|
||||
|
||||
step_result.ended_at = datetime.utcnow()
|
||||
step_result.duration_ms = int((step_result.ended_at - step_result.started_at).total_seconds() * 1000)
|
||||
|
||||
if result.get('success'):
|
||||
step_result.status = 'success'
|
||||
step_result.output = result.get('output', {})
|
||||
execution.completed_steps += 1
|
||||
print(f"✅ [Execute] Étape {index + 1} réussie")
|
||||
else:
|
||||
step_result.status = 'error'
|
||||
step_result.error_message = result.get('error', 'Erreur inconnue')
|
||||
execution.failed_steps += 1
|
||||
print(f"❌ [Execute] Étape {index + 1} échouée: {step_result.error_message}")
|
||||
|
||||
# Arrêter sur erreur
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Erreur à l'étape {index + 1}: {step_result.error_message}"
|
||||
db.session.commit()
|
||||
break
|
||||
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Execute] Exception étape {index + 1}: {e}")
|
||||
step_result.status = 'error'
|
||||
step_result.error_message = str(e)
|
||||
step_result.ended_at = datetime.utcnow()
|
||||
execution.failed_steps += 1
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Exception à l'étape {index + 1}: {str(e)}"
|
||||
db.session.commit()
|
||||
break
|
||||
|
||||
# Finaliser l'exécution
|
||||
if execution.status == 'running':
|
||||
execution.status = 'completed'
|
||||
|
||||
execution.ended_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
print(f"🏁 [Execute] Workflow terminé: {execution.status}")
|
||||
print(f" Complétées: {execution.completed_steps}, Échouées: {execution.failed_steps}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Execute] Erreur fatale: {e}")
|
||||
try:
|
||||
execution = Execution.query.get(execution_id)
|
||||
if execution:
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Erreur fatale: {str(e)}"
|
||||
execution.ended_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
finally:
|
||||
_execution_state['is_running'] = False
|
||||
_execution_state['current_execution_id'] = None
|
||||
|
||||
|
||||
def execute_action(action_type: str, params: dict) -> dict:
|
||||
"""
|
||||
Exécute une action RPA.
|
||||
Utilise pyautogui pour les interactions.
|
||||
"""
|
||||
import pyautogui
|
||||
import time
|
||||
|
||||
try:
|
||||
if action_type in ['click_anchor', 'click', 'double_click_anchor', 'right_click_anchor']:
|
||||
# Récupérer les coordonnées depuis l'ancre
|
||||
anchor = params.get('visual_anchor', {})
|
||||
bbox = anchor.get('bounding_box', {})
|
||||
|
||||
if not bbox:
|
||||
return {'success': False, 'error': 'Pas de bounding_box dans visual_anchor'}
|
||||
|
||||
# Calculer le centre
|
||||
x = bbox.get('x', 0) + bbox.get('width', 0) / 2
|
||||
y = bbox.get('y', 0) + bbox.get('height', 0) / 2
|
||||
|
||||
# TODO: Utiliser la détection visuelle (OmniParser/VLM) ici
|
||||
# Pour l'instant, on utilise les coordonnées statiques
|
||||
|
||||
print(f"🖱️ [Action] Clic à ({x}, {y})")
|
||||
|
||||
if action_type == 'double_click_anchor':
|
||||
pyautogui.doubleClick(x, y)
|
||||
elif action_type == 'right_click_anchor':
|
||||
pyautogui.rightClick(x, y)
|
||||
else:
|
||||
pyautogui.click(x, y)
|
||||
|
||||
return {'success': True, 'output': {'clicked_at': {'x': x, 'y': y}}}
|
||||
|
||||
elif action_type in ['type_text', 'type']:
|
||||
text = params.get('text', '')
|
||||
if not text:
|
||||
return {'success': False, 'error': 'Pas de texte à saisir'}
|
||||
|
||||
print(f"⌨️ [Action] Saisie: {text[:30]}...")
|
||||
|
||||
# Petit délai pour s'assurer que le focus est bon
|
||||
time.sleep(0.2)
|
||||
|
||||
if text.isascii():
|
||||
pyautogui.typewrite(text, interval=0.05)
|
||||
else:
|
||||
pyautogui.write(text)
|
||||
|
||||
return {'success': True, 'output': {'typed': text}}
|
||||
|
||||
elif action_type in ['wait_for_anchor', 'wait']:
|
||||
timeout_ms = params.get('timeout_ms', params.get('timeout', 5000))
|
||||
print(f"⏳ [Action] Attente {timeout_ms}ms")
|
||||
time.sleep(timeout_ms / 1000)
|
||||
return {'success': True, 'output': {'waited_ms': timeout_ms}}
|
||||
|
||||
elif action_type == 'keyboard_shortcut':
|
||||
keys = params.get('keys', [])
|
||||
if not keys:
|
||||
return {'success': False, 'error': 'Pas de touches définies'}
|
||||
|
||||
print(f"⌨️ [Action] Raccourci: {'+'.join(keys)}")
|
||||
pyautogui.hotkey(*keys)
|
||||
return {'success': True, 'output': {'hotkey': keys}}
|
||||
|
||||
else:
|
||||
return {'success': False, 'error': f"Type d'action non supporté: {action_type}"}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/start', methods=['POST'])
|
||||
def start_execution():
|
||||
"""
|
||||
Lance l'exécution d'un workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"workflow_id": "wf_123" // Optionnel, utilise le workflow actif sinon
|
||||
}
|
||||
"""
|
||||
global _execution_state
|
||||
|
||||
try:
|
||||
if _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Une exécution est déjà en cours"
|
||||
}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
workflow_id = data.get('workflow_id')
|
||||
|
||||
# Utiliser le workflow actif si non spécifié
|
||||
if not workflow_id:
|
||||
session = get_session_state()
|
||||
workflow_id = session.active_workflow_id
|
||||
|
||||
if not workflow_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucun workflow spécifié ou actif"
|
||||
}), 400
|
||||
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
if workflow.steps.count() == 0:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Le workflow n'a aucune étape"
|
||||
}), 400
|
||||
|
||||
# Créer l'exécution
|
||||
execution = Execution(
|
||||
id=generate_id('exec'),
|
||||
workflow_id=workflow_id,
|
||||
status='pending'
|
||||
)
|
||||
db.session.add(execution)
|
||||
db.session.commit()
|
||||
|
||||
# Mettre à jour la session
|
||||
session = get_session_state()
|
||||
session.active_execution_id = execution.id
|
||||
|
||||
# Réinitialiser l'état
|
||||
_execution_state['is_running'] = True
|
||||
_execution_state['is_paused'] = False
|
||||
_execution_state['should_stop'] = False
|
||||
_execution_state['current_execution_id'] = execution.id
|
||||
|
||||
# Lancer le thread d'exécution
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
|
||||
thread = threading.Thread(
|
||||
target=execute_workflow_thread,
|
||||
args=(execution.id, workflow_id, app)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
_execution_state['thread'] = thread
|
||||
|
||||
print(f"🚀 [API v3] Exécution lancée: {execution.id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict(),
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_execution_state['is_running'] = False
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/pause', methods=['POST'])
|
||||
def pause_execution():
|
||||
"""Met en pause l'exécution"""
|
||||
global _execution_state
|
||||
|
||||
if not _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune exécution en cours"
|
||||
}), 400
|
||||
|
||||
_execution_state['is_paused'] = True
|
||||
|
||||
execution = Execution.query.get(_execution_state['current_execution_id'])
|
||||
if execution:
|
||||
execution.status = 'paused'
|
||||
db.session.commit()
|
||||
|
||||
print(f"⏸️ [API v3] Exécution en pause")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict() if execution else None
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/resume', methods=['POST'])
|
||||
def resume_execution():
|
||||
"""Reprend l'exécution"""
|
||||
global _execution_state
|
||||
|
||||
if not _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune exécution en cours"
|
||||
}), 400
|
||||
|
||||
if not _execution_state['is_paused']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "L'exécution n'est pas en pause"
|
||||
}), 400
|
||||
|
||||
_execution_state['is_paused'] = False
|
||||
|
||||
execution = Execution.query.get(_execution_state['current_execution_id'])
|
||||
if execution:
|
||||
execution.status = 'running'
|
||||
db.session.commit()
|
||||
|
||||
print(f"▶️ [API v3] Exécution reprise")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict() if execution else None
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/stop', methods=['POST'])
|
||||
def stop_execution():
|
||||
"""Arrête l'exécution"""
|
||||
global _execution_state
|
||||
|
||||
if not _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune exécution en cours"
|
||||
}), 400
|
||||
|
||||
_execution_state['should_stop'] = True
|
||||
_execution_state['is_paused'] = False
|
||||
|
||||
print(f"⛔ [API v3] Arrêt demandé")
|
||||
|
||||
# Attendre un peu que le thread réagisse
|
||||
time.sleep(0.5)
|
||||
|
||||
execution = Execution.query.get(_execution_state['current_execution_id'])
|
||||
|
||||
session = get_session_state()
|
||||
session.active_execution_id = None
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict() if execution else None,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/status', methods=['GET'])
|
||||
def get_execution_status():
|
||||
"""Retourne l'état de l'exécution en cours"""
|
||||
global _execution_state
|
||||
|
||||
session = get_session_state()
|
||||
|
||||
execution = None
|
||||
if session.active_execution_id:
|
||||
execution = Execution.query.get(session.active_execution_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'is_running': _execution_state['is_running'],
|
||||
'is_paused': _execution_state['is_paused'],
|
||||
'execution': execution.to_dict() if execution else None,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/history', methods=['GET'])
|
||||
def get_execution_history():
|
||||
"""Retourne l'historique des exécutions"""
|
||||
try:
|
||||
workflow_id = request.args.get('workflow_id')
|
||||
|
||||
query = Execution.query.order_by(Execution.started_at.desc())
|
||||
|
||||
if workflow_id:
|
||||
query = query.filter_by(workflow_id=workflow_id)
|
||||
|
||||
executions = query.limit(50).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'executions': [e.to_dict() for e in executions]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
157
visual_workflow_builder/backend/api_v3/session.py
Normal file
157
visual_workflow_builder/backend/api_v3/session.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
API v3 - Session State
|
||||
Endpoint principal qui retourne l'état complet de l'UI
|
||||
|
||||
GET /api/v3/session/state → Tout ce que le frontend doit afficher
|
||||
"""
|
||||
|
||||
from flask import jsonify
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Workflow, Step, Execution, get_session_state
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/state', methods=['GET'])
|
||||
def get_state():
|
||||
"""
|
||||
Retourne l'état complet de l'UI.
|
||||
|
||||
Le frontend thin client appelle cet endpoint et affiche ce qu'il reçoit.
|
||||
C'est LA source de vérité.
|
||||
|
||||
Response:
|
||||
{
|
||||
"session": {
|
||||
"active_workflow_id": "...",
|
||||
"selected_step_id": "...",
|
||||
"active_execution_id": "..."
|
||||
},
|
||||
"workflow": { ... } ou null,
|
||||
"execution": { ... } ou null,
|
||||
"workflows_list": [ {id, name, step_count, updated_at}, ... ]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
|
||||
# Workflow actif
|
||||
active_workflow = None
|
||||
if session.active_workflow_id:
|
||||
wf = Workflow.query.get(session.active_workflow_id)
|
||||
if wf:
|
||||
active_workflow = wf.to_dict()
|
||||
|
||||
# Exécution active
|
||||
active_execution = None
|
||||
if session.active_execution_id:
|
||||
exe = Execution.query.get(session.active_execution_id)
|
||||
if exe:
|
||||
active_execution = exe.to_dict()
|
||||
|
||||
# Liste des workflows (résumé)
|
||||
workflows_list = []
|
||||
for wf in Workflow.query.filter_by(is_active=True).order_by(Workflow.updated_at.desc()).all():
|
||||
workflows_list.append({
|
||||
'id': wf.id,
|
||||
'name': wf.name,
|
||||
'step_count': wf.steps.count(),
|
||||
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict(),
|
||||
'workflow': active_workflow,
|
||||
'execution': active_execution,
|
||||
'workflows_list': workflows_list
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/select-workflow/<workflow_id>', methods=['POST'])
|
||||
def select_workflow(workflow_id: str):
|
||||
"""Sélectionne un workflow comme actif"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
|
||||
# Vérifier que le workflow existe
|
||||
wf = Workflow.query.get(workflow_id)
|
||||
if not wf:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
session.active_workflow_id = workflow_id
|
||||
session.selected_step_id = None
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict(),
|
||||
'workflow': wf.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/select-step/<step_id>', methods=['POST'])
|
||||
def select_step(step_id: str):
|
||||
"""Sélectionne une étape"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
|
||||
# Vérifier que l'étape existe
|
||||
step = Step.query.get(step_id)
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
session.selected_step_id = step_id
|
||||
|
||||
# S'assurer que le workflow parent est actif
|
||||
if session.active_workflow_id != step.workflow_id:
|
||||
session.active_workflow_id = step.workflow_id
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict(),
|
||||
'step': step.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/clear', methods=['POST'])
|
||||
def clear_session():
|
||||
"""Réinitialise la session"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
session.active_workflow_id = None
|
||||
session.selected_step_id = None
|
||||
session.active_execution_id = None
|
||||
session.last_capture = None
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
403
visual_workflow_builder/backend/api_v3/workflow.py
Normal file
403
visual_workflow_builder/backend/api_v3/workflow.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
API v3 - Workflow CRUD
|
||||
Gestion des workflows et étapes
|
||||
|
||||
POST /api/v3/workflow/create
|
||||
GET /api/v3/workflow/{id}
|
||||
POST /api/v3/workflow/{id}/step
|
||||
PUT /api/v3/workflow/{id}/step/{step_id}
|
||||
DELETE /api/v3/workflow/{id}/step/{step_id}
|
||||
"""
|
||||
|
||||
from flask import jsonify, request
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Workflow, Step, VisualAnchor, get_session_state
|
||||
from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/create', methods=['POST'])
|
||||
def create_workflow():
|
||||
"""
|
||||
Crée un nouveau workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"name": "Mon workflow",
|
||||
"description": "Description optionnelle"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... },
|
||||
"session": { ... }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = data.get('name', f"Workflow {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||
description = data.get('description', '')
|
||||
|
||||
workflow = Workflow(
|
||||
id=generate_id('wf'),
|
||||
name=name,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.session.add(workflow)
|
||||
db.session.commit()
|
||||
|
||||
# Activer ce workflow dans la session
|
||||
session = get_session_state()
|
||||
session.active_workflow_id = workflow.id
|
||||
session.selected_step_id = None
|
||||
|
||||
print(f"✅ [API v3] Workflow créé: {workflow.id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>', methods=['GET'])
|
||||
def get_workflow(workflow_id: str):
|
||||
"""Récupère un workflow complet"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>', methods=['DELETE'])
|
||||
def delete_workflow(workflow_id: str):
|
||||
"""Supprime un workflow"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
# Désactiver si c'est le workflow actif
|
||||
session = get_session_state()
|
||||
if session.active_workflow_id == workflow_id:
|
||||
session.active_workflow_id = None
|
||||
session.selected_step_id = None
|
||||
|
||||
db.session.delete(workflow)
|
||||
db.session.commit()
|
||||
|
||||
print(f"🗑️ [API v3] Workflow supprimé: {workflow_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_id': workflow_id,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/step', methods=['POST'])
|
||||
def add_step(workflow_id: str):
|
||||
"""
|
||||
Ajoute une étape au workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"action_type": "click_anchor",
|
||||
"position": {"x": 100, "y": 200},
|
||||
"parameters": {},
|
||||
"label": "Clic sur bouton",
|
||||
"insert_after": "step_123" // Optionnel: insérer après cette étape
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... }, // Workflow complet mis à jour
|
||||
"step": { ... }, // Nouvelle étape
|
||||
"needs_anchor": true // Si l'action requiert une ancre visuelle
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
action_type = data.get('action_type')
|
||||
if not action_type:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "action_type requis"
|
||||
}), 400
|
||||
|
||||
position = data.get('position', {'x': 100, 'y': 100})
|
||||
parameters = data.get('parameters', {})
|
||||
label = data.get('label', action_type)
|
||||
insert_after = data.get('insert_after') # ID de l'étape après laquelle insérer
|
||||
|
||||
# Calculer l'ordre
|
||||
if insert_after:
|
||||
# Insérer après une étape spécifique
|
||||
prev_step = Step.query.filter_by(id=insert_after, workflow_id=workflow_id).first()
|
||||
if not prev_step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{insert_after}' non trouvée"
|
||||
}), 404
|
||||
|
||||
new_order = prev_step.order + 1
|
||||
|
||||
# Décaler toutes les étapes suivantes
|
||||
Step.query.filter(
|
||||
Step.workflow_id == workflow_id,
|
||||
Step.order >= new_order
|
||||
).update({Step.order: Step.order + 1})
|
||||
else:
|
||||
# Ajouter à la fin
|
||||
max_order = db.session.query(db.func.max(Step.order)).filter(
|
||||
Step.workflow_id == workflow_id
|
||||
).scalar() or -1
|
||||
new_order = max_order + 1
|
||||
|
||||
step = Step(
|
||||
id=generate_id('step'),
|
||||
workflow_id=workflow_id,
|
||||
action_type=action_type,
|
||||
order=new_order,
|
||||
position_x=position.get('x', 100),
|
||||
position_y=position.get('y', 100),
|
||||
label=label
|
||||
)
|
||||
step.parameters = parameters
|
||||
|
||||
db.session.add(step)
|
||||
db.session.commit()
|
||||
|
||||
# Sélectionner cette étape
|
||||
session = get_session_state()
|
||||
session.selected_step_id = step.id
|
||||
|
||||
# Vérifier si l'action requiert une ancre visuelle
|
||||
required_params = get_required_params(action_type)
|
||||
needs_anchor = 'visual_anchor' in required_params
|
||||
|
||||
print(f"✅ [API v3] Étape ajoutée: {step.id} ({action_type}) au workflow {workflow_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'step': step.to_dict(),
|
||||
'needs_anchor': needs_anchor,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/step/<step_id>', methods=['PUT'])
|
||||
def update_step(workflow_id: str, step_id: str):
|
||||
"""
|
||||
Met à jour une étape.
|
||||
|
||||
Request:
|
||||
{
|
||||
"action_type": "...", // Optionnel
|
||||
"position": {"x": ..., "y": ...}, // Optionnel
|
||||
"parameters": {...}, // Optionnel
|
||||
"label": "...", // Optionnel
|
||||
"anchor_id": "..." // Optionnel
|
||||
}
|
||||
"""
|
||||
try:
|
||||
step = Step.query.filter_by(id=step_id, workflow_id=workflow_id).first()
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée dans le workflow '{workflow_id}'"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Mettre à jour les champs fournis
|
||||
if 'action_type' in data:
|
||||
step.action_type = data['action_type']
|
||||
|
||||
if 'position' in data:
|
||||
step.position_x = data['position'].get('x', step.position_x)
|
||||
step.position_y = data['position'].get('y', step.position_y)
|
||||
|
||||
if 'parameters' in data:
|
||||
step.parameters = data['parameters']
|
||||
|
||||
if 'label' in data:
|
||||
step.label = data['label']
|
||||
|
||||
if 'anchor_id' in data:
|
||||
# Vérifier que l'ancre existe
|
||||
if data['anchor_id']:
|
||||
anchor = VisualAnchor.query.get(data['anchor_id'])
|
||||
if not anchor:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{data['anchor_id']}' non trouvée"
|
||||
}), 404
|
||||
step.anchor_id = data['anchor_id']
|
||||
|
||||
step.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
|
||||
print(f"✅ [API v3] Étape mise à jour: {step_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'step': step.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/step/<step_id>', methods=['DELETE'])
|
||||
def delete_step(workflow_id: str, step_id: str):
|
||||
"""Supprime une étape"""
|
||||
try:
|
||||
step = Step.query.filter_by(id=step_id, workflow_id=workflow_id).first()
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
deleted_order = step.order
|
||||
|
||||
db.session.delete(step)
|
||||
|
||||
# Réordonner les étapes suivantes
|
||||
Step.query.filter(
|
||||
Step.workflow_id == workflow_id,
|
||||
Step.order > deleted_order
|
||||
).update({Step.order: Step.order - 1})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Désélectionner si c'était l'étape sélectionnée
|
||||
session = get_session_state()
|
||||
if session.selected_step_id == step_id:
|
||||
session.selected_step_id = None
|
||||
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
|
||||
print(f"🗑️ [API v3] Étape supprimée: {step_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/reorder', methods=['POST'])
|
||||
def reorder_steps(workflow_id: str):
|
||||
"""
|
||||
Réordonne les étapes d'un workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"step_ids": ["step_1", "step_2", "step_3"] // Nouvel ordre
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
step_ids = data.get('step_ids', [])
|
||||
|
||||
# Mettre à jour l'ordre de chaque étape
|
||||
for index, step_id in enumerate(step_ids):
|
||||
step = Step.query.filter_by(id=step_id, workflow_id=workflow_id).first()
|
||||
if step:
|
||||
step.order = index
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print(f"🔄 [API v3] Étapes réordonnées pour workflow {workflow_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
Reference in New Issue
Block a user