odoo to odoo
This commit is contained in:
parent
1b95a6c2ea
commit
cc66512df2
18 changed files with 1823 additions and 0 deletions
676
odoo_to_odoo_sync/Spécifications.md
Normal file
676
odoo_to_odoo_sync/Spécifications.md
Normal file
|
|
@ -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
|
||||
<record id="group_sync_user" model="res.groups">
|
||||
<field name="name">Synchronisation : Utilisateur</field>
|
||||
<field name="category_id" ref="base.module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record id="group_sync_manager" model="res.groups">
|
||||
<field name="name">Synchronisation : Manager</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_sync_user'))]"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
### Règles de Sécurité
|
||||
```xml
|
||||
<record id="rule_sync_model_manager" model="ir.rule">
|
||||
<field name="name">Sync Manager : Accès Total</field>
|
||||
<field name="model_id" ref="model_odoo_sync_model"/>
|
||||
<field name="groups" eval="[(4, ref('group_sync_manager'))]"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
## 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
|
||||
<record id="view_sync_config_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.config.form</field>
|
||||
<field name="model">odoo.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="model_id"/>
|
||||
<field name="active"/>
|
||||
<field name="deletion_strategy"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Champs">
|
||||
<field name="field_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="field_id"/>
|
||||
<field name="sync_type"/>
|
||||
<field name="conflict_strategy"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
## 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
|
||||
1
odoo_to_odoo_sync/__init__.py
Normal file
1
odoo_to_odoo_sync/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
28
odoo_to_odoo_sync/__manifest__.py
Normal file
28
odoo_to_odoo_sync/__manifest__.py
Normal file
|
|
@ -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',
|
||||
}
|
||||
15
odoo_to_odoo_sync/data/ir_cron_data.xml
Normal file
15
odoo_to_odoo_sync/data/ir_cron_data.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_sync_queue_processor" model="ir.cron">
|
||||
<field name="name">Traitement de la file de synchronisation</field>
|
||||
<field name="model_id" ref="model_odoo_sync_queue"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._process_sync_queue()</field>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
6
odoo_to_odoo_sync/models/__init__.py
Normal file
6
odoo_to_odoo_sync/models/__init__.py
Normal file
|
|
@ -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
|
||||
127
odoo_to_odoo_sync/models/sync_instance.py
Normal file
127
odoo_to_odoo_sync/models/sync_instance.py
Normal file
|
|
@ -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
|
||||
50
odoo_to_odoo_sync/models/sync_log.py
Normal file
50
odoo_to_odoo_sync/models/sync_log.py
Normal file
|
|
@ -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}'
|
||||
204
odoo_to_odoo_sync/models/sync_manager.py
Normal file
204
odoo_to_odoo_sync/models/sync_manager.py
Normal file
|
|
@ -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')
|
||||
94
odoo_to_odoo_sync/models/sync_model.py
Normal file
94
odoo_to_odoo_sync/models/sync_model.py
Normal file
|
|
@ -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]
|
||||
41
odoo_to_odoo_sync/models/sync_model_field.py
Normal file
41
odoo_to_odoo_sync/models/sync_model_field.py
Normal file
|
|
@ -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!')
|
||||
]
|
||||
204
odoo_to_odoo_sync/models/sync_queue.py
Normal file
204
odoo_to_odoo_sync/models/sync_queue.py
Normal file
|
|
@ -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
|
||||
13
odoo_to_odoo_sync/security/ir.model.access.csv
Normal file
13
odoo_to_odoo_sync/security/ir.model.access.csv
Normal file
|
|
@ -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
|
||||
|
1
odoo_to_odoo_sync/static/description/icon.png
Normal file
1
odoo_to_odoo_sync/static/description/icon.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
45
odoo_to_odoo_sync/views/menus.xml
Normal file
45
odoo_to_odoo_sync/views/menus.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menu principal -->
|
||||
<menuitem id="menu_sync_root"
|
||||
name="Synchronisation"
|
||||
sequence="100"
|
||||
web_icon="odoo_to_odoo_sync,static/description/icon.png"/>
|
||||
|
||||
<!-- Sous-menus -->
|
||||
<menuitem id="menu_sync_config"
|
||||
name="Configuration"
|
||||
parent="menu_sync_root"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_sync_monitoring"
|
||||
name="Monitoring"
|
||||
parent="menu_sync_root"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Éléments de configuration -->
|
||||
<menuitem id="menu_sync_instance"
|
||||
name="Instances Odoo"
|
||||
parent="menu_sync_config"
|
||||
action="action_sync_instance"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_sync_model"
|
||||
name="Modèles synchronisés"
|
||||
parent="menu_sync_config"
|
||||
action="action_sync_model"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Éléments de monitoring -->
|
||||
<menuitem id="menu_sync_queue"
|
||||
name="File d'attente"
|
||||
parent="menu_sync_monitoring"
|
||||
action="action_sync_queue"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_sync_log"
|
||||
name="Journaux"
|
||||
parent="menu_sync_monitoring"
|
||||
action="action_sync_log"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
68
odoo_to_odoo_sync/views/sync_instance_views.xml
Normal file
68
odoo_to_odoo_sync/views/sync_instance_views.xml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_sync_instance_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.instance.list</field>
|
||||
<field name="model">odoo.sync.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
<field name="state" decoration-success="state == 'connected'"
|
||||
decoration-warning="state == 'testing'"
|
||||
decoration-danger="state == 'error'"/>
|
||||
<field name="last_connection"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_instance_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.instance.form</field>
|
||||
<field name="model">odoo.sync.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="test_connection" string="Tester la connexion"
|
||||
type="object" class="btn-primary"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,testing,connected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Nom de l'instance"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="url" placeholder="https://example.odoo.com"/>
|
||||
<field name="database"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username"/>
|
||||
<field name="password" password="True"/>
|
||||
<field name="last_connection" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook invisible="error_message == False">
|
||||
<page string="Messages d'erreur">
|
||||
<field name="error_message" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_instance" model="ir.actions.act_window">
|
||||
<field name="name">Instances Odoo</field>
|
||||
<field name="res_model">odoo.sync.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
51
odoo_to_odoo_sync/views/sync_log_views.xml
Normal file
51
odoo_to_odoo_sync/views/sync_log_views.xml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_sync_log_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.log.list</field>
|
||||
<field name="model">odoo.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="queue_id"/>
|
||||
<field name="state"/>
|
||||
<field name="message"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_log_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.log.form</field>
|
||||
<field name="model">odoo.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="queue_id"/>
|
||||
<field name="state"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="create_date"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Message">
|
||||
<field name="message" nolabel="1"/>
|
||||
</page>
|
||||
<page string="Technical Details">
|
||||
<field name="details" nolabel="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_log" model="ir.actions.act_window">
|
||||
<field name="name">Journaux de synchronisation</field>
|
||||
<field name="res_model">odoo.sync.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
69
odoo_to_odoo_sync/views/sync_model_views.xml
Normal file
69
odoo_to_odoo_sync/views/sync_model_views.xml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_sync_model_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.model.list</field>
|
||||
<field name="model">odoo.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="model_id"/>
|
||||
<field name="instance_id"/>
|
||||
<field name="target_model"/>
|
||||
<field name="priority"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_model_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.model.form</field>
|
||||
<field name="model">odoo.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="model_id" options="{'no_create': True}"/>
|
||||
<field name="instance_id" options="{'no_create': True}"/>
|
||||
<field name="target_model" placeholder="Exemple: res.partner"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="priority"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Champs synchronisés">
|
||||
<field name="field_ids" context="{'default_model_sync_id': id}">
|
||||
<list editable="bottom">
|
||||
<field name="field_id" options="{'no_create': True}"/>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="required"/>
|
||||
<field name="sync_default" placeholder="Valeur par défaut si vide"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_model" model="ir.actions.act_window">
|
||||
<field name="name">Modèles synchronisés</field>
|
||||
<field name="res_model">odoo.sync.model</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucun modèle synchronisé défini
|
||||
</p>
|
||||
<p>
|
||||
Définissez les modèles à synchroniser entre vos instances Odoo.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
130
odoo_to_odoo_sync/views/sync_queue_views.xml
Normal file
130
odoo_to_odoo_sync/views/sync_queue_views.xml
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_sync_queue_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.queue.list</field>
|
||||
<field name="model">odoo.sync.queue</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="record_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="state" decoration-danger="state == 'error'"
|
||||
decoration-info="state == 'processing'"
|
||||
decoration-success="state == 'done'"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="next_retry"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_queue_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.queue.form</field>
|
||||
<field name="model">odoo.sync.queue</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_reset" string="Réinitialiser" type="object"
|
||||
invisible="state not in ('error', 'cancelled')"/>
|
||||
<button name="action_cancel" string="Annuler" type="object"
|
||||
invisible="state in ('done', 'cancelled')"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,pending,processing,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="model_id" options="{'no_create': True}"/>
|
||||
<field name="record_id"/>
|
||||
<field name="operation"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="priority"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="max_retries"/>
|
||||
<field name="next_retry"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Données">
|
||||
<field name="data" widget="ace" options="{'mode': 'json'}"/>
|
||||
</page>
|
||||
<page string="Logs">
|
||||
<field name="log_ids" readonly="1">
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="state" decoration-danger="state == 'error'"
|
||||
decoration-success="state == 'success'"/>
|
||||
<field name="message"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Message d'erreur" invisible="error_message == False">
|
||||
<field name="error_message" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_log_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.log.list</field>
|
||||
<field name="model">odoo.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="queue_id"/>
|
||||
<field name="state" decoration-danger="state == 'error'"
|
||||
decoration-success="state == 'success'"/>
|
||||
<field name="message"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_log_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.log.form</field>
|
||||
<field name="model">odoo.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="queue_id" options="{'no_create': True}"/>
|
||||
<field name="state"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="create_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Message">
|
||||
<field name="message" readonly="1"/>
|
||||
</page>
|
||||
<page string="Détails techniques" invisible="details == False">
|
||||
<field name="details" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_queue" model="ir.actions.act_window">
|
||||
<field name="name">File d'attente de synchronisation</field>
|
||||
<field name="res_model">odoo.sync.queue</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_log" model="ir.actions.act_window">
|
||||
<field name="name">Journaux de synchronisation</field>
|
||||
<field name="res_model">odoo.sync.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue