- 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
517 lines
21 KiB
Python
517 lines
21 KiB
Python
# Copyright 2025 Bemade
|
||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||
|
||
"""Journal de synchronisation pour Odoo to Bemade Customer.
|
||
|
||
Ce module enregistre toutes les opérations de synchronisation effectuées
|
||
entre Odoo client et Bemade pour des fins d'audit et de dépannage.
|
||
"""
|
||
|
||
# -*- coding: utf-8 -*-
|
||
|
||
import json
|
||
import logging
|
||
import odoo
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import ValidationError
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OdooToBemadeCustomerSyncLog(models.Model):
|
||
"""Journal des synchronisations.
|
||
|
||
Enregistre toutes les opérations de synchronisation pour traçabilité.
|
||
"""
|
||
|
||
_name = 'odoo.to.bemade.customer.sync.log'
|
||
_description = 'Journal de synchronisation Bemade'
|
||
_inherit = 'odoo.sync.log'
|
||
_order = 'create_date desc'
|
||
|
||
name = fields.Char(
|
||
string='Opération',
|
||
required=True,
|
||
)
|
||
instance_id = fields.Many2one(
|
||
comodel_name='odoo.to.bemade.customer.instance',
|
||
string='Instance',
|
||
ondelete='cascade',
|
||
)
|
||
model_id = fields.Many2one(
|
||
comodel_name='odoo.to.bemade.customer.sync.model',
|
||
string='Modèle',
|
||
ondelete='set null',
|
||
)
|
||
record_id = fields.Integer(
|
||
string='ID Enregistrement',
|
||
)
|
||
operation = fields.Selection(
|
||
selection=[
|
||
('sync', 'Synchronisation'),
|
||
('delete', 'Suppression'),
|
||
('conflict', 'Conflit'),
|
||
('error', 'Erreur'),
|
||
],
|
||
string='Type d\'opération',
|
||
required=True,
|
||
)
|
||
direction = fields.Selection(
|
||
selection=[
|
||
('to_bemade', 'Odoo → Bemade'),
|
||
('from_bemade', 'Bemade → Odoo'),
|
||
],
|
||
string='Direction',
|
||
)
|
||
result = fields.Selection(
|
||
selection=[
|
||
('success', 'Succès'),
|
||
('warning', 'Avertissement'),
|
||
('error', 'Erreur'),
|
||
],
|
||
string='Résultat',
|
||
required=True,
|
||
)
|
||
execution_time = fields.Float(
|
||
string='Temps d\'exécution (s)',
|
||
digits=(10, 3),
|
||
)
|
||
details = fields.Text(
|
||
string='Détails',
|
||
help='Détails de l\'opération de synchronisation',
|
||
)
|
||
remote_id = fields.Char(
|
||
string='ID Bemade',
|
||
)
|
||
queue_id = fields.Many2one(
|
||
comodel_name='odoo.to.bemade.customer.sync.queue',
|
||
string='Entrée file d\'attente',
|
||
ondelete='restrict',
|
||
)
|
||
user_id = fields.Many2one(
|
||
comodel_name='res.users',
|
||
string='Utilisateur',
|
||
default=lambda self: self.env.user.id,
|
||
readonly=True,
|
||
)
|
||
error_message = fields.Text(
|
||
string='Message d\'erreur',
|
||
)
|
||
metadata = fields.Text(
|
||
string='Métadonnées',
|
||
help='Métadonnées d\'audit (utilisateur, IP, timestamp)',
|
||
)
|
||
|
||
@api.constrains('model_id', 'record_id')
|
||
def _check_record_consistency(self):
|
||
"""Vérifie la cohérence entre le modèle et l'ID d'enregistrement."""
|
||
for log in self:
|
||
# Vérifier que si un ID d'enregistrement est spécifié, un modèle est aussi spécifié
|
||
if log.record_id and not log.model_id:
|
||
raise ValidationError(_("Un ID d'enregistrement a été spécifié sans modèle associé."))
|
||
|
||
def _collect_audit_metadata(self):
|
||
"""Collecte les métadonnées d'audit pour la traçabilité.
|
||
|
||
Returns:
|
||
dict: Métadonnées d'audit (utilisateur, adresse IP, timestamp, etc.)
|
||
"""
|
||
metadata = {
|
||
'timestamp': fields.Datetime.now(),
|
||
'user_id': self.env.user.id if self.env.user else None,
|
||
'user_name': self.env.user.name if self.env.user else 'System',
|
||
'user_login': self.env.user.login if self.env.user else None,
|
||
}
|
||
|
||
# Ajout de l'adresse IP si disponible
|
||
try:
|
||
if hasattr(odoo.http, 'request') and odoo.http.request:
|
||
metadata['ip_address'] = odoo.http.request.httprequest.remote_addr
|
||
except Exception:
|
||
pass # Ignorer si l'accès à la requête n'est pas disponible
|
||
|
||
return metadata
|
||
|
||
def track_changes(self, old_values=None):
|
||
"""Enregistre les changements effectués sur cette entrée de journal.
|
||
|
||
Cette méthode permet de garder une trace des modifications apportées à une entrée
|
||
de journal, ce qui est utile pour l'audit et la traçabilité.
|
||
|
||
Args:
|
||
old_values (dict): Valeurs précédentes des champs modifiés
|
||
"""
|
||
if not old_values:
|
||
return
|
||
|
||
# Champs à suivre pour l'audit
|
||
tracked_fields = [
|
||
'result', 'operation', 'details', 'error_message',
|
||
'remote_id', 'record_id', 'model_id', 'instance_id'
|
||
]
|
||
|
||
changes = []
|
||
for field in tracked_fields:
|
||
if field in old_values and hasattr(self, field) and getattr(self, field) != old_values[field]:
|
||
# Pour les champs Many2one, on stocke le nom plutôt que l'ID
|
||
if field.endswith('_id') and hasattr(getattr(self, field), 'name'):
|
||
old_value = old_values[field].name if old_values[field] else False
|
||
new_value = getattr(self, field).name if getattr(self, field) else False
|
||
else:
|
||
old_value = old_values[field]
|
||
new_value = getattr(self, field)
|
||
|
||
changes.append({
|
||
'field': field,
|
||
'old': old_value,
|
||
'new': new_value
|
||
})
|
||
|
||
if changes:
|
||
# Collecte des métadonnées d'audit
|
||
metadata = self._collect_audit_metadata()
|
||
|
||
# Format des détails pour inclure les changements
|
||
current_details = self.details or ''
|
||
change_log = '\n--- Modifications %s ---\n' % fields.Datetime.now()
|
||
|
||
for change in changes:
|
||
field_label = self._fields[change['field']].string if change['field'] in self._fields else change['field']
|
||
change_log += '%s: %s -> %s\n' % (field_label, change['old'], change['new'])
|
||
|
||
self.write({
|
||
'details': current_details + '\n' + change_log if current_details else change_log,
|
||
'metadata': json.dumps(metadata, default=str)
|
||
})
|
||
|
||
# Journaliser les modifications importantes
|
||
if any(c['field'] in ['result', 'operation'] for c in changes):
|
||
change_str = ', '.join(['%s: %s -> %s' % (c['field'], c['old'], c['new']) for c in changes])
|
||
_logger.info('Sync Log #%s modifié: %s', self.id, change_str)
|
||
|
||
@api.constrains('result', 'error_message')
|
||
def _check_result_consistency(self):
|
||
"""Vérifie la cohérence entre le résultat et le message d'erreur.
|
||
|
||
Si le résultat est 'error', un message d'erreur doit être fourni.
|
||
Si le résultat est 'success', aucun message d'erreur ne devrait être présent.
|
||
"""
|
||
for log in self:
|
||
if log.result == 'error' and not log.error_message:
|
||
raise ValidationError(_("Un message d'erreur est requis lorsque le résultat est 'error'."))
|
||
if log.result == 'success' and log.error_message:
|
||
raise ValidationError(_("Aucun message d'erreur ne devrait être présent lorsque le résultat est 'success'."))
|
||
|
||
def _validate_data_consistency(self):
|
||
"""Vérifie la cohérence des données du journal de synchronisation et retourne le résultat.
|
||
|
||
Cette méthode s'assure que:
|
||
1. Si un ID d'enregistrement est spécifié, un modèle est également spécifié
|
||
2. Le modèle spécifié existe dans l'environnement
|
||
3. L'enregistrement existe (sauf pour les opérations de suppression)
|
||
|
||
Returns:
|
||
dict: Résultat de la validation avec les clés:
|
||
- valid (bool): True si la validation est réussie
|
||
- errors (list): Liste des erreurs rencontrées
|
||
- warnings (list): Liste des avertissements
|
||
- record_exists (bool): True si l'enregistrement existe
|
||
- model_name (str): Nom du modèle si valide
|
||
"""
|
||
result = {
|
||
'valid': False,
|
||
'errors': [],
|
||
'warnings': [],
|
||
'record_exists': False,
|
||
'model_name': False
|
||
}
|
||
|
||
# Vérifier que si un ID d'enregistrement est spécifié, un modèle est aussi spécifié
|
||
if self.record_id and not self.model_id:
|
||
result['errors'].append(_("Un ID d'enregistrement a été spécifié sans modèle associé."))
|
||
return result
|
||
|
||
# Vérifier que le modèle existe dans l'environnement
|
||
if self.model_id and self.model_id.model:
|
||
model_name = self.model_id.model
|
||
result['model_name'] = model_name
|
||
|
||
if model_name not in self.env:
|
||
result['errors'].append(_("Le modèle %s n'existe pas dans l'environnement.") % model_name)
|
||
return result
|
||
|
||
# Vérifier l'existence de l'enregistrement sauf pour les suppressions
|
||
if self.record_id:
|
||
if self.operation == 'delete':
|
||
# Pour les suppressions, on ne vérifie pas l'existence de l'enregistrement
|
||
result['warnings'].append(_("L'enregistrement a été supprimé et n'est plus accessible."))
|
||
else:
|
||
record = self.env[model_name].sudo().browse(self.record_id)
|
||
if record.exists():
|
||
result['record_exists'] = True
|
||
else:
|
||
result['warnings'].append(
|
||
_("L'enregistrement %s #%s n'existe pas ou plus.") % (model_name, self.record_id)
|
||
)
|
||
_logger.warning(
|
||
"Log #%s: L'enregistrement %s #%s n'existe pas ou plus.",
|
||
self.id, model_name, self.record_id
|
||
)
|
||
|
||
# Vérifier que le résultat est une valeur valide selon la définition du champ
|
||
valid_results = ['success', 'warning', 'error']
|
||
if self.result and self.result not in valid_results:
|
||
result['errors'].append(
|
||
_("Résultat invalide: %s. Les valeurs autorisées sont: %s") %
|
||
(self.result, ', '.join(valid_results))
|
||
)
|
||
|
||
if not result['errors']:
|
||
result['valid'] = True
|
||
|
||
return result
|
||
|
||
@api.constrains('record_id', 'model_id')
|
||
def validate_data_consistency(self):
|
||
"""Vérifie la cohérence des données du journal de synchronisation.
|
||
|
||
Cette méthode s'assure que:
|
||
1. Si un ID d'enregistrement est spécifié, un modèle est également spécifié
|
||
2. Le modèle spécifié existe dans l'environnement
|
||
3. L'enregistrement existe (sauf pour les opérations de suppression)
|
||
"""
|
||
for log in self:
|
||
result = log._validate_data_consistency()
|
||
if not result['valid']:
|
||
raise ValidationError('\n'.join(result['errors']))
|
||
|
||
@api.constrains('queue_id', 'model_id', 'record_id')
|
||
def _check_queue_consistency(self):
|
||
"""Vérifie la cohérence avec l'entrée de file d'attente liée."""
|
||
for log in self:
|
||
# Si aucune file d'attente n'est spécifiée, rien à valider
|
||
if not log.queue_id:
|
||
continue
|
||
|
||
# Vérifier que la file d'attente existe toujours
|
||
if not log.queue_id.exists():
|
||
_logger.warning(
|
||
"Log #%s: L'entrée de file d'attente #%s n'existe plus.",
|
||
log.id, log.queue_id.id
|
||
)
|
||
continue
|
||
|
||
# Vérifier la cohérence entre la file d'attente et le modèle
|
||
if log.model_id and log.queue_id.model_id and log.queue_id.model_id != log.model_id:
|
||
message = _("Incohérence entre le modèle de la file d'attente et celui du log.")
|
||
# On génère un avertissement plutôt qu'une erreur pour ne pas bloquer le processus
|
||
_logger.warning(
|
||
"Log #%s: %s Queue: %s, Log: %s",
|
||
log.id, message,
|
||
log.queue_id.model_id.name if log.queue_id.model_id else 'Non défini',
|
||
log.model_id.name if log.model_id else 'Non défini'
|
||
)
|
||
|
||
# Vérifier la cohérence entre la file d'attente et l'ID d'enregistrement
|
||
if log.record_id and log.queue_id.record_id and log.queue_id.record_id != log.record_id:
|
||
message = _("Incohérence entre l'ID d'enregistrement de la file d'attente et celui du log.")
|
||
# On génère un avertissement plutôt qu'une erreur pour ne pas bloquer le processus
|
||
_logger.warning(
|
||
"Log #%s: %s Queue: %s, Log: %s",
|
||
log.id, message, log.queue_id.record_id, log.record_id
|
||
)
|
||
|
||
# Vérifier la cohérence de l'opération si les deux sont spécifiées
|
||
if log.operation and log.queue_id.operation and log.operation not in ['error', 'conflict'] and log.operation != log.queue_id.operation:
|
||
_logger.warning(
|
||
"Log #%s: L'opération du journal (%s) ne correspond pas à celle de la file d'attente (%s).",
|
||
log.id, log.operation, log.queue_id.operation
|
||
)
|
||
|
||
def action_view_record(self):
|
||
"""Affiche l'enregistrement associé à cette entrée de journal."""
|
||
self.ensure_one()
|
||
validation = self._validate_data_consistency()
|
||
|
||
if validation['errors']:
|
||
raise ValidationError('\n'.join(validation['errors']))
|
||
|
||
# Afficher les avertissements s'il y en a
|
||
if validation.get('warnings'):
|
||
warning_message = '\n'.join(validation.get('warnings', []))
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'display_notification',
|
||
'params': {
|
||
'title': _('Avertissement'),
|
||
'message': warning_message,
|
||
'sticky': True,
|
||
'type': 'warning',
|
||
'next': {
|
||
'type': 'ir.actions.act_window',
|
||
'name': _('Enregistrement synchronisé'),
|
||
'view_mode': 'form',
|
||
'res_model': self.model_id.model,
|
||
'res_id': self.record_id,
|
||
'target': 'current',
|
||
} if validation['record_exists'] else None
|
||
}
|
||
}
|
||
|
||
# Si l'enregistrement n'existe pas (cas de suppression validé), afficher un message
|
||
if not validation['record_exists']:
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'display_notification',
|
||
'params': {
|
||
'title': _('Information'),
|
||
'message': _('L’enregistrement a été supprimé et n’est plus accessible.'),
|
||
'sticky': False,
|
||
'type': 'info',
|
||
}
|
||
}
|
||
|
||
# Retourner l'action pour afficher l'enregistrement
|
||
model_name = self.model_id.model
|
||
return {
|
||
'name': _('Enregistrement synchronisé'),
|
||
'type': 'ir.actions.act_window',
|
||
'view_mode': 'form',
|
||
'res_model': model_name,
|
||
'res_id': self.record_id,
|
||
'target': 'current',
|
||
}
|
||
|
||
@api.model
|
||
def log(self, operation, model=None, record_id=None, result='success', details=None,
|
||
direction=None, remote_id=None, queue_id=None, execution_time=0, metadata=None):
|
||
"""Crée une entrée dans le journal des synchronisations avec audit trail amélioré.
|
||
|
||
Args:
|
||
operation (str): Type d'opération ('sync', 'delete', 'conflict', 'error')
|
||
model: Modèle concerné (str ou odoo.to.bemade.customer.sync.model)
|
||
record_id (int): ID de l'enregistrement concerné
|
||
result (str): Résultat de l'opération ('success', 'warning', 'error')
|
||
details (str): Détails supplémentaires
|
||
direction (str): Direction de la synchronisation ('to_bemade', 'from_bemade')
|
||
remote_id (str): ID distant de l'enregistrement
|
||
queue_id: Entrée de file d'attente associée (int ou odoo.to.bemade.customer.sync.queue)
|
||
execution_time (float): Temps d'exécution en secondes
|
||
metadata (dict): Métadonnées supplémentaires pour l'audit trail
|
||
"""
|
||
model_id = False
|
||
if model and isinstance(model, str):
|
||
model_rec = self.env['odoo.to.bemade.customer.sync.model'].search(
|
||
[('model', '=', model)], limit=1)
|
||
if model_rec:
|
||
model_id = model_rec.id
|
||
elif model and hasattr(model, 'id'):
|
||
model_id = model.id
|
||
|
||
name = f"{operation.capitalize()}"
|
||
if model_id:
|
||
model_name = self.env['odoo.to.bemade.customer.sync.model'].browse(model_id).name
|
||
name += f" - {model_name}"
|
||
if record_id:
|
||
name += f" #{record_id}"
|
||
|
||
# Collecter les métadonnées d'audit
|
||
audit_data = self._collect_audit_metadata(metadata)
|
||
|
||
# Enrichir les détails avec les métadonnées d'audit si spécifié
|
||
if audit_data:
|
||
audit_details = '\n'.join([f"{k}: {v}" for k, v in audit_data.items()])
|
||
if details:
|
||
details = f"{details}\n\n--- Métadonnées d'audit ---\n{audit_details}"
|
||
else:
|
||
details = f"--- Métadonnées d'audit ---\n{audit_details}"
|
||
|
||
vals = {
|
||
'name': name,
|
||
'model_id': model_id,
|
||
'record_id': record_id,
|
||
'operation': operation,
|
||
'result': result,
|
||
'details': details,
|
||
'direction': direction,
|
||
'remote_id': remote_id,
|
||
'queue_id': queue_id and queue_id if isinstance(queue_id, int) else queue_id and queue_id.id,
|
||
'execution_time': execution_time,
|
||
}
|
||
|
||
return self.create(vals)
|
||
|
||
@api.model
|
||
def _collect_audit_metadata(self, additional_metadata=None):
|
||
"""Collecte les métadonnées d'audit pour le journal de synchronisation.
|
||
|
||
Cette méthode recueille des informations importantes pour l'audit trail,
|
||
comme l'adresse IP de l'utilisateur, l'horodatage précis, l'agent utilisateur,
|
||
et d'autres données contextuelles.
|
||
|
||
Args:
|
||
additional_metadata (dict): Métadonnées supplémentaires fournies par l'appelant
|
||
|
||
Returns:
|
||
dict: Métadonnées d'audit enrichies
|
||
"""
|
||
metadata = {}
|
||
|
||
# Obtenir le contexte de la requête HTTP si disponible
|
||
try:
|
||
from odoo.http import request
|
||
if request and hasattr(request, 'httprequest'):
|
||
# Adresse IP de l'utilisateur
|
||
metadata['ip_address'] = request.httprequest.remote_addr
|
||
|
||
# Agent utilisateur
|
||
if hasattr(request.httprequest, 'user_agent') and request.httprequest.user_agent:
|
||
metadata['user_agent'] = str(request.httprequest.user_agent)
|
||
|
||
# Méthode HTTP
|
||
metadata['http_method'] = request.httprequest.method
|
||
|
||
# URL de la requête
|
||
metadata['request_url'] = request.httprequest.url
|
||
except (ImportError, RuntimeError):
|
||
# Pas de requête HTTP active ou module non disponible
|
||
pass
|
||
|
||
# Horodatage précis avec microseconds
|
||
from datetime import datetime
|
||
metadata['timestamp_precise'] = datetime.now().isoformat()
|
||
|
||
# Identifiant de session
|
||
if self.env.context.get('session_id'):
|
||
metadata['session_id'] = self.env.context.get('session_id')
|
||
|
||
# Informations sur l'utilisateur
|
||
if self.env.user:
|
||
metadata['user_id'] = self.env.user.id
|
||
metadata['user_login'] = self.env.user.login
|
||
if self.env.user.partner_id:
|
||
metadata['user_partner_name'] = self.env.user.partner_id.name
|
||
metadata['user_partner_id'] = self.env.user.partner_id.id
|
||
|
||
# Contexte technique Odoo
|
||
metadata['odoo_context'] = str(self.env.context)
|
||
metadata['odoo_lang'] = self.env.context.get('lang', 'fr_FR')
|
||
metadata['odoo_tz'] = self.env.context.get('tz', 'Europe/Paris')
|
||
|
||
# Informations sur la base de données
|
||
metadata['db_name'] = self.env.cr.dbname
|
||
|
||
# Informations sur l'instance et le modèle
|
||
if hasattr(self, 'instance_id') and self.instance_id:
|
||
metadata['instance_id'] = self.instance_id.id
|
||
metadata['instance_name'] = self.instance_id.name
|
||
|
||
if hasattr(self, 'model_id') and self.model_id:
|
||
metadata['model_id'] = self.model_id.id
|
||
metadata['model_name'] = self.model_id.name
|
||
|
||
# Ajouter les métadonnées supplémentaires si fournies
|
||
if additional_metadata and isinstance(additional_metadata, dict):
|
||
metadata.update(additional_metadata)
|
||
|
||
return metadata
|