# Copyright 2025 Bemade # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html) """Remote Odoo Instance Management. This module handles the configuration and connection management for remote Odoo instances. It provides functionality to store connection credentials, test connections, and maintain the connection state with remote instances. """ import logging import xmlrpc.client from urllib.parse import urlparse from odoo import api, fields, models from odoo.exceptions import UserError from ..utils.encryption import encrypt_value, decrypt_value _logger = logging.getLogger(__name__) class OdooSyncInstance(models.Model): """Remote Odoo instance for synchronization. This model stores connection information and credentials for remote Odoo instances that will be synchronized with the current instance. """ _name = 'odoo.sync.instance' _description = 'Remote Odoo Instance' name = fields.Char( string='Name', required=True, help='Name to identify this remote instance', ) url = fields.Char( string='URL', required=True, help='Base URL of the remote Odoo instance', ) database = fields.Char( string='Database', required=True, help='Database name on the remote instance', ) username = fields.Char( string='Technical User', required=True, help='Username of the technical user for synchronization', ) password = fields.Char( string='Password', required=True, help='Password of the technical user', copy=False, ) # Store encrypted password separately encrypted_password = fields.Char( string='Encrypted Password', copy=False, help='Encrypted password for secure storage', ) connection_type = fields.Selection( selection=[ ('xmlrpc', 'XML-RPC'), ('jsonrpc', 'JSON-RPC') ], string='Connection Type', default='xmlrpc', required=True, help='Protocol to use for connection to the remote instance', ) connection_timeout = fields.Integer( string='Connection Timeout', default=30, help='Timeout in seconds for connection to the remote instance', ) retry_count = fields.Integer( string='Max Retries', default=3, help='Maximum number of connection retry attempts', ) 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='Strategy for resolving synchronization conflicts with this instance') retry_delay = fields.Integer( string='Retry Delay', default=5, help='Delay in seconds between retry attempts', ) auto_reconnect = fields.Boolean( string='Auto Reconnect', default=True, help='Automatically attempt to reconnect when connection is lost', ) active = fields.Boolean( string='Active', default=True, help='Whether this instance is active for synchronization', ) state = fields.Selection( selection=[ ('draft', 'Draft'), ('testing', 'Testing Connection'), ('connected', 'Connected'), ('error', 'Error') ], default='draft', string='State', help='Current state of the connection', ) last_connection = fields.Datetime( string='Last Connection', help='Timestamp of the last successful connection', ) error_message = fields.Text( string='Error Message', help='Details of the last error that occurred', ) @api.onchange('url') def _onchange_url(self): """Reset state when URL changes.""" for record in self: if record.url != record._origin.url: record.state = 'draft' record.error_message = False @api.onchange('password') def _onchange_password(self): """Mark password for encryption when it changes.""" for record in self: if record.password != record._origin.password: # Password changed, will be encrypted on save record.encrypted_password = False def test_connection(self): """Test the connection to the remote Odoo instance. This method attempts to authenticate with the remote instance and updates the connection state accordingly. Returns: bool: True if connection successful, False otherwise """ for record in self: record.state = 'testing' if record.connection_type == 'xmlrpc': return record._test_xmlrpc_connection() elif record.connection_type == 'jsonrpc': # À implémenter si nécessaire raise UserError("La connexion JSON-RPC n'est pas encore implémentée") else: raise UserError(f"Type de connexion non supporté: {record.connection_type}") def _encrypt_sensitive_data(self): """Encrypt sensitive data before saving to database.""" for record in self: if record.password and not record.encrypted_password: # Only encrypt if we have a password and it's not already encrypted record.encrypted_password = encrypt_value(self.env, record.password) def _decrypt_sensitive_data(self): """Decrypt sensitive data for use in connections.""" self.ensure_one() if self.encrypted_password and not self.password: # Only decrypt if we have an encrypted password and need the cleartext return decrypt_value(self.env, self.encrypted_password) return self.password @api.model_create_multi def create(self, vals_list): """Override create to encrypt sensitive data.""" records = super().create(vals_list) records._encrypt_sensitive_data() return records def write(self, vals): """Override write to encrypt sensitive data.""" result = super().write(vals) self._encrypt_sensitive_data() return result def _test_xmlrpc_connection(self): """Test XML-RPC connection to the remote instance.""" self.ensure_one() try: # Validate URL format if not self.url: raise UserError("L'URL est requise pour tester la connexion") # Parse URL to extract connection details parsed_url = urlparse(self.url) if not parsed_url.scheme or not parsed_url.netloc: raise UserError("Format d'URL invalide. Exemple valide: https://exemple.odoo.com") # Get decrypted password for authentication password = self._decrypt_sensitive_data() # Attempt to connect and authenticate common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common') uid = common.authenticate(self.database, self.username, password, {}) if uid: self.write({ 'state': 'connected', 'last_connection': fields.Datetime.now(), 'error_message': False }) return True else: raise UserError('Échec d\'authentification') except UserError as e: # Pass through our UserError without modification self.write({ 'state': 'error', 'error_message': str(e) }) _logger.error("Erreur d'authentification XML-RPC: %s", str(e)) return False except (ConnectionError, TimeoutError, xmlrpc.client.Fault, xmlrpc.client.ProtocolError) as e: # Catch specific exceptions that can be raised during XML-RPC connection self.write({ 'state': 'error', 'error_message': str(e) }) _logger.error("Erreur de connexion XML-RPC: %s", str(e)) return False except Exception as e: # pylint: disable=broad-except # Fall back for any unexpected exceptions self.write({ 'state': 'error', 'error_message': f"Erreur inattendue: {str(e)}" }) _logger.error("Erreur inattendue XML-RPC: %s", str(e)) return False def get_connection(self): """Return an active connection to the remote instance. Returns: tuple: (models_proxy, user_id) for XML-RPC connection Raises: UserError: If connection fails """ self.ensure_one() # Ensure we have an active connection if self.state != 'connected': self.test_connection() if self.state != 'connected': # Use UserError for better user experience raise UserError(f'Impossible de se connecter à {self.name}: {self.error_message}') # Return appropriate connection based on connection type if self.connection_type == 'xmlrpc': return self._get_xmlrpc_connection() elif self.connection_type == 'jsonrpc': # À implémenter si nécessaire raise UserError("La connexion JSON-RPC n'est pas encore implémentée") else: raise UserError(f"Type de connexion non supporté: {self.connection_type}") def _get_xmlrpc_connection(self): """Get XML-RPC connection to the remote instance.""" # Get decrypted password for authentication password = self._decrypt_sensitive_data() common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common') uid = common.authenticate(self.database, self.username, password, {}) models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object') return models, uid def execute_kw(self, model, method, args=None, kwargs=None): """Execute a method on the remote Odoo instance. This method provides a unified interface for executing methods on remote models regardless of the connection type. Args: model (str): The model name method (str): The method to call args (list, optional): Positional arguments kwargs (dict, optional): Keyword arguments Returns: The result of the method call Raises: UserError: If execution fails """ self.ensure_one() if args is None: args = [] if kwargs is None: kwargs = {} try: models, uid = self.get_connection() # Get decrypted password for authentication password = self._decrypt_sensitive_data() result = models.execute_kw( self.database, uid, password, model, method, args, kwargs ) return result except UserError: # Re-raise UserError as it already has appropriate message raise except Exception as e: # pylint: disable=broad-except # Convert other exceptions to UserError for better user experience raise UserError(f"Erreur d'exécution de {model}.{method}: {str(e)}") from e