- 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
253 lines
10 KiB
Python
253 lines
10 KiB
Python
# Copyright 2025 Bemade
|
|
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
|
|
|
"""Synchronization Conflict Resolution Wizard.
|
|
|
|
This module provides a wizard interface for manually resolving
|
|
synchronization conflicts between Odoo instances.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
class OdooSyncConflictWizard(models.TransientModel):
|
|
"""Wizard for manual conflict resolution.
|
|
|
|
This wizard allows users to manually resolve conflicts by:
|
|
1. Viewing differences between local and remote data
|
|
2. Selecting which fields to keep from each version
|
|
3. Creating a custom merged resolution
|
|
"""
|
|
_name = 'odoo.sync.conflict.wizard'
|
|
_description = 'Conflict Resolution Wizard'
|
|
|
|
conflict_id = fields.Many2one(
|
|
comodel_name='odoo.sync.conflict',
|
|
string='Conflit',
|
|
required=True
|
|
)
|
|
|
|
# Related fields from conflict
|
|
model_name = fields.Char(
|
|
related='conflict_id.model_name',
|
|
string='Modèle',
|
|
readonly=True
|
|
)
|
|
|
|
record_id = fields.Integer(
|
|
related='conflict_id.record_id',
|
|
string='ID Enregistrement',
|
|
readonly=True
|
|
)
|
|
|
|
record_name = fields.Char(
|
|
related='conflict_id.name',
|
|
string='Nom',
|
|
readonly=True
|
|
)
|
|
|
|
diff_html = fields.Html(
|
|
related='conflict_id.diff_html',
|
|
string='Différences',
|
|
readonly=True,
|
|
sanitize=False
|
|
)
|
|
|
|
resolution_fields = fields.One2many(
|
|
comodel_name='odoo.sync.conflict.wizard.field',
|
|
inverse_name='wizard_id',
|
|
string='Champs à résoudre'
|
|
)
|
|
|
|
# Custom resolution data
|
|
custom_data = fields.Text(
|
|
string='Données personnalisées'
|
|
)
|
|
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
"""Initialize the wizard with conflict data."""
|
|
res = super(OdooSyncConflictWizard, self).default_get(fields_list)
|
|
|
|
if 'conflict_id' in res:
|
|
conflict = self.env['odoo.sync.conflict'].browse(res['conflict_id'])
|
|
|
|
try:
|
|
# Parse JSON data
|
|
local_data = json.loads(conflict.local_data)
|
|
remote_data = json.loads(conflict.remote_data)
|
|
|
|
# Find differing fields
|
|
differing_fields = []
|
|
|
|
# Compare all fields in both datasets
|
|
all_fields = set(list(local_data.keys()) + list(remote_data.keys()))
|
|
|
|
# Extract write dates if available
|
|
local_write_date = None
|
|
remote_write_date = None
|
|
|
|
if '__write_date' in dlocal_data:
|
|
try:
|
|
local_write_date = fields.Datetime.from_string(local_data['__write_date'])
|
|
except Exception:
|
|
_logger.warning('Invalid local write date format')
|
|
|
|
if '__write_date' in remote_data:
|
|
try:
|
|
remote_write_date = fields.Datetime.from_string(remote_data['__write_date'])
|
|
except Exception:
|
|
_logger.warning('Invalid remote write date format')
|
|
|
|
# Extract field-specific write dates if available
|
|
field_write_dates = {}
|
|
if '__field_write_dates' in local_data and isinstance(local_data['__field_write_dates'], dict):
|
|
field_write_dates['local'] = local_data['__field_write_dates']
|
|
else:
|
|
field_write_dates['local'] = {}
|
|
|
|
if '__field_write_dates' in remote_data and isinstance(remote_data['__field_write_dates'], dict):
|
|
field_write_dates['remote'] = remote_data['__field_write_dates']
|
|
else:
|
|
field_write_dates['remote'] = {}
|
|
|
|
for field_name in all_fields:
|
|
# Skip system fields
|
|
if field_name.startswith('__') or field_name in ('id', 'write_date', 'create_date'):
|
|
continue
|
|
|
|
local_value = local_data.get(field_name)
|
|
remote_value = remote_data.get(field_name)
|
|
|
|
# Get field-specific write dates if available
|
|
field_local_write_date = None
|
|
field_remote_write_date = None
|
|
|
|
if field_name in field_write_dates['local']:
|
|
try:
|
|
field_local_write_date = fields.Datetime.from_string(
|
|
field_write_dates['local'][field_name]
|
|
)
|
|
except Exception:
|
|
field_local_write_date = local_write_date
|
|
else:
|
|
field_local_write_date = local_write_date
|
|
|
|
if field_name in field_write_dates['remote']:
|
|
try:
|
|
field_remote_write_date = fields.Datetime.from_string(
|
|
field_write_dates['remote'][field_name]
|
|
)
|
|
except Exception:
|
|
field_remote_write_date = remote_write_date
|
|
else:
|
|
field_remote_write_date = remote_write_date
|
|
|
|
# Check if values differ
|
|
if local_value != remote_value:
|
|
# Determine default source based on timestamps if available
|
|
source = 'local' # Default to local
|
|
|
|
if field_local_write_date and field_remote_write_date:
|
|
if field_remote_write_date > field_local_write_date:
|
|
source = 'remote'
|
|
|
|
differing_fields.append({
|
|
'field_name': field_name,
|
|
'local_value': str(local_value) if local_value is not None else '',
|
|
'remote_value': str(remote_value) if remote_value is not None else '',
|
|
'source': source,
|
|
'local_write_date': field_local_write_date,
|
|
'remote_write_date': field_remote_write_date
|
|
})
|
|
|
|
res['resolution_fields'] = [(0, 0, vals) for vals in differing_fields]
|
|
|
|
except Exception as e:
|
|
_logger.error('Error initializing conflict resolution: %s', str(e))
|
|
|
|
return res
|
|
|
|
def action_select_all_local(self):
|
|
"""Set all fields to use local values."""
|
|
self.ensure_one()
|
|
self.resolution_fields.write({'source': 'local'})
|
|
return {'type': 'ir.actions.do_nothing'}
|
|
|
|
def action_select_all_remote(self):
|
|
"""Set all fields to use remote values."""
|
|
self.ensure_one()
|
|
self.resolution_fields.write({'source': 'remote'})
|
|
return {'type': 'ir.actions.do_nothing'}
|
|
|
|
def action_select_newest(self):
|
|
"""Set each field to use the newest value based on write_date."""
|
|
self.ensure_one()
|
|
|
|
for field in self.resolution_fields:
|
|
if field.local_write_date and field.remote_write_date:
|
|
if field.local_write_date >= field.remote_write_date:
|
|
field.source = 'local'
|
|
else:
|
|
field.source = 'remote'
|
|
elif field.local_write_date:
|
|
field.source = 'local'
|
|
elif field.remote_write_date:
|
|
field.source = 'remote'
|
|
# If neither has a timestamp, keep the current selection
|
|
|
|
return {'type': 'ir.actions.do_nothing'}
|
|
|
|
def action_reset_selections(self):
|
|
"""Reset all field selections to default."""
|
|
self.ensure_one()
|
|
self.resolution_fields.write({'source': 'local', 'custom_value': False})
|
|
return {'type': 'ir.actions.do_nothing'}
|
|
|
|
def action_apply_resolution(self):
|
|
"""Apply the custom resolution."""
|
|
self.ensure_one()
|
|
|
|
try:
|
|
# Get original data
|
|
local_data = json.loads(self.conflict_id.local_data)
|
|
remote_data = json.loads(self.conflict_id.remote_data)
|
|
|
|
# Create merged data based on field selections
|
|
merged_data = {}
|
|
|
|
# Start with all fields from local data
|
|
merged_data.update(local_data)
|
|
|
|
# Override with selected remote fields or custom values
|
|
for field in self.resolution_fields:
|
|
if field.source == 'remote' and field.field_name in remote_data:
|
|
merged_data[field.field_name] = remote_data[field.field_name]
|
|
elif field.source == 'custom':
|
|
merged_data[field.field_name] = field.custom_value
|
|
elif field.source == 'ignore':
|
|
# Remove field from merged data if it should be ignored
|
|
if field.field_name in merged_data:
|
|
del merged_data[field.field_name]
|
|
|
|
# Update the conflict with the resolution
|
|
self.conflict_id.write({
|
|
'resolution': 'custom',
|
|
'custom_data': json.dumps(merged_data),
|
|
'state': 'resolved',
|
|
'resolved_by': self.env.user.id,
|
|
'resolved_date': fields.Datetime.now()
|
|
})
|
|
|
|
# Apply the resolution
|
|
return self.conflict_id._apply_resolution()
|
|
|
|
except Exception as e:
|
|
_logger.error('Error applying custom resolution: %s', str(e))
|
|
raise UserError(f'Erreur lors de l\'application de la résolution personnalisée: {str(e)}')
|
|
|