import xmlrpc.client from odoo import models, fields, api from odoo.exceptions import UserError import logging _logger = logging.getLogger(__name__) class ProjectSyncConfig(models.Model): _name = 'project.sync.config' _description = 'Project Server Synchronization Configuration' name = fields.Char(string='Connection Name', required=True) remote_url = fields.Char(string='Remote URL', required=True, help='e.g., https://remote-odoo.com') remote_db = fields.Char(string='Remote Database', required=True) remote_user = fields.Char(string='Remote Username', required=True) remote_password = fields.Char(string='Remote Password', required=True) remote_project_ids = fields.Many2many( 'project.sync.remote.project', relation='project_sync_config_all_projects_rel', column1='config_id', column2='project_id', string='All Remote Projects', readonly=True ) selected_project_ids = fields.Many2many( 'project.sync.remote.project', relation='project_sync_config_selected_rel', column1='config_id', column2='selected_project_id', string='Projects to Sync', domain="[('sync_config_id', '=', id)]" ) sync_direction = fields.Selection([ ('remote_to_local', 'Remote to Local'), ('local_to_remote', 'Local to Remote') ], string='Direction', default='remote_to_local') last_sync = fields.Datetime(string='Last Sync', readonly=True) active = fields.Boolean(default=True) def _get_connection(self): """Test connection and return XML-RPC proxies""" self.ensure_one() try: url = self.remote_url.rstrip('/') # Test common endpoint common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common') # Authenticate uid = common.authenticate(self.remote_db, self.remote_user, self.remote_password, {}) if not uid: raise UserError( f"Authentication failed for user '{self.remote_user}' on database '{self.remote_db}'. Check credentials and user access rights.") # Get models proxy models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object') # Verify project module exists on remote modules = models_proxy.execute_kw( self.remote_db, uid, self.remote_password, 'ir.module.module', 'search_read', [[('name', '=', 'project'), ('state', '=', 'installed')]], {'fields': ['name', 'state']} ) if not modules: raise UserError("Remote server does not have the 'project' module installed!") _logger.info(f"Connected to remote server {url} as UID {uid}") return uid, models_proxy except xmlrpc.client.Fault as e: raise UserError(f"XML-RPC Fault: {e.faultString}") except Exception as e: raise UserError(f"Connection failed: {str(e)}\nURL: {self.remote_url}\nDB: {self.remote_db}") def action_fetch_remote_projects(self): """Fetch all projects from remote server""" self.ensure_one() try: uid, models_proxy = self._get_connection() # Fetch projects remote_projects = models_proxy.execute_kw( self.remote_db, uid, self.remote_password, 'project.project', 'search_read', [[]], {'fields': ['id', 'name', 'active']} ) _logger.info(f"Fetched {len(remote_projects)} projects from remote") # Clear old mappings old_mappings = self.env['project.sync.remote.project'].search([('sync_config_id', '=', self.id)]) old_mappings.unlink() # Create new mappings vals_list = [] for p in remote_projects: vals_list.append({ 'sync_config_id': self.id, 'remote_id': p['id'], 'remote_name': p['name'], 'sync_status': 'pending', 'error_message': False, }) if vals_list: self.env['project.sync.remote.project'].create(vals_list) # Refresh cache self.invalidate_recordset(['remote_project_ids', 'selected_project_ids']) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Success', 'message': f'Loaded {len(remote_projects)} remote projects.', 'type': 'success', 'sticky': False, } } except Exception as e: _logger.error(f"Fetch failed: {str(e)}", exc_info=True) raise UserError(f"Failed to fetch projects: {str(e)}") def action_sync(self): """Sync selected projects from remote to local""" if not self.selected_project_ids: raise UserError("Please select at least one project to sync. Go to 'Remote Projects' tab first.") success_count = 0 error_count = 0 for mapping in self.selected_project_ids: try: tasks_synced = self._sync_single_project(mapping) mapping.write({ 'sync_status': 'synced', 'error_message': False, 'last_sync_attempt': fields.Datetime.now(), 'tasks_synced_count': tasks_synced }) success_count += 1 _logger.info(f"Synced project '{mapping.remote_name}': {tasks_synced} tasks") except Exception as e: error_msg = str(e) mapping.write({ 'sync_status': 'error', 'error_message': error_msg, 'last_sync_attempt': fields.Datetime.now() }) error_count += 1 _logger.error(f"Sync failed for {mapping.remote_name}: {error_msg}", exc_info=True) self.last_sync = fields.Datetime.now() # Show result msg = f"Sync completed: {success_count} succeeded, {error_count} failed." if error_count > 0: msg += " Check error details on each project." return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Sync Result', 'message': msg, 'type': 'danger' if error_count > 0 else 'success', 'sticky': error_count > 0, } } def _sync_single_project(self, mapping): """Sync tasks from one remote project""" self.ensure_one() uid, models_proxy = self._get_connection() # Check if local project is mapped local_project_id = mapping.local_project_id.id if mapping.local_project_id else False # Fetch remote tasks with all needed fields domain = [('project_id', '=', mapping.remote_id)] # Get available fields on remote task task_fields = models_proxy.execute_kw( self.remote_db, uid, self.remote_password, 'project.task', 'fields_get', [], {'attributes': ['string']} ) # Build field list dynamically fields_to_fetch = ['id', 'name', 'description'] if 'stage_id' in task_fields: fields_to_fetch.append('stage_id') if 'user_ids' in task_fields: fields_to_fetch.append('user_ids') if 'tag_ids' in task_fields: fields_to_fetch.append('tag_ids') if 'priority' in task_fields: fields_to_fetch.append('priority') if 'date_deadline' in task_fields: fields_to_fetch.append('date_deadline') remote_tasks = models_proxy.execute_kw( self.remote_db, uid, self.remote_password, 'project.task', 'search_read', [domain], {'fields': fields_to_fetch, 'limit': 1000} ) _logger.info(f"Found {len(remote_tasks)} tasks in remote project '{mapping.remote_name}'") tasks_synced = 0 for rt in remote_tasks: try: # Prepare task values task_vals = { 'name': rt.get('name', 'Untitled Task'), 'description': rt.get('description', ''), 'project_id': local_project_id, 'remote_task_id': rt['id'], } # Map stage if exists if 'stage_id' in rt and rt['stage_id']: # Try to find matching stage locally by name stage_id = rt['stage_id'][0] stage_name = rt['stage_id'][1] local_stage = self.env['project.task.type'].search([('name', '=', stage_name)], limit=1) if local_stage: task_vals['stage_id'] = local_stage.id # Map assignees if exists if 'user_ids' in rt and rt['user_ids']: # Get remote user IDs remote_user_ids = [u[0] for u in rt['user_ids']] # For now, skip assignee mapping (requires user mapping table) _logger.debug(f"Remote task {rt['id']} has assignees: {remote_user_ids}") # Map tags if exists if 'tag_ids' in rt and rt['tag_ids']: remote_tag_ids = [t[0] for t in rt['tag_ids']] # For now, skip tag mapping (requires tag mapping table) # Map priority if 'priority' in rt: task_vals['priority'] = rt.get('priority', '0') # Map deadline if 'date_deadline' in rt and rt.get('date_deadline'): task_vals['date_deadline'] = rt['date_deadline'] # Check if task already exists existing_task = self.env['project.task'].search([ ('remote_task_id', '=', rt['id']), ('project_id', '=', local_project_id) ], limit=1) if existing_task: # Update existing existing_task.write(task_vals) _logger.debug(f"Updated local task {existing_task.id}") else: # Create new new_task = self.env['project.task'].create(task_vals) _logger.debug(f"Created local task {new_task.id}") tasks_synced += 1 except Exception as e: _logger.error(f"Failed to sync task {rt.get('id')}: {str(e)}", exc_info=True) continue return tasks_synced def action_clear_errors(self): """Reset error status for all selected projects""" for mapping in self.selected_project_ids: mapping.write({ 'sync_status': 'pending', 'error_message': False }) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Cleared', 'message': 'Error status cleared. You can retry sync.', 'type': 'success' } }