299 lines
11 KiB
Python
299 lines
11 KiB
Python
|
|
|
||
|
|
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'
|
||
|
|
}
|
||
|
|
}
|