- Added comprehensive README files for all three sync modules - Implemented advanced logging system with multiple log levels (DEBUG, INFO, WARNING, ERROR) - Added automatic sensitive data masking for security in log payloads - Implemented log retention policy (90 days local, 7 years in AWS S3 Glacier) - Added AWS S3 integration for long-term log archiving - Fixed validation error with bemade_instance_id in sync model creation - Refactored model inheritance structure from _inherit to _inherits for proper delegation - Fixed duplicate sync model prevention logic - Restored Test Connection button in instance view - Fixed connection test logging to properly record success/failure - Fixed queue creation for connection test logs - Removed unnecessary Synchronized Module tab from odoo_to_odoo_bemade - Added database indexing on create_date for performance optimization - Fixed foreign key constraint violations in field mappings creation - Enhanced error handling throughout the synchronization process
838 lines
40 KiB
Python
838 lines
40 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 _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.
|
|
"""
|
|
_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
|
|
|
|
# Récupérer les champs à synchroniser si spécifiés
|
|
sync_fields = sync_model.field_ids.mapped('field_id.name') if hasattr(sync_model, 'field_ids') and sync_model.field_ids else None
|
|
|
|
# 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']
|
|
|
|
for key, value in data.items():
|
|
# Skip ID for create operations, we already handled it for write/unlink
|
|
if key == 'id' and queue_item.operation == 'create':
|
|
continue
|
|
|
|
# Ignorer les champs système et les champs non synchronisés
|
|
if key in excluded_fields and key != 'id':
|
|
continue
|
|
|
|
if sync_fields and key not in sync_fields and key != 'id':
|
|
continue
|
|
|
|
# Traitement des champs binaires encodés en base64
|
|
if isinstance(value, str) and sync_model.field_ids and sync_model.field_ids.filtered(lambda f: f.field_id.name == key and f.field_id.ttype == 'binary'):
|
|
try:
|
|
# Pas besoin de décoder, Odoo accepte les chaînes base64 pour les champs binaires
|
|
result[key] = value
|
|
except Exception as e:
|
|
_logger.warning(f"[SYNC MANAGER] Erreur lors du traitement du champ binaire {key}: {str(e)}")
|
|
continue
|
|
# Traitement des relations many2one (stockées comme dictionnaires)
|
|
elif isinstance(value, dict) and 'id' in value and 'name' in value:
|
|
# Pour les relations many2one, on envoie uniquement l'ID
|
|
result[key] = value['id']
|
|
# Traitement des relations one2many/many2many (stockées comme listes de dictionnaires)
|
|
elif isinstance(value, list) and all(isinstance(item, dict) and 'id' in item for item in value):
|
|
# Pour les relations one2many/many2many, on envoie une liste d'IDs
|
|
result[key] = [(6, 0, [item['id'] for item in value])]
|
|
else:
|
|
result[key] = value
|
|
|
|
_logger.debug(f"[SYNC MANAGER] [SEQUENCE STEP] Prepared sync data for queue item {queue_item.id}: {result}")
|
|
|
|
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)
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': 'Success',
|
|
'message': 'Conflict resolution strategy updated successfully',
|
|
'type': 'success',
|
|
}
|
|
}
|
|
|
|
@api.model
|
|
def init_conflict_strategy(self):
|
|
"""Initialize the conflict resolution strategy from system parameters."""
|
|
strategy = self.env['ir.config_parameter'].sudo().get_param(
|
|
'odoo_to_odoo_sync.default_conflict_strategy', 'manual')
|
|
manager = self.search([], limit=1)
|
|
if manager:
|
|
manager.write({'conflict_resolution_strategy': strategy})
|
|
return True
|
|
return result
|