Feat: Actions database sauvegarder/charger_donnees
Module complet de persistance SQLite pour VWB: GestionnaireDB: - Interface clé-valeur avec typage auto (string, number, bool, json) - Collections pour données structurées avec historique - Requêtes SQL personnalisées (SELECT/modifications) - Thread-safe, singleton par chemin de DB - Statistiques et nettoyage Actions: - sauvegarder_donnees: 3 modes (cle_valeur, collection, sql) - charger_donnees: 4 modes (cle_valeur, collection, sql, lister) Base par défaut: ~/.vwb/data.db Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
33
visual_workflow_builder/backend/actions/database/__init__.py
Normal file
33
visual_workflow_builder/backend/actions/database/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Actions Database VWB - Module d'initialisation
|
||||||
|
Auteur : Dom, Claude - 14 janvier 2026
|
||||||
|
|
||||||
|
Ce module contient les actions de persistance de données
|
||||||
|
pour le Visual Workflow Builder.
|
||||||
|
|
||||||
|
Actions disponibles :
|
||||||
|
- VWBSauvegarderDonneesAction : Sauvegarde de données (clé-valeur, collection, SQL)
|
||||||
|
- VWBChargerDonneesAction : Chargement de données
|
||||||
|
|
||||||
|
Utilitaires :
|
||||||
|
- GestionnaireDB : Gestionnaire SQLite thread-safe
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .gestionnaire_db import GestionnaireDB
|
||||||
|
from .sauvegarder_donnees import VWBSauvegarderDonneesAction, VWBDBSaveDataAction
|
||||||
|
from .charger_donnees import VWBChargerDonneesAction, VWBDBReadDataAction
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Gestionnaire
|
||||||
|
'GestionnaireDB',
|
||||||
|
# Actions françaises
|
||||||
|
'VWBSauvegarderDonneesAction',
|
||||||
|
'VWBChargerDonneesAction',
|
||||||
|
# Alias anglais
|
||||||
|
'VWBDBSaveDataAction',
|
||||||
|
'VWBDBReadDataAction',
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = '1.0.0'
|
||||||
|
__author__ = 'Dom, Claude'
|
||||||
|
__date__ = '14 janvier 2026'
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
Action Charger Données - Récupération des données persistantes
|
||||||
|
Auteur : Dom, Claude - 14 janvier 2026
|
||||||
|
|
||||||
|
Cette action permet de charger des données sauvegardées précédemment
|
||||||
|
pour les utiliser dans le workflow courant.
|
||||||
|
|
||||||
|
Cas d'usage :
|
||||||
|
- Récupérer des résultats d'exécutions précédentes
|
||||||
|
- Charger une configuration
|
||||||
|
- Comparer avec des données historiques
|
||||||
|
- Reprendre un workflow interrompu
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..base_action import BaseVWBAction, VWBActionResult, VWBActionStatus
|
||||||
|
from ...contracts.error import VWBErrorType, create_vwb_error
|
||||||
|
from .gestionnaire_db import GestionnaireDB
|
||||||
|
|
||||||
|
|
||||||
|
class VWBChargerDonneesAction(BaseVWBAction):
|
||||||
|
"""
|
||||||
|
Action de chargement de données.
|
||||||
|
|
||||||
|
Modes de chargement :
|
||||||
|
- cle_valeur: Récupération par clé
|
||||||
|
- collection: Récupération d'une collection
|
||||||
|
- sql: Requête SQL personnalisée
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
action_id: str,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
screen_capturer=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialise l'action de chargement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action_id: Identifiant unique de l'action
|
||||||
|
parameters: Paramètres de chargement
|
||||||
|
screen_capturer: Non utilisé, présent pour compatibilité
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
action_id=action_id,
|
||||||
|
name="Charger Données",
|
||||||
|
description="Charge des données sauvegardées précédemment",
|
||||||
|
parameters=parameters,
|
||||||
|
screen_capturer=screen_capturer
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mode de chargement
|
||||||
|
self.mode = parameters.get('mode', 'cle_valeur') # cle_valeur, collection, sql, lister
|
||||||
|
|
||||||
|
# Paramètres clé-valeur
|
||||||
|
self.cle = parameters.get('cle', parameters.get('key', ''))
|
||||||
|
self.valeur_defaut = parameters.get('valeur_defaut', parameters.get('default_value'))
|
||||||
|
|
||||||
|
# Paramètres collection
|
||||||
|
self.nom_collection = parameters.get('nom_collection', parameters.get('collection_name', ''))
|
||||||
|
self.limite = parameters.get('limite', parameters.get('limit', 100))
|
||||||
|
self.dernier_seulement = parameters.get('dernier_seulement', parameters.get('last_only', False))
|
||||||
|
|
||||||
|
# Paramètres SQL
|
||||||
|
self.requete_sql = parameters.get('requete_sql', parameters.get('sql_query', ''))
|
||||||
|
self.parametres_sql = parameters.get('parametres_sql', parameters.get('sql_params', ()))
|
||||||
|
|
||||||
|
# Paramètres liste
|
||||||
|
self.prefixe_cle = parameters.get('prefixe_cle', parameters.get('key_prefix', ''))
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
self.chemin_db = parameters.get('chemin_db', parameters.get('db_path'))
|
||||||
|
self.workflow_id = parameters.get('workflow_id')
|
||||||
|
|
||||||
|
# Variable de sortie
|
||||||
|
self.variable_sortie = parameters.get('variable_sortie', parameters.get('output_variable', 'donnees_chargees'))
|
||||||
|
|
||||||
|
def validate_parameters(self) -> List[str]:
|
||||||
|
"""Valide les paramètres de l'action."""
|
||||||
|
erreurs = []
|
||||||
|
|
||||||
|
if self.mode not in ['cle_valeur', 'collection', 'sql', 'lister']:
|
||||||
|
erreurs.append("Mode doit être 'cle_valeur', 'collection', 'sql' ou 'lister'")
|
||||||
|
|
||||||
|
if self.mode == 'cle_valeur' and not self.cle:
|
||||||
|
erreurs.append("Clé requise pour le mode clé-valeur")
|
||||||
|
|
||||||
|
if self.mode == 'collection' and not self.nom_collection:
|
||||||
|
erreurs.append("Nom de collection requis")
|
||||||
|
|
||||||
|
if self.mode == 'sql' and not self.requete_sql:
|
||||||
|
erreurs.append("Requête SQL requise")
|
||||||
|
|
||||||
|
if self.limite < 1 or self.limite > 10000:
|
||||||
|
erreurs.append("Limite doit être entre 1 et 10000")
|
||||||
|
|
||||||
|
return erreurs
|
||||||
|
|
||||||
|
def execute_core(self, step_id: str) -> VWBActionResult:
|
||||||
|
"""
|
||||||
|
Exécute le chargement des données.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step_id: Identifiant de l'étape
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Résultat avec les données chargées
|
||||||
|
"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtenir le gestionnaire de DB
|
||||||
|
db = GestionnaireDB.obtenir_instance(self.chemin_db)
|
||||||
|
|
||||||
|
# Exécuter selon le mode
|
||||||
|
if self.mode == 'cle_valeur':
|
||||||
|
resultat = self._charger_cle_valeur(db)
|
||||||
|
elif self.mode == 'collection':
|
||||||
|
resultat = self._charger_collection(db)
|
||||||
|
elif self.mode == 'sql':
|
||||||
|
resultat = self._executer_sql(db)
|
||||||
|
else: # lister
|
||||||
|
resultat = self._lister_cles(db)
|
||||||
|
|
||||||
|
end_time = datetime.now()
|
||||||
|
execution_time = (end_time - start_time).total_seconds() * 1000
|
||||||
|
|
||||||
|
donnees = resultat.get('donnees')
|
||||||
|
trouve = resultat.get('trouve', donnees is not None)
|
||||||
|
|
||||||
|
if trouve:
|
||||||
|
print(f"📂 Données chargées ({self.mode})")
|
||||||
|
self._afficher_resume(donnees)
|
||||||
|
else:
|
||||||
|
print(f"📂 Aucune donnée trouvée ({self.mode})")
|
||||||
|
|
||||||
|
return VWBActionResult(
|
||||||
|
action_id=self.action_id,
|
||||||
|
step_id=step_id,
|
||||||
|
status=VWBActionStatus.SUCCESS,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
execution_time_ms=execution_time,
|
||||||
|
output_data={
|
||||||
|
'donnees': donnees,
|
||||||
|
'trouve': trouve,
|
||||||
|
'mode': self.mode,
|
||||||
|
'variable_sortie': self.variable_sortie,
|
||||||
|
'nb_resultats': self._compter_resultats(donnees)
|
||||||
|
},
|
||||||
|
evidence_list=self.evidence_list.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self._create_error_result(
|
||||||
|
step_id=step_id,
|
||||||
|
start_time=start_time,
|
||||||
|
error_type=VWBErrorType.SYSTEM_ERROR,
|
||||||
|
message=f"Erreur: {str(e)}",
|
||||||
|
technical_details={'exception': str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _charger_cle_valeur(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Charge une valeur par clé."""
|
||||||
|
valeur = db.charger(
|
||||||
|
cle=self.cle,
|
||||||
|
defaut=self.valeur_defaut
|
||||||
|
)
|
||||||
|
|
||||||
|
trouve = valeur != self.valeur_defaut or db.charger(self.cle) is not None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'donnees': valeur,
|
||||||
|
'trouve': trouve
|
||||||
|
}
|
||||||
|
|
||||||
|
def _charger_collection(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Charge les données d'une collection."""
|
||||||
|
if self.dernier_seulement:
|
||||||
|
resultat = db.dernier_enregistrement(
|
||||||
|
nom_collection=self.nom_collection,
|
||||||
|
workflow_id=self.workflow_id
|
||||||
|
)
|
||||||
|
if resultat:
|
||||||
|
return {
|
||||||
|
'donnees': resultat['donnees'],
|
||||||
|
'trouve': True,
|
||||||
|
'metadata': {
|
||||||
|
'id': resultat['id'],
|
||||||
|
'created_at': resultat['created_at']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {'donnees': None, 'trouve': False}
|
||||||
|
|
||||||
|
else:
|
||||||
|
resultats = db.charger_collection(
|
||||||
|
nom_collection=self.nom_collection,
|
||||||
|
limite=self.limite,
|
||||||
|
workflow_id=self.workflow_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'donnees': [r['donnees'] for r in resultats],
|
||||||
|
'trouve': len(resultats) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _executer_sql(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Exécute une requête SQL SELECT."""
|
||||||
|
resultats = db.executer_sql(
|
||||||
|
requete=self.requete_sql,
|
||||||
|
parametres=tuple(self.parametres_sql) if self.parametres_sql else ()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'donnees': resultats,
|
||||||
|
'trouve': len(resultats) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _lister_cles(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Liste les clés disponibles."""
|
||||||
|
cles = db.lister_cles(
|
||||||
|
prefixe=self.prefixe_cle if self.prefixe_cle else None,
|
||||||
|
workflow_id=self.workflow_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'donnees': cles,
|
||||||
|
'trouve': len(cles) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compter_resultats(self, donnees: Any) -> int:
|
||||||
|
"""Compte le nombre de résultats."""
|
||||||
|
if donnees is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(donnees, list):
|
||||||
|
return len(donnees)
|
||||||
|
if isinstance(donnees, dict):
|
||||||
|
return len(donnees)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _afficher_resume(self, donnees: Any):
|
||||||
|
"""Affiche un résumé des données chargées."""
|
||||||
|
if donnees is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(donnees, list):
|
||||||
|
print(f" {len(donnees)} élément(s)")
|
||||||
|
elif isinstance(donnees, dict):
|
||||||
|
print(f" {len(donnees)} clé(s)")
|
||||||
|
else:
|
||||||
|
valeur_str = str(donnees)
|
||||||
|
if len(valeur_str) > 50:
|
||||||
|
valeur_str = valeur_str[:50] + '...'
|
||||||
|
print(f" Valeur: {valeur_str}")
|
||||||
|
|
||||||
|
def get_action_info(self) -> Dict[str, Any]:
|
||||||
|
"""Retourne les informations de l'action."""
|
||||||
|
return {
|
||||||
|
'action_id': self.action_id,
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'type': 'charger_donnees',
|
||||||
|
'parameters': {
|
||||||
|
'mode': self.mode,
|
||||||
|
'cle': self.cle if self.mode == 'cle_valeur' else None,
|
||||||
|
'collection': self.nom_collection if self.mode == 'collection' else None,
|
||||||
|
'variable_sortie': self.variable_sortie
|
||||||
|
},
|
||||||
|
'status': self.current_status.value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Alias anglais
|
||||||
|
VWBDBReadDataAction = VWBChargerDonneesAction
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
"""
|
||||||
|
Gestionnaire de Base de Données VWB
|
||||||
|
Auteur : Dom, Claude - 14 janvier 2026
|
||||||
|
|
||||||
|
Module utilitaire pour la gestion SQLite des workflows VWB.
|
||||||
|
Fournit une interface simple pour le stockage persistant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
class GestionnaireDB:
|
||||||
|
"""
|
||||||
|
Gestionnaire de base de données SQLite pour VWB.
|
||||||
|
|
||||||
|
Fournit :
|
||||||
|
- Stockage clé-valeur simple
|
||||||
|
- Tables structurées
|
||||||
|
- Requêtes SQL avancées
|
||||||
|
- Thread-safe
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instances: Dict[str, 'GestionnaireDB'] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __init__(self, chemin_db: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialise le gestionnaire.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chemin_db: Chemin vers le fichier SQLite (défaut: ~/.vwb/data.db)
|
||||||
|
"""
|
||||||
|
if chemin_db:
|
||||||
|
self.chemin_db = Path(chemin_db).expanduser()
|
||||||
|
else:
|
||||||
|
self.chemin_db = Path.home() / '.vwb' / 'data.db'
|
||||||
|
|
||||||
|
# Créer le dossier parent si nécessaire
|
||||||
|
self.chemin_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialiser la base
|
||||||
|
self._initialiser_schema()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def obtenir_instance(cls, chemin_db: Optional[str] = None) -> 'GestionnaireDB':
|
||||||
|
"""Obtient une instance singleton par chemin de DB."""
|
||||||
|
chemin = chemin_db or str(Path.home() / '.vwb' / 'data.db')
|
||||||
|
|
||||||
|
with cls._lock:
|
||||||
|
if chemin not in cls._instances:
|
||||||
|
cls._instances[chemin] = cls(chemin_db)
|
||||||
|
return cls._instances[chemin]
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connexion(self):
|
||||||
|
"""Context manager pour connexion thread-safe."""
|
||||||
|
conn = sqlite3.connect(str(self.chemin_db), timeout=30.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _initialiser_schema(self):
|
||||||
|
"""Initialise les tables de base."""
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Table clé-valeur simple
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS kv_store (
|
||||||
|
cle TEXT PRIMARY KEY,
|
||||||
|
valeur TEXT,
|
||||||
|
type_donnee TEXT DEFAULT 'string',
|
||||||
|
workflow_id TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Table pour données structurées (JSON)
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS donnees_structurees (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
nom_collection TEXT NOT NULL,
|
||||||
|
donnees TEXT NOT NULL,
|
||||||
|
workflow_id TEXT,
|
||||||
|
execution_id TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Index pour performances
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kv_workflow
|
||||||
|
ON kv_store(workflow_id)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_struct_collection
|
||||||
|
ON donnees_structurees(nom_collection)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_struct_workflow
|
||||||
|
ON donnees_structurees(workflow_id)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# ==================== Interface Clé-Valeur ====================
|
||||||
|
|
||||||
|
def sauvegarder(
|
||||||
|
self,
|
||||||
|
cle: str,
|
||||||
|
valeur: Any,
|
||||||
|
workflow_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Sauvegarde une valeur avec une clé.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cle: Clé unique
|
||||||
|
valeur: Valeur à sauvegarder (sera sérialisée en JSON si nécessaire)
|
||||||
|
workflow_id: ID du workflow (optionnel)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si succès
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Déterminer le type et sérialiser
|
||||||
|
if isinstance(valeur, (dict, list)):
|
||||||
|
valeur_str = json.dumps(valeur, ensure_ascii=False, default=str)
|
||||||
|
type_donnee = 'json'
|
||||||
|
elif isinstance(valeur, bool):
|
||||||
|
valeur_str = str(valeur).lower()
|
||||||
|
type_donnee = 'bool'
|
||||||
|
elif isinstance(valeur, (int, float)):
|
||||||
|
valeur_str = str(valeur)
|
||||||
|
type_donnee = 'number'
|
||||||
|
else:
|
||||||
|
valeur_str = str(valeur)
|
||||||
|
type_donnee = 'string'
|
||||||
|
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR REPLACE INTO kv_store
|
||||||
|
(cle, valeur, type_donnee, workflow_id, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (cle, valeur_str, type_donnee, workflow_id, datetime.now().isoformat()))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur sauvegarde: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def charger(
|
||||||
|
self,
|
||||||
|
cle: str,
|
||||||
|
defaut: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Charge une valeur par sa clé.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cle: Clé à rechercher
|
||||||
|
defaut: Valeur par défaut si non trouvée
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Valeur désérialisée ou défaut
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT valeur, type_donnee FROM kv_store WHERE cle = ?',
|
||||||
|
(cle,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return defaut
|
||||||
|
|
||||||
|
valeur_str, type_donnee = row['valeur'], row['type_donnee']
|
||||||
|
|
||||||
|
# Désérialiser selon le type
|
||||||
|
if type_donnee == 'json':
|
||||||
|
return json.loads(valeur_str)
|
||||||
|
elif type_donnee == 'bool':
|
||||||
|
return valeur_str.lower() == 'true'
|
||||||
|
elif type_donnee == 'number':
|
||||||
|
return float(valeur_str) if '.' in valeur_str else int(valeur_str)
|
||||||
|
else:
|
||||||
|
return valeur_str
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur chargement: {e}")
|
||||||
|
return defaut
|
||||||
|
|
||||||
|
def supprimer(self, cle: str) -> bool:
|
||||||
|
"""Supprime une entrée par sa clé."""
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM kv_store WHERE cle = ?', (cle,))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def lister_cles(
|
||||||
|
self,
|
||||||
|
prefixe: Optional[str] = None,
|
||||||
|
workflow_id: Optional[str] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Liste les clés disponibles."""
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if prefixe and workflow_id:
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT cle FROM kv_store WHERE cle LIKE ? AND workflow_id = ?',
|
||||||
|
(f'{prefixe}%', workflow_id)
|
||||||
|
)
|
||||||
|
elif prefixe:
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT cle FROM kv_store WHERE cle LIKE ?',
|
||||||
|
(f'{prefixe}%',)
|
||||||
|
)
|
||||||
|
elif workflow_id:
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT cle FROM kv_store WHERE workflow_id = ?',
|
||||||
|
(workflow_id,)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute('SELECT cle FROM kv_store')
|
||||||
|
|
||||||
|
return [row['cle'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ==================== Interface Collections ====================
|
||||||
|
|
||||||
|
def sauvegarder_collection(
|
||||||
|
self,
|
||||||
|
nom_collection: str,
|
||||||
|
donnees: Union[Dict, List],
|
||||||
|
workflow_id: Optional[str] = None,
|
||||||
|
execution_id: Optional[str] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Sauvegarde des données structurées dans une collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nom_collection: Nom de la collection
|
||||||
|
donnees: Données (dict ou list)
|
||||||
|
workflow_id: ID du workflow
|
||||||
|
execution_id: ID de l'exécution
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ID de l'enregistrement créé
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
donnees_json = json.dumps(donnees, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO donnees_structurees
|
||||||
|
(nom_collection, donnees, workflow_id, execution_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (nom_collection, donnees_json, workflow_id, execution_id))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur sauvegarde collection: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def charger_collection(
|
||||||
|
self,
|
||||||
|
nom_collection: str,
|
||||||
|
limite: int = 100,
|
||||||
|
workflow_id: Optional[str] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Charge les données d'une collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nom_collection: Nom de la collection
|
||||||
|
limite: Nombre max d'enregistrements
|
||||||
|
workflow_id: Filtrer par workflow
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste des enregistrements
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if workflow_id:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, donnees, workflow_id, execution_id, created_at
|
||||||
|
FROM donnees_structurees
|
||||||
|
WHERE nom_collection = ? AND workflow_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', (nom_collection, workflow_id, limite))
|
||||||
|
else:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, donnees, workflow_id, execution_id, created_at
|
||||||
|
FROM donnees_structurees
|
||||||
|
WHERE nom_collection = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', (nom_collection, limite))
|
||||||
|
|
||||||
|
resultats = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
resultats.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'donnees': json.loads(row['donnees']),
|
||||||
|
'workflow_id': row['workflow_id'],
|
||||||
|
'execution_id': row['execution_id'],
|
||||||
|
'created_at': row['created_at']
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur chargement collection: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def dernier_enregistrement(
|
||||||
|
self,
|
||||||
|
nom_collection: str,
|
||||||
|
workflow_id: Optional[str] = None
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""Récupère le dernier enregistrement d'une collection."""
|
||||||
|
resultats = self.charger_collection(nom_collection, limite=1, workflow_id=workflow_id)
|
||||||
|
return resultats[0] if resultats else None
|
||||||
|
|
||||||
|
# ==================== Interface SQL Avancée ====================
|
||||||
|
|
||||||
|
def executer_sql(
|
||||||
|
self,
|
||||||
|
requete: str,
|
||||||
|
parametres: tuple = ()
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Exécute une requête SQL personnalisée (SELECT uniquement pour sécurité).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requete: Requête SQL
|
||||||
|
parametres: Paramètres de la requête
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Résultats sous forme de liste de dicts
|
||||||
|
"""
|
||||||
|
# Sécurité: n'autoriser que SELECT
|
||||||
|
requete_upper = requete.strip().upper()
|
||||||
|
if not requete_upper.startswith('SELECT'):
|
||||||
|
raise ValueError("Seules les requêtes SELECT sont autorisées")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(requete, parametres)
|
||||||
|
|
||||||
|
colonnes = [description[0] for description in cursor.description]
|
||||||
|
resultats = []
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
resultats.append(dict(zip(colonnes, row)))
|
||||||
|
|
||||||
|
return resultats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur SQL: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def executer_modification(
|
||||||
|
self,
|
||||||
|
requete: str,
|
||||||
|
parametres: tuple = ()
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Exécute une requête de modification (INSERT, UPDATE, DELETE).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requete: Requête SQL
|
||||||
|
parametres: Paramètres
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nombre de lignes affectées
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(requete, parametres)
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur modification: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# ==================== Utilitaires ====================
|
||||||
|
|
||||||
|
def statistiques(self) -> Dict[str, Any]:
|
||||||
|
"""Retourne des statistiques sur la base."""
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute('SELECT COUNT(*) as count FROM kv_store')
|
||||||
|
nb_cles = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
cursor.execute('SELECT COUNT(*) as count FROM donnees_structurees')
|
||||||
|
nb_collections = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
cursor.execute('SELECT DISTINCT nom_collection FROM donnees_structurees')
|
||||||
|
collections = [row['nom_collection'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
taille = self.chemin_db.stat().st_size if self.chemin_db.exists() else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'chemin': str(self.chemin_db),
|
||||||
|
'taille_octets': taille,
|
||||||
|
'nb_cles': nb_cles,
|
||||||
|
'nb_enregistrements': nb_collections,
|
||||||
|
'collections': collections
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'erreur': str(e)}
|
||||||
|
|
||||||
|
def nettoyer(
|
||||||
|
self,
|
||||||
|
workflow_id: Optional[str] = None,
|
||||||
|
avant_date: Optional[str] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Nettoie les anciennes données.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow_id: Supprimer pour ce workflow uniquement
|
||||||
|
avant_date: Supprimer les données avant cette date (ISO format)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nombre d'entrées supprimées
|
||||||
|
"""
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._connexion() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if workflow_id and avant_date:
|
||||||
|
cursor.execute(
|
||||||
|
'DELETE FROM kv_store WHERE workflow_id = ? AND updated_at < ?',
|
||||||
|
(workflow_id, avant_date)
|
||||||
|
)
|
||||||
|
total += cursor.rowcount
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
'DELETE FROM donnees_structurees WHERE workflow_id = ? AND created_at < ?',
|
||||||
|
(workflow_id, avant_date)
|
||||||
|
)
|
||||||
|
total += cursor.rowcount
|
||||||
|
|
||||||
|
elif workflow_id:
|
||||||
|
cursor.execute('DELETE FROM kv_store WHERE workflow_id = ?', (workflow_id,))
|
||||||
|
total += cursor.rowcount
|
||||||
|
|
||||||
|
cursor.execute('DELETE FROM donnees_structurees WHERE workflow_id = ?', (workflow_id,))
|
||||||
|
total += cursor.rowcount
|
||||||
|
|
||||||
|
elif avant_date:
|
||||||
|
cursor.execute('DELETE FROM kv_store WHERE updated_at < ?', (avant_date,))
|
||||||
|
total += cursor.rowcount
|
||||||
|
|
||||||
|
cursor.execute('DELETE FROM donnees_structurees WHERE created_at < ?', (avant_date,))
|
||||||
|
total += cursor.rowcount
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur nettoyage: {e}")
|
||||||
|
return -1
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
Action Sauvegarder Données - Persistance des données de workflow
|
||||||
|
Auteur : Dom, Claude - 14 janvier 2026
|
||||||
|
|
||||||
|
Cette action permet de sauvegarder des données de manière persistante
|
||||||
|
pour les réutiliser entre exécutions ou dans d'autres workflows.
|
||||||
|
|
||||||
|
Cas d'usage :
|
||||||
|
- Sauvegarder les résultats d'extraction
|
||||||
|
- Stocker l'état du workflow
|
||||||
|
- Logger les données pour audit
|
||||||
|
- Partager des données entre workflows
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..base_action import BaseVWBAction, VWBActionResult, VWBActionStatus
|
||||||
|
from ...contracts.error import VWBErrorType, create_vwb_error
|
||||||
|
from .gestionnaire_db import GestionnaireDB
|
||||||
|
|
||||||
|
|
||||||
|
class VWBSauvegarderDonneesAction(BaseVWBAction):
|
||||||
|
"""
|
||||||
|
Action de sauvegarde de données.
|
||||||
|
|
||||||
|
Modes de sauvegarde :
|
||||||
|
- cle_valeur: Stockage simple clé-valeur
|
||||||
|
- collection: Ajout à une collection (historique)
|
||||||
|
- sql: Requête SQL personnalisée
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
action_id: str,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
screen_capturer=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialise l'action de sauvegarde.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action_id: Identifiant unique de l'action
|
||||||
|
parameters: Paramètres de sauvegarde
|
||||||
|
screen_capturer: Non utilisé, présent pour compatibilité
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
action_id=action_id,
|
||||||
|
name="Sauvegarder Données",
|
||||||
|
description="Sauvegarde des données de manière persistante",
|
||||||
|
parameters=parameters,
|
||||||
|
screen_capturer=screen_capturer
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mode de sauvegarde
|
||||||
|
self.mode = parameters.get('mode', 'cle_valeur') # cle_valeur, collection, sql
|
||||||
|
|
||||||
|
# Paramètres clé-valeur
|
||||||
|
self.cle = parameters.get('cle', parameters.get('key', ''))
|
||||||
|
self.valeur = parameters.get('valeur', parameters.get('value'))
|
||||||
|
|
||||||
|
# Paramètres collection
|
||||||
|
self.nom_collection = parameters.get('nom_collection', parameters.get('collection_name', ''))
|
||||||
|
self.donnees = parameters.get('donnees', parameters.get('data'))
|
||||||
|
|
||||||
|
# Paramètres SQL avancés
|
||||||
|
self.requete_sql = parameters.get('requete_sql', parameters.get('sql_query', ''))
|
||||||
|
self.parametres_sql = parameters.get('parametres_sql', parameters.get('sql_params', ()))
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
self.chemin_db = parameters.get('chemin_db', parameters.get('db_path'))
|
||||||
|
self.workflow_id = parameters.get('workflow_id')
|
||||||
|
self.execution_id = parameters.get('execution_id')
|
||||||
|
|
||||||
|
# Options
|
||||||
|
self.ecraser = parameters.get('ecraser', parameters.get('overwrite', True))
|
||||||
|
|
||||||
|
def validate_parameters(self) -> List[str]:
|
||||||
|
"""Valide les paramètres de l'action."""
|
||||||
|
erreurs = []
|
||||||
|
|
||||||
|
if self.mode not in ['cle_valeur', 'collection', 'sql']:
|
||||||
|
erreurs.append("Mode doit être 'cle_valeur', 'collection' ou 'sql'")
|
||||||
|
|
||||||
|
if self.mode == 'cle_valeur':
|
||||||
|
if not self.cle:
|
||||||
|
erreurs.append("Clé requise pour le mode clé-valeur")
|
||||||
|
if self.valeur is None:
|
||||||
|
erreurs.append("Valeur requise pour le mode clé-valeur")
|
||||||
|
|
||||||
|
elif self.mode == 'collection':
|
||||||
|
if not self.nom_collection:
|
||||||
|
erreurs.append("Nom de collection requis")
|
||||||
|
if self.donnees is None:
|
||||||
|
erreurs.append("Données requises pour la collection")
|
||||||
|
|
||||||
|
elif self.mode == 'sql':
|
||||||
|
if not self.requete_sql:
|
||||||
|
erreurs.append("Requête SQL requise")
|
||||||
|
|
||||||
|
return erreurs
|
||||||
|
|
||||||
|
def execute_core(self, step_id: str) -> VWBActionResult:
|
||||||
|
"""
|
||||||
|
Exécute la sauvegarde des données.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step_id: Identifiant de l'étape
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Résultat de l'exécution
|
||||||
|
"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtenir le gestionnaire de DB
|
||||||
|
db = GestionnaireDB.obtenir_instance(self.chemin_db)
|
||||||
|
|
||||||
|
# Exécuter selon le mode
|
||||||
|
if self.mode == 'cle_valeur':
|
||||||
|
resultat = self._sauvegarder_cle_valeur(db)
|
||||||
|
elif self.mode == 'collection':
|
||||||
|
resultat = self._sauvegarder_collection(db)
|
||||||
|
else: # sql
|
||||||
|
resultat = self._executer_sql(db)
|
||||||
|
|
||||||
|
if not resultat['succes']:
|
||||||
|
return self._create_error_result(
|
||||||
|
step_id=step_id,
|
||||||
|
start_time=start_time,
|
||||||
|
error_type=VWBErrorType.SYSTEM_ERROR,
|
||||||
|
message=resultat.get('erreur', 'Échec de la sauvegarde')
|
||||||
|
)
|
||||||
|
|
||||||
|
end_time = datetime.now()
|
||||||
|
execution_time = (end_time - start_time).total_seconds() * 1000
|
||||||
|
|
||||||
|
print(f"💾 Données sauvegardées ({self.mode})")
|
||||||
|
|
||||||
|
return VWBActionResult(
|
||||||
|
action_id=self.action_id,
|
||||||
|
step_id=step_id,
|
||||||
|
status=VWBActionStatus.SUCCESS,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
execution_time_ms=execution_time,
|
||||||
|
output_data={
|
||||||
|
'mode': self.mode,
|
||||||
|
'details': resultat.get('details', {}),
|
||||||
|
'chemin_db': str(db.chemin_db)
|
||||||
|
},
|
||||||
|
evidence_list=self.evidence_list.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self._create_error_result(
|
||||||
|
step_id=step_id,
|
||||||
|
start_time=start_time,
|
||||||
|
error_type=VWBErrorType.SYSTEM_ERROR,
|
||||||
|
message=f"Erreur: {str(e)}",
|
||||||
|
technical_details={'exception': str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sauvegarder_cle_valeur(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Sauvegarde en mode clé-valeur."""
|
||||||
|
succes = db.sauvegarder(
|
||||||
|
cle=self.cle,
|
||||||
|
valeur=self.valeur,
|
||||||
|
workflow_id=self.workflow_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if succes:
|
||||||
|
print(f" Clé: {self.cle}")
|
||||||
|
return {
|
||||||
|
'succes': True,
|
||||||
|
'details': {
|
||||||
|
'cle': self.cle,
|
||||||
|
'type_valeur': type(self.valeur).__name__
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {'succes': False, 'erreur': 'Échec sauvegarde clé-valeur'}
|
||||||
|
|
||||||
|
def _sauvegarder_collection(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Sauvegarde en mode collection."""
|
||||||
|
id_enregistrement = db.sauvegarder_collection(
|
||||||
|
nom_collection=self.nom_collection,
|
||||||
|
donnees=self.donnees,
|
||||||
|
workflow_id=self.workflow_id,
|
||||||
|
execution_id=self.execution_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if id_enregistrement > 0:
|
||||||
|
print(f" Collection: {self.nom_collection} (ID: {id_enregistrement})")
|
||||||
|
return {
|
||||||
|
'succes': True,
|
||||||
|
'details': {
|
||||||
|
'collection': self.nom_collection,
|
||||||
|
'id': id_enregistrement,
|
||||||
|
'nb_elements': len(self.donnees) if isinstance(self.donnees, (list, dict)) else 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {'succes': False, 'erreur': 'Échec sauvegarde collection'}
|
||||||
|
|
||||||
|
def _executer_sql(self, db: GestionnaireDB) -> Dict[str, Any]:
|
||||||
|
"""Exécute une requête SQL de modification."""
|
||||||
|
nb_lignes = db.executer_modification(
|
||||||
|
requete=self.requete_sql,
|
||||||
|
parametres=tuple(self.parametres_sql) if self.parametres_sql else ()
|
||||||
|
)
|
||||||
|
|
||||||
|
if nb_lignes >= 0:
|
||||||
|
print(f" SQL: {nb_lignes} ligne(s) affectée(s)")
|
||||||
|
return {
|
||||||
|
'succes': True,
|
||||||
|
'details': {
|
||||||
|
'lignes_affectees': nb_lignes,
|
||||||
|
'requete': self.requete_sql[:50] + '...' if len(self.requete_sql) > 50 else self.requete_sql
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {'succes': False, 'erreur': 'Échec requête SQL'}
|
||||||
|
|
||||||
|
def get_action_info(self) -> Dict[str, Any]:
|
||||||
|
"""Retourne les informations de l'action."""
|
||||||
|
return {
|
||||||
|
'action_id': self.action_id,
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'type': 'sauvegarder_donnees',
|
||||||
|
'parameters': {
|
||||||
|
'mode': self.mode,
|
||||||
|
'cle': self.cle if self.mode == 'cle_valeur' else None,
|
||||||
|
'collection': self.nom_collection if self.mode == 'collection' else None
|
||||||
|
},
|
||||||
|
'status': self.current_status.value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Alias anglais
|
||||||
|
VWBDBSaveDataAction = VWBSauvegarderDonneesAction
|
||||||
Reference in New Issue
Block a user