165 lines
7.5 KiB
Python
165 lines
7.5 KiB
Python
|
|
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,
|
||
|
|
}
|
||
|
|
}
|