bemade-addons/odoo_to_odoo_sync/models/sync_manager.py
mathis 4d92c5a72f [IMP] odoo_to_odoo_sync: implement dependency management, fix connection errors, and add field mapping
- Added comprehensive dependency management with proper OdooRPC library integration
- Fixed XML-RPC, JSON-RPC, and OdooRPC connection authentication issues
- Implemented proper Odoo 18 API key format support with scope parameter
- Added intelligent field mapping wizard with Full/Required/Balanced modes
- Enhanced error handling and connection state management
- Removed legacy API key system for clean reinstall capability
- Added detailed debug logging for connection troubleshooting
2025-08-15 08:40:48 -04:00

999 lines
46 KiB
Python

# 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
import traceback
import base64
from datetime import datetime, timedelta
import xmlrpc.client
from odoo import api, 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'
name = fields.Char(
string='Name',
default='Global Configuration',
required=True
)
conflict_resolution_strategy = fields.Selection([
('manual', 'Manual Resolution'),
('timestamp', 'Newest Wins'),
('source_priority', 'Source Instance Wins'),
('destination_priority', 'Destination Instance Wins')
], string='Conflict Resolution Strategy', default='manual',
help='Default strategy for resolving synchronization conflicts')
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, fields_list=None):
"""Queue a synchronization operation.
This method implements the "Create SyncRecord" step in the synchronization sequence diagram.
It creates a queue entry for the specified record and operation.
Args:
record: The record to synchronize
operation: The operation type ('create', 'write', 'unlink')
fields_list: Optional list of fields to synchronize (for write operations)
Returns:
odoo.sync.queue: The created queue entry or False if failed
"""
try:
_logger.debug(f"[SYNC MANAGER] Create SyncRecord: {operation} on {record._name} (ID: {record.id})")
# Check if the model is configured for synchronization
sync_model = self.env['odoo.sync.model'].sudo().search([
('model_id.model', '=', record._name),
('active', '=', True)
], limit=1)
if not sync_model:
_logger.debug(f"[SYNC MANAGER] Model {record._name} not configured for sync")
return False
# Prepare data for synchronization
data = {}
# For create/write operations, get all relevant data
if operation in ['create', 'write']:
# Get all model fields
model_fields = self.env[record._name]._fields
# Filter fields to synchronize if a list is provided
if fields_list:
fields_to_sync = [f for f in fields_list if f in model_fields]
else:
fields_to_sync = list(model_fields.keys())
# Add fields configured for synchronization
if sync_model.field_ids:
sync_fields = sync_model.field_ids.mapped('name')
fields_to_sync = [f for f in fields_to_sync if f in sync_fields]
# Get field values
for field in fields_to_sync:
if field in model_fields:
value = record[field]
data[field] = self._prepare_data_for_json(value)
# Add ID for update operations
data['id'] = record.id
# For delete operations, we just need the ID
elif operation == 'unlink':
data['id'] = record.id
# Create the queue entry
queue_entry = self.env['odoo.sync.queue'].create({
'model_id': sync_model.id,
'record_id': record.id,
'operation': operation,
'state': 'pending',
'priority': sync_model.priority or 10,
'data': json.dumps(data)
})
_logger.debug(f"[SYNC MANAGER] Created queue entry {queue_entry.id} for {operation} operation on {record._name} (ID: {record.id})")
return queue_entry
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error creating SyncRecord: {str(e)}")
_logger.error(traceback.format_exc())
_logger.error(traceback.format_exc())
def _process_sync_queue(self):
"""Process the synchronization queue following the sequence diagram.
This method is the entry point for the cron job that processes the queue.
It delegates to the SyncQueue model's _process_sync_queue method to follow
the exact sequence in the diagram.
"""
_logger.debug("[SYNC MANAGER] [SEQUENCE STEP] Starting sync queue processing")
_logger.debug("[SYNC MANAGER] [SEQUENCE STEP] Delegating to SyncQueue._process_sync_queue")
result = self.env['odoo.sync.queue']._process_sync_queue()
_logger.debug("[SYNC MANAGER] [SEQUENCE STEP] Sync queue processing completed")
return result
def _process_dequeued_item(self, queue_item):
"""Process a dequeued sync queue item.
This method follows the exact sequence diagram flow:
1. Prepare payload
2. Transform/validate
3. Apply changes
4. Receive ACK/NACK
5. Handle success or error paths
Args:
queue_item: The dequeued sync queue item to process
Returns:
bool: True if processing was successful, False otherwise
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Processing dequeued item {queue_item.id} for {queue_item.operation} operation on model {queue_item.model_id.model_id.model if queue_item.model_id and queue_item.model_id.model_id else 'unknown'}")
try:
# Get the sync model
sync_model = queue_item.model_id
if not sync_model:
_logger.error(f"[SYNC MANAGER] No sync model found for queue item {queue_item.id}")
return False
# Get the remote instance
remote_instance = sync_model.instance_id
if not remote_instance:
_logger.error(f"[SYNC MANAGER] No remote instance found for sync model {sync_model.name}")
return False
# Parse the data
data = json.loads(queue_item.data)
# Prepare the payload (Step 1 in sequence diagram)
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Preparing payload for queue item {queue_item.id}")
payload = self._prepare_sync_data(queue_item, data)
# Transform/validate the payload (Step 2 in sequence diagram)
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Transforming/validating payload for queue item {queue_item.id}")
# Validate the payload structure and data types
if not isinstance(payload, dict):
error_msg = f"Invalid payload format for queue item {queue_item.id}. Expected dict, got {type(payload)}"
_logger.error(f"[SYNC MANAGER] {error_msg}")
queue_item.increment_retry(error_msg)
return False
# Validate required fields are present
required_fields = ['id', 'model']
for field in required_fields:
if field not in payload:
error_msg = f"Missing required field '{field}' in payload for queue item {queue_item.id}"
_logger.error(f"[SYNC MANAGER] {error_msg}")
queue_item.increment_retry(error_msg)
return False
# Validate operation is supported
valid_operations = ['create', 'write', 'unlink']
if queue_item.operation not in valid_operations:
error_msg = f"Invalid operation '{queue_item.operation}' for queue item {queue_item.id}. Must be one of {valid_operations}"
_logger.error(f"[SYNC MANAGER] {error_msg}")
queue_item.increment_retry(error_msg)
return False
# Apply changes to remote instance (Step 3 in sequence diagram)
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Applying changes to remote instance for queue item {queue_item.id}")
result = self._apply_sync_to_remote(remote_instance, queue_item.operation, sync_model.model_id.model, payload)
# Handle result (Step 4 & 5 in sequence diagram)
if result.get('success'):
# Process ACK (Step 4 in sequence diagram)
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Received ACK for queue item {queue_item.id}")
self._handle_sync_ack(queue_item, result)
return True
else:
# Process NACK (Step 4 in sequence diagram)
error_message = result.get('error', 'Unknown error')
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Received NACK for queue item {queue_item.id}: {error_message}")
self._handle_sync_nack(queue_item, error_message)
return False
except xmlrpc.client.Fault as e:
# Step 4: NACK received (implicit in RPC fault)
error_msg = str(e)
_logger.error(f"[SYNC MANAGER] [SEQUENCE STEP] Received NACK from remote instance: {error_msg}")
# Check if this is a potential conflict (record not found or modified)
if "Record does not exist" in error_msg and queue_item.operation in ['write', 'unlink']:
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Potential conflict detected: {error_msg}")
try:
# Try to get the remote record state
remote_data = None
data = json.loads(queue_item.data)
model_name = queue_item.model_id.model_id.model
remote_instance = queue_item.model_id.instance_id
if 'id' in data:
# Try to search for the record by other criteria
domain = []
# Use name field if available
if 'name' in data:
domain.append(('name', '=', data.get('name')))
# Use code/reference field if available
if 'code' in data:
domain.append(('code', '=', data.get('code')))
# Use other unique identifiers if available
if domain:
# Connect to remote instance
url = remote_instance.url
db = remote_instance.database
username = remote_instance.username
password = remote_instance.password
# Connect to XML-RPC endpoint
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
models_api = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')
# Authenticate
uid = common.authenticate(db, username, password, {})
if uid:
remote_ids = models_api.execute_kw(
db, uid, password,
model_name, 'search',
[domain]
)
if remote_ids:
remote_data_list = models_api.execute_kw(
db, uid, password,
model_name, 'read',
[remote_ids[0], list(data.keys())]
)
if remote_data_list:
remote_data = remote_data_list[0]
# Create conflict record
if remote_data:
# Add model info to data for conflict resolution
local_data = data.copy()
local_data['model'] = model_name
remote_data['model'] = model_name
self._handle_sync_conflict(queue_item, local_data, remote_data)
else:
# No remote data found, just mark as error
queue_item.increment_retry(error_msg)
except Exception as conflict_e:
_logger.error(f"[SYNC MANAGER] Error during conflict handling: {str(conflict_e)}")
_logger.error(traceback.format_exc())
queue_item.increment_retry(f"Conflict handling error: {str(conflict_e)}")
else:
# Not a conflict, just a regular error
queue_item.increment_retry(error_msg)
return False
except Exception as e:
_logger.error(f"[SYNC MANAGER] Unexpected error processing queue item: {str(e)}")
_logger.error(traceback.format_exc())
queue_item.increment_retry(str(e))
return False
def _handle_sync_ack(self, queue_item, result):
"""Handle successful synchronization (ACK received).
This method implements the "Success Path" in the synchronization sequence diagram.
It marks the queue item as successful and creates a success log entry.
Args:
queue_item: The queue item that was successfully processed
result: The result data from the remote operation
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Handling ACK for queue item {queue_item.id} - Success Path")
# Mark as done
queue_item.mark_success()
# Create success log
sync_model = queue_item.model_id
remote_instance = sync_model.instance_id
model_name = sync_model.model_id.model
self.env['odoo.sync.log'].create({
'queue_id': queue_item.id,
'state': 'success',
'message': f"Synchronisation réussie: {queue_item.operation} sur {model_name} vers {remote_instance.name}"
})
return True
def _handle_sync_nack(self, queue_item, error_message):
"""Handle failed synchronization (NACK received).
This method implements part of the "Error Path" in the synchronization sequence diagram.
It increments the retry count and creates an error log entry.
Args:
queue_item: The queue item that failed to process
error_message: The error message from the remote operation
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Handling NACK for queue item {queue_item.id} - Error Path: {error_message}")
# Increment retry count
queue_item.increment_retry(error_message)
# Create error log
self.env['odoo.sync.log'].create({
'queue_id': queue_item.id,
'state': 'error',
'message': f"Erreur de synchronisation: {error_message}"
})
return True
def _check_dependencies(self, queue_item):
"""Check if all dependencies are satisfied for this queue item.
Args:
queue_item: The queue item to check dependencies for
Returns:
bool: True if dependencies are satisfied, False otherwise
"""
try:
resolver = self.env['odoo.sync.dependency.resolver']
return resolver.resolve_missing_dependencies(queue_item)
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error checking dependencies for queue item {queue_item.id}: {str(e)}")
return True # Allow processing to continue if dependency check fails
def _handle_missing_dependency(self, queue_item, error_message):
"""Handle missing dependency during synchronization.
Args:
queue_item: The queue item with missing dependencies
error_message: The error message indicating missing dependencies
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Handling missing dependency for queue item {queue_item.id}")
# Increment retry count with dependency-specific handling
queue_item.write({
'retry_count': queue_item.retry_count + 1,
'next_retry': fields.Datetime.now() + timedelta(minutes=5), # Longer delay for dependencies
'error_message': f"Missing dependency: {error_message}"
})
# Create dependency-specific log
self.env['odoo.sync.log'].create({
'queue_id': queue_item.id,
'state': 'error',
'message': f"Missing dependency detected: {error_message}"
})
return True
def update_model_dependencies(self, model_sync_ids=None):
"""Update dependency information for synchronized models.
Args:
model_sync_ids: List of model sync IDs to analyze. If None, analyzes all active models.
"""
if model_sync_ids is None:
model_sync_ids = self.env['odoo.sync.model'].search([('active', '=', True)]).ids
if not model_sync_ids:
return
try:
resolver = self.env['odoo.sync.dependency.resolver']
analysis = resolver.analyze_model_dependencies(model_sync_ids)
# Log dependency analysis results
_logger.info(f"[SYNC MANAGER] Updated dependencies for {len(model_sync_ids)} models")
if analysis['cycles']:
_logger.warning(f"[SYNC MANAGER] Detected circular dependencies: {analysis['cycles']}")
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error updating model dependencies: {str(e)}")
def _handle_sync_conflict(self, queue_item, local_data, remote_data):
"""Handle synchronization conflict.
This method implements the conflict handling branch of the "Error Path" in the sequence diagram.
It creates a conflict record and marks the queue item as conflicted.
Args:
queue_item: The queue item that encountered a conflict
local_data: The local data that was being synchronized
remote_data: The conflicting remote data
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Handling conflict for queue item {queue_item.id} - Conflict Path")
# Get the conflict resolution strategy from the remote instance
remote_instance = queue_item.model_id.instance_id
strategy = remote_instance.conflict_resolution_strategy if remote_instance else \
self.env['ir.config_parameter'].sudo().get_param(
'odoo_to_odoo_sync.default_conflict_strategy', 'manual')
# Create conflict record
conflict = self.env['odoo.sync.conflict'].create({
'queue_id': queue_item.id,
'model_id': queue_item.model_id.id,
'record_id': queue_item.record_id,
'local_data': json.dumps(local_data),
'remote_data': json.dumps(remote_data),
'state': 'pending',
'resolution_strategy': strategy
})
# Mark queue item as conflicted
queue_item.write({
'state': 'conflict',
'conflict_id': conflict.id
})
# Create conflict log
self.env['odoo.sync.log'].create({
'queue_id': queue_item.id,
'state': 'conflict',
'message': f"Conflit détecté et enregistré pour résolution manuelle"
})
return True
def _apply_sync_to_remote(self, remote_instance, operation, model_name, data):
"""Apply synchronization changes to the remote instance.
This method implements the "Apply Changes" step in the synchronization sequence diagram.
It sends the prepared data to the remote instance via XML-RPC.
Args:
remote_instance: The remote instance to connect to
operation: The operation type ('create', 'write', 'unlink')
model_name: The name of the model to operate on
data: The data to send to the remote instance
Returns:
dict: A result dictionary with success flag and any returned data or error message
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Applying {operation} on {model_name} to remote instance {remote_instance.name}")
try:
# Get connection to remote instance
url = remote_instance.url
db = remote_instance.database
username = remote_instance.username
password = remote_instance.password
# Connect to XML-RPC endpoint
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')
# Authenticate
uid = common.authenticate(db, username, password, {})
if not uid:
return {'success': False, 'error': 'Authentication failed'}
# Execute the operation
result = None
if operation == 'create':
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Creating record in {model_name} with data: {data}")
record_id = models.execute_kw(db, uid, password, model_name, 'create', [data], {'context': {'no_sync': True}})
result = {'success': True, 'record_id': record_id}
elif operation == 'write':
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Updating record {data.get('id')} in {model_name}")
record_id = data.pop('id', False)
if not record_id:
return {'success': False, 'error': 'No ID provided for write operation'}
try:
# First try direct ID lookup
success = models.execute_kw(db, uid, password, model_name, 'write', [[record_id], data], {'context': {'no_sync': True}})
result = {'success': success}
except xmlrpc.client.Fault as fault:
# If record not found by ID, try to find it by name
if "Record does not exist" in fault.faultString and 'name' in data:
_logger.debug(f"[SYNC MANAGER] Record {record_id} not found, trying lookup by name: {data.get('name')}")
# Search for record with exact name match
domain = [('name', '=', data.get('name'))]
record_ids = models.execute_kw(db, uid, password, model_name, 'search', [domain])
if record_ids:
remote_id = record_ids[0]
_logger.debug(f"[SYNC MANAGER] Found record by name, remote ID: {remote_id}")
# Update the record using the found ID
success = models.execute_kw(db, uid, password, model_name, 'write', [[remote_id], data], {'context': {'no_sync': True}})
result = {'success': success, 'mapped_id': remote_id}
else:
# No record found by name either
raise xmlrpc.client.Fault(fault.faultCode,
f"Record not found by ID ({record_id}) or name ({data.get('name')})")
else:
# Re-raise the original fault if it's not a 'record not found' error
# or if we don't have a name to search with
raise
elif operation == 'unlink':
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Deleting record {data.get('id')} from {model_name}")
record_id = data.get('id', False)
if not record_id:
return {'success': False, 'error': 'No ID provided for unlink operation'}
success = models.execute_kw(db, uid, password, model_name, 'unlink', [[record_id]], {'context': {'no_sync': True}})
result = {'success': success}
else:
return {'success': False, 'error': f"Unknown operation: {operation}"}
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Remote operation successful: {result}")
return result
except xmlrpc.client.Fault as fault:
error_msg = f"XML-RPC Fault: {fault.faultCode}: {fault.faultString}"
_logger.error(f"[SYNC MANAGER] {error_msg}")
# Check for conflict indicators in the error message
if 'concurrent' in fault.faultString.lower() or 'conflict' in fault.faultString.lower():
return {'success': False, 'error': error_msg, 'conflict': True}
return {'success': False, 'error': error_msg}
except Exception as e:
error_msg = f"Error applying sync to remote: {str(e)}"
_logger.error(f"[SYNC MANAGER] {error_msg}")
_logger.error(traceback.format_exc())
return {'success': False, 'error': error_msg}
def _prepare_data_for_json(self, data):
"""Prépare les données pour la sérialisation JSON en convertissant les types non-sérialisables"""
if not data:
return {}
# If data is a string, return it directly
if isinstance(data, str):
return data
# If data is a primitive type that's JSON serializable, return it directly
if isinstance(data, (int, float, bool, type(None))):
return data
result = {}
# Only try to iterate if data is a dict
if isinstance(data, dict):
for key, value in data.items():
# Traitement des types binaires (bytes)
if isinstance(value, bytes):
try:
# Convertir les données binaires en chaîne base64
result[key] = base64.b64encode(value).decode('utf-8')
except Exception as e:
_logger.warning(f"[SYNC MANAGER] Impossible de convertir le champ binaire {key}: {str(e)}")
result[key] = None
# Traitement des tuples (souvent utilisés pour les relations many2one)
elif isinstance(value, tuple) and len(value) == 2:
# Stocker uniquement l'ID et le nom pour les relations
result[key] = {'id': value[0], 'name': value[1]}
# Traitement des listes (souvent utilisées pour les relations one2many/many2many)
elif isinstance(value, list) and all(isinstance(item, tuple) and len(item) == 2 for item in value):
# Convertir les listes de tuples en listes de dictionnaires
result[key] = [{'id': item[0], 'name': item[1]} for item in value]
# Traitement des dates et datetimes
elif hasattr(value, 'isoformat'):
result[key] = value.isoformat()
# Recursively process nested dictionaries
elif isinstance(value, dict):
result[key] = self._prepare_data_for_json(value)
# Recursively process lists of non-tuple items
elif isinstance(value, list):
result[key] = [self._prepare_data_for_json(item) for item in value]
# Pour les autres types, les laisser tels quels s'ils sont sérialisables
else:
result[key] = value
return result
def _detect_conflict_type(self, local_data, remote_data):
"""Détecte et catégorise le type de conflit entre données locales et distantes.
Args:
local_data: Données locales (dict)
remote_data: Données distantes (dict)
Returns:
tuple: (type_conflit, champs_conflit)
type_conflit: 'version', 'data', 'relation' ou None
champs_conflit: liste des champs en conflit
"""
conflict_type = None
conflict_fields = []
# Vérifier si les deux enregistrements existent et ont été modifiés
local_write_date = local_data.get('write_date')
remote_write_date = remote_data.get('write_date')
if local_write_date and remote_write_date:
# Conflit de version: les deux côtés ont été modifiés depuis la dernière synchronisation
try:
local_date = fields.Datetime.from_string(local_write_date)
remote_date = fields.Datetime.from_string(remote_write_date)
# Si les dates sont différentes, il y a potentiellement un conflit
if local_date and remote_date and abs((local_date - remote_date).total_seconds()) > 1: # Tolérance d'une seconde
conflict_type = 'version'
except Exception as e:
_logger.warning(f"Erreur lors de la comparaison des dates: {str(e)}")
# En cas d'erreur, considérer qu'il y a un conflit de version
conflict_type = 'version'
# Identifier les champs modifiés des deux côtés, même sans conflit de version
for field, local_value in local_data.items():
if field in remote_data and local_value != remote_data[field]:
# Exclure les champs système
if field not in ['id', 'write_date', 'create_date', 'write_uid', 'create_uid', '__last_update']:
conflict_fields.append(field)
# Si des champs sont en conflit, c'est un conflit de données
if conflict_fields:
conflict_type = 'data'
# Vérifier s'il s'agit d'un conflit de relations
relation_conflict = False
for field in conflict_fields:
local_value = local_data.get(field)
remote_value = remote_data.get(field)
# Détecter les relations (many2one, one2many, many2many)
if isinstance(local_value, dict) and 'id' in local_value:
relation_conflict = True
break
elif isinstance(local_value, list) and all(isinstance(item, dict) and 'id' in item for item in local_value if isinstance(item, dict)):
relation_conflict = True
break
if relation_conflict:
conflict_type = 'relation'
return conflict_type, conflict_fields
def _handle_conflict(self, local_data, remote_data, strategy=None):
"""Gestion des conflits selon la stratégie configurée"""
# Use the provided strategy or fall back to system parameter
if strategy is None:
strategy = self.env['ir.config_parameter'].sudo().get_param(
'odoo_to_odoo_sync.default_conflict_strategy')
_logger.debug(f'Résolution de conflit avec stratégie: {strategy}')
# Détecter le type de conflit
conflict_type, conflict_fields = self._detect_conflict_type(local_data, remote_data)
_logger.debug(f'Type de conflit détecté: {conflict_type}, champs: {conflict_fields}')
if not conflict_type or not conflict_fields:
# Pas de conflit réel, utiliser la stratégie globale
if strategy == 'timestamp':
return self._resolve_by_timestamp(local_data, remote_data)
elif strategy == 'source_priority':
return local_data
elif strategy == 'destination_priority':
return remote_data
else: # manual
return self._flag_for_manual_resolution(local_data, remote_data)
# Résoudre le conflit champ par champ selon les stratégies configurées
result = local_data.copy()
# Récupérer le modèle synchronisé
model_name = local_data.get('model')
if not model_name:
return self._flag_for_manual_resolution(local_data, remote_data)
sync_model = self.env['odoo.sync.model'].sudo().search([('model_id.model', '=', model_name)], limit=1)
if not sync_model:
return self._flag_for_manual_resolution(local_data, remote_data)
# Parcourir les champs en conflit
for field in conflict_fields:
# Récupérer la stratégie de conflit pour ce champ
field_config = sync_model.field_ids.filtered(lambda f: f.name == field)
if not field_config:
# Champ non configuré, utiliser la stratégie globale
if strategy == 'timestamp':
result[field] = self._resolve_field_by_timestamp(field, local_data, remote_data)
elif strategy == 'source_priority':
result[field] = local_data[field]
elif strategy == 'destination_priority':
result[field] = remote_data[field]
else: # manual
# Si au moins un champ nécessite une résolution manuelle, tout le conflit est manuel
return self._flag_for_manual_resolution(local_data, remote_data)
else:
# Utiliser la stratégie configurée pour ce champ
field_strategy = field_config.conflict_strategy
if field_strategy == 'source_wins':
result[field] = local_data[field]
elif field_strategy == 'dest_wins':
result[field] = remote_data[field]
elif field_strategy == 'newest':
result[field] = self._resolve_field_by_timestamp(field, local_data, remote_data)
else: # manual
# Si au moins un champ nécessite une résolution manuelle, tout le conflit est manuel
return self._flag_for_manual_resolution(local_data, remote_data)
return result
def _resolve_field_by_timestamp(self, field, local_data, remote_data):
"""Résout un conflit de champ spécifique en utilisant l'horodatage
Args:
field: Nom du champ en conflit
local_data: Données locales
remote_data: Données distantes
Returns:
La valeur du champ la plus récente
"""
local_date = fields.Datetime.from_string(local_data.get('write_date', '')) if local_data.get('write_date') else None
remote_date = fields.Datetime.from_string(remote_data.get('write_date', '')) if remote_data.get('write_date') else None
if local_date and remote_date:
return local_data[field] if local_date > remote_date else remote_data[field]
elif local_date:
return local_data[field]
elif remote_date:
return remote_data[field]
else:
# Si pas d'horodatage, utiliser la valeur locale par défaut
return local_data[field]
def _resolve_by_timestamp(self, local, remote):
"""Résout un conflit en utilisant l'horodatage"""
local_date = fields.Datetime.from_string(local.get('write_date', '')) if local.get('write_date') else None
remote_date = fields.Datetime.from_string(remote.get('write_date', '')) if remote.get('write_date') else None
if local_date and remote_date:
return local if local_date > remote_date else remote
elif local_date:
return local
elif remote_date:
return remote
else:
# Si pas d'horodatage, utiliser les données locales par défaut
return local
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.get('model', 'unknown'),
'record_id': local.get('id', 0),
'state': 'pending'
})
raise SyncConflictException('Conflit nécessitant une résolution manuelle')
def _prepare_sync_data(self, queue_item, data):
"""Prépare les données pour l'envoi vers l'instance distante
Cette méthode transforme les données JSON-sérialisées en format compatible
avec l'API Odoo de l'instance distante, en appliquant les mappings avancés.
"""
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Preparing sync data for queue item {queue_item.id} - Payload Preparation")
result = {}
# Get the sync model from the queue item
sync_model = queue_item.model_id
if not sync_model:
_logger.error(f"[SYNC MANAGER] No sync model found for queue item {queue_item.id}")
return data
# Get the original record for advanced mapping
record = self.env[sync_model.model_id.model].browse(queue_item.record_id)
if not record.exists():
_logger.error(f"[SYNC MANAGER] Record {queue_item.record_id} not found in model {sync_model.model_id.model}")
return data
# Get all configured field mappings
field_mappings = sync_model.field_ids
# Supprimer les champs système et les champs non synchronisés
excluded_fields = ['id', 'write_date', 'create_date', 'write_uid', 'create_uid', '__last_update']
# For write and unlink operations, we need to keep the ID
if queue_item.operation in ['write', 'unlink'] and 'id' in data:
result['id'] = data['id']
# Process each field mapping
for field_mapping in field_mappings:
if not field_mapping.active:
continue
field_name = field_mapping.field_id.name
target_field = field_mapping.target_field or field_name
try:
value = self._apply_field_mapping(field_mapping, record, data.get(field_name))
if value is not None:
result[target_field] = value
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error applying mapping for field {field_name}: {str(e)}")
if field_mapping.required:
raise
continue
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Prepared sync data for queue item {queue_item.id}: {result}")
return result
def _apply_field_mapping(self, field_mapping, record, original_value):
"""Apply advanced field mapping based on mapping type.
Args:
field_mapping: The field mapping configuration
record: The source record being synchronized
original_value: The original field value
Returns:
The transformed value for synchronization
"""
mapping_type = field_mapping.mapping_type
if mapping_type == 'direct':
# Direct mapping - return value as-is
return original_value
elif mapping_type == 'function':
# Function mapping - execute specified function
if not field_mapping.mapping_function:
_logger.warning(f"[SYNC MANAGER] Function mapping requested but no function specified for field {field_mapping.name}")
return original_value
try:
# Parse function name - can be model.method_name or just method_name
func_name = field_mapping.mapping_function
if '.' in func_name:
model_name, method_name = func_name.split('.', 1)
model = self.env[model_name]
if hasattr(model, method_name):
method = getattr(model, method_name)
return method(record, original_value)
else:
_logger.error(f"[SYNC MANAGER] Method {method_name} not found in model {model_name}")
return original_value
else:
# Try to find method on the record's model
if hasattr(record, func_name):
method = getattr(record, func_name)
return method(original_value)
else:
_logger.error(f"[SYNC MANAGER] Method {func_name} not found on record")
return original_value
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error executing function mapping for field {field_mapping.name}: {str(e)}")
if field_mapping.required:
raise
return original_value
elif mapping_type == 'computed':
# Computed mapping - evaluate expression
if not field_mapping.mapping_expression:
_logger.warning(f"[SYNC MANAGER] Computed mapping requested but no expression specified for field {field_mapping.name}")
return original_value
try:
# Create safe evaluation context
context = {
'record': record,
'value': original_value,
'env': self.env,
'datetime': __import__('datetime'),
'date': __import__('datetime').date,
'time': __import__('time'),
'json': __import__('json'),
'str': str,
'int': int,
'float': float,
'bool': bool,
'len': len,
}
# Evaluate expression safely
result = eval(field_mapping.mapping_expression, {"__builtins__": {}}, context)
return result
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error evaluating computed expression for field {field_mapping.name}: {str(e)}")
if field_mapping.required:
raise
return original_value
elif mapping_type == 'relation':
# Relation mapping - find related record by field value
if not field_mapping.relation_model or not field_mapping.relation_field:
_logger.warning(f"[SYNC MANAGER] Relation mapping requested but missing model or field for {field_mapping.name}")
return original_value
try:
relation_model = self.env[field_mapping.relation_model]
# Build domain
domain = [(field_mapping.relation_field, '=', original_value)]
# Add additional domain if specified
if field_mapping.relation_domain:
try:
additional_domain = __import__('json').loads(field_mapping.relation_domain)
if isinstance(additional_domain, list):
domain.extend(additional_domain)
except __import__('json').JSONDecodeError:
_logger.warning(f"[SYNC MANAGER] Invalid relation domain JSON for field {field_mapping.name}")
# Search for related record
related_records = relation_model.search(domain, limit=1)
if related_records:
return related_records.id
else:
_logger.warning(f"[SYNC MANAGER] No related record found in {field_mapping.relation_model} with {field_mapping.relation_field}={original_value}")
return None
except Exception as e:
_logger.error(f"[SYNC MANAGER] Error in relation mapping for field {field_mapping.name}: {str(e)}")
if field_mapping.required:
raise
return original_value
else:
_logger.warning(f"[SYNC MANAGER] Unknown mapping type: {mapping_type}")
return original_value
def set_conflict_strategy(self):
"""Set the system parameter for conflict resolution strategy based on the selected value."""
self.ensure_one()
if self.conflict_resolution_strategy:
self.env['ir.config_parameter'].sudo().set_param(
'odoo_to_odoo_sync.default_conflict_strategy', self.conflict_resolution_strategy)