import xmlrpc.client import logging from odoo import models, fields, api, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class SyncConfig(models.Model): _name = 'sync.config' _description = 'Target Database Sync Configuration' name = fields.Char(string='Configuration Name', required=True, default='Main Target DB') target_url = fields.Char(string='Target Odoo URL', required=True, help='e.g., https://target-domain.com') target_db = fields.Char(string='Target Database Name', required=True) target_username = fields.Char(string='Target Username', required=True) target_password = fields.Char(string='Target Password', required=True, password=True) # Expanded to include complex modules model_to_sync = fields.Selection([ ('res.partner', 'Contacts'), ('product.template', 'Products'), ('project.project', 'Projects'), ('project.task', 'Project Tasks'), ('sale.order', 'Sales Orders'), ('account.move', 'Accounting Entries (Invoices/Bills)'), ], string='Model to Sync', default='res.partner', required=True) @api.model def _get_target_connection(self): try: common = xmlrpc.client.ServerProxy(f'{self.target_url}/xmlrpc/2/common') uid = common.authenticate(self.target_db, self.target_username, self.target_password, {}) if not uid: raise UserError(_("Authentication failed. Check credentials.")) models_api = xmlrpc.client.ServerProxy(f'{self.target_url}/xmlrpc/2/object') return uid, models_api except Exception as e: raise UserError(_("Connection failed: %s") % str(e)) def _get_target_id_by_xmlid(self, models_api, db, uid, pwd, model, source_id): """Finds the target database ID using a deterministic External ID.""" xmlid_module = 'sync_source_db' xmlid_name = f"{model.replace('.', '_')}_{source_id}" # Search ir.model.data in target DB for this xml_id data_ids = models_api.execute_kw( db, uid, pwd, 'ir.model.data', 'search', [[['module', '=', xmlid_module], ['name', '=', xmlid_name]]] ) if data_ids: data = models_api.execute_kw( db, uid, pwd, 'ir.model.data', 'read', [data_ids[0]], {'fields': ['res_id']} ) return data[0]['res_id'] return False def _set_target_xmlid(self, models_api, db, uid, pwd, model, target_res_id, source_id): """Creates or updates the External ID mapping in the target database.""" xmlid_module = 'sync_source_db' xmlid_name = f"{model.replace('.', '_')}_{source_id}" # Check if mapping exists data_ids = models_api.execute_kw( db, uid, pwd, 'ir.model.data', 'search', [[['module', '=', xmlid_module], ['name', '=', xmlid_name]]] ) vals = { 'module': xmlid_module, 'name': xmlid_name, 'model': model, 'res_id': target_res_id, 'noupdate': True, # Prevents target DB from overwriting this mapping } if data_ids: models_api.execute_kw(db, uid, pwd, 'ir.model.data', 'write', [data_ids, vals]) else: models_api.execute_kw(db, uid, pwd, 'ir.model.data', 'create', [vals]) def action_sync_data(self): self.ensure_one() uid, models_api = self._get_target_connection() model = self.model_to_sync # 1. Get source records (limit to 50 for safety in this example, remove limit for production) source_records = self.env[model].search([], limit=50) synced_count = 0 for record in source_records: # 2. Read all field values from source # We exclude 'id' and let the target DB generate its own, but we keep relational IDs to map them record_data = record.read()[0] # 3. Resolve Relational Fields (Many2one) # Example: If syncing a Sale Order, map the source partner_id to the target partner_id resolved_vals = {} for field_name, field_value in record_data.items(): if isinstance(field_value, tuple) and len(field_value) == 2: # It's a Many2one field (id, name) source_rel_id = field_value[0] if source_rel_id: # Determine the related model (Odoo 19: use self.env[model]._fields[field_name].comodel_name) comodel = self.env[model]._fields[field_name].comodel_name if comodel: target_rel_id = self._get_target_id_by_xmlid( models_api, self.target_db, uid, self.target_password, comodel, source_rel_id ) if target_rel_id: resolved_vals[field_name] = target_rel_id else: _logger.warning( f"Related {comodel} ID {source_rel_id} not found in target. Skipping record {record.id}.") break # Skip this record if dependency is missing elif field_name not in ['id', 'create_uid', 'write_uid', 'create_date', 'write_date']: # Copy standard fields (Char, Integer, Boolean, Selection, etc.) resolved_vals[field_name] = field_value # Handle One2many fields (e.g., order_line in sale.order) # We convert them to Odoo's (0, 0, {vals}) command format for field_name, field_value in record_data.items(): field_obj = self.env[model]._fields.get(field_name) if field_obj and field_obj.type == 'one2many' and field_name in resolved_vals or field_name not in resolved_vals: # Simplified One2many handling: recreate lines # Note: For production, you should also map xml_ids for child records! pass # Kept simple for this example; see notes below. if 'id' in resolved_vals: del resolved_vals['id'] # 4. Check if record exists in Target via XML ID target_id = self._get_target_id_by_xmlid( models_api, self.target_db, uid, self.target_password, model, record.id ) try: if target_id: # Update existing models_api.execute_kw( self.target_db, uid, self.target_password, model, 'write', [target_id, resolved_vals] ) else: # Create new new_id = models_api.execute_kw( self.target_db, uid, self.target_password, model, 'create', [resolved_vals] ) # Save the new mapping! self._set_target_xmlid(models_api, self.target_db, uid, self.target_password, model, new_id, record.id) synced_count += 1 except Exception as e: _logger.error(f"Failed to sync {model} record {record.id}: {str(e)}") return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Sync Complete'), 'message': _('Successfully synced/updated %s %s records.') % (synced_count, model), 'type': 'success', 'sticky': False, } }