- 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
489 lines
18 KiB
Python
489 lines
18 KiB
Python
# Copyright 2025 Bemade
|
|
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
|
|
|
"""Model Synchronization Configuration for Bemade clients.
|
|
|
|
This module defines how models are synchronized between client instances
|
|
and the Bemade platform. It is simplified to focus on the specific needs
|
|
of Bemade clients.
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import ast
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def safe_eval_domain(domain_str):
|
|
"""Safely evaluate a domain string to a domain list.
|
|
|
|
Args:
|
|
domain_str (str): Domain string to evaluate
|
|
|
|
Returns:
|
|
list: Domain list
|
|
|
|
Raises:
|
|
ValidationError: If domain is invalid or contains dangerous code
|
|
"""
|
|
if not domain_str:
|
|
return []
|
|
|
|
# Check for potentially dangerous code
|
|
dangerous_terms = ['import', 'exec', 'eval', 'os.', 'sys.', 'subprocess', 'open(', '__']
|
|
for term in dangerous_terms:
|
|
if term in domain_str:
|
|
raise ValidationError(_("Terme non autorisé dans le domaine: %s") % term)
|
|
|
|
try:
|
|
# Use ast.literal_eval for safer evaluation
|
|
domain = ast.literal_eval(domain_str)
|
|
return domain
|
|
except (SyntaxError, ValueError) as e:
|
|
raise ValidationError(_("Erreur de syntaxe dans le domaine: %s") % str(e))
|
|
|
|
class OdooToBemadeCustomerSyncModel(models.Model):
|
|
_name = 'odoo.to.bemade.customer.sync.model'
|
|
_description = 'Modèle synchronisé avec Bemade'
|
|
_inherit = 'odoo.sync.model'
|
|
_order = 'priority, id'
|
|
|
|
name = fields.Char(
|
|
string='Nom',
|
|
required=True,
|
|
help='Nom descriptif du modèle synchronisé',
|
|
)
|
|
|
|
model = fields.Char(
|
|
string='Modèle local',
|
|
required=True,
|
|
help='Nom technique du modèle local (ex: res.partner)',
|
|
)
|
|
|
|
bemade_model = fields.Char(
|
|
string='Modèle Bemade',
|
|
required=True,
|
|
help='Nom technique du modèle correspondant chez Bemade',
|
|
)
|
|
|
|
config_id = fields.Many2one(
|
|
comodel_name='odoo.to.bemade.customer.config',
|
|
string='Configuration',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
|
|
customer_instance_id = fields.Many2one(
|
|
comodel_name='odoo.to.bemade.customer.instance',
|
|
string='Instance',
|
|
ondelete='cascade',
|
|
)
|
|
|
|
active = fields.Boolean(
|
|
string='Actif',
|
|
default=True,
|
|
help='Indique si ce modèle est synchronisé activement',
|
|
)
|
|
|
|
priority = fields.Integer(
|
|
string='Priorité',
|
|
default=10,
|
|
help='Ordre de synchronisation (les valeurs plus élevées sont prioritaires)',
|
|
)
|
|
|
|
field_mapping = fields.Text(
|
|
string='Mapping des champs',
|
|
help='Mapping JSON des champs entre le modèle local et Bemade',
|
|
)
|
|
|
|
field_ids = fields.One2many(
|
|
comodel_name='odoo.to.bemade.customer.sync.model.field',
|
|
inverse_name='sync_model_id',
|
|
string='Champs synchronisés'
|
|
)
|
|
|
|
field_count = fields.Integer(
|
|
string='Nombre de champs',
|
|
compute='_compute_field_count',
|
|
)
|
|
|
|
@api.depends('field_ids')
|
|
def _compute_field_count(self):
|
|
"""Calcule le nombre de champs synchronisés pour ce modèle"""
|
|
for record in self:
|
|
record.field_count = len(record.field_ids) if record.field_ids else 0
|
|
|
|
queue_ids = fields.One2many(
|
|
comodel_name='odoo.to.bemade.customer.sync.queue',
|
|
inverse_name='model_id',
|
|
string='File d\'attente',
|
|
)
|
|
|
|
create_active = fields.Boolean(
|
|
string='Création active',
|
|
default=True,
|
|
help='Activer la synchronisation des créations',
|
|
)
|
|
|
|
write_active = fields.Boolean(
|
|
string='Modification active',
|
|
default=True,
|
|
help='Activer la synchronisation des modifications',
|
|
)
|
|
|
|
unlink_active = fields.Boolean(
|
|
string='Suppression active',
|
|
default=True,
|
|
help='Activer la synchronisation des suppressions',
|
|
)
|
|
|
|
sync_domain = fields.Char(
|
|
string='Domaine de synchronisation',
|
|
default='[]',
|
|
help='Domaine pour filtrer les enregistrements à synchroniser, au format liste Python',
|
|
)
|
|
|
|
last_error = fields.Text(
|
|
string='Dernière erreur',
|
|
readonly=True,
|
|
help='Description de la dernière erreur rencontrée lors de la synchronisation',
|
|
)
|
|
|
|
last_sync = fields.Datetime(
|
|
string='Dernière synchronisation',
|
|
readonly=True,
|
|
)
|
|
|
|
sync_status = fields.Selection(
|
|
selection=[
|
|
('pending', 'En attente'),
|
|
('synced', 'Synchronisé'),
|
|
('error', 'Erreur')
|
|
],
|
|
default='pending',
|
|
string='Statut de synchronisation',
|
|
)
|
|
|
|
error_count = fields.Integer(
|
|
string='Nombre d\'erreurs',
|
|
default=0,
|
|
)
|
|
|
|
record_count = fields.Integer(
|
|
string='Enregistrements synchronisés',
|
|
compute='_compute_record_count',
|
|
)
|
|
|
|
@api.depends('model', 'sync_domain')
|
|
def _compute_record_count(self):
|
|
"""Calcule le nombre d'enregistrements synchronisés pour ce modèle"""
|
|
for record in self:
|
|
try:
|
|
if not record.model:
|
|
record.record_count = 0
|
|
continue
|
|
|
|
model_obj = self.env.get(record.model)
|
|
if not model_obj:
|
|
record.record_count = 0
|
|
continue
|
|
|
|
domain = safe_eval_domain(record.sync_domain)
|
|
record.record_count = model_obj.search_count(domain)
|
|
except Exception as e:
|
|
_logger.error("Erreur lors du calcul du nombre d'enregistrements pour %s: %s",
|
|
record.model, str(e))
|
|
record.record_count = 0
|
|
|
|
@api.constrains('model')
|
|
def _check_model_exists(self):
|
|
"""Vérifie que le modèle existe dans l'instance Odoo."""
|
|
for record in self:
|
|
if record.model:
|
|
model_obj = self.env.get(record.model)
|
|
if not model_obj:
|
|
raise ValidationError(_("Le modèle '%s' n'existe pas dans cette instance Odoo") % record.model)
|
|
|
|
@api.constrains('sync_domain')
|
|
def _check_sync_domain(self):
|
|
"""Vérifie que le domaine de synchronisation est valide."""
|
|
for record in self:
|
|
if record.sync_domain:
|
|
try:
|
|
domain = safe_eval_domain(record.sync_domain)
|
|
|
|
# Validate domain structure
|
|
if not isinstance(domain, list):
|
|
raise ValidationError(_("Le domaine de synchronisation doit être une liste"))
|
|
|
|
# Check if model exists before validating domain
|
|
if record.model:
|
|
model_obj = self.env.get(record.model)
|
|
if model_obj:
|
|
# Try a search with the domain to validate it
|
|
try:
|
|
model_obj.search(domain, limit=1)
|
|
except Exception as e:
|
|
raise ValidationError(_("Domaine de synchronisation invalide pour le modèle '%s': %s") %
|
|
(record.model, str(e)))
|
|
except Exception as e:
|
|
raise ValidationError(_("Erreur dans le domaine de synchronisation: %s") % str(e))
|
|
|
|
@api.constrains('field_mapping')
|
|
def _check_field_mapping(self):
|
|
"""Vérifie que le mapping des champs est un JSON valide et que les champs existent."""
|
|
for record in self:
|
|
if record.field_mapping:
|
|
try:
|
|
# Validate JSON format
|
|
mapping = json.loads(record.field_mapping)
|
|
|
|
# Validate mapping structure
|
|
if not isinstance(mapping, dict):
|
|
raise ValidationError(_("Le mapping des champs doit être un dictionnaire JSON"))
|
|
|
|
# Check if model exists before validating fields
|
|
if record.model:
|
|
model_obj = self.env.get(record.model)
|
|
if model_obj:
|
|
# Validate field existence
|
|
for local_field in mapping.keys():
|
|
if local_field not in model_obj._fields:
|
|
raise ValidationError(_("Le champ '%s' n'existe pas dans le modèle '%s'") %
|
|
(local_field, record.model))
|
|
except json.JSONDecodeError:
|
|
raise ValidationError(_("Le mapping des champs n'est pas un JSON valide"))
|
|
except Exception as e:
|
|
raise ValidationError(_("Erreur dans le mapping des champs: %s") % str(e))
|
|
|
|
@api.constrains('bemade_model')
|
|
def _check_bemade_model(self):
|
|
"""Vérifie que le modèle Bemade est spécifié et a un format valide."""
|
|
for record in self:
|
|
if record.bemade_model:
|
|
# Check format (should be in the form 'module.model')
|
|
if '.' not in record.bemade_model:
|
|
raise ValidationError(_("Le modèle Bemade doit être au format 'module.model'"))
|
|
|
|
# Additional checks could be added here if we had a way to validate
|
|
# against the actual Bemade instance models
|
|
|
|
def create_all_fields(self):
|
|
"""Create field mappings for all fields in the model.
|
|
|
|
This method is called from the view button to automatically
|
|
generate field mappings for all fields in the model.
|
|
|
|
Enhanced with intelligent field mapping based on field types and names.
|
|
|
|
Returns:
|
|
dict: Action dictionary for refreshing the view
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Get the model
|
|
model = self.env[self.model]
|
|
model_fields = model._fields
|
|
|
|
# Create field mappings for each relevant field
|
|
field_mapping_model = self.env['odoo.to.bemade.customer.sync.model.field']
|
|
|
|
# Get target model fields if possible
|
|
target_fields = {}
|
|
try:
|
|
# Try to get information about the target model structure
|
|
if self.customer_instance_id and self.bemade_model:
|
|
# This would be replaced with actual API call in production
|
|
_logger.info(f"Attempting to get fields for {self.bemade_model} from Bemade")
|
|
# For now we'll simulate with empty dict
|
|
target_fields = {}
|
|
except Exception as e:
|
|
_logger.warning(f"Could not retrieve target model fields: {str(e)}")
|
|
|
|
# Common field name variations to check
|
|
name_variations = {
|
|
'_id': ['_id', '_uid', '_identifier'],
|
|
'name': ['name', 'label', 'title', 'display_name'],
|
|
'code': ['code', 'reference', 'ref'],
|
|
'date': ['date', 'datetime', 'day', 'timestamp'],
|
|
'partner': ['partner', 'customer', 'client'],
|
|
'product': ['product', 'item', 'article'],
|
|
'quantity': ['quantity', 'qty', 'amount', 'number'],
|
|
'price': ['price', 'cost', 'rate', 'value'],
|
|
'total': ['total', 'sum', 'amount_total'],
|
|
'state': ['state', 'status', 'stage'],
|
|
'active': ['active', 'is_active', 'enabled'],
|
|
}
|
|
|
|
for field_name, field in model_fields.items():
|
|
# Skip fields that shouldn't be synchronized
|
|
if field.type in ['one2many', 'many2many']:
|
|
continue
|
|
|
|
if field_name in ['id', 'create_uid', 'create_date', 'write_uid', 'write_date', '__last_update']:
|
|
continue
|
|
|
|
# Check if field mapping already exists
|
|
existing = field_mapping_model.search([
|
|
('sync_model_id', '=', self.id),
|
|
('source_field', '=', field_name)
|
|
], limit=1)
|
|
|
|
if not existing:
|
|
# Determine best target field and transform type
|
|
target_field = field_name
|
|
transform_type = 'direct'
|
|
|
|
# Check if we need special handling based on field type
|
|
if field.type == 'many2one':
|
|
# For many2one fields, we might want to map to a different field
|
|
# or use a different transform type
|
|
transform_type = 'relation_id'
|
|
|
|
# If field ends with _id, suggest the base name as target
|
|
if field_name.endswith('_id') and len(field_name) > 3:
|
|
target_field = field_name[:-3] # Remove _id suffix
|
|
|
|
# Check for common name variations
|
|
for base, variations in name_variations.items():
|
|
for part in field_name.split('_'):
|
|
if part in variations:
|
|
# Found a variation, suggest the base name
|
|
for var in variations:
|
|
if var != part and f"{field_name.replace(part, var)}" in target_fields:
|
|
target_field = field_name.replace(part, base)
|
|
break
|
|
|
|
# Create the field mapping with intelligent defaults
|
|
field_mapping_model.create({
|
|
'sync_model_id': self.id,
|
|
'source_field': field_name,
|
|
'target_field': target_field,
|
|
'active': True,
|
|
'transform_type': transform_type,
|
|
'notes': f"Auto-mapped from {field.type} field",
|
|
})
|
|
|
|
# Return action to refresh the view
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'reload',
|
|
}
|
|
|
|
def sync_all_records(self):
|
|
"""Synchronize all records of this model.
|
|
|
|
This method is called from the view button to trigger
|
|
synchronization for all records of this model.
|
|
|
|
Returns:
|
|
dict: Action dictionary for displaying a success message
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not self.active:
|
|
raise UserError(_("Ce modèle n'est pas actif pour la synchronisation."))
|
|
|
|
# Get all records of this model
|
|
model_obj = self.env[self.model]
|
|
domain = eval(self.sync_domain)
|
|
records = model_obj.search(domain)
|
|
|
|
# Queue synchronization for each record
|
|
queue_obj = self.env['odoo.to.bemade.customer.sync.queue']
|
|
count = 0
|
|
|
|
for record in records:
|
|
queue_obj.create({
|
|
'model_id': self.id,
|
|
'record_id': record.id,
|
|
'operation': 'sync',
|
|
'state': 'pending',
|
|
'priority': self.priority,
|
|
})
|
|
count += 1
|
|
|
|
# Return action to show success message
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _("Synchronisation démarrée"),
|
|
'message': _(f"{count} enregistrements ont été mis en file d'attente pour synchronisation."),
|
|
'type': 'success',
|
|
'sticky': False,
|
|
}
|
|
}
|
|
|
|
def action_sync_model(self):
|
|
"""Synchroniser ce modèle spécifique avec Bemade"""
|
|
self.ensure_one()
|
|
if not self.active:
|
|
raise UserError(_("Ce modèle n'est pas actif pour la synchronisation."))
|
|
|
|
# Obtenir la configuration
|
|
config = self.config_id
|
|
if not config or config.state != 'connected':
|
|
raise UserError(_("La connexion avec Bemade n'est pas établie."))
|
|
|
|
# Obtenir les enregistrements à synchroniser
|
|
model_obj = self.env[self.model]
|
|
domain = eval(self.sync_domain)
|
|
records = model_obj.search(domain)
|
|
|
|
# Queue des enregistrements pour synchronisation
|
|
queue_obj = self.env['odoo.to.bemade.customer.sync.queue']
|
|
count = 0
|
|
|
|
for record in records:
|
|
queue_obj.create({
|
|
'model_id': self.id,
|
|
'record_id': record.id,
|
|
'operation': 'sync',
|
|
'state': 'pending',
|
|
'priority': self.priority,
|
|
})
|
|
count += 1
|
|
|
|
# Mettre à jour le statut
|
|
self.write({
|
|
'last_sync': fields.Datetime.now(),
|
|
'sync_status': 'pending',
|
|
})
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Synchronisation programmée'),
|
|
'message': _(f"{count} enregistrements ont été mis en file d'attente pour la synchronisation."),
|
|
'sticky': False,
|
|
'type': 'success',
|
|
}
|
|
}
|
|
|
|
def action_view_records(self):
|
|
"""Afficher les enregistrements correspondant à ce modèle"""
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Enregistrements à synchroniser'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': self.model,
|
|
'view_mode': 'tree,form',
|
|
'domain': self.sync_domain,
|
|
}
|
|
|
|
def action_view_queue(self):
|
|
"""Afficher les entrées de file d'attente pour ce modèle"""
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('File d\'attente de synchronisation'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'odoo.to.bemade.customer.sync.queue',
|
|
'view_mode': 'tree,form',
|
|
'domain': [('model_id', '=', self.id)],
|
|
}
|