Files

299 lines
11 KiB
Python
Raw Permalink Normal View History

2026-07-01 14:41:49 +07:00
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'
}
}