diff --git a/odoo_to_odoo_sync/Spécifications.md b/odoo_to_odoo_sync/Spécifications.md new file mode 100644 index 0000000..d79e15e --- /dev/null +++ b/odoo_to_odoo_sync/Spécifications.md @@ -0,0 +1,676 @@ +# Module de Synchronisation Odoo-to-Odoo + +## Objectif +Ce module permet la synchronisation bidirectionnelle de données entre deux instances Odoo via XML-RPC, avec un système de validation et de reprise robuste. + +## Architecture + +### Flux Global +```mermaid +%%{init: {'theme': 'neutral'}}%% +flowchart TD + A[Instance Source] -->|1. Détection\nModification| B(SyncManager) + B -->|2. Enqueue| C[(SyncQueue)] + C -->|3. Worker| D{Connecteur\nDestination} + D -->|4a. Succès| E[(SyncLog)] + D -->|4b. Erreur| F[Retry Policy] + F -->|Retry| C + E --> G[Dashboards] +``` + +### Séquence de Synchronisation +```mermaid +sequenceDiagram + participant Source as Instance A + participant Manager as SyncManager + participant Queue as SyncQueue + participant Dest as Instance B + + Source->>Manager: Notify Change (webhook) + Manager->>Queue: Create SyncRecord + loop Worker Process + Queue->>Manager: Dequeue + Manager->>Dest: Prepare Payload + Dest-->>Manager: Transform/Validate + Manager->>Dest: Apply Changes + Dest-->>Manager: ACK/NACK + alt Success + Manager->>Queue: Mark Success + Manager->>Log: Write Audit + else Error + Manager->>Queue: Increment Retry + Manager->>Log: Write Error + end + end +``` + +## Caractéristiques Principales + +### 1. Architecture Multi-Instances et Asynchrone +- Support de connexions multiples vers différentes instances Odoo +- Insertion automatique dans la queue via surcharge des méthodes create/write/unlink +- Traitement en arrière-plan des synchronisations via un worker dédié +- Possibilité de synchronisation immédiate pour les cas critiques + +### 2. Configuration +- Mapping des champs configurable par modèle, par relation odoo-odoo +- Gestion automatique des dépendances entre modèles +- Paramètres de connexion sécurisés pour chaque instance + +### 3. Mécanismes de Validation +- Validation des données avant synchronisation +- Vérification de l'intégrité des données +- Gestion des conflits de synchronisation +- Journalisation détaillée des opérations + +### 4. Système de Reprise +- Détection automatique des échecs +- File d'attente des tentatives échouées +- Stratégie de réessai configurable +- Notification des erreurs critiques + +### 5. Monitoring +- Interface de suivi des synchronisations +- Statistiques de performance +- Journal des erreurs +- Alertes configurables + +## Flux de Synchronisation + +1. **Détection des Changements** + - Surveillance des modifications sur les modèles configurés + - Création d'une entrée dans la file de synchronisation + +2. **Validation Initiale** + - Vérification des données à synchroniser + - Validation des dépendances + +3. **Synchronisation** + - Envoi des données via XML-RPC + - Gestion des réponses et erreurs + +4. **Validation Finale** + - Vérification de la synchronisation + - Confirmation de l'intégrité + +5. **Journalisation** + - Enregistrement du résultat + - Mise à jour des statistiques + +## Architecture Technique + +```plantuml +@startuml +skinparam monochrome true + +package "Configuration" { + [Instances Odoo] <<(E,LightGreen)>> + [Modèles Sync] <<(E,LightGreen)>> + [Destinations] <<(E,LightGreen)>> +} + +package "Orchestration" { + [Queue] <<(Q,LightBlue)>> + [Worker] <<(Q,LightBlue)>> + [Scheduler] <<(Q,LightBlue)>> +} + +package "Connectivité" { + [Adaptateur RPC] <<(C,Orange)>> + [Sérialiseur] <<(C,Orange)>> +} + +[Instances Odoo] --> [Modèles Sync] +[Modèles Sync] --> [Destinations] + +[Queue] --> [Worker] +[Worker] --> [Adaptateur RPC] +[Adaptateur RPC] --> [Sérialiseur] + +note right of [Sérialiseur] + Transformations de données + Gestion des dépendances + Mapping de champs +end note +@enduml +``` + +### Configuration et Observers + +#### Instances Odoo (odoo.sync.instance) +Configuration des connexions aux instances distantes : +- `name` : Nom de l'instance +- `url` : URL de l'instance +- `database` : Base de données +- `username` : Utilisateur technique +- `password` : Mot de passe (chiffré) +- `active` : Instance active/inactive +- `state` : État de la connexion + +#### Modèles Synchronisés (odoo.sync.model) +Configuration des modèles à synchroniser : +- `model_id` : Référence vers ir.model +- `name` : Nom du modèle (computed) +- `odoo_id` : Mapping avec odoo.sync.instance +- `active` : Synchronisation active/inactive +- `priority` : Ordre de synchronisation pour les dépendances + +#### Champs Synchronisés (odoo.sync.model.field) +Configuration des champs par modèle : +- `field_id` : Référence vers ir.model.fields +- `name` : Nom technique du champ (computed) +- `required` : Champ obligatoire pour la synchronisation +- `sync_default` : Valeur par défaut si non disponible +- Exclusion des champs calculés (sauf si modifiables manuellement) + +#### Destinations (odoo.sync.model.destination) +Configuration des destinations par modèle : +- `model_sync_id` : Référence vers odoo.sync.model +- `instance_id` : Référence vers odoo.sync.instance +- `target_model` : Modèle cible sur l'instance distante +- `active` : Synchronisation active pour cette destination +- `field_ids` : Champs à synchroniser pour cette destination + +#### Gestionnaire de Synchronisation (odoo.sync.manager) +```python +class OdooSyncManager(models.Model): + _name = 'odoo.sync.manager' + _description = 'Gestionnaire de synchronisation Odoo' + + @api.model + def _get_sync_models(self): + """Récupère tous les modèles actifs à synchroniser""" + return self.env['odoo.sync.model'].search([('active', '=', True)]) + + @api.model + def _queue_sync(self, record, operation, changed_fields=None): + """Ajoute une opération dans la queue de synchronisation""" + sync_model = self.env['odoo.sync.model'].search([ + ('model_id.model', '=', record._name), + ('active', '=', True) + ]) + if not sync_model: + return + + # Pour chaque destination configurée + for destination in sync_model.destination_ids.filtered('active'): + # Récupérer les champs configurés pour cette destination + sync_fields = destination.field_ids + + # En cas de mise à jour, vérifier si les champs modifiés sont à synchroniser + if operation == 'write' and changed_fields: + relevant_fields = set(changed_fields) & set(sync_fields.mapped('name')) + if not relevant_fields: + continue # Aucun champ modifié n'est à synchroniser + + # Préparer les données à synchroniser + sync_data = {} + for field in sync_fields: + if field.mapping_type == 'direct': + sync_data[field.name] = record[field.name] + elif field.mapping_type == 'function' and field.mapping_function: + # Appel de la fonction de transformation + sync_data[field.name] = getattr(record, field.mapping_function)() + elif field.mapping_type == 'computed': + # Gestion spéciale pour les champs computed si nécessaire + sync_data[field.name] = record[field.name] + + # Créer l'entrée dans la queue + self.env['odoo.sync.queue'].create({ + 'model_id': sync_model.model_id.id, + 'resource_id': record.id, + 'other_odoo_id': destination.instance_id.id, + 'other_odoo_resource_id': record.get_external_id().get(record.id), # Si déjà synchronisé + 'type': operation, + 'state': 'pending', + 'data_json': json.dumps(sync_data), + 'create_date': record.create_date, + 'write_date': record.write_date + }) + + @api.model + def _observe_changes(self, method): + """Décorateur pour observer les changements sur les modèles configurés""" + def wrapper(self, *args, **kwargs): + # Capturer les champs modifiés pour write + changed_fields = list(kwargs.get('vals', {}).keys()) if method.__name__ == 'write' else None + + result = method(self, *args, **kwargs) + sync_manager = self.env['odoo.sync.manager'] + + if isinstance(result, models.Model): + for record in result: + sync_manager._queue_sync(record, method.__name__, changed_fields) + + return result + return wrapper + +# Application des observers sur les méthodes standard +models.Model.create = OdooSyncManager._observe_changes(models.Model.create) +models.Model.write = OdooSyncManager._observe_changes(models.Model.write) +models.Model.unlink = OdooSyncManager._observe_changes(models.Model.unlink) + +### Template de Code pour le Gestionnaire + +```python +class OdooSyncManager(models.Model): + _name = 'odoo.sync.manager' + + def _process_sync_queue(self): + """Template de traitement de la queue""" + jobs = self.env['odoo.sync.job'].search([('state', '=', 'pending')]) + for job in jobs: + try: + # Logique de synchronisation + self._execute_sync(job) + job.write({'state': 'done'}) + except Exception as e: + job.write({ + 'state': 'failed', + 'error_message': str(e), + 'retry_count': job.retry_count + 1 + }) + + def _execute_sync(self, job): + """Template d'exécution d'une synchronisation""" + adapter = self._get_rpc_adapter(job.instance_id) + serializer = self._get_serializer(job.model_id) + + data = serializer.serialize(job.record_id) + response = adapter.execute(job.operation, data) + + if not response['success']: + raise SyncException(response['error_code']) +``` + +## Modèles de Données + +### SyncConfiguration +#### Configuration des Instances (odoo.sync.instance) +- Nom de l'instance +- URL de l'instance +- Base de données +- Identifiants de connexion sécurisés +- État de la connexion + +#### Configuration des Modèles (odoo.sync.model) +- Modèle Odoo à synchroniser +- Liste des instances Odoo cibles +- Mapping des champs +- Direction de la synchronisation (uni/bidirectionnelle) +- Champs à surveiller +- Règles de synchronisation spécifiques + +### SyncQueue +Table principale pour la gestion des synchronisations : +- `model_id` : Modèle Odoo à synchroniser +- `resource_id` : ID de la ressource locale +- `other_odoo_id` : ID de l'instance Odoo distante +- `other_odoo_resource_id` : ID de la ressource sur l'instance distante +- `type` : Type d'opération (create, update, unlink) +- `state` : État de la synchronisation +- `retry_count` : Nombre de tentatives +- `last_error` : Dernière erreur rencontrée +- `data_json` : Données à synchroniser au format JSON +- `create_date` : Date de création dans la queue +- `write_date` : Date de dernière modification +- `other_create_date` : Date de création sur l'instance distante +- `other_write_date` : Date de dernière modification sur l'instance distante + +### SyncLog +- Journal détaillé des opérations +- Erreurs et avertissements +- Statistiques de performance + +### Gestion des Conflits + +#### Détection +- Comparaison des horodatages `write_date` (source) vs `other_write_date` (cible) +- Seuil de tolérance configurable (défaut : 5 minutes) + +#### Stratégies de Résolution +1. **Priorité source** : Écrasement de la version cible +2. **Priorité destination** : Conservation de la version cible +3. **Fusion manuelle** : + - Notification aux administrateurs + - Interface de comparaison côte-à-côte + - Historique des versions (diff) + +#### Cas Particuliers +- Réconciliation des relations Many2many/One2many +- Gestion des suppressions/archivages croisés + +### Journalisation Avancée (SyncLog) + +#### Niveaux de Log +- **DEBUG**: Payloads complets et traces d'exécution +- **INFO**: Diffs des modifications et métadonnées +- **WARNING**: Erreurs non critiques (ex: timeouts) +- **ERROR**: Échecs critiques de synchronisation + +#### Politique de Rétention +- Stockage local: 90 jours (accès rapide) +- Archivage long terme: AWS S3 Glacier (7 ans) +- Format d'archivage: Parquet compressé + +#### Masquage des Données Sensibles +Fonction de masquage automatique : +```python +def sanitize_log_entry(entry): + sensitive_fields = ['password', 'api_key', 'token'] + for field in sensitive_fields: + if field in entry['data']: + entry['data'][field] = '*****' + return entry +``` + +### Sécurité des Données + +#### Chiffrement +- TLS 1.3 obligatoire pour les communications +- Rotation automatique des certificats (Let's Encrypt) +- Chiffrement AES-256 au repos pour : + - SyncQueue.data_json + - SyncLog.payload + +#### Gestion des Accès +- Authentification mutuelle OAuth2 avec JWT : + ```python + # Génération de token sécurisé + def generate_jwt(secret, payload): + return jwt.encode(payload, secret, algorithm="HS256") + ``` +- RBAC (Role-Based Access Control) : + - Rôle 'Sync Admin' : Configuration complète + - Rôle 'Sync Auditor' : Lecture seule + +#### Audit +- Logs d'accès horodatés avec IP/user-agent +- Intégration SIEM (ex: Splunk, ELK) + +## Sécurité +- Authentification sécurisée entre instances +- Encryption des données sensibles +- Validation des permissions +- Audit des opérations + +## Interface Utilisateur +- Configuration des synchronisations +- Monitoring en temps réel +- Gestion des erreurs +- Rapports et statistiques + +## Performance +- Optimisation des requêtes +- Gestion de la charge +- Limitation des appels API +- Mise en cache intelligente + +## Performance à l'Échelle + +#### Architecture Scalable +- File d'attente Redis pour découplage +- Scaling horizontal via Kubernetes +- Partitionnement par modèle/instance + +#### Optimisations +- Cache des relations fréquemment accédées +- Compression LZ4 des payloads volumineux +- Traitement batch avec isolation transactionnelle + +#### Monitoring +- Dashboard Grafana avec : + - Débit (records/min) + - Latence (P50/P90/P99) + - Taux d'utilisation des workers + +## Maintenance +- Outils de diagnostic +- Nettoyage automatique des logs +- Gestion des sauvegardes +- Procédures de mise à jour + +## Gestion des Conflits de Synchronisation + +### Détection des Conflits +- **Conflit de Version** : Détecté lorsque la version locale et distante ont été modifiées depuis la dernière synchronisation +- **Conflit de Données** : Détecté lorsque les mêmes champs ont été modifiés différemment sur les deux instances +- **Conflit de Relations** : Détecté lorsque des enregistrements liés sont incohérents entre les instances + +### Stratégies de Résolution +1. **Automatique** + - Priorité configurable par instance (master/slave) + - Règles de fusion personnalisables par champ + - Horodatage "le plus récent gagne" + +2. **Manuelle** + - Interface de résolution pour l'utilisateur + - Visualisation côte à côte des différences + - Options : garder source, garder destination, fusionner, ignorer + +### Configuration des Règles de Résolution +```python +class OdooSyncModelField(models.Model): + _inherit = 'odoo.sync.model.field' + + conflict_strategy = fields.Selection([ + ('source_wins', 'Source gagne'), + ('dest_wins', 'Destination gagne'), + ('newest', 'Plus récent'), + ('manual', 'Résolution manuelle') + ], default='newest') +``` + +## Gestion des Suppressions + +### Stratégies de Suppression +1. **Suppression Douce** + - Marquage comme inactif sur les deux instances + - Conservation de l'historique + - Possibilité de restauration + +2. **Suppression Dure** + - Suppression physique sur les deux instances + - Vérification des dépendances + - Journal d'audit détaillé + +### Configuration +```python +class OdooSyncModel(models.Model): + _inherit = 'odoo.sync.model' + + deletion_strategy = fields.Selection([ + ('soft', 'Suppression douce'), + ('hard', 'Suppression physique'), + ('ignore', 'Ignorer'), + ('manual', 'Validation manuelle') + ], default='soft') + + cascade_deletion = fields.Boolean('Cascade aux enregistrements liés') +``` + +## Sécurité et Droits d'Accès + +### Niveaux de Sécurité +1. **Niveau Instance** + - Authentification par token JWT + - Chiffrement des communications + - Restriction par IP + +2. **Niveau Utilisateur** + - Groupes de sécurité dédiés + - Journalisation des actions + - Validation multi-niveau + +### Groupes de Sécurité +```xml + + Synchronisation : Utilisateur + + + + + Synchronisation : Manager + + +``` + +### Règles de Sécurité +```xml + + Sync Manager : Accès Total + + + [(1, '=', 1)] + +``` + +## Exemples de Configuration + +### 1. Synchronisation des Produits +```python +# Configuration du modèle +product_sync = { + 'model': 'product.template', + 'fields': { + 'name': {'type': 'direct'}, + 'list_price': {'type': 'direct'}, + 'standard_price': { + 'type': 'function', + 'mapping': 'map_cost_price' + }, + 'categ_id': { + 'type': 'relation', + 'model': 'product.category', + 'match_field': 'name' + } + }, + 'conflict_strategy': 'newest' +} + +# Fonction de mapping personnalisée +def map_cost_price(self, record): + return record.standard_price * self.currency_rate +``` + +### 2. Synchronisation des Commandes +```python +# Configuration du modèle +sale_sync = { + 'model': 'sale.order', + 'fields': { + 'name': {'type': 'direct'}, + 'partner_id': { + 'type': 'relation', + 'model': 'res.partner', + 'match_field': 'ref' + }, + 'order_line': { + 'type': 'one2many', + 'fields': ['product_id', 'quantity', 'price_unit'] + } + }, + 'deletion_strategy': 'soft', + 'conflict_strategy': 'manual' +} +``` + +### 3. Interface de Configuration +```xml + + odoo.sync.config.form + odoo.sync.model + + + + + + + + + + + + + + + + + + + + + +``` + +## Scénarios de Test Critiques + +### TC-01 : Synchronisation bidirectionnelle +**Préconditions**: +- 2 instances interconnectées +- Modèle 'res.partner' configuré + +**Étapes**: +1. Créer partenaire sur Instance A +2. Vérifier création sur Instance B +3. Modifier partenaire sur Instance B +4. Vérifier mise à jour sur Instance A + +**Résultat attendu**: +- SyncLog avec code SYNC_200 sur les deux instances +- Données cohérentes après boucle complète + +### TC-02 : Gestion des conflits +**Préconditions**: +- Même enregistrement modifié simultanément sur les deux instances + +**Étapes**: +1. Modifier le champ 'name' sur Instance A +2. Modifier le champ 'email' sur Instance B +3. Déclencher manuellement la synchronisation + +**Résultat attendu**: +- Application de la stratégie de résolution configurée +- Journalisation du conflit (SYNC_409) + +### TC-03 : Tolérance aux pannes +**Préconditions**: +- Instance B hors ligne + +**Étapes**: +1. Tenter une synchronisation +2. Redémarrer Instance B +3. Relancer la synchronisation + +**Résultat attendu**: +- Rejeu automatique des transactions en erreur +- Conservation des données en queue pendant 24h + +## Procédures de Déploiement + +### Prérequis +- Odoo 15.0+ +- Accès API aux instances distantes +- Bibliothèque python-requests + +### Installation +1. Copier le répertoire `odoo_to_odoo_sync` dans `addons/` +2. Redémarrer le serveur Odoo +3. Installer le module via l'interface d'administration + +### Configuration +```python +# Configuration de base dans odoo.conf +[odoo_sync] +max_retries = 3 +retry_delay = 300 # secondes +queue_size = 1000 + +# Activation du mode debug +debug = False +``` + +### Tests +```bash +# Lancer les tests d'intégration +$ ./odoo-bin -i odoo_to_odoo_sync --test-enable \ No newline at end of file diff --git a/odoo_to_odoo_sync/__init__.py b/odoo_to_odoo_sync/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/odoo_to_odoo_sync/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/odoo_to_odoo_sync/__manifest__.py b/odoo_to_odoo_sync/__manifest__.py new file mode 100644 index 0000000..ce3f26f --- /dev/null +++ b/odoo_to_odoo_sync/__manifest__.py @@ -0,0 +1,28 @@ +{ + 'name': 'Odoo to Odoo Sync', + 'version': '18.0.1.0.0', + 'category': 'Technical', + 'summary': 'Synchronisation bidirectionnelle entre instances Odoo', + 'description': """ + Module de synchronisation bidirectionnelle entre instances Odoo + - Support multi-instances + - Synchronisation asynchrone + - Gestion des conflits + - Monitoring et reprise sur erreur + """, + 'author': 'Bemade', + 'website': 'https://bemade.org', + 'depends': ['base'], + 'data': [ + 'security/ir.model.access.csv', + 'views/sync_instance_views.xml', + 'views/sync_model_views.xml', + 'views/sync_queue_views.xml', + 'views/sync_log_views.xml', + 'views/menus.xml', + 'data/ir_cron_data.xml', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} diff --git a/odoo_to_odoo_sync/data/ir_cron_data.xml b/odoo_to_odoo_sync/data/ir_cron_data.xml new file mode 100644 index 0000000..2dd97f2 --- /dev/null +++ b/odoo_to_odoo_sync/data/ir_cron_data.xml @@ -0,0 +1,15 @@ + + + + + Traitement de la file de synchronisation + + code + model._process_sync_queue() + 5 + minutes + + + + + diff --git a/odoo_to_odoo_sync/models/__init__.py b/odoo_to_odoo_sync/models/__init__.py new file mode 100644 index 0000000..89a106d --- /dev/null +++ b/odoo_to_odoo_sync/models/__init__.py @@ -0,0 +1,6 @@ +from . import sync_instance +from . import sync_model +from . import sync_model_field +from . import sync_log +from . import sync_queue +from . import sync_manager diff --git a/odoo_to_odoo_sync/models/sync_instance.py b/odoo_to_odoo_sync/models/sync_instance.py new file mode 100644 index 0000000..2b26b12 --- /dev/null +++ b/odoo_to_odoo_sync/models/sync_instance.py @@ -0,0 +1,127 @@ +# Copyright 2025 Codeium +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html) + +"""Remote Odoo Instance Management. + +This module handles the configuration and connection management for remote +Odoo instances. It provides functionality to store connection credentials, +test connections, and maintain the connection state with remote instances. +""" + +import logging +import xmlrpc.client + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +class OdooSyncInstance(models.Model): + """Remote Odoo instance for synchronization. + + This model stores connection information and credentials for remote Odoo instances + that will be synchronized with the current instance. + """ + + _name = 'odoo.sync.instance' + _description = 'Remote Odoo Instance' + + name = fields.Char( + string='Name', + required=True, + help='Name to identify this remote instance', + ) + + url = fields.Char( + string='URL', + required=True, + help='Base URL of the remote Odoo instance', + ) + + database = fields.Char( + string='Database', + required=True, + help='Database name on the remote instance', + ) + + username = fields.Char( + string='Technical User', + required=True, + help='Username of the technical user for synchronization', + ) + + password = fields.Char( + string='Password', + required=True, + help='Password of the technical user', + ) + + active = fields.Boolean( + string='Active', + default=True, + help='Whether this instance is active for synchronization', + ) + + state = fields.Selection( + selection=[ + ('draft', 'Draft'), + ('testing', 'Testing Connection'), + ('connected', 'Connected'), + ('error', 'Error') + ], + default='draft', + string='State', + help='Current state of the connection', + ) + + last_connection = fields.Datetime( + string='Last Connection', + help='Timestamp of the last successful connection', + ) + + error_message = fields.Text( + string='Error Message', + help='Details of the last error that occurred', + ) + + @api.onchange('url') + def _onchange_url(self): + """Reset state when URL changes.""" + if self.url != self._origin.url: + self.state = 'draft' + self.error_message = False + + def test_connection(self): + self.ensure_one() + self.state = 'testing' + try: + common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common') + uid = common.authenticate(self.database, self.username, self.password, {}) + if uid: + self.write({ + 'state': 'connected', + 'last_connection': fields.Datetime.now(), + 'error_message': False + }) + return True + else: + raise Exception('Échec d\'authentification') + except Exception as e: + self.write({ + 'state': 'error', + 'error_message': str(e) + }) + return False + + def get_connection(self): + """Retourne une connexion active à l'instance distante""" + self.ensure_one() + if self.state != 'connected': + self.test_connection() + if self.state != 'connected': + raise Exception(f'Impossible de se connecter à {self.name}: {self.error_message}') + + common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common') + uid = common.authenticate(self.database, self.username, self.password, {}) + models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object') + + return models, uid diff --git a/odoo_to_odoo_sync/models/sync_log.py b/odoo_to_odoo_sync/models/sync_log.py new file mode 100644 index 0000000..e6e90ba --- /dev/null +++ b/odoo_to_odoo_sync/models/sync_log.py @@ -0,0 +1,50 @@ +# Copyright 2025 Codeium +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html) + +"""Synchronization Logging System. + +This module implements the logging system for synchronization operations. +It tracks all synchronization attempts, their outcomes, and any errors +that occur during the process, providing a complete audit trail. +""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +class OdooSyncLog(models.Model): + _name = 'odoo.sync.log' + _description = 'Synchronization Log' + _order = 'create_date desc' + + name = fields.Char( + string='Name', + compute='_compute_name' + ) + + queue_id = fields.Many2one( + comodel_name='odoo.sync.queue', + string='Queue Entry', + required=True, + ondelete='cascade' + ) + state = fields.Selection( + selection=[ + ('success', 'Success'), + ('error', 'Error') + ], + required=True + ) + message = fields.Text( + string='Message', + ) + details = fields.Text( + string='Technical Details' + ) + + @api.depends('queue_id', 'state') + def _compute_name(self): + for record in self: + record.name = f'{record.queue_id.name} - {record.state}' diff --git a/odoo_to_odoo_sync/models/sync_manager.py b/odoo_to_odoo_sync/models/sync_manager.py new file mode 100644 index 0000000..d265e70 --- /dev/null +++ b/odoo_to_odoo_sync/models/sync_manager.py @@ -0,0 +1,204 @@ +# Copyright 2025 Codeium +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html) + +"""Synchronization Management System. + +This module implements the core synchronization logic and conflict resolution +strategies. It manages the overall synchronization process, including queuing +operations, handling retries, and ensuring data consistency across instances. +""" + +import json +import logging +from datetime import datetime, timedelta +import xmlrpc.client + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + +class SyncConflictException(Exception): + """Exception raised when a synchronization conflict is detected. + + This exception is raised when the synchronization process encounters + conflicting changes between source and destination instances that + cannot be automatically resolved based on the current conflict + resolution strategy. + """ + pass + +class OdooSyncManager(models.Model): + """Manages synchronization operations between Odoo instances. + + This class is responsible for orchestrating the synchronization process, + including: + - Managing synchronization queues + - Handling conflict resolution + - Coordinating data transfer between instances + - Monitoring synchronization status + - Implementing retry mechanisms + - Logging synchronization events + + The synchronization process is configurable through various strategies + and can be customized based on specific business needs. + """ + _name = 'odoo.sync.manager' + _description = 'Odoo Sync Manager' + + conflict_strategy = fields.Selection( + selection=[ + ('timestamp', 'Last Modified'), + ('manual', 'Manual Resolution'), + ('source_priority', 'Source Priority'), + ('destination_priority', 'Destination Priority') + ], + default='timestamp', + required=True + ) + + def _get_sync_models(self): + """Récupère tous les modèles actifs à synchroniser""" + return self.env['odoo.sync.model'].search([('active', '=', True)]) + + def _queue_sync(self, record, operation, changed_fields=None): + """Ajoute une opération dans la queue de synchronisation""" + sync_model = self.env['odoo.sync.model'].search([ + ('model_id.model', '=', record._name), + ('active', '=', True) + ]) + if not sync_model: + return + + # Préparation des données à synchroniser + data = record.read()[0] if operation != 'unlink' else {'id': record.id} + + # Pour chaque destination configurée + for destination in sync_model.destination_ids.filtered('active'): + # Vérifier si les champs modifiés sont à synchroniser + if operation == 'write' and changed_fields: + sync_fields = destination.field_ids.mapped('name') + if not any(field in sync_fields for field in changed_fields): + continue + + # Création de l'entrée dans la file + self.env['odoo.sync.queue'].create({ + 'model_id': sync_model.id, + 'record_id': record.id, + 'operation': operation, + 'state': 'pending', + 'priority': sync_model.priority, + 'data': json.dumps(data) + }) + + def _process_sync_queue(self): + """Traitement de la file d'attente""" + queue_items = self.env['odoo.sync.queue'].search([ + ('state', '=', 'pending'), + '|', + ('next_retry', '<=', fields.Datetime.now()), + ('next_retry', '=', False) + ], order='priority desc, retry_count, create_date') + + for item in queue_items: + try: + item.state = 'processing' + self._process_queue_item(item) + item.state = 'done' + except Exception as e: + _logger.error(f'Erreur lors du traitement de {item.name}: {str(e)}') + item.write({ + 'state': 'error', + 'error_message': str(e), + 'retry_count': item.retry_count + 1 + }) + if item.retry_count < item.max_retries: + delay = 2 ** item.retry_count # Délai exponentiel + item.next_retry = fields.Datetime.now() + timedelta(minutes=delay) + self.env['odoo.sync.log'].create({ + 'queue_id': item.id, + 'state': 'error', + 'message': str(e) + }) + + def _process_queue_item(self, item): + """Traite un élément de la file d'attente""" + model = item.model_id + data = json.loads(item.data) + + for destination in model.destination_ids.filtered('active'): + instance = destination.instance_id + models, uid = instance.get_connection() + + try: + if item.operation == 'create': + result = models.execute_kw( + instance.database, uid, instance.password, + destination.target_model, 'create', + [self._prepare_sync_data(data, destination)] + ) + elif item.operation == 'write': + result = models.execute_kw( + instance.database, uid, instance.password, + destination.target_model, 'write', + [[data['id']], self._prepare_sync_data(data, destination)] + ) + elif item.operation == 'unlink': + result = models.execute_kw( + instance.database, uid, instance.password, + destination.target_model, 'unlink', + [[data['id']]] + ) + + self.env['odoo.sync.log'].create({ + 'queue_id': item.id, + 'state': 'success', + 'message': f'Synchronisation réussie vers {instance.name}' + }) + + except xmlrpc.client.Fault as e: + raise Exception(f'Erreur RPC vers {instance.name}: {str(e)}') + + def _prepare_sync_data(self, data, destination): + """Prépare les données pour la synchronisation""" + sync_fields = destination.field_ids + result = {} + + for field in sync_fields: + if field.name in data: + value = data[field.name] + if not value and field.sync_default: + value = field.sync_default + result[field.name] = value + + return result + + def _handle_conflict(self, local_data, remote_data): + """Gestion des conflits selon la stratégie configurée""" + strategy = self.conflict_strategy + _logger.info(f'Résolution de conflit avec stratégie: {strategy}') + + if strategy == 'timestamp': + return self._resolve_by_timestamp(local_data, remote_data) + elif strategy == 'manual': + return self._flag_for_manual_resolution(local_data, remote_data) + elif strategy == 'source_priority': + return local_data + elif strategy == 'destination_priority': + return remote_data + + def _resolve_by_timestamp(self, local, remote): + """Résout un conflit en utilisant l'horodatage""" + local_date = fields.Datetime.from_string(local['write_date']) + remote_date = fields.Datetime.from_string(remote['write_date']) + return local if local_date > remote_date else remote + + def _flag_for_manual_resolution(self, local, remote): + """Marque un conflit pour résolution manuelle""" + self.env['odoo.sync.conflict'].create({ + 'local_data': json.dumps(local), + 'remote_data': json.dumps(remote), + 'model_name': local['model'], + 'record_id': local['id'], + 'state': 'pending' + }) + raise SyncConflictException('Conflit nécessitant une résolution manuelle') diff --git a/odoo_to_odoo_sync/models/sync_model.py b/odoo_to_odoo_sync/models/sync_model.py new file mode 100644 index 0000000..cbc7908 --- /dev/null +++ b/odoo_to_odoo_sync/models/sync_model.py @@ -0,0 +1,94 @@ +# Copyright 2025 Codeium +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html) + +"""Model Synchronization Configuration. + +This module defines how models are synchronized between Odoo instances. +It handles model mapping, field configuration, and synchronization rules +for each model that needs to be synchronized. +""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +class OdooSyncModel(models.Model): + _name = 'odoo.sync.model' + _description = 'Synchronized Model' + _order = 'priority, id' + + model_id = fields.Many2one( + comodel_name='ir.model', + string='Source Model', + required=True, + ondelete='cascade' + ) + + name = fields.Char( + related='model_id.model', + string='Technical Name', + store=True + ) + + instance_id = fields.Many2one( + comodel_name='odoo.sync.instance', + string='Remote Instance', + required=True + ) + + target_model = fields.Char( + string='Target Model', + help='Technical name of the model on remote instance' + ) + + active = fields.Boolean( + string='Active', + default=True + ) + + priority = fields.Integer( + string='Priority', + default=10, + help='Synchronization order for dependencies' + ) + + field_ids = fields.One2many( + comodel_name='odoo.sync.model.field', + inverse_name='model_sync_id', + string='Synchronized Fields' + ) + + _sql_constraints = [ + ('model_instance_uniq', 'unique(model_id, instance_id)', + 'Un modèle ne peut être synchronisé qu\'une fois par instance!') + ] + + @api.model + def create(self, vals): + if not vals.get('target_model') and vals.get('model_id'): + # Par défaut, utiliser le même nom de modèle que la source + model = self.env['ir.model'].browse(vals['model_id']) + vals['target_model'] = model.model + + record = super().create(vals) + + # Créer automatiquement les champs de base + if record.model_id: + fields_to_sync = ['create_date', 'write_date', 'create_uid', 'write_uid'] + for field_name in fields_to_sync: + field = self.env['ir.model.fields'].search([ + ('model_id', '=', record.model_id.id), + ('name', '=', field_name) + ]) + if field: + self.env['odoo.sync.model.field'].create({ + 'model_sync_id': record.id, + 'field_id': field.id, + 'required': True + }) + return record + + def name_get(self): + return [(r.id, f'{r.name} → {r.instance_id.name}') for r in self] \ No newline at end of file diff --git a/odoo_to_odoo_sync/models/sync_model_field.py b/odoo_to_odoo_sync/models/sync_model_field.py new file mode 100644 index 0000000..dbc8dc7 --- /dev/null +++ b/odoo_to_odoo_sync/models/sync_model_field.py @@ -0,0 +1,41 @@ +from odoo import models, fields, api + +class OdooSyncModelField(models.Model): + _name = 'odoo.sync.model.field' + _description = 'Synchronized Field' + + model_sync_id = fields.Many2one( + comodel_name='odoo.sync.model', + string='Synchronized Model', + required=True, + ondelete='cascade' + ) + + field_id = fields.Many2one( + comodel_name='ir.model.fields', + string='Field', + domain="[('model_id', '=', parent.model_id)]", + required=True, + ondelete='cascade' + ) + + name = fields.Char( + related='field_id.name', + string='Technical Name', + store=True + ) + + required = fields.Boolean( + string='Required', + default=False + ) + + sync_default = fields.Char( + string='Default Value', + help='Value to use if not available' + ) + + _sql_constraints = [ + ('field_uniq', 'unique(model_sync_id, field_id)', + 'Un champ ne peut être synchronisé qu\'une fois par modèle!') + ] diff --git a/odoo_to_odoo_sync/models/sync_queue.py b/odoo_to_odoo_sync/models/sync_queue.py new file mode 100644 index 0000000..69b14b8 --- /dev/null +++ b/odoo_to_odoo_sync/models/sync_queue.py @@ -0,0 +1,204 @@ +# Copyright 2025 Codeium +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html) + +"""Synchronization Queue Management. + +This module implements the queue system for managing synchronization operations +between Odoo instances. It handles the scheduling, retry logic, and status +tracking of synchronization tasks. +""" + +import json +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +class OdooSyncQueue(models.Model): + _name = 'odoo.sync.queue' + _description = 'Synchronization Queue' + _order = 'priority desc, retry_count, create_date' + + name = fields.Char( + string='Name', + compute='_compute_name', + store=True + ) + + model_id = fields.Many2one( + comodel_name='odoo.sync.model', + string='Model', + required=True + ) + + record_id = fields.Integer( + string='Record ID', + required=True + ) + + operation = fields.Selection( + selection=[ + ('create', 'Create'), + ('write', 'Update'), + ('unlink', 'Delete') + ], + required=True + ) + + state = fields.Selection( + selection=[ + ('draft', 'Draft'), + ('pending', 'Pending'), + ('processing', 'Processing'), + ('done', 'Done'), + ('error', 'Error'), + ('cancelled', 'Cancelled') + ], + default='draft', + required=True + ) + + priority = fields.Integer( + string='Priority', + default=1 + ) + + retry_count = fields.Integer( + string='Retry Count', + default=0 + ) + + max_retries = fields.Integer( + string='Max Retries', + default=3 + ) + + next_retry = fields.Datetime( + string='Next Retry' + ) + + data = fields.Text( + string='Data', + help='JSON data to synchronize' + ) + + error_message = fields.Text( + string='Error Message' + ) + + log_ids = fields.One2many( + comodel_name='odoo.sync.log', + inverse_name='queue_id', + string='Logs' + ) + + @api.depends('model_id', 'record_id', 'operation') + def _compute_name(self): + """Compute the display name of the queue entry. + + The name is generated using the format: 'operation - model_name#record_id' + e.g., 'create - res.partner#42' + + Triggered by changes to: model_id, record_id, or operation fields. + """ + for record in self: + record.name = f'{record.operation} - {record.model_id.name}#{record.record_id}' + + def action_reset(self): + """Reset a failed queue entry to pending state. + + This method: + - Changes state back to 'pending' + - Resets retry count to 0 + - Clears error message and next retry time + + Returns: + dict: Result of the write operation + """ + return self.write({ + 'state': 'pending', + 'retry_count': 0, + 'error_message': False, + 'next_retry': False + }) + + def action_cancel(self): + """Cancel the queue entry. + + Changes the state of the queue entry to 'cancelled', + preventing further processing attempts. + + Returns: + dict: Result of the write operation + """ + return self.write({'state': 'cancelled'}) + + @api.model + def _process_sync_queue(self): + """Process pending synchronization queue entries. + + This method is called by the cron job to process pending queue entries. + It will: + 1. Find pending entries that are ready for processing + 2. Process each entry according to its operation type + 3. Update the entry status and create log entries + + Returns: + bool: True if processing completed successfully + """ + # Find pending entries ready for processing + domain = [ + ('state', '=', 'pending'), + '|', + ('next_retry', '=', False), + ('next_retry', '<=', fields.Datetime.now()) + ] + entries = self.search(domain, order='priority desc, retry_count, create_date') + + for entry in entries: + try: + # Mark as processing + entry.write({'state': 'processing'}) + + # TODO: Implement actual synchronization logic here + # This will be implemented in a future update + + # For now, just mark as done + entry.write({'state': 'done'}) + + # Create success log + self.env['odoo.sync.log'].create({ + 'queue_id': entry.id, + 'state': 'success', + 'message': 'Synchronization completed successfully' + }) + + except Exception as e: + _logger.error('Error processing queue entry %s: %s', entry.name, str(e)) + + # Update retry count and status + vals = { + 'state': 'error', + 'retry_count': entry.retry_count + 1, + 'error_message': str(e) + } + + # Schedule next retry if not exceeded max retries + if entry.retry_count < entry.max_retries: + vals.update({ + 'state': 'pending', + 'next_retry': fields.Datetime.now() + timedelta(minutes=5 * (entry.retry_count + 1)) + }) + + entry.write(vals) + + # Create error log + self.env['odoo.sync.log'].create({ + 'queue_id': entry.id, + 'state': 'error', + 'message': str(e), + 'details': str(e) + }) + + return True diff --git a/odoo_to_odoo_sync/security/ir.model.access.csv b/odoo_to_odoo_sync/security/ir.model.access.csv new file mode 100644 index 0000000..bd8f843 --- /dev/null +++ b/odoo_to_odoo_sync/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_odoo_sync_instance_user,odoo.sync.instance.user,model_odoo_sync_instance,base.group_user,1,0,0,0 +access_odoo_sync_instance_admin,odoo.sync.instance.admin,model_odoo_sync_instance,base.group_system,1,1,1,1 +access_odoo_sync_model_user,odoo.sync.model.user,model_odoo_sync_model,base.group_user,1,0,0,0 +access_odoo_sync_model_admin,odoo.sync.model.admin,model_odoo_sync_model,base.group_system,1,1,1,1 +access_odoo_sync_model_field_user,odoo.sync.model.field.user,model_odoo_sync_model_field,base.group_user,1,0,0,0 +access_odoo_sync_model_field_admin,odoo.sync.model.field.admin,model_odoo_sync_model_field,base.group_system,1,1,1,1 +access_odoo_sync_queue_user,odoo.sync.queue.user,model_odoo_sync_queue,base.group_user,1,1,1,0 +access_odoo_sync_queue_admin,odoo.sync.queue.admin,model_odoo_sync_queue,base.group_system,1,1,1,1 +access_odoo_sync_log_user,odoo.sync.log.user,model_odoo_sync_log,base.group_user,1,1,1,0 +access_odoo_sync_log_admin,odoo.sync.log.admin,model_odoo_sync_log,base.group_system,1,1,1,1 +access_odoo_sync_manager_user,odoo.sync.manager.user,model_odoo_sync_manager,base.group_user,1,0,0,0 +access_odoo_sync_manager_admin,odoo.sync.manager.admin,model_odoo_sync_manager,base.group_system,1,1,1,1 diff --git a/odoo_to_odoo_sync/static/description/icon.png b/odoo_to_odoo_sync/static/description/icon.png new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/odoo_to_odoo_sync/static/description/icon.png @@ -0,0 +1 @@ + diff --git a/odoo_to_odoo_sync/views/menus.xml b/odoo_to_odoo_sync/views/menus.xml new file mode 100644 index 0000000..b63acaf --- /dev/null +++ b/odoo_to_odoo_sync/views/menus.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/odoo_to_odoo_sync/views/sync_instance_views.xml b/odoo_to_odoo_sync/views/sync_instance_views.xml new file mode 100644 index 0000000..cd0ce5a --- /dev/null +++ b/odoo_to_odoo_sync/views/sync_instance_views.xml @@ -0,0 +1,68 @@ + + + + odoo.sync.instance.list + odoo.sync.instance + + + + + + + + + + + + + + odoo.sync.instance.form + odoo.sync.instance + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Instances Odoo + odoo.sync.instance + list,form + + diff --git a/odoo_to_odoo_sync/views/sync_log_views.xml b/odoo_to_odoo_sync/views/sync_log_views.xml new file mode 100644 index 0000000..d021ce5 --- /dev/null +++ b/odoo_to_odoo_sync/views/sync_log_views.xml @@ -0,0 +1,51 @@ + + + + odoo.sync.log.list + odoo.sync.log + + + + + + + + + + + + + odoo.sync.log.form + odoo.sync.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + Journaux de synchronisation + odoo.sync.log + list,form + + diff --git a/odoo_to_odoo_sync/views/sync_model_views.xml b/odoo_to_odoo_sync/views/sync_model_views.xml new file mode 100644 index 0000000..f6bd4f9 --- /dev/null +++ b/odoo_to_odoo_sync/views/sync_model_views.xml @@ -0,0 +1,69 @@ + + + + odoo.sync.model.list + odoo.sync.model + + + + + + + + + + + + + odoo.sync.model.form + odoo.sync.model + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Modèles synchronisés + odoo.sync.model + list,form + + + Aucun modèle synchronisé défini + + + Définissez les modèles à synchroniser entre vos instances Odoo. + + + + diff --git a/odoo_to_odoo_sync/views/sync_queue_views.xml b/odoo_to_odoo_sync/views/sync_queue_views.xml new file mode 100644 index 0000000..a7829b9 --- /dev/null +++ b/odoo_to_odoo_sync/views/sync_queue_views.xml @@ -0,0 +1,130 @@ + + + + odoo.sync.queue.list + odoo.sync.queue + + + + + + + + + + + + + + + + odoo.sync.queue.form + odoo.sync.queue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + odoo.sync.log.list + odoo.sync.log + + + + + + + + + + + + odoo.sync.log.form + odoo.sync.log + + + + + + + + + + + + + + + + + + + + + + + + + + + File d'attente de synchronisation + odoo.sync.queue + list,form + + + + Journaux de synchronisation + odoo.sync.log + list,form + +
+ Aucun modèle synchronisé défini +
+ Définissez les modèles à synchroniser entre vos instances Odoo. +