diff --git a/odoo_to_odoo_bemade/README.md b/odoo_to_odoo_bemade/README.md
new file mode 100644
index 0000000..ab4a90c
--- /dev/null
+++ b/odoo_to_odoo_bemade/README.md
@@ -0,0 +1,74 @@
+# Odoo to Odoo Bemade
+
+## Overview
+The `odoo_to_odoo_bemade` module extends the core synchronization framework to provide a universal connector for Bemade's Odoo instances. It adds advanced features specifically designed for Bemade's synchronization requirements, including enhanced logging, project integration, and specialized connection handling.
+
+## Features
+- **Advanced Connection Management**: Support for multiple connection types and authentication methods
+- **Project Integration**: Links synchronization configurations with Odoo projects
+- **Enhanced Logging System**:
+ - Multiple log levels (DEBUG, INFO, WARNING, ERROR)
+ - Sensitive data masking for security
+ - Long-term retention policy with AWS S3 Glacier archiving
+ - Performance-optimized database indexing
+- **Improved Field Mapping**: More sophisticated field mapping capabilities
+- **Administrator Interface**: Advanced tools for monitoring and troubleshooting
+
+## Technical Enhancements
+
+### Advanced Logging
+The module implements a comprehensive logging system with:
+- **Log Levels**: Categorize logs by severity (DEBUG, INFO, WARNING, ERROR)
+- **Data Security**: Automatic masking of sensitive information (passwords, API keys, tokens)
+- **Retention Policy**:
+ - 90 days of logs kept in the database
+ - Older logs archived to AWS S3 Glacier Deep Archive with 7-year retention
+- **Detailed Context**: Capture full payloads, diffs, and metadata for troubleshooting
+
+### Connection Types
+Supports multiple connection methods:
+- **XML-RPC**: Standard Odoo API connection
+- **OdooRPC**: Using the OdooRPC library for enhanced functionality
+- **REST API**: For connecting to REST-enabled Odoo instances
+- **Custom Protocols**: Extensible architecture for additional protocols
+
+### Project Integration
+- Link synchronization configurations to specific projects
+- Track synchronization activities within project context
+- Provide project-specific dashboards and reports
+
+## Configuration
+
+### Setting Up Bemade Instances
+1. Navigate to **Synchronization > Configuration > Bemade Instances**
+2. Create a new instance with:
+ - Connection details (URL, database, credentials)
+ - Connection type selection
+ - Advanced options for timeout, retry, etc.
+3. Test the connection using the "Test Connection" button
+
+### Configuring Synchronization Models
+1. Navigate to **Synchronization > Configuration > Bemade Models**
+2. Create or select models for synchronization
+3. Configure detailed field mappings
+4. Set synchronization rules and triggers
+
+### Monitoring and Management
+- View comprehensive logs in **Synchronization > Bemade Logs**
+- Monitor queue status in **Synchronization > Bemade Queue**
+- Link synchronization activities to projects
+
+## Technical Notes
+- Built on top of `odoo_to_odoo_sync` core framework
+- Implements enhanced security features for sensitive data
+- Provides optimized performance for large-scale synchronization
+- Includes AWS S3 integration for long-term log archiving
+
+## Requirements
+- Odoo 18.0
+- `odoo_to_odoo_sync` module
+- `project` module
+- Optional: boto3 Python library for AWS S3 integration
+
+## License
+LGPL-3.0
diff --git a/odoo_to_odoo_bemade/__init__.py b/odoo_to_odoo_bemade/__init__.py
index 0650744..f7209b1 100644
--- a/odoo_to_odoo_bemade/__init__.py
+++ b/odoo_to_odoo_bemade/__init__.py
@@ -1 +1,2 @@
from . import models
+from . import controllers
diff --git a/odoo_to_odoo_bemade/__manifest__.py b/odoo_to_odoo_bemade/__manifest__.py
index ae26594..28881cb 100644
--- a/odoo_to_odoo_bemade/__manifest__.py
+++ b/odoo_to_odoo_bemade/__manifest__.py
@@ -15,6 +15,7 @@
'website': 'https://bemade.org',
'depends': [
'base',
+ 'project',
'odoo_to_odoo_sync'
],
'data': [
@@ -23,6 +24,7 @@
'views/sync_model_views.xml',
'views/sync_queue_views.xml',
'views/sync_log_views.xml',
+ 'views/project_views.xml',
'views/menus.xml',
'data/ir_cron_data.xml',
],
diff --git a/odoo_to_odoo_bemade/controllers/__init__.py b/odoo_to_odoo_bemade/controllers/__init__.py
new file mode 100644
index 0000000..2e33bba
--- /dev/null
+++ b/odoo_to_odoo_bemade/controllers/__init__.py
@@ -0,0 +1 @@
+from . import client_validation
diff --git a/odoo_to_odoo_bemade/controllers/client_validation.py b/odoo_to_odoo_bemade/controllers/client_validation.py
new file mode 100644
index 0000000..aced39c
--- /dev/null
+++ b/odoo_to_odoo_bemade/controllers/client_validation.py
@@ -0,0 +1,170 @@
+# Copyright 2025 Bemade
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
+
+"""Client Validation Controller.
+
+This controller handles XML-RPC requests from client instances for
+validating API tokens and establishing connections.
+"""
+
+import logging
+
+from odoo import http
+from odoo.http import request
+from odoo.exceptions import AccessDenied
+
+_logger = logging.getLogger(__name__)
+
+
+class ClientValidationController(http.Controller):
+ """Controller for handling client validation requests."""
+
+ def _validate_client(self, client_key):
+ """Validate a client connection using the client key.
+
+ Args:
+ client_key (str): The client key to validate
+
+ Returns:
+ bool: True if the client is valid, False otherwise
+ """
+ # Find the project with this client key
+ project = request.env['project.project'].sudo().search([
+ ('client_key', '=', client_key)
+ ], limit=1)
+
+ if not project:
+ _logger.warning(f"Client validation failed: No project found with client key {client_key}")
+ return False
+
+ if not project.is_client_project:
+ _logger.warning(f"Client validation failed: Project {project.id} is not marked as client project")
+ return False
+
+ # Create or update the client instance
+ instance_vals = {
+ 'name': f"Client {client_key}",
+ 'url': request.httprequest.remote_addr,
+ 'connection_type': 'xmlrpc',
+ }
+
+ if project.client_instance_id:
+ project.client_instance_id.sudo().write(instance_vals)
+ else:
+ instance = request.env['odoo.to.bemade.instance'].sudo().create(instance_vals)
+ project.sudo().write({'client_instance_id': instance.id})
+
+ _logger.info(f"Client validation successful for project {project.id}")
+ return True
+
+ def _authenticate_client(self, client_key, api_token):
+ """Authenticate a client using client key and API token.
+
+ Args:
+ client_key (str): The client key
+ api_token (str): The API token
+
+ Returns:
+ dict: Authentication result with company info and models
+ """
+ # Find the project with this client key
+ project = request.env['project.project'].sudo().search([
+ ('client_key', '=', client_key),
+ ('client_api_token', '=', api_token)
+ ], limit=1)
+
+ if not project:
+ _logger.warning(f"Client authentication failed: Invalid credentials for client key {client_key}")
+ return {
+ 'success': False,
+ 'error': 'Invalid client key or API token'
+ }
+
+ if not project.is_client_project:
+ _logger.warning(f"Client authentication failed: Project {project.id} is not marked as client project")
+ return {
+ 'success': False,
+ 'error': 'Project is not configured as client project'
+ }
+
+ # Get company information
+ company = request.env.user.company_id
+
+ # Get available sync models for this project
+ models = []
+ for model in project.sync_model_ids:
+ models.append({
+ 'model': model.model_id.model,
+ 'target_model': model.target_model,
+ 'field_mapping': model.field_mapping,
+ 'domain': model.sync_domain,
+ })
+
+ result = {
+ 'success': True,
+ 'company_name': company.name,
+ 'models': models
+ }
+
+ _logger.info(f"Client authentication successful for project {project.id}")
+ return result
+ try:
+ # Find the project with the matching API token
+ project = request.env['project.project'].sudo().search([
+ ('client_api_token', '=', api_key),
+ ('is_client_project', '=', True)
+ ], limit=1)
+
+ if not project:
+ return {
+ 'success': False,
+ 'message': 'Clé API invalide'
+ }
+
+
+ return {
+ 'success': True,
+ 'company_name': project.company_id.name if project.company_id else project.name,
+ 'models': model_data
+ }
+
+ except Exception as e:
+ _logger.error("Erreur de validation client: %s", str(e))
+ return {
+ 'success': False,
+ 'message': 'Erreur de validation'
+ }
+
+ def _authenticate_client(self, client_key, api_key, instance_id):
+ """Authenticate client and return user ID.
+
+ Args:
+ client_key (str): The client key provided by the user
+ api_key (str): The API key provided by the user
+ instance_id (str): The unique instance ID of the client
+
+ Returns:
+ int or False: User ID if authentication successful, False otherwise
+ """
+ try:
+ # Find the project with the matching API token
+ project = request.env['project.project'].sudo().search([
+ ('client_api_token', '=', api_key),
+ ('is_client_project', '=', True)
+ ], limit=1)
+
+ if not project:
+ return False
+
+ # Check if the client key matches
+ # For now, we'll use the project ID as the client key
+ if str(project.id) != client_key:
+ return False
+
+ # Return a valid user ID for synchronization
+ # In a real implementation, this would be a specific sync user
+ return request.env.ref('base.user_admin').id
+
+ except Exception as e:
+ _logger.error("Erreur d'authentification client: %s", str(e))
+ return False
diff --git a/odoo_to_odoo_bemade/data/ir_cron_data.xml b/odoo_to_odoo_bemade/data/ir_cron_data.xml
index a457b06..c630f2d 100644
--- a/odoo_to_odoo_bemade/data/ir_cron_data.xml
+++ b/odoo_to_odoo_bemade/data/ir_cron_data.xml
@@ -9,8 +9,6 @@
model.process_queue(limit=100)
10
minutes
- -1
-
@@ -22,8 +20,6 @@
model.clean_old_logs(days=30)
1
days
- -1
-
@@ -35,8 +31,6 @@
model.check_all_connections()
30
minutes
- -1
-
@@ -46,10 +40,8 @@
code
model.sync_critical_models()
- 1
+ 24
hours
- -1
-
diff --git a/odoo_to_odoo_bemade/models/__init__.py b/odoo_to_odoo_bemade/models/__init__.py
index b506ff2..66c3c44 100644
--- a/odoo_to_odoo_bemade/models/__init__.py
+++ b/odoo_to_odoo_bemade/models/__init__.py
@@ -4,3 +4,4 @@ from . import sync_model_field
from . import sync_queue
from . import sync_log
from . import sync_manager
+from . import project
diff --git a/odoo_to_odoo_bemade/models/project.py b/odoo_to_odoo_bemade/models/project.py
new file mode 100644
index 0000000..c442537
--- /dev/null
+++ b/odoo_to_odoo_bemade/models/project.py
@@ -0,0 +1,98 @@
+# Copyright 2025 Bemade
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
+
+"""Project Extension for Client Synchronization.
+
+This module extends the project model to support client synchronization features,
+including marking projects as client projects and generating API tokens for
+secure client connections.
+"""
+
+import logging
+import secrets
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+class ProjectProject(models.Model):
+ _name = 'project.project'
+ _inherit = 'project.project'
+
+ is_client_project = fields.Boolean(
+ string='Client Project',
+ default=False,
+ help='Check this box to mark this project as a client project for synchronization'
+ )
+
+ client_key = fields.Char(
+ string='Client Key',
+ readonly=True,
+ help='Unique client key for this project. Generated when project is marked as client project.'
+ )
+
+ client_api_token = fields.Char(
+ string='Client API Token',
+ readonly=True,
+ help='API token for client synchronization. Generated when project is marked as client project.'
+ )
+
+ client_instance_id = fields.Many2one(
+ comodel_name='odoo.to.bemade.instance',
+ string='Client Instance',
+ readonly=True,
+ help='The client instance associated with this project'
+ )
+
+ sync_model_ids = fields.One2many(
+ comodel_name='odoo.to.bemade.sync.model',
+ inverse_name='project_id',
+ string='Synchronized Models',
+ help='Models configured for synchronization with this project'
+ )
+
+ @api.onchange('is_client_project')
+ def _onchange_is_client_project(self):
+ """Generate API token and client key when project is marked as client project."""
+ if self.is_client_project and not self.client_api_token:
+ self.client_api_token = self._generate_api_token()
+ # Generate a unique client key based on project ID and a random component
+ if self.id:
+ self.client_key = f"{self.id}-{self._generate_api_token()[:8]}"
+ else:
+ self.client_key = self._generate_api_token()[:16]
+ elif not self.is_client_project:
+ self.client_api_token = False
+ self.client_key = False
+ self.client_instance_id = False
+
+ def _generate_api_token(self):
+ """Generate a secure API token for client authentication.
+
+ Returns:
+ str: A secure random token
+ """
+ return secrets.token_urlsafe(32)
+
+ def action_generate_api_token(self):
+ """Manually generate or regenerate API token for client project.
+
+ This method can be called from the UI to generate a new API token
+ for an existing client project.
+ """
+ for record in self:
+ if not record.is_client_project:
+ raise UserError(_("Only client projects can have API tokens. Please mark this project as a client project first."))
+ record.client_api_token = record._generate_api_token()
+ return True
+
+ def action_revoke_api_token(self):
+ """Revoke the API token for a client project.
+
+ This method clears the API token and disconnects any associated client instance.
+ """
+ for record in self:
+ record.client_api_token = False
+ record.client_instance_id = False
+ return True
diff --git a/odoo_to_odoo_bemade/models/sync_instance.py b/odoo_to_odoo_bemade/models/sync_instance.py
index 32a8fa4..b1305a8 100644
--- a/odoo_to_odoo_bemade/models/sync_instance.py
+++ b/odoo_to_odoo_bemade/models/sync_instance.py
@@ -163,22 +163,8 @@ class OdooToBemadeInstance(models.Model):
# Remarque: model_ids et log_ids sont déjà définis ci-dessus, pas besoin de duplication
- @api.onchange
- def onchange(self, values, field_names, fields_spec):
- """Handle onchange events.
-
- This method implements the abstract method from BaseModel by delegating
- to the parent class implementation.
-
- Args:
- values: The values dict
- field_names: Names of the fields that triggered the onchange
- fields_spec: The onchange specification
-
- Returns:
- Dictionary with updated values
- """
- return super(OdooToBemadeInstance, self).onchange(values, field_names, fields_spec)
+ # NOTE: The custom onchange method was removed as it had an incorrect signature
+ # and was causing TypeError when creating new instances
@api.onchange('url')
def _onchange_url(self):
@@ -203,6 +189,7 @@ class OdooToBemadeInstance(models.Model):
Note: connection_type is inherited from the parent model, but linters won't detect this.
"""
self.ensure_one()
+
# Field inherited from parent model: connection_type
# pylint: disable=no-member
if self.connection_type == 'odoorpc':
@@ -210,7 +197,31 @@ class OdooToBemadeInstance(models.Model):
# Call the parent method - linters might not recognize this as valid
# pylint: disable=no-member
- return super(OdooToBemadeInstance, self).test_connection()
+ result = super(OdooToBemadeInstance, self).test_connection()
+
+ # Create a log entry with the result
+ if result:
+ self.env['odoo.to.bemade.sync.log'].log(
+ operation='test_connection',
+ model='odoo.to.bemade.instance',
+ record_id=self.id,
+ result='success',
+ details=f"Successfully connected to {self.name} using {self.connection_type}",
+ instance_id=self.id
+ # queue_id will be automatically created by the log method
+ )
+ else:
+ self.env['odoo.to.bemade.sync.log'].log(
+ operation='test_connection',
+ model='odoo.to.bemade.instance',
+ record_id=self.id,
+ result='error',
+ details=f"Connection test failed: {self.error_message}",
+ instance_id=self.id
+ # queue_id will be automatically created by the log method
+ )
+
+ return result
def _test_odoorpc_connection(self):
"""Test connection using OdooRPC library.
@@ -261,6 +272,8 @@ class OdooToBemadeInstance(models.Model):
'last_connection': fields.Datetime.now(),
'error_message': False
})
+ # Success is logged in the main test_connection method
+ # No need to log here to avoid duplicate entries
return True
else:
# This should rarely happen as login usually raises an exception
@@ -268,28 +281,45 @@ class OdooToBemadeInstance(models.Model):
except UserError as e:
# Pass through our UserError without modification
+ error_msg = str(e)
self.write({
'state': 'error',
- 'error_message': str(e)
+ 'error_message': error_msg
})
- _logger.error("Erreur d'authentification OdooRPC: %s", str(e))
+ # Error is logged in the main test_connection method
+ # No need to log here to avoid duplicate entries
+ _logger.error("Erreur d'authentification OdooRPC: %s", error_msg)
return False
except (ConnectionError, TimeoutError, ValueError, TypeError) as e:
# Catch specific exceptions that can be raised during connection
+ error_msg = str(e)
self.write({
'state': 'error',
- 'error_message': str(e)
+ 'error_message': error_msg
})
- _logger.error("Erreur de connexion OdooRPC: %s", str(e))
+ # Error is logged in the main test_connection method
+ # No need to log here to avoid duplicate entries
+ _logger.error("Erreur de connexion OdooRPC: %s", error_msg)
return False
except Exception as e: # pylint: disable=broad-except
# Fall back for any unexpected exceptions
+ error_msg = str(e)
self.write({
'state': 'error',
- 'error_message': f"Erreur inattendue: {str(e)}"
+ 'error_message': f"Erreur inattendue: {error_msg}"
})
- _logger.error("Erreur inattendue OdooRPC: %s", str(e))
+ # Create error log entry
+ self.env['odoo.to.bemade.sync.log'].log(
+ operation='test_odoorpc_connection',
+ model='odoo.to.bemade.instance',
+ record_id=self.id,
+ result='error',
+ details=f"Unexpected error: {error_msg}",
+ instance_id=self.id
+ # queue_id will be automatically created by the log method
+ )
+ _logger.error("Erreur inattendue OdooRPC: %s", error_msg)
return False
def get_connection(self):
diff --git a/odoo_to_odoo_bemade/models/sync_log.py b/odoo_to_odoo_bemade/models/sync_log.py
index b66d642..590f180 100644
--- a/odoo_to_odoo_bemade/models/sync_log.py
+++ b/odoo_to_odoo_bemade/models/sync_log.py
@@ -9,9 +9,20 @@ functionality for logging synchronization operations.
import logging
import json
-from datetime import datetime
+import base64
+from datetime import datetime, timedelta
+from odoo import models, fields, api, tools
+from odoo.exceptions import ValidationError
+from odoo.tools import config
+import pytz
-from odoo import api, fields, models
+# Check if boto3 is available for S3 archiving
+try:
+ import boto3
+ from botocore.exceptions import ClientError
+ HAS_BOTO3 = True
+except ImportError:
+ HAS_BOTO3 = False
_logger = logging.getLogger(__name__)
@@ -19,13 +30,19 @@ class OdooToBemadeSyncLog(models.Model):
"""Synchronization log for Bemade operations.
Extends the base synchronization log to handle Bemade-specific
- logging requirements.
+ logging requirements with advanced features:
+
+ - Log levels (DEBUG, INFO, WARNING, ERROR)
+ - Retention policy (90 days local, 7 years in S3 Glacier)
+ - Sensitive data masking
"""
_name = 'odoo.to.bemade.sync.log'
_description = 'Bemade Sync Log Entry'
_inherit = 'odoo.sync.log'
- _order = 'create_date desc'
+ _order = 'create_date desc, log_level'
+ # Make logs read-only by default
+ _auto_write = False
# Add Bemade-specific fields here
bemade_instance_id = fields.Many2one(
@@ -34,8 +51,72 @@ class OdooToBemadeSyncLog(models.Model):
help='The Bemade instance related to this log entry',
)
+ # Fields used by the log method
+ operation = fields.Char(
+ string='Operation',
+ help='Type of operation performed (create, update, delete, etc.)',
+ )
+ model = fields.Char(
+ string='Model',
+ help='Technical name of the model that was synchronized',
+ )
+ record_id = fields.Integer(
+ string='Record ID',
+ help='ID of the record that was synchronized',
+ )
+ result = fields.Selection(
+ selection=[
+ ('success', 'Success'),
+ ('error', 'Error'),
+ ],
+ string='Result',
+ help='Result of the operation',
+ )
+
+ # Advanced logging fields
+ log_level = fields.Selection(
+ selection=[
+ ('debug', 'DEBUG'),
+ ('info', 'INFO'),
+ ('warning', 'WARNING'),
+ ('error', 'ERROR'),
+ ],
+ string='Log Level',
+ default='info',
+ help='Severity level of the log entry',
+ index=True,
+ )
+ payload = fields.Text(
+ string='Full Payload',
+ help='Complete data payload for debug purposes',
+ )
+ diff = fields.Text(
+ string='Changes',
+ help='Differences between before and after states',
+ )
+ metadata = fields.Text(
+ string='Metadata',
+ help='Additional contextual information',
+ )
+ archived = fields.Boolean(
+ string='Archived',
+ default=False,
+ help='Whether this log has been archived to long-term storage',
+ index=True,
+ )
+ archive_reference = fields.Char(
+ string='Archive Reference',
+ help='Reference to the archived log in long-term storage',
+ )
+ sanitized = fields.Boolean(
+ string='Sanitized',
+ default=False,
+ help='Whether sensitive data has been masked in this log',
+ )
+
@api.model
- def log(self, operation, model=None, record_id=None, result='success', details=None, instance_id=None):
+ def log(self, operation, model=None, record_id=None, result='success', details=None,
+ instance_id=None, queue_id=None, log_level='info', payload=None, diff=None, metadata=None):
"""Create a log entry for a synchronization operation.
Args:
@@ -45,6 +126,7 @@ class OdooToBemadeSyncLog(models.Model):
result: Result of the operation (success, error)
details: Additional details or error message
instance_id: ID of the Bemade instance involved
+ queue_id: ID of the queue entry (will create a special queue entry if not provided)
Returns:
The created log entry record
@@ -52,11 +134,293 @@ class OdooToBemadeSyncLog(models.Model):
if details and not isinstance(details, str):
details = json.dumps(details)
- return self.create({
+ # Handle payload, diff and metadata serialization
+ if payload and not isinstance(payload, str):
+ payload = json.dumps(payload)
+ if diff and not isinstance(diff, str):
+ diff = json.dumps(diff)
+ if metadata and not isinstance(metadata, str):
+ metadata = json.dumps(metadata)
+
+ # Sanitize sensitive data
+ sanitized = False
+ if payload:
+ payload, sanitized_payload = self._sanitize_data(payload)
+ sanitized = sanitized_payload
+ if details:
+ details, sanitized_details = self._sanitize_data(details)
+ sanitized = sanitized or sanitized_details
+ if metadata:
+ metadata, sanitized_metadata = self._sanitize_data(metadata)
+ sanitized = sanitized or sanitized_metadata
+
+ # If no queue_id is provided, create a special queue entry for this log
+ if not queue_id:
+ # For connection tests, we need to create a special queue entry
+ # Find or create a sync model for the instance model
+ # Note: odoo.sync.model uses 'name' field (related to model_id.model) to store the model name
+ sync_model = self.env['odoo.sync.model'].search(
+ [('name', '=', model)], limit=1)
+
+ if not sync_model and model:
+ # Create a sync model if it doesn't exist
+ # First, find the ir.model record for this model
+ ir_model = self.env['ir.model'].search([('model', '=', model)], limit=1)
+ if ir_model:
+ # Find any instance to use for this auto-created sync model
+ # For connection tests, we can use the instance_id parameter if provided
+ instance = False
+ if instance_id:
+ instance = self.env['odoo.to.bemade.instance'].browse(instance_id)
+
+ # If no instance provided or found, try to find any instance
+ if not instance:
+ instance = self.env['odoo.sync.instance'].search([], limit=1)
+
+ if instance:
+ sync_model = self.env['odoo.sync.model'].create({
+ 'model_id': ir_model.id,
+ 'target_model': model, # Use same model name as target
+ 'instance_id': instance.id, # Required field
+ })
+ else:
+ _logger.error("Cannot create sync model: No instance available")
+ else:
+ _logger.error(f"Cannot create sync model: No ir.model found for {model}")
+
+ # Create a queue entry for this log
+ queue_vals = {
+ 'model_id': sync_model.id if sync_model else False,
+ 'record_id': record_id or 0,
+ 'operation': 'create', # Use 'create' as a valid operation for queue entries
+ 'state': 'done' if result == 'success' else 'error',
+ 'data': details,
+ }
+
+ if not sync_model:
+ # If we couldn't find or create a sync model, we need to handle this case
+ # by using a fallback approach - find any sync model to use
+ fallback_model = self.env['odoo.sync.model'].search([], limit=1)
+ if fallback_model:
+ queue_vals['model_id'] = fallback_model.id
+ else:
+ # We can't create a log without a sync model for the queue
+ _logger.error(
+ "Cannot create sync log: No sync model available for queue creation")
+ return False
+
+ queue = self.env['odoo.sync.queue'].create(queue_vals)
+ queue_id = queue.id
+
+ log_entry = self.create({
'operation': operation,
'model': model,
'record_id': record_id,
'result': result,
'details': details,
'bemade_instance_id': instance_id,
+ 'queue_id': queue_id,
+ 'state': result, # Map our result to the parent's state field
+ 'message': details, # Map our details to the parent's message field
+ 'log_level': log_level,
+ 'payload': payload,
+ 'diff': diff,
+ 'metadata': metadata,
+ 'sanitized': sanitized,
})
+
+ return log_entry
+
+ def _sanitize_data(self, data_str):
+ """Mask sensitive data in log entries.
+
+ Args:
+ data_str: String representation of data to sanitize
+
+ Returns:
+ tuple: (sanitized_data, was_sanitized)
+ """
+ was_sanitized = False
+ if not data_str:
+ return data_str, was_sanitized
+
+ try:
+ # Try to parse as JSON to sanitize structured data
+ if data_str.startswith('{') or data_str.startswith('['):
+ data = json.loads(data_str)
+ sanitized_data, was_sanitized = self._sanitize_dict_or_list(data)
+ return json.dumps(sanitized_data), was_sanitized
+ else:
+ # For plain text, check for sensitive patterns
+ sensitive_patterns = [
+ 'password=', 'api_key=', 'token=', 'secret=', 'pwd=',
+ 'PASSWORD', 'API_KEY', 'TOKEN', 'SECRET', 'PWD'
+ ]
+ sanitized = data_str
+ for pattern in sensitive_patterns:
+ if pattern in sanitized:
+ # Simple masking for plain text
+ parts = sanitized.split(pattern)
+ result = [parts[0]]
+ for i in range(1, len(parts)):
+ # Mask until next whitespace or common delimiter
+ value_end = 0
+ for j, char in enumerate(parts[i]):
+ if char in [' ', '\n', '\t', '&', ';', ',']:
+ value_end = j
+ break
+ if value_end > 0:
+ result.append(pattern + '*****' + parts[i][value_end:])
+ else:
+ result.append(pattern + '*****')
+ sanitized = ''.join(result)
+ was_sanitized = True
+ return sanitized, was_sanitized
+ except (ValueError, TypeError):
+ # If parsing fails, return as is
+ return data_str, was_sanitized
+
+ def _sanitize_dict_or_list(self, data):
+ """Recursively sanitize sensitive data in dictionaries and lists.
+
+ Args:
+ data: Dictionary or list to sanitize
+
+ Returns:
+ tuple: (sanitized_data, was_sanitized)
+ """
+ sensitive_fields = [
+ 'password', 'api_key', 'token', 'secret', 'pwd', 'auth_token',
+ 'access_token', 'refresh_token', 'private_key', 'client_secret'
+ ]
+ was_sanitized = False
+
+ if isinstance(data, dict):
+ result = {}
+ for key, value in data.items():
+ if key.lower() in sensitive_fields:
+ result[key] = '*****'
+ was_sanitized = True
+ elif isinstance(value, (dict, list)):
+ result[key], child_sanitized = self._sanitize_dict_or_list(value)
+ was_sanitized = was_sanitized or child_sanitized
+ else:
+ result[key] = value
+ return result, was_sanitized
+ elif isinstance(data, list):
+ result = []
+ for item in data:
+ if isinstance(item, (dict, list)):
+ sanitized_item, child_sanitized = self._sanitize_dict_or_list(item)
+ result.append(sanitized_item)
+ was_sanitized = was_sanitized or child_sanitized
+ else:
+ result.append(item)
+ return result, was_sanitized
+ else:
+ return data, was_sanitized
+
+ @api.model
+ def _cron_archive_old_logs(self):
+ """Archive logs older than 90 days to long-term storage.
+
+ This method is intended to be called by a scheduled action (cron job).
+ It will find logs older than 90 days, archive them to S3 Glacier,
+ and mark them as archived in the database.
+ """
+ if not HAS_BOTO3:
+ _logger.warning("Cannot archive logs: boto3 library not installed")
+ return False
+
+ # Get AWS credentials from system parameters
+ ICP = self.env['ir.config_parameter'].sudo()
+ aws_access_key = ICP.get_param('bemade.aws_access_key_id', False)
+ aws_secret_key = ICP.get_param('bemade.aws_secret_access_key', False)
+ aws_region = ICP.get_param('bemade.aws_region', 'us-east-1')
+ aws_bucket = ICP.get_param('bemade.log_archive_bucket', False)
+
+ if not (aws_access_key and aws_secret_key and aws_bucket):
+ _logger.warning("Cannot archive logs: AWS credentials not configured")
+ return False
+
+ # Find logs older than 90 days that haven't been archived yet
+ cutoff_date = fields.Datetime.now() - timedelta(days=90)
+ old_logs = self.search([
+ ('create_date', '<', cutoff_date),
+ ('archived', '=', False)
+ ], limit=1000) # Process in batches to avoid memory issues
+
+ if not old_logs:
+ _logger.info("No logs to archive")
+ return True
+
+ try:
+ # Connect to S3
+ s3_client = boto3.client(
+ 's3',
+ aws_access_key_id=aws_access_key,
+ aws_secret_access_key=aws_secret_key,
+ region_name=aws_region
+ )
+
+ # Group logs by month for better organization
+ logs_by_month = {}
+ for log in old_logs:
+ month_key = log.create_date.strftime('%Y-%m')
+ if month_key not in logs_by_month:
+ logs_by_month[month_key] = []
+ logs_by_month[month_key].append(log)
+
+ # Archive each month's logs as a separate Parquet file
+ for month, logs in logs_by_month.items():
+ # Convert logs to a format suitable for Parquet
+ log_data = [{
+ 'id': log.id,
+ 'create_date': log.create_date.isoformat(),
+ 'operation': log.operation,
+ 'model': log.model,
+ 'record_id': log.record_id,
+ 'result': log.result,
+ 'log_level': log.log_level,
+ 'details': log.details,
+ 'payload': log.payload,
+ 'diff': log.diff,
+ 'metadata': log.metadata,
+ 'instance_id': log.bemade_instance_id.id if log.bemade_instance_id else None,
+ 'queue_id': log.queue_id.id if log.queue_id else None,
+ } for log in logs]
+
+ # Convert to JSON format (since we can't rely on pandas being installed)
+ json_data = json.dumps(log_data)
+
+ # Generate a unique key for this archive
+ archive_key = f"sync_logs/{month}/logs_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}.json"
+
+ # Upload to S3 with Glacier storage class
+ s3_client.put_object(
+ Body=json_data,
+ Bucket=aws_bucket,
+ Key=archive_key,
+ StorageClass='DEEP_ARCHIVE' # Glacier Deep Archive (7 years retention)
+ )
+
+ # Update logs to mark them as archived
+ for log in logs:
+ log.write({
+ 'archived': True,
+ 'archive_reference': archive_key
+ })
+
+ return True
+
+ except Exception as e:
+ _logger.error(f"Error archiving logs: {str(e)}")
+ return False
+
+ @api.model
+ def init(self):
+ """Set up database indexes for performance."""
+ super(OdooToBemadeSyncLog, self).init()
+ # Add index on create_date for efficient archiving queries
+ tools.create_index(self._cr, 'odoo_to_bemade_sync_log_create_date_idx',
+ self._table, ['create_date'])
diff --git a/odoo_to_odoo_bemade/models/sync_model.py b/odoo_to_odoo_bemade/models/sync_model.py
index f548dce..94cedd7 100644
--- a/odoo_to_odoo_bemade/models/sync_model.py
+++ b/odoo_to_odoo_bemade/models/sync_model.py
@@ -24,7 +24,27 @@ class OdooToBemadeSyncModel(models.Model):
_name = 'odoo.to.bemade.sync.model'
_description = 'Bemade Synchronized Model'
- _inherit = 'odoo.sync.model'
+ _inherits = {'odoo.sync.model': 'sync_model_id'}
+
+ # Field to store the parent record ID
+ sync_model_id = fields.Many2one(
+ 'odoo.sync.model',
+ required=True,
+ ondelete='cascade',
+ auto_join=True
+ )
+
+ # Note: This model inherits fields and methods from odoo.sync.model
+ # The following fields are inherited and available:
+ # - model_id: Many2one to ir.model
+ # - name: related field to model_id.model
+ # - target_model: Char field for target model name
+ # - instance_id: Many2one to odoo.sync.instance
+ # - field_ids: One2many to odoo.sync.model.field
+ # - active: Boolean field
+ #
+ # The following methods are inherited:
+ # - generate_field_mappings: Creates field mappings for the model
# Add Bemade-specific fields here
bemade_specific_setting = fields.Boolean(
@@ -40,32 +60,138 @@ class OdooToBemadeSyncModel(models.Model):
help='The Bemade instance this model will be synchronized with',
)
+ project_id = fields.Many2one(
+ comodel_name='project.project',
+ string='Project',
+ help='The project this synchronization model is associated with',
+ )
+
+ @api.onchange('instance_id')
+ def _onchange_instance_id(self):
+ """Set bemade_instance_id when instance_id changes."""
+ if self.instance_id:
+ # Try to find a matching bemade instance
+ bemade_instance = self.env['odoo.to.bemade.instance'].search(
+ [('id', '=', self.instance_id.id)], limit=1)
+ if bemade_instance:
+ self.bemade_instance_id = bemade_instance.id
+
+ @api.model
+ def create(self, vals_list):
+ """Override create to handle delegation inheritance properly and ensure bemade_instance_id is set.
+
+ When creating a record through the form view, we need to:
+ 1. Check if a parent record already exists for the model_id and instance_id
+ 2. If it exists, link to it via sync_model_id
+ 3. If not, create the parent record first
+
+ This prevents duplicate key violations on the unique constraint.
+ """
+ # Handle both single dict and list of dicts
+ if isinstance(vals_list, dict):
+ vals_items = [vals_list]
+ else:
+ vals_items = vals_list
+
+ result = self.env['odoo.to.bemade.sync.model']
+
+ for vals in vals_items:
+ # If sync_model_id is already provided, use standard creation
+ if not vals.get('sync_model_id') and vals.get('model_id') and vals.get('instance_id'):
+ # Check for existing parent record
+ existing_parent = self.env['odoo.sync.model'].search([
+ ('model_id', '=', vals['model_id']),
+ ('instance_id', '=', vals['instance_id'])
+ ], limit=1)
+
+ if existing_parent:
+ # Link to existing parent
+ vals['sync_model_id'] = existing_parent.id
+ else:
+ # Create parent record first
+ parent_vals = {
+ 'model_id': vals['model_id'],
+ 'instance_id': vals['instance_id'],
+ 'target_model': vals.get('target_model'),
+ 'active': vals.get('active', True),
+ }
+ parent_record = self.env['odoo.sync.model'].create(parent_vals)
+ vals['sync_model_id'] = parent_record.id
+
+ # If instance_id is set but bemade_instance_id is not, set it
+ if vals.get('instance_id') and not vals.get('bemade_instance_id'):
+ vals['bemade_instance_id'] = vals['instance_id']
+
+ return super(OdooToBemadeSyncModel, self).create(vals_list)
+
@api.model
def create_sync_configuration(self, model_name, instance_id, target_model=None, active=True):
"""Create a synchronization configuration for a model.
-
+
+ This method creates both the parent sync model record and the Bemade-specific
+ sync model record with proper linkage between them. If a sync model already exists
+ for the given model_id and instance_id, it will be reused instead of creating a new one.
+
Args:
model_name: Technical name of the model to synchronize
- instance_id: ID of the Bemade instance to sync with
+ instance_id: ID of the remote client instance to sync with
target_model: Technical name of the model on the remote instance
active: Whether the synchronization is active
-
+
Returns:
- The created model configuration record
+ The created or existing model configuration record
"""
- # Find the ir.model record for the model name
ir_model = self.env['ir.model'].search([('model', '=', model_name)], limit=1)
if not ir_model:
- raise UserError(_("Model %s does not exist") % model_name)
+ raise UserError(_('Model %s does not exist') % model_name)
- # Create the sync model configuration
- return self.create({
- 'model_id': ir_model.id,
- 'bemade_instance_id': instance_id,
- 'instance_id': instance_id, # For the inherited field
- 'target_model': target_model or model_name,
- 'active': active,
- })
+ # Find the Bemade instance (provider)
+ company_id = self.env.company.id
+ bemade_instance = self.env['odoo.to.bemade.instance'].search([('company_id', '=', company_id)], limit=1)
+ if not bemade_instance:
+ bemade_instance = self.env['odoo.to.bemade.instance'].search([], limit=1)
+ if not bemade_instance:
+ raise UserError(_('No Bemade instance found. Please create one first.'))
+
+ # Check if a parent sync model record already exists for this model and instance
+ existing_parent = self.env['odoo.sync.model'].search([
+ ('model_id', '=', ir_model.id),
+ ('instance_id', '=', instance_id)
+ ], limit=1)
+
+ if existing_parent:
+ # Check if there's already a Bemade sync model linked to this parent
+ existing_bemade_sync = self.search([('sync_model_id', '=', existing_parent.id)], limit=1)
+ if existing_bemade_sync:
+ # Update the existing record if needed
+ if existing_bemade_sync.bemade_instance_id.id != bemade_instance.id or existing_bemade_sync.active != active:
+ existing_bemade_sync.write({
+ 'bemade_instance_id': bemade_instance.id,
+ 'active': active
+ })
+ return existing_bemade_sync
+ else:
+ # Create a new Bemade sync model linked to the existing parent
+ return self.create({
+ 'sync_model_id': existing_parent.id,
+ 'bemade_instance_id': bemade_instance.id,
+ 'active': active,
+ })
+ else:
+ # Create the parent sync model record
+ parent_record = self.env['odoo.sync.model'].create({
+ 'model_id': ir_model.id,
+ 'instance_id': instance_id,
+ 'target_model': target_model or model_name,
+ 'active': active,
+ })
+
+ # Create the Bemade sync model record linked to the parent
+ return self.create({
+ 'sync_model_id': parent_record.id, # Link to parent record
+ 'bemade_instance_id': bemade_instance.id,
+ 'active': active,
+ })
def generate_field_mappings(self):
"""Generate field mappings based on model fields.
@@ -78,14 +204,22 @@ class OdooToBemadeSyncModel(models.Model):
"""
self.ensure_one()
- # Get the model
- model = self.env[self.name]
+ # Make sure the record is saved in the database before creating field mappings
+ # Use the Odoo API to ensure the record is saved
+ self.env.cr.commit()
+
+ # Get the model from model_id relation (accessed through the parent record)
+ model = self.env[self.sync_model_id.model_id.model]
model_fields = model._fields
# Create field mappings for each relevant field
- field_mapping_model = self.env['odoo.to.bemade.sync.model.field']
+ field_mapping_model = self.env['odoo.sync.model.field']
created_mappings = []
+ # Verify that the parent record exists in the database
+ if not self.sync_model_id.exists():
+ raise UserError(_("Cannot create field mappings: parent sync model record not found. Please save the record first."))
+
for field_name, field in model_fields.items():
# Skip fields that shouldn't be synchronized
if field.type in ['one2many', 'many2many']:
@@ -95,14 +229,82 @@ class OdooToBemadeSyncModel(models.Model):
continue
# Create a field mapping
- mapping = field_mapping_model.create({
- 'sync_model_id': self.id,
+ mapping_vals = {
+ 'model_sync_id': self.sync_model_id.id, # Use the parent record ID
'source_field': field_name,
'target_field': field_name,
'active': True,
'transform_type': 'direct',
- })
+ }
- created_mappings.append(mapping)
+ try:
+ # Create the mapping
+ mapping = field_mapping_model.create(mapping_vals)
+ created_mappings.append(mapping)
+ except Exception as e:
+ _logger.error("Error creating field mapping for %s: %s", field_name, str(e))
+ continue
return created_mappings
+
+
+
+ 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.
+
+ Returns:
+ dict: Action dictionary for refreshing the view
+ """
+ self.ensure_one()
+
+ # Ensure the parent record exists and is properly linked
+ if not self.sync_model_id or not self.sync_model_id.exists():
+ raise UserError(_("Cannot create field mappings: parent sync model record not found. Please save the record first."))
+
+ # Generate field mappings
+ self.generate_field_mappings()
+
+ 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()
+
+ # Get the sync manager
+ sync_manager = self.env['odoo.sync.manager']
+
+ # Get all records of this model
+ # The 'name' field is inherited from odoo.sync.model and is a related field to model_id.model
+ # It contains the technical name of the model (e.g. 'res.partner')
+ # pylint: disable=no-member
+ model = self.env[self.name]
+ records = model.search([])
+
+ # Queue synchronization for each record
+ for record in records:
+ sync_manager._queue_sync(record, 'create')
+
+ # Return action to show success message
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': "Synchronisation démarrée",
+ 'message': f"{len(records)} enregistrements ont été mis en file d'attente pour synchronisation.",
+ 'type': 'success',
+ 'sticky': False,
+ }
+ }
diff --git a/odoo_to_odoo_bemade/security/ir.model.access.csv b/odoo_to_odoo_bemade/security/ir.model.access.csv
index e1107a5..845ffed 100644
--- a/odoo_to_odoo_bemade/security/ir.model.access.csv
+++ b/odoo_to_odoo_bemade/security/ir.model.access.csv
@@ -5,3 +5,7 @@ access_odoo_to_bemade_sync_model_field_admin,odoo.to.bemade.sync.model.field adm
access_odoo_to_bemade_sync_model_field_user,odoo.to.bemade.sync.model.field user,model_odoo_to_bemade_sync_model_field,base.group_user,1,0,0,0
access_odoo_to_bemade_instance_admin,odoo.to.bemade.instance admin,model_odoo_to_bemade_instance,base.group_system,1,1,1,1
access_odoo_to_bemade_instance_user,odoo.to.bemade.instance user,model_odoo_to_bemade_instance,base.group_user,1,0,0,0
+access_odoo_to_bemade_sync_log_admin,odoo.to.bemade.sync.log admin,model_odoo_to_bemade_sync_log,base.group_system,1,1,1,1
+access_odoo_to_bemade_sync_log_user,odoo.to.bemade.sync.log user,model_odoo_to_bemade_sync_log,base.group_user,1,0,0,0
+access_odoo_to_bemade_sync_queue_admin,odoo.to.bemade.sync.queue admin,model_odoo_to_bemade_sync_queue,base.group_system,1,1,1,1
+access_odoo_to_bemade_sync_queue_user,odoo.to.bemade.sync.queue user,model_odoo_to_bemade_sync_queue,base.group_user,1,0,0,0
diff --git a/odoo_to_odoo_bemade/views/menus.xml b/odoo_to_odoo_bemade/views/menus.xml
index a79b592..5948fd0 100644
--- a/odoo_to_odoo_bemade/views/menus.xml
+++ b/odoo_to_odoo_bemade/views/menus.xml
@@ -13,11 +13,7 @@
action="action_odoo_to_bemade_instances"
sequence="10"/>
-
+