Compare commits
4 commits
18.0
...
Feature/od
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df769be69 | ||
|
|
4d92c5a72f | ||
|
|
b792e43873 | ||
|
|
f4cc178d37 |
99 changed files with 11004 additions and 1126 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Time Off Alternative Follower",
|
||||
"version": "17.0.0.0.3",
|
||||
"version": "18.0.0.0.1",
|
||||
"category": "Extra Tools",
|
||||
"summary": "Add Alternative Follower When Receiving Message While On Time Off",
|
||||
"author": "Bemade",
|
||||
|
|
|
|||
476
helpdesk_sale_order_ai/models/helpdesk_ticket.py.backup
Normal file
476
helpdesk_sale_order_ai/models/helpdesk_ticket.py.backup
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HelpdeskTicket(models.Model):
|
||||
_inherit = 'helpdesk.ticket'
|
||||
|
||||
# Computed field to determine if team uses AI sale orders
|
||||
team_use_ai_sale_orders = fields.Boolean(
|
||||
string='Team Uses AI Sale Orders',
|
||||
compute='_compute_team_use_ai_sale_orders',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
ai_generated_products = fields.Text(
|
||||
string='AI Generated Products',
|
||||
readonly=True,
|
||||
help='Products suggested by AI based on ticket description',
|
||||
)
|
||||
|
||||
@api.depends('team_id')
|
||||
def _compute_team_use_ai_sale_orders(self):
|
||||
for ticket in self:
|
||||
ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders() if ticket.team_id else False
|
||||
|
||||
def action_convert_to_sale_order(self):
|
||||
"""Override to use AI if enabled"""
|
||||
self.ensure_one()
|
||||
|
||||
# Check if AI sale orders are enabled for this team
|
||||
if self.team_use_ai_sale_orders:
|
||||
return self._ai_convert_to_sale_order()
|
||||
|
||||
# Otherwise, use the standard method
|
||||
return super(HelpdeskTicket, self).action_convert_to_sale_order()
|
||||
|
||||
def _ai_convert_to_sale_order(self):
|
||||
"""Create a sale order using AI to suggest products based on ticket description"""
|
||||
self.ensure_one()
|
||||
|
||||
values = self._get_sale_order_values()
|
||||
values["partner_id"] = self.partner_id.id
|
||||
|
||||
# Ensure date_order is set and is a datetime object
|
||||
if 'date_order' not in values or not values['date_order']:
|
||||
from datetime import datetime
|
||||
values['date_order'] = datetime.now()
|
||||
|
||||
# Fix empty string dates by converting them to None
|
||||
# This prevents PostgreSQL errors with empty string timestamps
|
||||
date_fields = ['date_order', 'commitment_date', 'validity_date']
|
||||
for field in date_fields:
|
||||
if field in values and values[field] == '':
|
||||
values[field] = None
|
||||
|
||||
|
||||
# Create the sale order
|
||||
sale_order = self.env['sale.order'].create(values)
|
||||
|
||||
# Link the ticket to the sale order
|
||||
sale_order.ticket_id = self.id
|
||||
|
||||
# Post original email contents and document information to the chatter
|
||||
self._post_source_information_message(sale_order)
|
||||
|
||||
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sale Order'),
|
||||
'res_model': 'sale.order',
|
||||
'res_id': sale_order.id,
|
||||
'view_mode': 'form,list',
|
||||
'context': self.env.context,
|
||||
}
|
||||
|
||||
def _post_source_information_message(self, sale_order):
|
||||
"""
|
||||
Post a chatter message on the sale order with the original email contents and document information.
|
||||
This message will NOT be deleted when the sale order is confirmed, providing permanent context.
|
||||
Also attaches the original attachments from the helpdesk ticket to the sale order message.
|
||||
|
||||
Args:
|
||||
sale_order: The sale order record
|
||||
"""
|
||||
# Get ticket data that was used for AI analysis
|
||||
ticket_data = self._prepare_ai_prompt_data()
|
||||
description = ticket_data.get('ticket_description', '')
|
||||
chatter_messages = ticket_data.get('ticket_messages', '')
|
||||
|
||||
# Escape HTML special characters to prevent rendering issues
|
||||
import html
|
||||
description = html.escape(description)
|
||||
chatter_messages = html.escape(chatter_messages)
|
||||
|
||||
# Create the message content
|
||||
message_body = f"""<div>
|
||||
<p><strong>🔍 Source Information from Helpdesk Ticket #{self.id}</strong></p>
|
||||
<p>This sale order was created from helpdesk ticket <a href='/web#id={self.id}&model=helpdesk.ticket&view_type=form'>{self.name}</a></p>
|
||||
<!-- SOURCE_INFORMATION_MESSAGE -->
|
||||
"""
|
||||
|
||||
# Add original description if available
|
||||
if description:
|
||||
message_body += f"""<div style='margin-top: 15px;'>
|
||||
<p><strong>Original Ticket Description:</strong></p>
|
||||
<pre style='white-space: pre-wrap; background-color: #f8f9fa; padding: 10px; border-radius: 4px;'>{description}</pre>
|
||||
</div>"""
|
||||
|
||||
# Add chatter messages if available (limited to avoid huge messages)
|
||||
if chatter_messages:
|
||||
# Limit the size of chatter messages to avoid huge messages
|
||||
max_chars = 2000
|
||||
if len(chatter_messages) > max_chars:
|
||||
chatter_messages = chatter_messages[:max_chars] + "... (truncated)"
|
||||
|
||||
message_body += f"""<div style='margin-top: 15px;'>
|
||||
<p><strong>Relevant Chatter Messages:</strong></p>
|
||||
<pre style='white-space: pre-wrap; background-color: #f8f9fa; padding: 10px; border-radius: 4px;'>{chatter_messages}</pre>
|
||||
</div>"""
|
||||
|
||||
# Get the original attachments from the helpdesk ticket
|
||||
attachments = self.env['ir.attachment'].search([
|
||||
('res_id', '=', self.id),
|
||||
('res_model', '=', self._name)
|
||||
])
|
||||
|
||||
# Add attachment information to the message
|
||||
if attachments:
|
||||
message_body += f"""<div style='margin-top: 15px;'>
|
||||
<p><strong>Attachments:</strong> {len(attachments)} file(s) attached to this message</p>
|
||||
</div>"""
|
||||
|
||||
# Close the main div
|
||||
message_body += "</div>"
|
||||
|
||||
try:
|
||||
_logger.debug(f"Attempting to post source information message for sale order {sale_order.id}")
|
||||
|
||||
# Prepare attachment IDs to forward with the message
|
||||
attachment_ids = attachments.ids if attachments else []
|
||||
_logger.debug(f"Forwarding {len(attachment_ids)} attachments from ticket {self.id} to sale order {sale_order.id}")
|
||||
|
||||
# Post the message with attachments
|
||||
result = sale_order.message_post(
|
||||
body=message_body,
|
||||
subject="Source Information",
|
||||
body_is_html=True,
|
||||
attachment_ids=attachment_ids
|
||||
)
|
||||
|
||||
_logger.debug(f"Message post result: {result}")
|
||||
_logger.debug(f"Successfully posted source information message for sale order {sale_order.id}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error posting source information message: {e}")
|
||||
|
||||
|
||||
|
||||
def _get_sale_order_values(self) -> dict:
|
||||
"""
|
||||
Generate sales order values using AI to analyze ticket content, chatter messages, and attachments.
|
||||
The AI will identify products from the content and match them to Odoo products.
|
||||
|
||||
Returns:
|
||||
dict: Values for creating a sales order including order lines
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.debug(f"Generating AI sales order values for ticket {self.id}")
|
||||
|
||||
# Get the ticket data
|
||||
ticket_data = self._prepare_ai_prompt_data()
|
||||
description = ticket_data.get('ticket_description', '')
|
||||
chatter_messages = ticket_data.get('ticket_messages', '')
|
||||
attachments_info = ticket_data.get('attachments_info', '')
|
||||
attachment_contents = ticket_data.get('attachment_contents', '')
|
||||
|
||||
# Log the content being analyzed
|
||||
_logger.debug(f"AI Analysis - Content lengths: Description={len(description)}, Chatter={len(chatter_messages)}, Attachments={len(attachment_contents)}")
|
||||
|
||||
# Get the OpenWebUI provider from company settings
|
||||
company = self.env.company
|
||||
provider = company.openwebui_provider_id
|
||||
|
||||
if not provider:
|
||||
_logger.error("No OpenWebUI provider configured for company")
|
||||
return {"order_line": []}
|
||||
|
||||
# Get the OpenWebUI client from the provider
|
||||
ai_client = provider.get_client()
|
||||
|
||||
# Register the product finding methods as tools
|
||||
registry = ai_client.tool_registry
|
||||
|
||||
registry.register(
|
||||
self._ai_find_product_id_by_name,
|
||||
non_ai_params=["self"],
|
||||
description="Find a product by its name and return its ID. Input: name (string) - The name of the product to find. Returns the product ID if found, or null if not found."
|
||||
)
|
||||
registry.register(
|
||||
self._ai_find_product_id_by_code,
|
||||
non_ai_params=["self"],
|
||||
description="Find a product by its code/reference and return its ID. Input: code (string) - The code/reference of the product to find. Returns the product ID if found, or null if not found."
|
||||
)
|
||||
|
||||
# Process PDF attachments for analysis
|
||||
attachments_list = []
|
||||
attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
|
||||
|
||||
for attachment in attachments:
|
||||
if attachment.mimetype == 'application/pdf':
|
||||
try:
|
||||
temp_path = f"/tmp/{attachment.name}"
|
||||
shutil.copy(attachment._full_path(attachment.store_fname), temp_path)
|
||||
attachments_list.append(Path(temp_path))
|
||||
except Exception as e:
|
||||
_logger.error(f"Error processing attachment {attachment.name}: {e}")
|
||||
|
||||
# Create the prompt for the AI
|
||||
prompt = f"""IMPORTANT: YOU ARE NOT A CONVERSATIONAL ASSISTANT. YOU ARE A DATA EXTRACTION SYSTEM.
|
||||
|
||||
Your ONLY function is to analyze the provided content and return a structured JSON object so that later it can be used to create a sales order.
|
||||
DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation.
|
||||
ONLY RETURN THE REQUESTED JSON DATA STRUCTURE.
|
||||
|
||||
TASK: Extract product information and sales order details from the following content:
|
||||
|
||||
Customer Request:
|
||||
{description}
|
||||
|
||||
Chatter Messages (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION):
|
||||
{chatter_messages}
|
||||
|
||||
Attachments Information:
|
||||
{attachments_info}
|
||||
|
||||
Attachment Contents (CRITICALLY IMPORTANT - ANALYZE PDF CONTENTS FOR ALL PRODUCT DETAILS):
|
||||
{attachment_contents}
|
||||
|
||||
WORKFLOW - FOLLOW THESE STEPS EXACTLY:
|
||||
1. Identify ONLY ACTUAL PRODUCTS mentioned in the content (product names, codes, references) from BOTH chatter messages AND PDF attachments
|
||||
2. For EACH product identified (from BOTH sources):
|
||||
a. If you find a product code/reference, call _ai_find_product_id_by_code with that code
|
||||
b. If you only have a product name, call _ai_find_product_id_by_name with that name
|
||||
c. If the product is not found in the database, use the display_type: 'line_note' format with this EXACT format: "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)"
|
||||
3. Extract order details (client reference, dates, notes)
|
||||
4. Construct the JSON response using the product IDs you obtained from tool calls
|
||||
|
||||
RESPONSE FORMAT:
|
||||
Your response MUST ONLY be a valid JSON object with the following structure:
|
||||
|
||||
{{
|
||||
"client_order_ref": "Customer PO number if mentioned",
|
||||
"date_order": "YYYY-MM-DD format if a specific order date is mentioned",
|
||||
"commitment_date": "YYYY-MM-DD format if a delivery date is mentioned",
|
||||
"note": "Any special instructions or notes for the order",
|
||||
"order_line": [
|
||||
[0, 0, {{
|
||||
"product_id": NUMERIC_ID_FROM_TOOL_CALL, // Must be an actual ID returned from a tool call
|
||||
"product_uom_qty": QUANTITY,
|
||||
"price_unit": PRICE
|
||||
}}],
|
||||
[0, 0, {{
|
||||
"display_type": "line_note",
|
||||
"name": "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)",
|
||||
"product_uom_qty": 0.0
|
||||
}}]
|
||||
]
|
||||
}}
|
||||
|
||||
CRITICAL RULES FOR IDENTIFYING PRODUCTS:
|
||||
1. A product MUST have AT LEAST ONE of the following to be considered a valid product:
|
||||
- A quantity AND a price
|
||||
- A product code/reference
|
||||
- A clearly identifiable product name with quantity
|
||||
2. DO NOT identify random descriptions, paragraphs, or sections of text as products
|
||||
3. Be aware that some items may have very long descriptions - this doesn't make them separate products
|
||||
4. If a product contains subproducts (e.g., a kit or bundle), only identify the MAIN product, not each subproduct
|
||||
5. If uncertain whether something is a product or just a description, look for quantity and price indicators
|
||||
6. VERY IMPORTANT: Read product descriptions carefully as they often contain critical information to help identify the correct product
|
||||
7. For unfound products, capture as much of the description as possible - this helps users identify what the product actually is
|
||||
8. IMPORTANT: ANY item in a PDF that looks like it could be a product MUST be checked using the tools
|
||||
9. When in doubt about whether something is a product, ALWAYS check it using the tools
|
||||
|
||||
CRITICAL RULES FOR RESPONSE:
|
||||
1. You MUST use the provided tools for EVERY valid product mentioned in BOTH chatter messages AND PDF attachments
|
||||
2. product_id MUST be a numeric ID returned by a tool call, NEVER make up IDs
|
||||
3. For products not found in the database, use the display_type: 'line_note' format with this EXACT format: "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)"
|
||||
4. Include as much detail as possible for unmatched products including quantity, reference, and description if available
|
||||
5. Return ONLY valid JSON with no text before or after
|
||||
6. DO NOT explain what you're doing or respond conversationally
|
||||
7. DO NOT say you can't create a sales order - your job is ONLY to return the JSON data
|
||||
|
||||
IMPORTANT: You must invoke the tools directly using function calling, not just output text that looks like a tool call. Use the provided tools via function calling for _ai_find_product_id_by_code and _ai_find_product_id_by_name.
|
||||
"""
|
||||
|
||||
|
||||
# Call the AI with tools
|
||||
try:
|
||||
_logger.debug("Sending request to AI for sales order generation")
|
||||
# First, get the AI's analysis with tool calls to find product IDs
|
||||
response = ai_client.chat_with_tools(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a data extraction system with access to tools for finding product IDs in an Odoo database. YOU MUST USE THE TOOLS PROVIDED TO ACCURATELY MATCH PRODUCTS PROVIDED TO THE DATABASE. Your ONLY job is to extract product information and return a structured JSON object. DO NOT engage in conversation or explain what you can or cannot do. ONLY return the requested JSON data structure. CRITICALLY IMPORTANT: You MUST identify ONLY ACTUAL PRODUCTS mentioned in BOTH chatter messages AND PDF attachments. A valid product MUST have a quantity AND either a price or product code. DO NOT identify random descriptions or paragraphs as products. VERY IMPORTANT: Read product descriptions carefully as they often contain critical information to help identify the correct product. For ANY product that cannot be found in the database, you MUST include it as a line note with display_type: 'line_note' in your response, and include as much description as possible."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
tools=["_ai_find_product_id_by_name", "_ai_find_product_id_by_code"],
|
||||
tool_params={},
|
||||
max_tool_calls=25,
|
||||
files=attachments_list
|
||||
)
|
||||
_logger.debug(f"Received AI response for ticket {self.id}")
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in AI request: {e}")
|
||||
import traceback
|
||||
_logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI Error: {str(e)}"
|
||||
}
|
||||
|
||||
# Process the AI response
|
||||
_logger.debug(f"AI response type: {type(response)}")
|
||||
|
||||
# Add detailed logging of the response content
|
||||
try:
|
||||
_logger.debug(f"AI response content: {json.dumps(response, indent=2)[:1000]}...")
|
||||
except:
|
||||
_logger.debug("Could not serialize AI response for logging")
|
||||
|
||||
# If it's a dictionary, use it directly
|
||||
if isinstance(response, dict):
|
||||
_logger.debug("AI returned dictionary response, using directly")
|
||||
return response
|
||||
|
||||
# Handle string responses (extract JSON if possible)
|
||||
if isinstance(response, str):
|
||||
_logger.debug("AI returned text response, attempting to extract JSON")
|
||||
try:
|
||||
# Look for JSON pattern in the text
|
||||
json_pattern = r'```(?:json)?\s*({[\s\S]*?})\s*```'
|
||||
json_matches = re.findall(json_pattern, response)
|
||||
|
||||
if json_matches:
|
||||
response_data = json.loads(json_matches[0])
|
||||
return response_data
|
||||
elif response.strip().startswith('{') and response.strip().endswith('}'):
|
||||
response_data = json.loads(response.strip())
|
||||
return response_data
|
||||
else:
|
||||
_logger.error("Could not extract JSON from text response")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI returned invalid format: {response[:200]}..."
|
||||
}
|
||||
except Exception as parse_error:
|
||||
_logger.error(f"Failed to extract JSON from text response: {parse_error}")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI parsing error: {str(parse_error)}"
|
||||
}
|
||||
|
||||
# If we get here, the response is in an unexpected format
|
||||
_logger.error(f"Unexpected response format: {type(response)}")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI returned unexpected format: {type(response)}"
|
||||
}
|
||||
|
||||
def _prepare_ai_prompt_data(self):
|
||||
"""
|
||||
Extract and prepare all relevant data from the helpdesk ticket for AI analysis.
|
||||
This includes ticket description, chatter messages, and attachment contents.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing ticket data for AI analysis
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.debug(f"Preparing AI prompt data for ticket {self.id}")
|
||||
|
||||
result = {
|
||||
'ticket_description': '',
|
||||
'ticket_messages': '',
|
||||
'attachments_info': '',
|
||||
'attachment_contents': ''
|
||||
}
|
||||
|
||||
# Get ticket description
|
||||
if self.description:
|
||||
result['ticket_description'] = self.description
|
||||
|
||||
# Get chatter messages
|
||||
messages = []
|
||||
if self.message_ids:
|
||||
for message in self.message_ids:
|
||||
if message.body and not message.is_internal:
|
||||
# Skip system messages and focus on actual conversation
|
||||
if not message.author_id or message.author_id.name != 'OdooBot':
|
||||
# Format: [Author] on [Date]: [Message]
|
||||
author = message.author_id.name if message.author_id else 'System'
|
||||
date = message.date.strftime('%Y-%m-%d %H:%M') if message.date else ''
|
||||
# Clean HTML from message body
|
||||
body = re.sub(r'<[^>]+>', ' ', message.body)
|
||||
messages.append(f"[{author}] on {date}: {body}")
|
||||
|
||||
result['ticket_messages'] = '\n\n'.join(messages)
|
||||
|
||||
# Get attachments
|
||||
attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
|
||||
attachment_infos = []
|
||||
attachment_contents = []
|
||||
|
||||
for attachment in attachments:
|
||||
# Add attachment metadata
|
||||
attachment_infos.append(f"File: {attachment.name} ({attachment.mimetype}, {attachment.file_size} bytes)")
|
||||
|
||||
# Add PDF content reference
|
||||
if attachment.mimetype == 'application/pdf':
|
||||
attachment_contents.append(f"IMPORTANT PDF CONTENT: {attachment.name} - The AI must analyze this PDF for ALL product mentions and include ANY products found as either matched products or unmatched line notes")
|
||||
|
||||
result['attachments_info'] = '\n'.join(attachment_infos)
|
||||
result['attachment_contents'] = '\n\n'.join(attachment_contents)
|
||||
|
||||
return result
|
||||
|
||||
def _prepare_order_line_values(self, product, quantity, description=""):
|
||||
"""Prepare values for creating a sale order line"""
|
||||
return {
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': quantity,
|
||||
'name': description or product.name,
|
||||
}
|
||||
|
||||
def _ai_find_product_id_by_name(self, product_name: str) -> int | None:
|
||||
"""Find a product by name
|
||||
|
||||
Args:
|
||||
product_name: The name of the product to find
|
||||
|
||||
Returns:
|
||||
The ID of the product if found, or None if not found
|
||||
"""
|
||||
return self.env['product.product'].search([
|
||||
('name', 'ilike', product_name),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1).id
|
||||
|
||||
def _ai_find_product_id_by_code(self, product_reference: str) -> int | None:
|
||||
"""Find a product by code
|
||||
|
||||
Args:
|
||||
product_reference: The code of the product to find
|
||||
|
||||
Returns:
|
||||
The ID of the product if found, or None if not found
|
||||
"""
|
||||
return self.env['product.product'].search([
|
||||
('default_code', 'ilike', product_reference),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1).id
|
||||
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ _logger = logging.getLogger(__name__)
|
|||
|
||||
_msg_import_logger = logging.getLogger("msg.import")
|
||||
|
||||
<<<<<<< HEAD
|
||||
# BV: THIS IS FOR REMOVING ERROR IN DEV
|
||||
# DO WE STILL NEED IT
|
||||
#handler = logging.FileHandler("/var/log/odoo/msg_import.log")
|
||||
|
|
@ -40,6 +41,10 @@ _msg_import_logger = logging.getLogger("msg.import")
|
|||
#handler.setFormatter(formatter)
|
||||
#_msg_import_logger.addHandler(handler)
|
||||
#_msg_import_logger.setLevel(logging.ERROR)
|
||||
=======
|
||||
# Use standard Odoo logger instead of file handler to avoid directory issues
|
||||
_msg_import_logger.setLevel(logging.ERROR)
|
||||
>>>>>>> 979196f ([IMP] odoo_to_odoo_sync: implement dependency management, fix connection errors, and add field mapping)
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = "ir.attachment"
|
||||
|
|
|
|||
74
odoo_to_odoo_bemade/README.md
Normal file
74
odoo_to_odoo_bemade/README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Odoo to Odoo Bemade
|
||||
|
||||
## Overview
|
||||
The `odoo_to_odoo_bemade` module extends the core synchronization framework to provide a universal connector for Bemade's Odoo instances. It adds advanced features specifically designed for Bemade's synchronization requirements, including enhanced logging, project integration, and specialized connection handling.
|
||||
|
||||
## Features
|
||||
- **Advanced Connection Management**: Support for multiple connection types and authentication methods
|
||||
- **Project Integration**: Links synchronization configurations with Odoo projects
|
||||
- **Enhanced Logging System**:
|
||||
- Multiple log levels (DEBUG, INFO, WARNING, ERROR)
|
||||
- Sensitive data masking for security
|
||||
- Long-term retention policy with AWS S3 Glacier archiving
|
||||
- Performance-optimized database indexing
|
||||
- **Improved Field Mapping**: More sophisticated field mapping capabilities
|
||||
- **Administrator Interface**: Advanced tools for monitoring and troubleshooting
|
||||
|
||||
## Technical Enhancements
|
||||
|
||||
### Advanced Logging
|
||||
The module implements a comprehensive logging system with:
|
||||
- **Log Levels**: Categorize logs by severity (DEBUG, INFO, WARNING, ERROR)
|
||||
- **Data Security**: Automatic masking of sensitive information (passwords, API keys, tokens)
|
||||
- **Retention Policy**:
|
||||
- 90 days of logs kept in the database
|
||||
- Older logs archived to AWS S3 Glacier Deep Archive with 7-year retention
|
||||
- **Detailed Context**: Capture full payloads, diffs, and metadata for troubleshooting
|
||||
|
||||
### Connection Types
|
||||
Supports multiple connection methods:
|
||||
- **XML-RPC**: Standard Odoo API connection
|
||||
- **OdooRPC**: Using the OdooRPC library for enhanced functionality
|
||||
- **REST API**: For connecting to REST-enabled Odoo instances
|
||||
- **Custom Protocols**: Extensible architecture for additional protocols
|
||||
|
||||
### Project Integration
|
||||
- Link synchronization configurations to specific projects
|
||||
- Track synchronization activities within project context
|
||||
- Provide project-specific dashboards and reports
|
||||
|
||||
## Configuration
|
||||
|
||||
### Setting Up Bemade Instances
|
||||
1. Navigate to **Synchronization > Configuration > Bemade Instances**
|
||||
2. Create a new instance with:
|
||||
- Connection details (URL, database, credentials)
|
||||
- Connection type selection
|
||||
- Advanced options for timeout, retry, etc.
|
||||
3. Test the connection using the "Test Connection" button
|
||||
|
||||
### Configuring Synchronization Models
|
||||
1. Navigate to **Synchronization > Configuration > Bemade Models**
|
||||
2. Create or select models for synchronization
|
||||
3. Configure detailed field mappings
|
||||
4. Set synchronization rules and triggers
|
||||
|
||||
### Monitoring and Management
|
||||
- View comprehensive logs in **Synchronization > Bemade Logs**
|
||||
- Monitor queue status in **Synchronization > Bemade Queue**
|
||||
- Link synchronization activities to projects
|
||||
|
||||
## Technical Notes
|
||||
- Built on top of `odoo_to_odoo_sync` core framework
|
||||
- Implements enhanced security features for sensitive data
|
||||
- Provides optimized performance for large-scale synchronization
|
||||
- Includes AWS S3 integration for long-term log archiving
|
||||
|
||||
## Requirements
|
||||
- Odoo 18.0
|
||||
- `odoo_to_odoo_sync` module
|
||||
- `project` module
|
||||
- Optional: boto3 Python library for AWS S3 integration
|
||||
|
||||
## License
|
||||
LGPL-3.0
|
||||
|
|
@ -1 +1,117 @@
|
|||
import os
|
||||
import logging
|
||||
import socket
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Post-install hook to set up Bemade-specific configuration."""
|
||||
# Set default configuration parameters for Bemade instances
|
||||
ICP = env['ir.config_parameter']
|
||||
|
||||
# Ensure the parameters exist with default values
|
||||
defaults = {
|
||||
'bemade.sync.default_url': 'https://odoo.bemade.org',
|
||||
'bemade.sync.default_database': 'bemade',
|
||||
'bemade.sync.default_username': 'sync_user',
|
||||
'bemade.sync.default_connection_type': 'odoorpc',
|
||||
'bemade.sync.default_timeout': '30',
|
||||
'bemade.sync.default_retry_count': '3',
|
||||
'bemade.sync.default_retry_delay': '5',
|
||||
}
|
||||
|
||||
for key, value in defaults.items():
|
||||
if not ICP.get_param(key):
|
||||
ICP.set_param(key, value)
|
||||
|
||||
# Optionally inject API key and other overrides from environment variables (never hardcode secrets)
|
||||
# Accepted env vars: BEMADE_SYNC_DEFAULT_URL, _DATABASE, _USERNAME, _API_KEY, _CONNECTION_TYPE, _NAME
|
||||
env_url = os.getenv('BEMADE_SYNC_DEFAULT_URL')
|
||||
env_db = os.getenv('BEMADE_SYNC_DEFAULT_DATABASE')
|
||||
env_user = os.getenv('BEMADE_SYNC_DEFAULT_USERNAME')
|
||||
env_api_key = os.getenv('BEMADE_SYNC_DEFAULT_API_KEY')
|
||||
env_conn = os.getenv('BEMADE_SYNC_DEFAULT_CONNECTION_TYPE')
|
||||
env_name = os.getenv('BEMADE_SYNC_DEFAULT_NAME')
|
||||
|
||||
# If provided via env, also persist as config parameters (except name)
|
||||
if env_url:
|
||||
ICP.set_param('bemade.sync.default_url', env_url)
|
||||
if env_db:
|
||||
ICP.set_param('bemade.sync.default_database', env_db)
|
||||
if env_user:
|
||||
ICP.set_param('bemade.sync.default_username', env_user)
|
||||
if env_conn:
|
||||
ICP.set_param('bemade.sync.default_connection_type', env_conn)
|
||||
if env_api_key:
|
||||
# Keep API key in parameters only if explicitly injected via env
|
||||
ICP.set_param('bemade.sync.default_api_key', env_api_key)
|
||||
|
||||
# Resolve final defaults (env overrides > ICP defaults)
|
||||
url = env_url or ICP.get_param('bemade.sync.default_url')
|
||||
database = env_db or ICP.get_param('bemade.sync.default_database')
|
||||
username = env_user or ICP.get_param('bemade.sync.default_username')
|
||||
api_key = env_api_key or ICP.get_param('bemade.sync.default_api_key')
|
||||
connection_type = str(env_conn or ICP.get_param('bemade.sync.default_connection_type') or 'jsonrpc').strip()
|
||||
timeout = int(ICP.get_param('bemade.sync.default_timeout') or 30)
|
||||
retry_count = int(ICP.get_param('bemade.sync.default_retry_count') or 3)
|
||||
retry_delay = int(ICP.get_param('bemade.sync.default_retry_delay') or 5)
|
||||
|
||||
# Create or update a default Bemade instance for immediate use in the Assign wizard
|
||||
try:
|
||||
Instance = env['odoo.to.bemade.instance'].sudo()
|
||||
|
||||
# Only proceed when we have the minimum secure credentials
|
||||
if url and database and username and api_key:
|
||||
domain = [('url', '=', url), ('database', '=', database), ('username', '=', username)]
|
||||
instance = Instance.search(domain, limit=1)
|
||||
|
||||
vals = {
|
||||
'name': (env_name or f"Bemade {database}").strip(),
|
||||
'url': url,
|
||||
'database': database,
|
||||
'username': username,
|
||||
'api_key': api_key,
|
||||
'connection_type': connection_type,
|
||||
'connection_timeout': timeout,
|
||||
'retry_count': retry_count,
|
||||
'retry_delay': retry_delay,
|
||||
'active': True,
|
||||
}
|
||||
|
||||
if instance:
|
||||
instance.write(vals)
|
||||
_logger.info("[Bemade Sync] Updated default instance '%s' (%s/%s)", instance.name, url, database)
|
||||
else:
|
||||
instance = Instance.create(vals)
|
||||
_logger.info("[Bemade Sync] Created default instance '%s' (%s/%s)", instance.name, url, database)
|
||||
|
||||
# Best-effort connection test; do not fail installation
|
||||
try:
|
||||
# Apply a temporary global socket timeout so lower-level libs (XML-RPC / OdooRPC)
|
||||
# cannot block the installation indefinitely.
|
||||
prev_timeout = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(timeout or 30)
|
||||
_logger.info("[Bemade Sync] Testing connection (type=%s, timeout=%ss)...", connection_type, timeout)
|
||||
instance.test_connection()
|
||||
except Exception as exc: # noqa: BLE001 - broad by design for robustness during install
|
||||
_logger.warning("[Bemade Sync] Connection test failed during post-init: %s", exc)
|
||||
finally:
|
||||
# Always restore previous socket timeout
|
||||
try:
|
||||
socket.setdefaulttimeout(prev_timeout)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
_logger.warning(
|
||||
"[Bemade Sync] Skipping default instance creation. Missing one of url/database/username/api_key. "
|
||||
"url=%s database=%s username=%s api_key=%s",
|
||||
bool(url), bool(database), bool(username), bool(api_key)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Never break installation; just log the error
|
||||
_logger.error("[Bemade Sync] Error while creating/updating default instance: %s", exc)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,30 @@
|
|||
{
|
||||
'name': 'Odoo to Odoo Bemade',
|
||||
'version': '18.0.1.0.0',
|
||||
'version': '18.0.2.0.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Connecteur universel Bemade pour synchronisation avec instances Odoo',
|
||||
'summary': 'Configuration Bemade pour synchronisation avec instances Odoo',
|
||||
'description': """
|
||||
Module de synchronisation pour Bemade avec n'importe quelle instance Odoo
|
||||
- Support multi-instances
|
||||
- Synchronisation asynchrone
|
||||
- Gestion des conflits
|
||||
- Monitoring et reprise sur erreur
|
||||
- Interface administrateur avancée
|
||||
Configuration pré-définie Bemade pour la synchronisation avec instances Odoo.
|
||||
Ce module est un wrapper minimal qui fournit la configuration par défaut
|
||||
pour les instances Bemade.
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://bemade.org',
|
||||
'depends': [
|
||||
'base',
|
||||
'project',
|
||||
'odoo_to_odoo_sync'
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/sync_instance_views.xml',
|
||||
'views/sync_model_views.xml',
|
||||
'views/sync_queue_views.xml',
|
||||
'views/sync_log_views.xml',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'views/menus.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'views/odoo_to_bemade_instance_views.xml',
|
||||
# Load project view extensions and wizard UI
|
||||
'views/project_views.xml',
|
||||
'wizards/assign_project_wizard_view.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
|
|
|||
1
odoo_to_odoo_bemade/controllers/__init__.py
Normal file
1
odoo_to_odoo_bemade/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import client_validation
|
||||
170
odoo_to_odoo_bemade/controllers/client_validation.py
Normal file
170
odoo_to_odoo_bemade/controllers/client_validation.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Client Validation Controller.
|
||||
|
||||
This controller handles XML-RPC requests from client instances for
|
||||
validating API tokens and establishing connections.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientValidationController(http.Controller):
|
||||
"""Controller for handling client validation requests."""
|
||||
|
||||
def _validate_client(self, client_key):
|
||||
"""Validate a client connection using the client key.
|
||||
|
||||
Args:
|
||||
client_key (str): The client key to validate
|
||||
|
||||
Returns:
|
||||
bool: True if the client is valid, False otherwise
|
||||
"""
|
||||
# Find the project with this client key
|
||||
project = request.env['project.project'].sudo().search([
|
||||
('client_key', '=', client_key)
|
||||
], limit=1)
|
||||
|
||||
if not project:
|
||||
_logger.warning(f"Client validation failed: No project found with client key {client_key}")
|
||||
return False
|
||||
|
||||
if not project.is_client_project:
|
||||
_logger.warning(f"Client validation failed: Project {project.id} is not marked as client project")
|
||||
return False
|
||||
|
||||
# Create or update the client instance
|
||||
instance_vals = {
|
||||
'name': f"Client {client_key}",
|
||||
'url': request.httprequest.remote_addr,
|
||||
'connection_type': 'xmlrpc',
|
||||
}
|
||||
|
||||
if project.client_instance_id:
|
||||
project.client_instance_id.sudo().write(instance_vals)
|
||||
else:
|
||||
instance = request.env['odoo.to.bemade.instance'].sudo().create(instance_vals)
|
||||
project.sudo().write({'client_instance_id': instance.id})
|
||||
|
||||
_logger.info(f"Client validation successful for project {project.id}")
|
||||
return True
|
||||
|
||||
def _authenticate_client(self, client_key, api_token):
|
||||
"""Authenticate a client using client key and API token.
|
||||
|
||||
Args:
|
||||
client_key (str): The client key
|
||||
api_token (str): The API token
|
||||
|
||||
Returns:
|
||||
dict: Authentication result with company info and models
|
||||
"""
|
||||
# Find the project with this client key
|
||||
project = request.env['project.project'].sudo().search([
|
||||
('client_key', '=', client_key),
|
||||
('client_api_token', '=', api_token)
|
||||
], limit=1)
|
||||
|
||||
if not project:
|
||||
_logger.warning(f"Client authentication failed: Invalid credentials for client key {client_key}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Invalid client key or API token'
|
||||
}
|
||||
|
||||
if not project.is_client_project:
|
||||
_logger.warning(f"Client authentication failed: Project {project.id} is not marked as client project")
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Project is not configured as client project'
|
||||
}
|
||||
|
||||
# Get company information
|
||||
company = request.env.user.company_id
|
||||
|
||||
# Get available sync models for this project
|
||||
models = []
|
||||
for model in project.sync_model_ids:
|
||||
models.append({
|
||||
'model': model.model_id.model,
|
||||
'target_model': model.target_model,
|
||||
'field_mapping': model.field_mapping,
|
||||
'domain': model.sync_domain,
|
||||
})
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'company_name': company.name,
|
||||
'models': models
|
||||
}
|
||||
|
||||
_logger.info(f"Client authentication successful for project {project.id}")
|
||||
return result
|
||||
try:
|
||||
# Find the project with the matching API token
|
||||
project = request.env['project.project'].sudo().search([
|
||||
('client_api_token', '=', api_key),
|
||||
('is_client_project', '=', True)
|
||||
], limit=1)
|
||||
|
||||
if not project:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Clé API invalide'
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'company_name': project.company_id.name if project.company_id else project.name,
|
||||
'models': model_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Erreur de validation client: %s", str(e))
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Erreur de validation'
|
||||
}
|
||||
|
||||
def _authenticate_client(self, client_key, api_key, instance_id):
|
||||
"""Authenticate client and return user ID.
|
||||
|
||||
Args:
|
||||
client_key (str): The client key provided by the user
|
||||
api_key (str): The API key provided by the user
|
||||
instance_id (str): The unique instance ID of the client
|
||||
|
||||
Returns:
|
||||
int or False: User ID if authentication successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Find the project with the matching API token
|
||||
project = request.env['project.project'].sudo().search([
|
||||
('client_api_token', '=', api_key),
|
||||
('is_client_project', '=', True)
|
||||
], limit=1)
|
||||
|
||||
if not project:
|
||||
return False
|
||||
|
||||
# Check if the client key matches
|
||||
# For now, we'll use the project ID as the client key
|
||||
if str(project.id) != client_key:
|
||||
return False
|
||||
|
||||
# Return a valid user ID for synchronization
|
||||
# In a real implementation, this would be a specific sync user
|
||||
return request.env.ref('base.user_admin').id
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Erreur d'authentification client: %s", str(e))
|
||||
return False
|
||||
40
odoo_to_odoo_bemade/data/ir_config_parameter_data.xml
Normal file
40
odoo_to_odoo_bemade/data/ir_config_parameter_data.xml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Bemade Default Configuration Parameters - using function tag to handle existing values -->
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_url</value>
|
||||
<value name="value">https://odoo.bemade.org</value>
|
||||
</function>
|
||||
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_database</value>
|
||||
<value name="value">bemade</value>
|
||||
</function>
|
||||
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_username</value>
|
||||
<value name="value">sync_user</value>
|
||||
</function>
|
||||
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_connection_type</value>
|
||||
<value name="value">odoorpc</value>
|
||||
</function>
|
||||
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_timeout</value>
|
||||
<value name="value">30</value>
|
||||
</function>
|
||||
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_retry_count</value>
|
||||
<value name="value">3</value>
|
||||
</function>
|
||||
|
||||
<function name="set_param" model="ir.config_parameter">
|
||||
<value name="key">bemade.sync.default_retry_delay</value>
|
||||
<value name="value">5</value>
|
||||
</function>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -9,8 +9,6 @@
|
|||
<field name="code">model.process_queue(limit=100)</field>
|
||||
<field name="interval_number">10</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -22,8 +20,6 @@
|
|||
<field name="code">model.clean_old_logs(days=30)</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -35,8 +31,6 @@
|
|||
<field name="code">model.check_all_connections()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -46,10 +40,8 @@
|
|||
<field name="model_id" ref="model_odoo_to_bemade_sync_model"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.sync_critical_models()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_number">24</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ from . import sync_model_field
|
|||
from . import sync_queue
|
||||
from . import sync_log
|
||||
from . import sync_manager
|
||||
from . import project
|
||||
from . import odoo_to_bemade_instance
|
||||
|
|
|
|||
67
odoo_to_odoo_bemade/models/odoo_to_bemade_instance.py
Normal file
67
odoo_to_odoo_bemade/models/odoo_to_bemade_instance.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
import logging
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OdooToBemadeInstance(models.Model):
|
||||
_name = 'odoo.to.bemade.instance'
|
||||
_description = 'Bemade Remote Odoo Instance'
|
||||
_inherit = ['odoo.to.bemade.instance', 'mail.thread', 'mail.activity.mixin']
|
||||
|
||||
# Bemade-specific fields (do not duplicate base sync fields)
|
||||
project_id = fields.Many2one(
|
||||
'project.project',
|
||||
string='Project',
|
||||
domain=[('is_client_project', '=', True)]
|
||||
)
|
||||
|
||||
client_key = fields.Char(string='Client Key', readonly=True)
|
||||
bemade_api_token = fields.Char(string='Bemade API Token', readonly=True)
|
||||
|
||||
is_client_instance = fields.Boolean(string='Is Client Instance', default=False)
|
||||
|
||||
# Additional informational field not present in base model
|
||||
last_sync_date = fields.Datetime(string='Last Sync Date')
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Delegate to the inherited test_connection and notify the user."""
|
||||
self.ensure_one()
|
||||
ok = False
|
||||
message = _('Connection test completed.')
|
||||
try:
|
||||
ok = bool(self.test_connection())
|
||||
message = _('Connection successful') if ok else _('Connection failed')
|
||||
except Exception as e: # noqa: BLE001 - notify without breaking UI
|
||||
message = _('Connection failed: %s') % str(e)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Connection Test'),
|
||||
'message': message,
|
||||
'type': 'success' if ok else 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
def action_sync_project_data(self):
|
||||
"""Sync project data with the client instance."""
|
||||
self.ensure_one()
|
||||
if self.state != 'connected':
|
||||
raise UserError(_('Please test the connection first.'))
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sync Project Data'),
|
||||
'res_model': 'odoo.sync.queue',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_instance_id': self.id,
|
||||
'default_project_id': self.project_id.id,
|
||||
}
|
||||
}
|
||||
98
odoo_to_odoo_bemade/models/project.py
Normal file
98
odoo_to_odoo_bemade/models/project.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Project Extension for Client Synchronization.
|
||||
|
||||
This module extends the project model to support client synchronization features,
|
||||
including marking projects as client projects and generating API tokens for
|
||||
secure client connections.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ProjectProject(models.Model):
|
||||
_name = 'project.project'
|
||||
_inherit = 'project.project'
|
||||
|
||||
is_client_project = fields.Boolean(
|
||||
string='Client Project',
|
||||
default=False,
|
||||
help='Check this box to mark this project as a client project for synchronization'
|
||||
)
|
||||
|
||||
client_key = fields.Char(
|
||||
string='Client Key',
|
||||
readonly=True,
|
||||
help='Unique client key for this project. Generated when project is marked as client project.'
|
||||
)
|
||||
|
||||
client_api_token = fields.Char(
|
||||
string='Client API Token',
|
||||
readonly=True,
|
||||
help='API token for client synchronization. Generated when project is marked as client project.'
|
||||
)
|
||||
|
||||
client_instance_id = fields.Many2one(
|
||||
comodel_name='odoo.to.bemade.instance',
|
||||
string='Client Instance',
|
||||
readonly=True,
|
||||
help='The client instance associated with this project'
|
||||
)
|
||||
|
||||
sync_model_ids = fields.One2many(
|
||||
comodel_name='odoo.to.bemade.sync.model',
|
||||
inverse_name='project_id',
|
||||
string='Synchronized Models',
|
||||
help='Models configured for synchronization with this project'
|
||||
)
|
||||
|
||||
@api.onchange('is_client_project')
|
||||
def _onchange_is_client_project(self):
|
||||
"""Generate API token and client key when project is marked as client project."""
|
||||
if self.is_client_project and not self.client_api_token:
|
||||
self.client_api_token = self._generate_api_token()
|
||||
# Generate a unique client key based on project ID and a random component
|
||||
if self.id:
|
||||
self.client_key = f"{self.id}-{self._generate_api_token()[:8]}"
|
||||
else:
|
||||
self.client_key = self._generate_api_token()[:16]
|
||||
elif not self.is_client_project:
|
||||
self.client_api_token = False
|
||||
self.client_key = False
|
||||
self.client_instance_id = False
|
||||
|
||||
def _generate_api_token(self):
|
||||
"""Generate a secure API token for client authentication.
|
||||
|
||||
Returns:
|
||||
str: A secure random token
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def action_generate_api_token(self):
|
||||
"""Manually generate or regenerate API token for client project.
|
||||
|
||||
This method can be called from the UI to generate a new API token
|
||||
for an existing client project.
|
||||
"""
|
||||
for record in self:
|
||||
if not record.is_client_project:
|
||||
raise UserError(_("Only client projects can have API tokens. Please mark this project as a client project first."))
|
||||
record.client_api_token = record._generate_api_token()
|
||||
return True
|
||||
|
||||
def action_revoke_api_token(self):
|
||||
"""Revoke the API token for a client project.
|
||||
|
||||
This method clears the API token and disconnects any associated client instance.
|
||||
"""
|
||||
for record in self:
|
||||
record.client_api_token = False
|
||||
record.client_instance_id = False
|
||||
return True
|
||||
|
|
@ -11,6 +11,7 @@ for OdooRPC and specialized connection handling for Bemade clients.
|
|||
import logging
|
||||
import time
|
||||
import random
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Import XML-RPC for standard connections
|
||||
|
|
@ -106,10 +107,8 @@ class OdooToBemadeInstance(models.Model):
|
|||
_inherit = 'odoo.sync.instance'
|
||||
|
||||
# Champs spécifiques à Bemade
|
||||
api_key = fields.Char(
|
||||
string='API Key',
|
||||
help='Optional API key for authentication with Bemade instance',
|
||||
)
|
||||
# Note: Legacy API key functionality has been removed
|
||||
# Use the built-in api_key field from parent model instead
|
||||
|
||||
# Override connection_type to add OdooRPC option
|
||||
connection_type = fields.Selection(
|
||||
|
|
@ -163,22 +162,8 @@ class OdooToBemadeInstance(models.Model):
|
|||
|
||||
# Remarque: model_ids et log_ids sont déjà définis ci-dessus, pas besoin de duplication
|
||||
|
||||
@api.onchange
|
||||
def onchange(self, values, field_names, fields_spec):
|
||||
"""Handle onchange events.
|
||||
|
||||
This method implements the abstract method from BaseModel by delegating
|
||||
to the parent class implementation.
|
||||
|
||||
Args:
|
||||
values: The values dict
|
||||
field_names: Names of the fields that triggered the onchange
|
||||
fields_spec: The onchange specification
|
||||
|
||||
Returns:
|
||||
Dictionary with updated values
|
||||
"""
|
||||
return super(OdooToBemadeInstance, self).onchange(values, field_names, fields_spec)
|
||||
# NOTE: The custom onchange method was removed as it had an incorrect signature
|
||||
# and was causing TypeError when creating new instances
|
||||
|
||||
@api.onchange('url')
|
||||
def _onchange_url(self):
|
||||
|
|
@ -203,6 +188,7 @@ class OdooToBemadeInstance(models.Model):
|
|||
Note: connection_type is inherited from the parent model, but linters won't detect this.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Field inherited from parent model: connection_type
|
||||
# pylint: disable=no-member
|
||||
if self.connection_type == 'odoorpc':
|
||||
|
|
@ -210,7 +196,135 @@ class OdooToBemadeInstance(models.Model):
|
|||
|
||||
# Call the parent method - linters might not recognize this as valid
|
||||
# pylint: disable=no-member
|
||||
return super(OdooToBemadeInstance, self).test_connection()
|
||||
result = super(OdooToBemadeInstance, self).test_connection()
|
||||
|
||||
# Create a log entry with the result
|
||||
if result:
|
||||
self.env['odoo.to.bemade.sync.log'].log(
|
||||
operation='test_connection',
|
||||
model='odoo.to.bemade.instance',
|
||||
record_id=self.id,
|
||||
result='success',
|
||||
details=f"Successfully connected to {self.name} using {self.connection_type}",
|
||||
instance_id=self.id
|
||||
# queue_id will be automatically created by the log method
|
||||
)
|
||||
else:
|
||||
self.env['odoo.to.bemade.sync.log'].log(
|
||||
operation='test_connection',
|
||||
model='odoo.to.bemade.instance',
|
||||
record_id=self.id,
|
||||
result='error',
|
||||
details=f"Connection test failed: {self.error_message}",
|
||||
instance_id=self.id
|
||||
# queue_id will be automatically created by the log method
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _test_xmlrpc_connection(self):
|
||||
"""Override parent's XML-RPC connection test to support API key authentication."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
# Validate URL format
|
||||
if not self.url:
|
||||
raise UserError("L'URL est requise pour tester la connexion")
|
||||
|
||||
# Parse URL to extract connection details
|
||||
parsed_url = urlparse(self.url)
|
||||
if not parsed_url.scheme or not parsed_url.netloc:
|
||||
raise UserError("Format d'URL invalide. Exemple valide: https://exemple.odoo.com")
|
||||
|
||||
# Get API token for authentication (Odoo 18+ expects dict with scope/key during authenticate)
|
||||
api_token = self._decrypt_sensitive_data() or self.api_key
|
||||
|
||||
# Timeout-aware XML-RPC transport
|
||||
class TimeoutTransport(xmlrpc.client.Transport):
|
||||
def __init__(self, timeout=None, use_datetime=False):
|
||||
super().__init__(use_datetime=use_datetime)
|
||||
self.timeout = timeout
|
||||
def make_connection(self, host):
|
||||
conn = super().make_connection(host)
|
||||
try:
|
||||
conn.timeout = self.timeout
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
transport = TimeoutTransport(timeout=self.connection_timeout or 30)
|
||||
common = xmlrpc.client.ServerProxy(
|
||||
f'{self.url}/xmlrpc/2/common', transport=transport, allow_none=True
|
||||
)
|
||||
uid = common.authenticate(
|
||||
self.database, self.username, {'scope': 'rpc', 'key': api_token}, {}
|
||||
)
|
||||
|
||||
if uid:
|
||||
self.write({
|
||||
'state': 'connected',
|
||||
'last_connection': fields.Datetime.now(),
|
||||
'error_message': False
|
||||
})
|
||||
return True
|
||||
else:
|
||||
raise UserError('Échec d\'authentification')
|
||||
|
||||
except UserError as e:
|
||||
# Pass through our UserError without modification
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': str(e)
|
||||
})
|
||||
_logger.error("Erreur d'authentification XML-RPC: %s", str(e))
|
||||
return False
|
||||
|
||||
except (ConnectionError, TimeoutError, xmlrpc.client.Fault, xmlrpc.client.ProtocolError) as e:
|
||||
# Catch specific exceptions that can be raised during XML-RPC connection
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': str(e)
|
||||
})
|
||||
_logger.error("Erreur de connexion XML-RPC: %s", str(e))
|
||||
return False
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# Fall back for any unexpected exceptions
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': f"Erreur inattendue: {str(e)}"
|
||||
})
|
||||
_logger.error("Erreur inattendue XML-RPC: %s", str(e))
|
||||
return False
|
||||
|
||||
def _get_xmlrpc_connection(self):
|
||||
"""Override parent's XML-RPC connection method to support API key authentication."""
|
||||
# Get API token for authentication
|
||||
api_token = self._decrypt_sensitive_data() or self.api_key
|
||||
|
||||
# Timeout-aware transport
|
||||
class TimeoutTransport(xmlrpc.client.Transport):
|
||||
def __init__(self, timeout=None, use_datetime=False):
|
||||
super().__init__(use_datetime=use_datetime)
|
||||
self.timeout = timeout
|
||||
def make_connection(self, host):
|
||||
conn = super().make_connection(host)
|
||||
try:
|
||||
conn.timeout = self.timeout
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
transport = TimeoutTransport(timeout=self.connection_timeout or 30)
|
||||
common = xmlrpc.client.ServerProxy(
|
||||
f'{self.url}/xmlrpc/2/common', transport=transport, allow_none=True
|
||||
)
|
||||
uid = common.authenticate(
|
||||
self.database, self.username, {'scope': 'rpc', 'key': api_token}, {}
|
||||
)
|
||||
models = xmlrpc.client.ServerProxy(
|
||||
f'{self.url}/xmlrpc/2/object', transport=transport, allow_none=True
|
||||
)
|
||||
return models, uid
|
||||
|
||||
def _test_odoorpc_connection(self):
|
||||
"""Test connection using OdooRPC library.
|
||||
|
|
@ -231,28 +345,65 @@ class OdooToBemadeInstance(models.Model):
|
|||
"Installez-la avec 'pip install odoorpc'"
|
||||
) from exc
|
||||
|
||||
# Parse URL to extract connection details
|
||||
parsed_url = urlparse(self.url)
|
||||
protocol = parsed_url.scheme
|
||||
host = parsed_url.netloc
|
||||
|
||||
# Extract port if present
|
||||
if ':' in host:
|
||||
host, port = host.split(':')
|
||||
port = int(port)
|
||||
else:
|
||||
port = 443 if protocol == 'https' else 80
|
||||
# Parse URL to extract connection details (ensure scheme present)
|
||||
raw_url = (self.url or '').strip()
|
||||
parsed_url = urlparse(raw_url)
|
||||
if not parsed_url.scheme:
|
||||
raw_url = f'https://{raw_url}'
|
||||
parsed_url = urlparse(raw_url)
|
||||
protocol = 'jsonrpc+ssl' if parsed_url.scheme == 'https' else 'jsonrpc'
|
||||
host = parsed_url.hostname
|
||||
if not host:
|
||||
raise UserError("URL invalide: hôte introuvable. Incluez le schéma (https://) et le domaine.")
|
||||
|
||||
# Determine port using parsed value or defaults
|
||||
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
|
||||
|
||||
# Preflight TCP connectivity to avoid hangs (DNS/TLS/connect)
|
||||
_logger.info(
|
||||
"[Bemade Sync][OdooRPC] Preflight TCP connect to %s:%s (timeout=%ss)",
|
||||
host, port, self.connection_timeout or 30,
|
||||
)
|
||||
try:
|
||||
start = time.time()
|
||||
with socket.create_connection((host, port), timeout=self.connection_timeout or 30):
|
||||
pass
|
||||
_logger.info(
|
||||
"[Bemade Sync][OdooRPC] Preflight OK in %.3fs",
|
||||
time.time() - start,
|
||||
)
|
||||
except Exception as e:
|
||||
raise UserError(f"Impossible de joindre {host}:{port} - {e}") from e
|
||||
|
||||
# Attempt connection with OdooRPC
|
||||
# Note: timeout is passed as a keyword argument during login phase
|
||||
odoo = odoorpc.ODOO(
|
||||
host,
|
||||
protocol=protocol,
|
||||
host,
|
||||
protocol=protocol,
|
||||
port=port
|
||||
)
|
||||
# Configure library timeout to prevent hanging connections
|
||||
if hasattr(odoo, 'config'):
|
||||
odoo.config['timeout'] = self.connection_timeout or 30
|
||||
_logger.info(
|
||||
"[Bemade Sync][OdooRPC] Connecting to %s:%s protocol=%s timeout=%ss",
|
||||
host, port, protocol, self.connection_timeout or 30,
|
||||
)
|
||||
|
||||
# Try to login with credentials
|
||||
odoo.login(self.database, self.username, self.password)
|
||||
api_key = self._decrypt_sensitive_data() or self.api_key
|
||||
prev_timeout = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(self.connection_timeout or 30)
|
||||
try:
|
||||
_logger.info(
|
||||
"[Bemade Sync][OdooRPC] Logging in db=%s user=%s",
|
||||
self.database, self.username,
|
||||
)
|
||||
odoo.login(self.database, self.username, api_key)
|
||||
finally:
|
||||
try:
|
||||
socket.setdefaulttimeout(prev_timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If successful, update record state
|
||||
if odoo.env:
|
||||
|
|
@ -261,6 +412,8 @@ class OdooToBemadeInstance(models.Model):
|
|||
'last_connection': fields.Datetime.now(),
|
||||
'error_message': False
|
||||
})
|
||||
# Success is logged in the main test_connection method
|
||||
# No need to log here to avoid duplicate entries
|
||||
return True
|
||||
else:
|
||||
# This should rarely happen as login usually raises an exception
|
||||
|
|
@ -268,28 +421,45 @@ class OdooToBemadeInstance(models.Model):
|
|||
|
||||
except UserError as e:
|
||||
# Pass through our UserError without modification
|
||||
error_msg = str(e)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': str(e)
|
||||
'error_message': error_msg
|
||||
})
|
||||
_logger.error("Erreur d'authentification OdooRPC: %s", str(e))
|
||||
# Error is logged in the main test_connection method
|
||||
# No need to log here to avoid duplicate entries
|
||||
_logger.error("Erreur d'authentification OdooRPC: %s", error_msg)
|
||||
return False
|
||||
|
||||
except (ConnectionError, TimeoutError, ValueError, TypeError) as e:
|
||||
except (ConnectionError, TimeoutError, socket.timeout, ValueError, TypeError) as e:
|
||||
# Catch specific exceptions that can be raised during connection
|
||||
error_msg = str(e)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': str(e)
|
||||
'error_message': error_msg
|
||||
})
|
||||
_logger.error("Erreur de connexion OdooRPC: %s", str(e))
|
||||
# Error is logged in the main test_connection method
|
||||
# No need to log here to avoid duplicate entries
|
||||
_logger.error("Erreur de connexion OdooRPC: %s", error_msg)
|
||||
return False
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# Fall back for any unexpected exceptions
|
||||
error_msg = str(e)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': f"Erreur inattendue: {str(e)}"
|
||||
'error_message': f"Erreur inattendue: {error_msg}"
|
||||
})
|
||||
_logger.error("Erreur inattendue OdooRPC: %s", str(e))
|
||||
# Create error log entry
|
||||
self.env['odoo.to.bemade.sync.log'].log(
|
||||
operation='test_odoorpc_connection',
|
||||
model='odoo.to.bemade.instance',
|
||||
record_id=self.id,
|
||||
result='error',
|
||||
details=f"Unexpected error: {error_msg}",
|
||||
instance_id=self.id
|
||||
# queue_id will be automatically created by the log method
|
||||
)
|
||||
_logger.error("Erreur inattendue OdooRPC: %s", error_msg)
|
||||
return False
|
||||
|
||||
def get_connection(self):
|
||||
|
|
@ -337,31 +507,47 @@ class OdooToBemadeInstance(models.Model):
|
|||
"Installez-la avec 'pip install odoorpc'"
|
||||
) from exc
|
||||
|
||||
# Parse URL to extract connection details
|
||||
parsed_url = urlparse(self.url)
|
||||
protocol = parsed_url.scheme
|
||||
host = parsed_url.netloc
|
||||
|
||||
# Extract port if present
|
||||
if ':' in host:
|
||||
host, port = host.split(':')
|
||||
port = int(port)
|
||||
else:
|
||||
port = 443 if protocol == 'https' else 80
|
||||
# Parse URL to extract connection details (ensure scheme present)
|
||||
raw_url = (self.url or '').strip()
|
||||
parsed_url = urlparse(raw_url)
|
||||
if not parsed_url.scheme:
|
||||
raw_url = f'https://{raw_url}'
|
||||
parsed_url = urlparse(raw_url)
|
||||
is_https = parsed_url.scheme == 'https'
|
||||
protocol = 'jsonrpc+ssl' if is_https else 'jsonrpc'
|
||||
host = parsed_url.hostname
|
||||
if not host:
|
||||
raise UserError("URL invalide: hôte introuvable. Incluez le schéma (https://) et le domaine.")
|
||||
port = parsed_url.port or (443 if is_https else 80)
|
||||
|
||||
# Preflight TCP connectivity to avoid hangs (DNS/TLS/connect)
|
||||
_logger.info(
|
||||
"[Bemade Sync][OdooRPC] get_connection preflight TCP to %s:%s (timeout=%ss)",
|
||||
host, port, self.connection_timeout or 30,
|
||||
)
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=self.connection_timeout or 30):
|
||||
pass
|
||||
except Exception as e:
|
||||
raise UserError(f"Impossible de joindre {host}:{port} - {e}") from e
|
||||
|
||||
# Create OdooRPC connection
|
||||
# Note: OdooRPC doesn't accept timeout in constructor, set it after
|
||||
odoo = odoorpc.ODOO(
|
||||
host,
|
||||
protocol=protocol,
|
||||
host,
|
||||
protocol=protocol,
|
||||
port=port
|
||||
)
|
||||
# Set timeout via attribute if available
|
||||
if hasattr(odoo, 'config'):
|
||||
odoo.config['timeout'] = self.connection_timeout
|
||||
odoo.config['timeout'] = self.connection_timeout or 30
|
||||
_logger.info(
|
||||
"[Bemade Sync][OdooRPC] get_connection -> %s:%s protocol=%s timeout=%ss",
|
||||
host, port, protocol, self.connection_timeout or 30,
|
||||
)
|
||||
|
||||
# Login with credentials
|
||||
odoo.login(self.database, self.username, self.password)
|
||||
# Login with API token credentials
|
||||
api_token = self._decrypt_sensitive_data() or self.api_key
|
||||
odoo.login(self.database, self.username, api_token)
|
||||
|
||||
# Create a wrapper class to make OdooRPC interface compatible with xmlrpc/jsonrpc
|
||||
class OdooRPCWrapper:
|
||||
|
|
@ -499,11 +685,12 @@ class OdooToBemadeInstance(models.Model):
|
|||
else:
|
||||
# For XML-RPC, delegate to parent implementation
|
||||
_logger.debug("Executing %s.%s via standard RPC on %s", model, method, self.name)
|
||||
# XML-RPC connection returns a tuple (common, models)
|
||||
common, models = connection
|
||||
# Execute the method directly on the model
|
||||
# XML-RPC connection returns a tuple (models, uid)
|
||||
models, uid = connection
|
||||
api_token = self._decrypt_sensitive_data() or self.api_key
|
||||
# Execute the method directly on the model with API token
|
||||
return models.execute_kw(
|
||||
self.database, self.env.uid, self.password,
|
||||
self.database, uid, api_token,
|
||||
model, method, args, kwargs
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,20 @@ functionality for logging synchronization operations.
|
|||
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api, tools
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import config
|
||||
import pytz
|
||||
|
||||
from odoo import api, fields, models
|
||||
# Check if boto3 is available for S3 archiving
|
||||
try:
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
HAS_BOTO3 = True
|
||||
except ImportError:
|
||||
HAS_BOTO3 = False
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -19,13 +30,19 @@ class OdooToBemadeSyncLog(models.Model):
|
|||
"""Synchronization log for Bemade operations.
|
||||
|
||||
Extends the base synchronization log to handle Bemade-specific
|
||||
logging requirements.
|
||||
logging requirements with advanced features:
|
||||
|
||||
- Log levels (DEBUG, INFO, WARNING, ERROR)
|
||||
- Retention policy (90 days local, 7 years in S3 Glacier)
|
||||
- Sensitive data masking
|
||||
"""
|
||||
|
||||
_name = 'odoo.to.bemade.sync.log'
|
||||
_description = 'Bemade Sync Log Entry'
|
||||
_inherit = 'odoo.sync.log'
|
||||
_order = 'create_date desc'
|
||||
_order = 'create_date desc, log_level'
|
||||
# Make logs read-only by default
|
||||
_auto_write = False
|
||||
|
||||
# Add Bemade-specific fields here
|
||||
bemade_instance_id = fields.Many2one(
|
||||
|
|
@ -34,8 +51,72 @@ class OdooToBemadeSyncLog(models.Model):
|
|||
help='The Bemade instance related to this log entry',
|
||||
)
|
||||
|
||||
# Fields used by the log method
|
||||
operation = fields.Char(
|
||||
string='Operation',
|
||||
help='Type of operation performed (create, update, delete, etc.)',
|
||||
)
|
||||
model = fields.Char(
|
||||
string='Model',
|
||||
help='Technical name of the model that was synchronized',
|
||||
)
|
||||
record_id = fields.Integer(
|
||||
string='Record ID',
|
||||
help='ID of the record that was synchronized',
|
||||
)
|
||||
result = fields.Selection(
|
||||
selection=[
|
||||
('success', 'Success'),
|
||||
('error', 'Error'),
|
||||
],
|
||||
string='Result',
|
||||
help='Result of the operation',
|
||||
)
|
||||
|
||||
# Advanced logging fields
|
||||
log_level = fields.Selection(
|
||||
selection=[
|
||||
('debug', 'DEBUG'),
|
||||
('info', 'INFO'),
|
||||
('warning', 'WARNING'),
|
||||
('error', 'ERROR'),
|
||||
],
|
||||
string='Log Level',
|
||||
default='info',
|
||||
help='Severity level of the log entry',
|
||||
index=True,
|
||||
)
|
||||
payload = fields.Text(
|
||||
string='Full Payload',
|
||||
help='Complete data payload for debug purposes',
|
||||
)
|
||||
diff = fields.Text(
|
||||
string='Changes',
|
||||
help='Differences between before and after states',
|
||||
)
|
||||
metadata = fields.Text(
|
||||
string='Metadata',
|
||||
help='Additional contextual information',
|
||||
)
|
||||
archived = fields.Boolean(
|
||||
string='Archived',
|
||||
default=False,
|
||||
help='Whether this log has been archived to long-term storage',
|
||||
index=True,
|
||||
)
|
||||
archive_reference = fields.Char(
|
||||
string='Archive Reference',
|
||||
help='Reference to the archived log in long-term storage',
|
||||
)
|
||||
sanitized = fields.Boolean(
|
||||
string='Sanitized',
|
||||
default=False,
|
||||
help='Whether sensitive data has been masked in this log',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def log(self, operation, model=None, record_id=None, result='success', details=None, instance_id=None):
|
||||
def log(self, operation, model=None, record_id=None, result='success', details=None,
|
||||
instance_id=None, queue_id=None, log_level='info', payload=None, diff=None, metadata=None):
|
||||
"""Create a log entry for a synchronization operation.
|
||||
|
||||
Args:
|
||||
|
|
@ -45,6 +126,7 @@ class OdooToBemadeSyncLog(models.Model):
|
|||
result: Result of the operation (success, error)
|
||||
details: Additional details or error message
|
||||
instance_id: ID of the Bemade instance involved
|
||||
queue_id: ID of the queue entry (will create a special queue entry if not provided)
|
||||
|
||||
Returns:
|
||||
The created log entry record
|
||||
|
|
@ -52,11 +134,293 @@ class OdooToBemadeSyncLog(models.Model):
|
|||
if details and not isinstance(details, str):
|
||||
details = json.dumps(details)
|
||||
|
||||
return self.create({
|
||||
# Handle payload, diff and metadata serialization
|
||||
if payload and not isinstance(payload, str):
|
||||
payload = json.dumps(payload)
|
||||
if diff and not isinstance(diff, str):
|
||||
diff = json.dumps(diff)
|
||||
if metadata and not isinstance(metadata, str):
|
||||
metadata = json.dumps(metadata)
|
||||
|
||||
# Sanitize sensitive data
|
||||
sanitized = False
|
||||
if payload:
|
||||
payload, sanitized_payload = self._sanitize_data(payload)
|
||||
sanitized = sanitized_payload
|
||||
if details:
|
||||
details, sanitized_details = self._sanitize_data(details)
|
||||
sanitized = sanitized or sanitized_details
|
||||
if metadata:
|
||||
metadata, sanitized_metadata = self._sanitize_data(metadata)
|
||||
sanitized = sanitized or sanitized_metadata
|
||||
|
||||
# If no queue_id is provided, create a special queue entry for this log
|
||||
if not queue_id:
|
||||
# For connection tests, we need to create a special queue entry
|
||||
# Find or create a sync model for the instance model
|
||||
# Note: odoo.sync.model uses 'name' field (related to model_id.model) to store the model name
|
||||
sync_model = self.env['odoo.sync.model'].search(
|
||||
[('name', '=', model)], limit=1)
|
||||
|
||||
if not sync_model and model:
|
||||
# Create a sync model if it doesn't exist
|
||||
# First, find the ir.model record for this model
|
||||
ir_model = self.env['ir.model'].search([('model', '=', model)], limit=1)
|
||||
if ir_model:
|
||||
# Find any instance to use for this auto-created sync model
|
||||
# For connection tests, we can use the instance_id parameter if provided
|
||||
instance = False
|
||||
if instance_id:
|
||||
instance = self.env['odoo.to.bemade.instance'].browse(instance_id)
|
||||
|
||||
# If no instance provided or found, try to find any instance
|
||||
if not instance:
|
||||
instance = self.env['odoo.sync.instance'].search([], limit=1)
|
||||
|
||||
if instance:
|
||||
sync_model = self.env['odoo.sync.model'].create({
|
||||
'model_id': ir_model.id,
|
||||
'target_model': model, # Use same model name as target
|
||||
'instance_id': instance.id, # Required field
|
||||
})
|
||||
else:
|
||||
_logger.error("Cannot create sync model: No instance available")
|
||||
else:
|
||||
_logger.error(f"Cannot create sync model: No ir.model found for {model}")
|
||||
|
||||
# Create a queue entry for this log
|
||||
queue_vals = {
|
||||
'model_id': sync_model.id if sync_model else False,
|
||||
'record_id': record_id or 0,
|
||||
'operation': 'create', # Use 'create' as a valid operation for queue entries
|
||||
'state': 'done' if result == 'success' else 'error',
|
||||
'data': details,
|
||||
}
|
||||
|
||||
if not sync_model:
|
||||
# If we couldn't find or create a sync model, we need to handle this case
|
||||
# by using a fallback approach - find any sync model to use
|
||||
fallback_model = self.env['odoo.sync.model'].search([], limit=1)
|
||||
if fallback_model:
|
||||
queue_vals['model_id'] = fallback_model.id
|
||||
else:
|
||||
# We can't create a log without a sync model for the queue
|
||||
_logger.error(
|
||||
"Cannot create sync log: No sync model available for queue creation")
|
||||
return False
|
||||
|
||||
queue = self.env['odoo.sync.queue'].create(queue_vals)
|
||||
queue_id = queue.id
|
||||
|
||||
log_entry = self.create({
|
||||
'operation': operation,
|
||||
'model': model,
|
||||
'record_id': record_id,
|
||||
'result': result,
|
||||
'details': details,
|
||||
'bemade_instance_id': instance_id,
|
||||
'queue_id': queue_id,
|
||||
'state': result, # Map our result to the parent's state field
|
||||
'message': details, # Map our details to the parent's message field
|
||||
'log_level': log_level,
|
||||
'payload': payload,
|
||||
'diff': diff,
|
||||
'metadata': metadata,
|
||||
'sanitized': sanitized,
|
||||
})
|
||||
|
||||
return log_entry
|
||||
|
||||
def _sanitize_data(self, data_str):
|
||||
"""Mask sensitive data in log entries.
|
||||
|
||||
Args:
|
||||
data_str: String representation of data to sanitize
|
||||
|
||||
Returns:
|
||||
tuple: (sanitized_data, was_sanitized)
|
||||
"""
|
||||
was_sanitized = False
|
||||
if not data_str:
|
||||
return data_str, was_sanitized
|
||||
|
||||
try:
|
||||
# Try to parse as JSON to sanitize structured data
|
||||
if data_str.startswith('{') or data_str.startswith('['):
|
||||
data = json.loads(data_str)
|
||||
sanitized_data, was_sanitized = self._sanitize_dict_or_list(data)
|
||||
return json.dumps(sanitized_data), was_sanitized
|
||||
else:
|
||||
# For plain text, check for sensitive patterns
|
||||
sensitive_patterns = [
|
||||
'password=', 'api_key=', 'token=', 'secret=', 'pwd=',
|
||||
'PASSWORD', 'API_KEY', 'TOKEN', 'SECRET', 'PWD'
|
||||
]
|
||||
sanitized = data_str
|
||||
for pattern in sensitive_patterns:
|
||||
if pattern in sanitized:
|
||||
# Simple masking for plain text
|
||||
parts = sanitized.split(pattern)
|
||||
result = [parts[0]]
|
||||
for i in range(1, len(parts)):
|
||||
# Mask until next whitespace or common delimiter
|
||||
value_end = 0
|
||||
for j, char in enumerate(parts[i]):
|
||||
if char in [' ', '\n', '\t', '&', ';', ',']:
|
||||
value_end = j
|
||||
break
|
||||
if value_end > 0:
|
||||
result.append(pattern + '*****' + parts[i][value_end:])
|
||||
else:
|
||||
result.append(pattern + '*****')
|
||||
sanitized = ''.join(result)
|
||||
was_sanitized = True
|
||||
return sanitized, was_sanitized
|
||||
except (ValueError, TypeError):
|
||||
# If parsing fails, return as is
|
||||
return data_str, was_sanitized
|
||||
|
||||
def _sanitize_dict_or_list(self, data):
|
||||
"""Recursively sanitize sensitive data in dictionaries and lists.
|
||||
|
||||
Args:
|
||||
data: Dictionary or list to sanitize
|
||||
|
||||
Returns:
|
||||
tuple: (sanitized_data, was_sanitized)
|
||||
"""
|
||||
sensitive_fields = [
|
||||
'password', 'api_key', 'token', 'secret', 'pwd', 'auth_token',
|
||||
'access_token', 'refresh_token', 'private_key', 'client_secret'
|
||||
]
|
||||
was_sanitized = False
|
||||
|
||||
if isinstance(data, dict):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if key.lower() in sensitive_fields:
|
||||
result[key] = '*****'
|
||||
was_sanitized = True
|
||||
elif isinstance(value, (dict, list)):
|
||||
result[key], child_sanitized = self._sanitize_dict_or_list(value)
|
||||
was_sanitized = was_sanitized or child_sanitized
|
||||
else:
|
||||
result[key] = value
|
||||
return result, was_sanitized
|
||||
elif isinstance(data, list):
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, (dict, list)):
|
||||
sanitized_item, child_sanitized = self._sanitize_dict_or_list(item)
|
||||
result.append(sanitized_item)
|
||||
was_sanitized = was_sanitized or child_sanitized
|
||||
else:
|
||||
result.append(item)
|
||||
return result, was_sanitized
|
||||
else:
|
||||
return data, was_sanitized
|
||||
|
||||
@api.model
|
||||
def _cron_archive_old_logs(self):
|
||||
"""Archive logs older than 90 days to long-term storage.
|
||||
|
||||
This method is intended to be called by a scheduled action (cron job).
|
||||
It will find logs older than 90 days, archive them to S3 Glacier,
|
||||
and mark them as archived in the database.
|
||||
"""
|
||||
if not HAS_BOTO3:
|
||||
_logger.warning("Cannot archive logs: boto3 library not installed")
|
||||
return False
|
||||
|
||||
# Get AWS credentials from system parameters
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
aws_access_key = ICP.get_param('bemade.aws_access_key_id', False)
|
||||
aws_secret_key = ICP.get_param('bemade.aws_secret_access_key', False)
|
||||
aws_region = ICP.get_param('bemade.aws_region', 'us-east-1')
|
||||
aws_bucket = ICP.get_param('bemade.log_archive_bucket', False)
|
||||
|
||||
if not (aws_access_key and aws_secret_key and aws_bucket):
|
||||
_logger.warning("Cannot archive logs: AWS credentials not configured")
|
||||
return False
|
||||
|
||||
# Find logs older than 90 days that haven't been archived yet
|
||||
cutoff_date = fields.Datetime.now() - timedelta(days=90)
|
||||
old_logs = self.search([
|
||||
('create_date', '<', cutoff_date),
|
||||
('archived', '=', False)
|
||||
], limit=1000) # Process in batches to avoid memory issues
|
||||
|
||||
if not old_logs:
|
||||
_logger.info("No logs to archive")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Connect to S3
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=aws_access_key,
|
||||
aws_secret_access_key=aws_secret_key,
|
||||
region_name=aws_region
|
||||
)
|
||||
|
||||
# Group logs by month for better organization
|
||||
logs_by_month = {}
|
||||
for log in old_logs:
|
||||
month_key = log.create_date.strftime('%Y-%m')
|
||||
if month_key not in logs_by_month:
|
||||
logs_by_month[month_key] = []
|
||||
logs_by_month[month_key].append(log)
|
||||
|
||||
# Archive each month's logs as a separate Parquet file
|
||||
for month, logs in logs_by_month.items():
|
||||
# Convert logs to a format suitable for Parquet
|
||||
log_data = [{
|
||||
'id': log.id,
|
||||
'create_date': log.create_date.isoformat(),
|
||||
'operation': log.operation,
|
||||
'model': log.model,
|
||||
'record_id': log.record_id,
|
||||
'result': log.result,
|
||||
'log_level': log.log_level,
|
||||
'details': log.details,
|
||||
'payload': log.payload,
|
||||
'diff': log.diff,
|
||||
'metadata': log.metadata,
|
||||
'instance_id': log.bemade_instance_id.id if log.bemade_instance_id else None,
|
||||
'queue_id': log.queue_id.id if log.queue_id else None,
|
||||
} for log in logs]
|
||||
|
||||
# Convert to JSON format (since we can't rely on pandas being installed)
|
||||
json_data = json.dumps(log_data)
|
||||
|
||||
# Generate a unique key for this archive
|
||||
archive_key = f"sync_logs/{month}/logs_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}.json"
|
||||
|
||||
# Upload to S3 with Glacier storage class
|
||||
s3_client.put_object(
|
||||
Body=json_data,
|
||||
Bucket=aws_bucket,
|
||||
Key=archive_key,
|
||||
StorageClass='DEEP_ARCHIVE' # Glacier Deep Archive (7 years retention)
|
||||
)
|
||||
|
||||
# Update logs to mark them as archived
|
||||
for log in logs:
|
||||
log.write({
|
||||
'archived': True,
|
||||
'archive_reference': archive_key
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error archiving logs: {str(e)}")
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def init(self):
|
||||
"""Set up database indexes for performance."""
|
||||
super(OdooToBemadeSyncLog, self).init()
|
||||
# Add index on create_date for efficient archiving queries
|
||||
tools.create_index(self._cr, 'odoo_to_bemade_sync_log_create_date_idx',
|
||||
self._table, ['create_date'])
|
||||
|
|
|
|||
|
|
@ -24,7 +24,27 @@ class OdooToBemadeSyncModel(models.Model):
|
|||
|
||||
_name = 'odoo.to.bemade.sync.model'
|
||||
_description = 'Bemade Synchronized Model'
|
||||
_inherit = 'odoo.sync.model'
|
||||
_inherits = {'odoo.sync.model': 'sync_model_id'}
|
||||
|
||||
# Field to store the parent record ID
|
||||
sync_model_id = fields.Many2one(
|
||||
'odoo.sync.model',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
auto_join=True
|
||||
)
|
||||
|
||||
# Note: This model inherits fields and methods from odoo.sync.model
|
||||
# The following fields are inherited and available:
|
||||
# - model_id: Many2one to ir.model
|
||||
# - name: related field to model_id.model
|
||||
# - target_model: Char field for target model name
|
||||
# - instance_id: Many2one to odoo.sync.instance
|
||||
# - field_ids: One2many to odoo.sync.model.field
|
||||
# - active: Boolean field
|
||||
#
|
||||
# The following methods are inherited:
|
||||
# - generate_field_mappings: Creates field mappings for the model
|
||||
|
||||
# Add Bemade-specific fields here
|
||||
bemade_specific_setting = fields.Boolean(
|
||||
|
|
@ -40,32 +60,138 @@ class OdooToBemadeSyncModel(models.Model):
|
|||
help='The Bemade instance this model will be synchronized with',
|
||||
)
|
||||
|
||||
project_id = fields.Many2one(
|
||||
comodel_name='project.project',
|
||||
string='Project',
|
||||
help='The project this synchronization model is associated with',
|
||||
)
|
||||
|
||||
@api.onchange('instance_id')
|
||||
def _onchange_instance_id(self):
|
||||
"""Set bemade_instance_id when instance_id changes."""
|
||||
if self.instance_id:
|
||||
# Try to find a matching bemade instance
|
||||
bemade_instance = self.env['odoo.to.bemade.instance'].search(
|
||||
[('id', '=', self.instance_id.id)], limit=1)
|
||||
if bemade_instance:
|
||||
self.bemade_instance_id = bemade_instance.id
|
||||
|
||||
@api.model
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle delegation inheritance properly and ensure bemade_instance_id is set.
|
||||
|
||||
When creating a record through the form view, we need to:
|
||||
1. Check if a parent record already exists for the model_id and instance_id
|
||||
2. If it exists, link to it via sync_model_id
|
||||
3. If not, create the parent record first
|
||||
|
||||
This prevents duplicate key violations on the unique constraint.
|
||||
"""
|
||||
# Handle both single dict and list of dicts
|
||||
if isinstance(vals_list, dict):
|
||||
vals_items = [vals_list]
|
||||
else:
|
||||
vals_items = vals_list
|
||||
|
||||
result = self.env['odoo.to.bemade.sync.model']
|
||||
|
||||
for vals in vals_items:
|
||||
# If sync_model_id is already provided, use standard creation
|
||||
if not vals.get('sync_model_id') and vals.get('model_id') and vals.get('instance_id'):
|
||||
# Check for existing parent record
|
||||
existing_parent = self.env['odoo.sync.model'].search([
|
||||
('model_id', '=', vals['model_id']),
|
||||
('instance_id', '=', vals['instance_id'])
|
||||
], limit=1)
|
||||
|
||||
if existing_parent:
|
||||
# Link to existing parent
|
||||
vals['sync_model_id'] = existing_parent.id
|
||||
else:
|
||||
# Create parent record first
|
||||
parent_vals = {
|
||||
'model_id': vals['model_id'],
|
||||
'instance_id': vals['instance_id'],
|
||||
'target_model': vals.get('target_model'),
|
||||
'active': vals.get('active', True),
|
||||
}
|
||||
parent_record = self.env['odoo.sync.model'].create(parent_vals)
|
||||
vals['sync_model_id'] = parent_record.id
|
||||
|
||||
# If instance_id is set but bemade_instance_id is not, set it
|
||||
if vals.get('instance_id') and not vals.get('bemade_instance_id'):
|
||||
vals['bemade_instance_id'] = vals['instance_id']
|
||||
|
||||
return super(OdooToBemadeSyncModel, self).create(vals_list)
|
||||
|
||||
@api.model
|
||||
def create_sync_configuration(self, model_name, instance_id, target_model=None, active=True):
|
||||
"""Create a synchronization configuration for a model.
|
||||
|
||||
|
||||
This method creates both the parent sync model record and the Bemade-specific
|
||||
sync model record with proper linkage between them. If a sync model already exists
|
||||
for the given model_id and instance_id, it will be reused instead of creating a new one.
|
||||
|
||||
Args:
|
||||
model_name: Technical name of the model to synchronize
|
||||
instance_id: ID of the Bemade instance to sync with
|
||||
instance_id: ID of the remote client instance to sync with
|
||||
target_model: Technical name of the model on the remote instance
|
||||
active: Whether the synchronization is active
|
||||
|
||||
|
||||
Returns:
|
||||
The created model configuration record
|
||||
The created or existing model configuration record
|
||||
"""
|
||||
# Find the ir.model record for the model name
|
||||
ir_model = self.env['ir.model'].search([('model', '=', model_name)], limit=1)
|
||||
if not ir_model:
|
||||
raise UserError(_("Model %s does not exist") % model_name)
|
||||
raise UserError(_('Model %s does not exist') % model_name)
|
||||
|
||||
# Create the sync model configuration
|
||||
return self.create({
|
||||
'model_id': ir_model.id,
|
||||
'bemade_instance_id': instance_id,
|
||||
'instance_id': instance_id, # For the inherited field
|
||||
'target_model': target_model or model_name,
|
||||
'active': active,
|
||||
})
|
||||
# Find the Bemade instance (provider)
|
||||
company_id = self.env.company.id
|
||||
bemade_instance = self.env['odoo.to.bemade.instance'].search([('company_id', '=', company_id)], limit=1)
|
||||
if not bemade_instance:
|
||||
bemade_instance = self.env['odoo.to.bemade.instance'].search([], limit=1)
|
||||
if not bemade_instance:
|
||||
raise UserError(_('No Bemade instance found. Please create one first.'))
|
||||
|
||||
# Check if a parent sync model record already exists for this model and instance
|
||||
existing_parent = self.env['odoo.sync.model'].search([
|
||||
('model_id', '=', ir_model.id),
|
||||
('instance_id', '=', instance_id)
|
||||
], limit=1)
|
||||
|
||||
if existing_parent:
|
||||
# Check if there's already a Bemade sync model linked to this parent
|
||||
existing_bemade_sync = self.search([('sync_model_id', '=', existing_parent.id)], limit=1)
|
||||
if existing_bemade_sync:
|
||||
# Update the existing record if needed
|
||||
if existing_bemade_sync.bemade_instance_id.id != bemade_instance.id or existing_bemade_sync.active != active:
|
||||
existing_bemade_sync.write({
|
||||
'bemade_instance_id': bemade_instance.id,
|
||||
'active': active
|
||||
})
|
||||
return existing_bemade_sync
|
||||
else:
|
||||
# Create a new Bemade sync model linked to the existing parent
|
||||
return self.create({
|
||||
'sync_model_id': existing_parent.id,
|
||||
'bemade_instance_id': bemade_instance.id,
|
||||
'active': active,
|
||||
})
|
||||
else:
|
||||
# Create the parent sync model record
|
||||
parent_record = self.env['odoo.sync.model'].create({
|
||||
'model_id': ir_model.id,
|
||||
'instance_id': instance_id,
|
||||
'target_model': target_model or model_name,
|
||||
'active': active,
|
||||
})
|
||||
|
||||
# Create the Bemade sync model record linked to the parent
|
||||
return self.create({
|
||||
'sync_model_id': parent_record.id, # Link to parent record
|
||||
'bemade_instance_id': bemade_instance.id,
|
||||
'active': active,
|
||||
})
|
||||
|
||||
def generate_field_mappings(self):
|
||||
"""Generate field mappings based on model fields.
|
||||
|
|
@ -78,14 +204,22 @@ class OdooToBemadeSyncModel(models.Model):
|
|||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Get the model
|
||||
model = self.env[self.name]
|
||||
# Make sure the record is saved in the database before creating field mappings
|
||||
# Use the Odoo API to ensure the record is saved
|
||||
self.env.cr.commit()
|
||||
|
||||
# Get the model from model_id relation (accessed through the parent record)
|
||||
model = self.env[self.sync_model_id.model_id.model]
|
||||
model_fields = model._fields
|
||||
|
||||
# Create field mappings for each relevant field
|
||||
field_mapping_model = self.env['odoo.to.bemade.sync.model.field']
|
||||
field_mapping_model = self.env['odoo.sync.model.field']
|
||||
created_mappings = []
|
||||
|
||||
# Verify that the parent record exists in the database
|
||||
if not self.sync_model_id.exists():
|
||||
raise UserError(_("Cannot create field mappings: parent sync model record not found. Please save the record first."))
|
||||
|
||||
for field_name, field in model_fields.items():
|
||||
# Skip fields that shouldn't be synchronized
|
||||
if field.type in ['one2many', 'many2many']:
|
||||
|
|
@ -95,14 +229,82 @@ class OdooToBemadeSyncModel(models.Model):
|
|||
continue
|
||||
|
||||
# Create a field mapping
|
||||
mapping = field_mapping_model.create({
|
||||
'sync_model_id': self.id,
|
||||
mapping_vals = {
|
||||
'model_sync_id': self.sync_model_id.id, # Use the parent record ID
|
||||
'source_field': field_name,
|
||||
'target_field': field_name,
|
||||
'active': True,
|
||||
'transform_type': 'direct',
|
||||
})
|
||||
}
|
||||
|
||||
created_mappings.append(mapping)
|
||||
try:
|
||||
# Create the mapping
|
||||
mapping = field_mapping_model.create(mapping_vals)
|
||||
created_mappings.append(mapping)
|
||||
except Exception as e:
|
||||
_logger.error("Error creating field mapping for %s: %s", field_name, str(e))
|
||||
continue
|
||||
|
||||
return created_mappings
|
||||
|
||||
|
||||
|
||||
def create_all_fields(self):
|
||||
"""Create field mappings for all fields in the model.
|
||||
|
||||
This method is called from the view button to automatically
|
||||
generate field mappings for all fields in the model.
|
||||
|
||||
Returns:
|
||||
dict: Action dictionary for refreshing the view
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Ensure the parent record exists and is properly linked
|
||||
if not self.sync_model_id or not self.sync_model_id.exists():
|
||||
raise UserError(_("Cannot create field mappings: parent sync model record not found. Please save the record first."))
|
||||
|
||||
# Generate field mappings
|
||||
self.generate_field_mappings()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'reload',
|
||||
}
|
||||
|
||||
def sync_all_records(self):
|
||||
"""Synchronize all records of this model.
|
||||
|
||||
This method is called from the view button to trigger
|
||||
synchronization for all records of this model.
|
||||
|
||||
Returns:
|
||||
dict: Action dictionary for displaying a success message
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Get the sync manager
|
||||
sync_manager = self.env['odoo.sync.manager']
|
||||
|
||||
# Get all records of this model
|
||||
# The 'name' field is inherited from odoo.sync.model and is a related field to model_id.model
|
||||
# It contains the technical name of the model (e.g. 'res.partner')
|
||||
# pylint: disable=no-member
|
||||
model = self.env[self.name]
|
||||
records = model.search([])
|
||||
|
||||
# Queue synchronization for each record
|
||||
for record in records:
|
||||
sync_manager._queue_sync(record, 'create')
|
||||
|
||||
# Return action to show success message
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': "Synchronisation démarrée",
|
||||
'message': f"{len(records)} enregistrements ont été mis en file d'attente pour synchronisation.",
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ class OdooToBemadeSyncModelField(models.Model):
|
|||
_inherit = 'odoo.sync.model.field'
|
||||
|
||||
# Add Bemade-specific fields here
|
||||
transform_type = fields.Selection([
|
||||
('direct', 'Direct'),
|
||||
('function', 'Function'),
|
||||
('computed', 'Computed'),
|
||||
('relation', 'Relation')
|
||||
], string='Transform Type', default='direct',
|
||||
help='Type of transformation to apply to the field value')
|
||||
|
||||
bemade_transformation = fields.Selection([
|
||||
('none', 'No Transformation'),
|
||||
('prefix', 'Add Prefix'),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_odoo_to_bemade_sync_model_admin,odoo.to.bemade.sync.model admin,model_odoo_to_bemade_sync_model,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_sync_model_user,odoo.to.bemade.sync.model user,model_odoo_to_bemade_sync_model,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_sync_model_field_admin,odoo.to.bemade.sync.model.field admin,model_odoo_to_bemade_sync_model_field,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_sync_model_field_user,odoo.to.bemade.sync.model.field user,model_odoo_to_bemade_sync_model_field,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_instance_admin,odoo.to.bemade.instance admin,model_odoo_to_bemade_instance,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_instance_user,odoo.to.bemade.instance user,model_odoo_to_bemade_instance,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_assign_project_wizard_admin,odoo.to.bemade.assign.project.wizard admin,model_odoo_to_bemade_assign_project_wizard,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_assign_project_wizard_user,odoo.to.bemade.assign.project.wizard user,model_odoo_to_bemade_assign_project_wizard,base.group_user,1,1,1,0
|
||||
|
|
|
|||
|
|
|
@ -1,33 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menu principal -->
|
||||
<menuitem id="menu_odoo_to_bemade_root"
|
||||
name="Synchronisation Bemade"
|
||||
sequence="80"
|
||||
web_icon="odoo_to_odoo_bemade,static/description/icon.svg"/>
|
||||
|
||||
<!-- Sous-menus -->
|
||||
<menuitem id="menu_odoo_to_bemade_instances"
|
||||
name="Instances"
|
||||
parent="menu_odoo_to_bemade_root"
|
||||
action="action_odoo_to_bemade_instances"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_sync_models"
|
||||
name="Modèles synchronisés"
|
||||
parent="menu_odoo_to_bemade_root"
|
||||
action="action_odoo_to_bemade_sync_models"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_sync_queue"
|
||||
name="File d'attente"
|
||||
parent="menu_odoo_to_bemade_root"
|
||||
action="action_odoo_to_bemade_sync_queue"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_sync_logs"
|
||||
name="Journaux de synchronisation"
|
||||
parent="menu_odoo_to_bemade_root"
|
||||
action="action_odoo_to_bemade_sync_logs"
|
||||
sequence="40"/>
|
||||
<data>
|
||||
<!-- No separate menu - use unified base module interface -->
|
||||
<!-- This file is intentionally minimal to avoid duplicate menus -->
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
64
odoo_to_odoo_bemade/views/odoo_to_bemade_instance_views.xml
Normal file
64
odoo_to_odoo_bemade/views/odoo_to_bemade_instance_views.xml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_odoo_to_bemade_instance_list" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.instance.list</field>
|
||||
<field name="model">odoo.to.bemade.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Client Instances">
|
||||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
<field name="state"/>
|
||||
<field name="last_sync_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_odoo_to_bemade_instance_form" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.instance.form</field>
|
||||
<field name="model">odoo.to.bemade.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Client Instance">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_test_connection" type="object" string="Test Connection" class="oe_stat_button" icon="fa-plug"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username"/>
|
||||
<field name="api_key" password="True"/>
|
||||
<field name="state"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Connected Projects">
|
||||
<field name="project_id" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="is_client_project"/>
|
||||
<field name="client_key"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_odoo_to_bemade_instance" model="ir.actions.act_window">
|
||||
<field name="name">Client Instances</field>
|
||||
<field name="res_model">odoo.to.bemade.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configure client Odoo instances for project synchronization.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_instance" name="Client Instances" parent="base.menu_custom" action="action_odoo_to_bemade_instance" sequence="20"/>
|
||||
</odoo>
|
||||
30
odoo_to_odoo_bemade/views/project_views.xml
Normal file
30
odoo_to_odoo_bemade/views/project_views.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Extend project form view to add client sync fields -->
|
||||
<record id="view_project_form_client_sync" model="ir.ui.view">
|
||||
<field name="name">project.project.form.client.sync</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='extra_settings']" position="inside">
|
||||
<group string="Client Synchronization" name="client_sync">
|
||||
<field name="is_client_project"/>
|
||||
<field name="client_api_token" invisible="not is_client_project"/>
|
||||
<field name="client_instance_id" invisible="not is_client_project"/>
|
||||
<button name="action_generate_api_token"
|
||||
string="Generate API Token"
|
||||
type="object"
|
||||
invisible="not is_client_project"
|
||||
class="btn btn-primary"/>
|
||||
<button name="action_revoke_api_token"
|
||||
string="Revoke API Token"
|
||||
type="object"
|
||||
invisible="not is_client_project"
|
||||
class="btn btn-secondary"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -7,8 +7,7 @@
|
|||
<field name="arch" type="xml">
|
||||
<form string="Instance Bemade">
|
||||
<header>
|
||||
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"
|
||||
attrs="{'invisible': [('state', '=', 'connected')]}"/>
|
||||
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,testing,error,connected"/>
|
||||
</header>
|
||||
|
|
@ -19,14 +18,26 @@
|
|||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="url" placeholder="https://exemple.bemade.org"
|
||||
attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="database" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="url" placeholder="https://exemple.bemade.org">
|
||||
<attribute name="readonly">state == 'connected'</attribute>
|
||||
</field>
|
||||
<field name="database">
|
||||
<attribute name="readonly">state == 'connected'</attribute>
|
||||
</field>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="password" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="api_key" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="username">
|
||||
<attribute name="readonly">state == 'connected'</attribute>
|
||||
</field>
|
||||
<field name="use_api_key" widget="boolean_toggle"/>
|
||||
<field name="password" password="True">
|
||||
<attribute name="readonly">state == 'connected'</attribute>
|
||||
<attribute name="invisible">use_api_key</attribute>
|
||||
</field>
|
||||
<field name="api_key" password="True">
|
||||
<attribute name="invisible">not use_api_key</attribute>
|
||||
<attribute name="required">use_api_key</attribute>
|
||||
</field>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
|
|
@ -35,37 +46,33 @@
|
|||
</group>
|
||||
<notebook>
|
||||
<page string="Modèles synchronisés">
|
||||
<field name="sync_model_ids">
|
||||
<tree>
|
||||
<field name="model_ids" mode="list">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="model_id"/>
|
||||
<field name="target_model"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Options avancées">
|
||||
<group>
|
||||
<field name="timeout"/>
|
||||
<field name="connection_timeout"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="retry_delay"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Journal de connexion">
|
||||
<field name="log_ids">
|
||||
<tree>
|
||||
<field name="log_ids" mode="list">
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="result"/>
|
||||
</tree>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
|
@ -75,7 +82,7 @@
|
|||
<field name="name">odoo.to.bemade.instance.tree</field>
|
||||
<field name="model">odoo.to.bemade.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Instances Bemade" decoration-success="state == 'connected'" decoration-danger="state == 'error'" decoration-info="state == 'testing'" decoration-muted="state == 'draft'">
|
||||
<list string="Instances Bemade" decoration-success="state == 'connected'" decoration-danger="state == 'error'" decoration-info="state == 'testing'" decoration-muted="state == 'draft'">
|
||||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
|
|
@ -83,7 +90,7 @@
|
|||
<field name="connection_type"/>
|
||||
<field name="state"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -112,7 +119,7 @@
|
|||
<record id="action_odoo_to_bemade_instances" model="ir.actions.act_window">
|
||||
<field name="name">Instances Bemade</field>
|
||||
<field name="res_model">odoo.to.bemade.instance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
|
|
|||
|
|
@ -13,22 +13,22 @@
|
|||
<group>
|
||||
<group>
|
||||
<field name="create_date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="queue_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="model"/>
|
||||
<field name="record_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="operation"/>
|
||||
<field name="state"/>
|
||||
<field name="result"/>
|
||||
<field name="user_id"/>
|
||||
<field name="bemade_instance_id"/>
|
||||
<field name="details"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Détails">
|
||||
<field name="details" widget="html"/>
|
||||
</page>
|
||||
<page string="Données">
|
||||
<field name="data_json"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
|
|
@ -40,15 +40,18 @@
|
|||
<field name="name">odoo.to.bemade.sync.log.tree</field>
|
||||
<field name="model">odoo.to.bemade.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Journaux de synchronisation" decoration-success="result == 'success'" decoration-danger="result == 'error'" decoration-info="result == 'info'" create="false">
|
||||
<list decoration-success="state == 'success'" decoration-danger="state == 'error'" create="false">
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="record_id"/>
|
||||
<field name="queue_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="model"/>
|
||||
<field name="record_id"/>
|
||||
<field name="state"/>
|
||||
<field name="result"/>
|
||||
<field name="user_id"/>
|
||||
</tree>
|
||||
<field name="message"/>
|
||||
<field name="bemade_instance_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -57,23 +60,25 @@
|
|||
<field name="name">odoo.to.bemade.sync.log.search</field>
|
||||
<field name="model">odoo.to.bemade.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher dans les journaux">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="queue_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="model"/>
|
||||
<field name="record_id"/>
|
||||
<field name="details"/>
|
||||
<field name="bemade_instance_id"/>
|
||||
<separator/>
|
||||
<filter string="Succès" name="success" domain="[('result', '=', 'success')]"/>
|
||||
<filter string="Erreurs" name="error" domain="[('result', '=', 'error')]"/>
|
||||
<filter string="Informations" name="info" domain="[('result', '=', 'info')]"/>
|
||||
<filter string="Succès" name="success" domain="[('state', '=', 'success')]"/>
|
||||
<filter string="Erreurs" name="error" domain="[('state', '=', 'error')]"/>
|
||||
<separator/>
|
||||
<filter string="Moi" name="my_logs" domain="[('user_id', '=', uid)]"/>
|
||||
<group expand="0" string="Regrouper par">
|
||||
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model_id'}"/>
|
||||
<group expand="0">
|
||||
<filter string="File d'attente" name="groupby_queue" domain="[]" context="{'group_by': 'queue_id'}"/>
|
||||
<filter string="Opération" name="groupby_operation" domain="[]" context="{'group_by': 'operation'}"/>
|
||||
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model'}"/>
|
||||
<filter string="Instance" name="groupby_instance" domain="[]" context="{'group_by': 'bemade_instance_id'}"/>
|
||||
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
|
||||
<filter string="Résultat" name="groupby_result" domain="[]" context="{'group_by': 'result'}"/>
|
||||
<filter string="Date" name="groupby_create_date" domain="[]" context="{'group_by': 'create_date:day'}"/>
|
||||
<filter string="Utilisateur" name="groupby_user" domain="[]" context="{'group_by': 'user_id'}"/>
|
||||
<filter string="Date" name="groupby_date" domain="[]" context="{'group_by': 'create_date:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
|
@ -83,7 +88,7 @@
|
|||
<record id="action_odoo_to_bemade_sync_logs" model="ir.actions.act_window">
|
||||
<field name="name">Journaux de synchronisation</field>
|
||||
<field name="res_model">odoo.to.bemade.sync.log</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_error': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
|
|
|||
|
|
@ -1,129 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue formulaire pour les modèles de synchronisation -->
|
||||
<record id="view_odoo_to_bemade_sync_model_form" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.sync.model.form</field>
|
||||
<field name="model">odoo.to.bemade.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Modèle synchronisé">
|
||||
<header>
|
||||
<button name="sync_all_records" string="Synchroniser tous les enregistrements" type="object"
|
||||
class="oe_highlight" attrs="{'invisible': [('active', '=', False)]}"/>
|
||||
<button name="create_all_fields" string="Créer tous les champs" type="object"
|
||||
confirm="Êtes-vous sûr de vouloir créer automatiquement tous les champs pour ce modèle ?"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="Nom du modèle synchronisé"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="model" placeholder="ex: res.partner"/>
|
||||
<field name="bemade_model" placeholder="ex: res.partner"/>
|
||||
<field name="bemade_instance_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sync_domain" placeholder="ex: [('active', '=', True)]"/>
|
||||
<field name="active"/>
|
||||
<field name="priority"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Champs synchronisés">
|
||||
<field name="field_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="source_field"/>
|
||||
<field name="target_field"/>
|
||||
<field name="transform_type"/>
|
||||
<field name="is_identifier"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Options avancées">
|
||||
<group>
|
||||
<field name="create_active"/>
|
||||
<field name="write_active"/>
|
||||
<field name="unlink_active"/>
|
||||
<field name="record_count"/>
|
||||
<field name="field_count"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="File d'attente">
|
||||
<field name="queue_ids">
|
||||
<tree>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="operation"/>
|
||||
<field name="state"/>
|
||||
<field name="error_message"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue liste pour les modèles de synchronisation -->
|
||||
<record id="view_odoo_to_bemade_sync_model_tree" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.sync.model.tree</field>
|
||||
<field name="model">odoo.to.bemade.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Modèles synchronisés" decoration-muted="active == False">
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="bemade_instance_id"/>
|
||||
<field name="priority"/>
|
||||
<field name="record_count"/>
|
||||
<field name="field_count"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue recherche pour les modèles de synchronisation -->
|
||||
<record id="view_odoo_to_bemade_sync_model_search" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.sync.model.search</field>
|
||||
<field name="model">odoo.to.bemade.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher un modèle">
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="bemade_instance_id"/>
|
||||
<filter string="Actifs" name="active" domain="[('active', '=', True)]"/>
|
||||
<group expand="0" string="Regrouper par">
|
||||
<filter string="Instance" name="groupby_instance" domain="[]" context="{'group_by': 'bemade_instance_id'}"/>
|
||||
<filter string="Modèle local" name="groupby_model" domain="[]" context="{'group_by': 'model'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les modèles -->
|
||||
<record id="action_odoo_to_bemade_sync_models" model="ir.actions.act_window">
|
||||
<field name="name">Modèles synchronisés</field>
|
||||
<field name="res_model">odoo.to.bemade.sync.model</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configurer votre premier modèle à synchroniser
|
||||
</p>
|
||||
<p>
|
||||
Les modèles définissent quelles données sont synchronisées avec Bemade
|
||||
et comment elles sont mappées entre les systèmes.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
<!-- Synchronized Module views and actions have been removed as they are now managed in the instance itself -->
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -5,18 +5,14 @@
|
|||
<field name="name">odoo.to.bemade.sync.queue.form</field>
|
||||
<field name="model">odoo.to.bemade.sync.queue</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="File d'attente de synchronisation">
|
||||
<form>
|
||||
<header>
|
||||
<button name="retry_sync" string="Réessayer" type="object" class="oe_highlight"
|
||||
attrs="{'invisible': [('state', 'not in', ['error', 'draft'])]}"/>
|
||||
<button name="cancel_sync" string="Annuler" type="object"
|
||||
attrs="{'invisible': [('state', 'in', ['done', 'cancel'])]}"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,pending,in_progress,done,error"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
<h1><field name="name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
|
|
@ -28,25 +24,12 @@
|
|||
<field name="create_date"/>
|
||||
<field name="priority"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="next_retry"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Données">
|
||||
<field name="data_json"/>
|
||||
</page>
|
||||
<page string="Erreurs" attrs="{'invisible': [('error_message', '=', False)]}">
|
||||
<page string="Errors">
|
||||
<field name="error_message"/>
|
||||
</page>
|
||||
<page string="Journaux">
|
||||
<field name="log_ids">
|
||||
<tree>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="result"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
|
|
@ -58,12 +41,12 @@
|
|||
<field name="name">odoo.to.bemade.sync.queue.tree</field>
|
||||
<field name="model">odoo.to.bemade.sync.queue</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="File d'attente de synchronisation"
|
||||
<list
|
||||
decoration-success="state == 'done'"
|
||||
decoration-info="state in ('draft', 'pending')"
|
||||
decoration-warning="state == 'in_progress'"
|
||||
decoration-warning="state == 'processing'"
|
||||
decoration-danger="state == 'error'"
|
||||
decoration-muted="state == 'cancel'">
|
||||
decoration-muted="state == 'cancelled'">
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="record_id"/>
|
||||
|
|
@ -71,8 +54,8 @@
|
|||
<field name="create_date"/>
|
||||
<field name="priority"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
<field name="state" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -81,19 +64,19 @@
|
|||
<field name="name">odoo.to.bemade.sync.queue.search</field>
|
||||
<field name="model">odoo.to.bemade.sync.queue</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher dans la file d'attente">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="record_id"/>
|
||||
<separator/>
|
||||
<filter string="À traiter" name="to_process" domain="[('state', 'in', ['draft', 'pending'])]"/>
|
||||
<filter string="En cours" name="in_progress" domain="[('state', '=', 'in_progress')]"/>
|
||||
<filter string="En cours" name="in_progress" domain="[('state', '=', 'processing')]"/>
|
||||
<filter string="En erreur" name="error" domain="[('state', '=', 'error')]"/>
|
||||
<filter string="Terminé" name="done" domain="[('state', '=', 'done')]"/>
|
||||
<filter string="Annulé" name="cancel" domain="[('state', '=', 'cancel')]"/>
|
||||
<filter string="Annulé" name="cancel" domain="[('state', '=', 'cancelled')]"/>
|
||||
<separator/>
|
||||
<filter string="Priorité haute" name="high_priority" domain="[('priority', '<=', 5)]"/>
|
||||
<group expand="0" string="Regrouper par">
|
||||
<group expand="0">
|
||||
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Opération" name="groupby_operation" domain="[]" context="{'group_by': 'operation'}"/>
|
||||
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
|
||||
|
|
@ -107,7 +90,7 @@
|
|||
<record id="action_odoo_to_bemade_sync_queue" model="ir.actions.act_window">
|
||||
<field name="name">File d'attente de synchronisation</field>
|
||||
<field name="res_model">odoo.to.bemade.sync.queue</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_to_process': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
|
|
|||
1
odoo_to_odoo_bemade/wizards/__init__.py
Normal file
1
odoo_to_odoo_bemade/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import assign_project_wizard
|
||||
159
odoo_to_odoo_bemade/wizards/assign_project_wizard.py
Normal file
159
odoo_to_odoo_bemade/wizards/assign_project_wizard.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class OdooToBemadeAssignProjectWizard(models.TransientModel):
|
||||
_name = 'odoo.to.bemade.assign.project.wizard'
|
||||
_description = 'Assign Project to Bemade Client'
|
||||
|
||||
project_id = fields.Many2one('project.project', string='Project', required=True)
|
||||
client_instance_id = fields.Many2one('odoo.to.bemade.instance', string='Client Instance', required=True)
|
||||
project_key = fields.Char(string='Project Key', readonly=True)
|
||||
client_api_token = fields.Char(string='Client API Token', readonly=True)
|
||||
sync_project_task = fields.Boolean(string='Sync Tasks', default=True)
|
||||
sync_project_timesheet = fields.Boolean(string='Sync Timesheets', default=True)
|
||||
protocol = fields.Selection([
|
||||
('xmlrpc', 'XML-RPC'),
|
||||
('jsonrpc', 'JSON-RPC'),
|
||||
('odoorpc', 'OdooRPC')
|
||||
], string='Protocol', default='jsonrpc', required=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Prefill defaults from system parameters and context.
|
||||
|
||||
- project_id from context (default_project_id)
|
||||
- protocol from bemade.sync.default_connection_type
|
||||
- client_instance_id from existing instance matching URL/DB/username
|
||||
"""
|
||||
res = super().default_get(fields_list)
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Context default project
|
||||
if 'project_id' in fields_list and self.env.context.get('default_project_id'):
|
||||
res['project_id'] = self.env.context['default_project_id']
|
||||
|
||||
# Protocol default
|
||||
proto = ICP.get_param('bemade.sync.default_connection_type') or 'jsonrpc'
|
||||
if 'protocol' in fields_list and proto:
|
||||
res.setdefault('protocol', proto)
|
||||
|
||||
# Try to preselect an instance based on defaults
|
||||
url = ICP.get_param('bemade.sync.default_url')
|
||||
database = ICP.get_param('bemade.sync.default_database')
|
||||
username = ICP.get_param('bemade.sync.default_username')
|
||||
|
||||
domain = []
|
||||
if url:
|
||||
domain.append(('url', '=', url))
|
||||
if database:
|
||||
domain.append(('database', '=', database))
|
||||
if username:
|
||||
domain.append(('username', '=', username))
|
||||
|
||||
if 'client_instance_id' in fields_list and domain:
|
||||
instance = self.env['odoo.to.bemade.instance'].search(domain, limit=1)
|
||||
if instance:
|
||||
res.setdefault('client_instance_id', instance.id)
|
||||
|
||||
return res
|
||||
|
||||
@api.onchange('project_id', 'client_instance_id')
|
||||
def _onchange_generate_keys(self):
|
||||
"""Generate unique project key and API token"""
|
||||
if self.project_id and self.client_instance_id:
|
||||
# Generate random 8-character project key
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
self.project_key = ''.join(random.choice(chars) for _ in range(8))
|
||||
|
||||
# Generate UUID for API token
|
||||
self.client_api_token = str(uuid.uuid4())
|
||||
|
||||
def action_assign_project(self):
|
||||
"""Assign project to client and create sync configuration"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.project_id:
|
||||
raise UserError(_("Please select a project to assign"))
|
||||
|
||||
if not self.client_instance_id:
|
||||
raise UserError(_("Please select a client instance"))
|
||||
|
||||
if not self.project_key or not self.client_api_token:
|
||||
self._onchange_generate_keys()
|
||||
|
||||
# Create sync project
|
||||
sync_project_vals = {
|
||||
'name': f"{self.project_id.name} - {self.client_instance_id.name}",
|
||||
'project_id': self.project_id.id,
|
||||
'client_instance_id': self.client_instance_id.id,
|
||||
'state': 'draft',
|
||||
'is_client_project': True,
|
||||
'client_key': self.project_key,
|
||||
'client_api_token': self.client_api_token,
|
||||
'remote_url': self.client_instance_id.url,
|
||||
'remote_database': self.client_instance_id.database,
|
||||
'remote_username': self.client_instance_id.username,
|
||||
'remote_api_key': self.client_instance_id.api_key,
|
||||
'protocol': self.protocol,
|
||||
}
|
||||
|
||||
sync_project = self.env['sync.project'].create(sync_project_vals)
|
||||
|
||||
# Update project with bemade flags
|
||||
self.project_id.write({
|
||||
'is_bemade_project': True,
|
||||
'bemade_sync_enabled': True,
|
||||
'bemade_project_key': self.project_key,
|
||||
})
|
||||
|
||||
# Create sync models for project and tasks
|
||||
self._create_sync_model(sync_project, 'project.project')
|
||||
|
||||
if self.sync_project_task:
|
||||
self._create_sync_model(sync_project, 'project.task')
|
||||
|
||||
if self.sync_project_timesheet and hasattr(self.env, 'account.analytic.line'):
|
||||
self._create_sync_model(sync_project, 'account.analytic.line')
|
||||
|
||||
# Show success message
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Project Assigned'),
|
||||
'message': _(f'Project {self.project_id.name} has been assigned to {self.client_instance_id.name} with key {self.project_key}'),
|
||||
'sticky': False,
|
||||
'next': {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sync Project'),
|
||||
'res_model': 'sync.project',
|
||||
'res_id': sync_project.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _create_sync_model(self, sync_project, model_name):
|
||||
"""Create sync model configuration for the given model"""
|
||||
model_vals = {
|
||||
'name': f"{model_name} - {sync_project.name}",
|
||||
'sync_project_id': sync_project.id,
|
||||
'model_name': model_name,
|
||||
'direction': 'bidirectional',
|
||||
'state': 'draft',
|
||||
}
|
||||
|
||||
sync_model = self.env['sync.model'].create(model_vals)
|
||||
|
||||
# Auto-populate fields based on model
|
||||
if hasattr(sync_model, 'action_auto_sync_fields'):
|
||||
sync_model.action_auto_sync_fields()
|
||||
|
||||
return sync_model
|
||||
44
odoo_to_odoo_bemade/wizards/assign_project_wizard_view.xml
Normal file
44
odoo_to_odoo_bemade/wizards/assign_project_wizard_view.xml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Form View for Assign Project Wizard -->
|
||||
<record id="view_odoo_to_bemade_assign_project_wizard_form" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.assign.project.wizard.form</field>
|
||||
<field name="model">odoo.to.bemade.assign.project.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Assign Project to Client">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="project_id"/>
|
||||
<field name="client_instance_id"/>
|
||||
<field name="protocol"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sync_project_task"/>
|
||||
<field name="sync_project_timesheet"/>
|
||||
</group>
|
||||
<group string="Generated Keys" invisible="not project_key">
|
||||
<field name="project_key"/>
|
||||
<field name="client_api_token"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_assign_project" string="Assign Project" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action to open the wizard -->
|
||||
<record id="action_odoo_to_bemade_assign_project_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Assign to Client</field>
|
||||
<field name="res_model">odoo.to.bemade.assign.project.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="project.model_project_project"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
<field name="context">{'default_project_id': active_id}</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
191
odoo_to_odoo_bemade_customer/README.md
Normal file
191
odoo_to_odoo_bemade_customer/README.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# Odoo to Odoo Bemade Customer
|
||||
|
||||
## Overview
|
||||
Complete bidirectional synchronization system for Bemade customers to securely sync with Odoo.bemade.org. Features wizards for project assignment/reception, multi-protocol support (XML-RPC, JSON-RPC, OdooRPC), and automated configuration.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Bidirectional Sync Wizards
|
||||
- **Receive Project Wizard**: `odoo.to.bemade.customer.receive.wizard`
|
||||
- **Assign Project Wizard**: `odoo.to.bemade.assign.project.wizard` (server-side)
|
||||
- **One-click setup**: Automated configuration with generated keys
|
||||
- **Protocol selection**: Choose XML-RPC, JSON-RPC, or OdooRPC
|
||||
|
||||
### ✅ Multi-Protocol Support
|
||||
- **XML-RPC**: Traditional XML-RPC with API key authentication
|
||||
- **JSON-RPC**: JSON-based protocol with Odoo 18 API format
|
||||
- **OdooRPC**: Native OdooRPC library with encrypted credentials
|
||||
|
||||
### ✅ Secure Authentication
|
||||
- **Encrypted API keys**: AES encryption for secure storage
|
||||
- **Project keys**: Unique identifiers for each sync project
|
||||
- **Token generation**: Automatic API token creation and sharing
|
||||
|
||||
### ✅ Project Management
|
||||
- **Project assignment**: Server assigns projects to clients
|
||||
- **Project reception**: Client receives and configures projects
|
||||
- **Key management**: Automatic key/token generation and validation
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Odoo 17+ or 18+
|
||||
- `odoo_to_odoo_sync` module (base framework)
|
||||
- Network access to Odoo.bemade.org
|
||||
|
||||
### Installation Steps
|
||||
1. Install base dependencies:
|
||||
```bash
|
||||
pip install odoorpc # For OdooRPC protocol
|
||||
```
|
||||
|
||||
2. Install in Odoo:
|
||||
- Go to Apps → Search "odoo_to_odoo_bemade_customer"
|
||||
- Click Install
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Side (Bemade)
|
||||
1. Install `odoo_to_odoo_bemade` module
|
||||
2. Navigate to **Project → Bemade Sync → Assign to Client**
|
||||
3. Select project and configure sync settings
|
||||
4. Generate project key and API token
|
||||
5. Share credentials with customer
|
||||
|
||||
### Client Side (Customer)
|
||||
1. Install `odoo_to_odoo_bemade_customer` module
|
||||
2. Navigate to **Project → Bemade Sync → Receive from Bemade**
|
||||
3. Enter provided credentials:
|
||||
- Bemade server URL (default: https://odoo.bemade.org)
|
||||
- Database name
|
||||
- Username
|
||||
- API Key
|
||||
- Project Key
|
||||
4. Select protocol (XML-RPC/JSON-RPC/OdooRPC)
|
||||
5. Test connection and receive project
|
||||
|
||||
## Usage
|
||||
|
||||
### Using the Receive Project Wizard
|
||||
1. **Open Wizard**: Project → Bemade Sync → Receive from Bemade
|
||||
2. **Enter Connection Details**:
|
||||
- Server URL: https://odoo.bemade.org (or custom)
|
||||
- Database: Target database name
|
||||
- Username: Your Bemade username
|
||||
- API Key: Generated from user preferences
|
||||
- Project Key: Provided by Bemade
|
||||
3. **Select Protocol**: XML-RPC, JSON-RPC, or OdooRPC
|
||||
4. **Test Connection**: Validate settings before proceeding
|
||||
5. **Receive Project**: Import project data and tasks
|
||||
|
||||
### Protocol Configuration
|
||||
- **XML-RPC**: Traditional XML-RPC protocol
|
||||
- **JSON-RPC**: JSON-based with Odoo 18 format: `{'scope': 'rpc', 'key': 'api_key'}`
|
||||
- **OdooRPC**: Native library with automatic connection handling
|
||||
|
||||
### Field Mapping
|
||||
- **Automatic**: Pre-configured field mappings for common models
|
||||
- **Manual**: Override mappings via sync model configuration
|
||||
- **Transformations**: Support for field value transformations
|
||||
|
||||
## Bidirectional Sync Workflow
|
||||
|
||||
### 1. Server Assignment
|
||||
```
|
||||
Bemade Server → Assign Project Wizard → Generate Keys → Share with Client
|
||||
```
|
||||
|
||||
### 2. Client Reception
|
||||
```
|
||||
Client → Receive Project Wizard → Enter Credentials → Import Project → Configure Sync
|
||||
```
|
||||
|
||||
### 3. Ongoing Synchronization
|
||||
- **Automatic**: Real-time sync based on triggers
|
||||
- **Manual**: Force sync via project actions
|
||||
- **Scheduled**: Cron-based regular synchronization
|
||||
|
||||
## API Authentication Format
|
||||
|
||||
### Odoo 18 API Key Format
|
||||
```python
|
||||
# XML-RPC
|
||||
{'scope': 'rpc', 'key': 'your_api_key_here'}
|
||||
|
||||
# JSON-RPC
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "common",
|
||||
"method": "login",
|
||||
"args": ["database", "username", {"scope": "rpc", "key": "api_key"}]
|
||||
}
|
||||
}
|
||||
|
||||
# OdooRPC
|
||||
odoo.login(database, username, api_key)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Connection Failed**: Check URL, credentials, and network access
|
||||
2. **Authentication Error**: Verify API key format (Odoo 18 requires scope parameter)
|
||||
3. **Project Not Found**: Ensure project key is correct and project exists
|
||||
4. **Protocol Error**: Try different protocol (XML-RPC/JSON-RPC/OdooRPC)
|
||||
|
||||
### Debug Commands
|
||||
```python
|
||||
# Enable debug logging
|
||||
import logging
|
||||
logging.getLogger('odoo.sync').setLevel(logging.DEBUG)
|
||||
|
||||
# Test connection programmatically
|
||||
sync_instance = env['odoo.sync.instance'].search([('name', '=', 'bemade')])
|
||||
sync_instance.test_connection()
|
||||
```
|
||||
|
||||
## Project Configuration
|
||||
|
||||
### Project Fields Added
|
||||
- **is_bemade_project**: Boolean flag for Bemade projects
|
||||
- **bemade_project_key**: Unique project identifier
|
||||
- **bemade_sync_enabled**: Enable/disable synchronization
|
||||
|
||||
### Security Access
|
||||
- **User Groups**: Bemade Sync User, Bemade Sync Manager
|
||||
- **Permissions**: Read, Create, Write, Delete based on role
|
||||
|
||||
## Development
|
||||
|
||||
### Extending Functionality
|
||||
- **Custom Wizards**: Inherit from base wizard classes
|
||||
- **Protocol Handlers**: Add new protocol support
|
||||
- **Field Transformers**: Custom field value transformations
|
||||
- **Conflict Resolvers**: Custom conflict resolution strategies
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run module tests
|
||||
./odoo-bin -d your_database -u odoo_to_odoo_bemade_customer --test-enable
|
||||
|
||||
# Test specific wizard
|
||||
env['odoo.to.bemade.customer.receive.wizard'].create({...}).action_receive_project()
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
### Getting Help
|
||||
- **Logs**: Check Odoo logs and sync logs
|
||||
- **Connection Test**: Use built-in connection validation
|
||||
- **Support**: Contact Bemade support with sync logs
|
||||
|
||||
### Log Locations
|
||||
- **Odoo Logs**: `/var/log/odoo/odoo.log`
|
||||
- **Sync Logs**: Settings → Technical → Odoo Sync → Logs
|
||||
- **Debug Mode**: Enable debug logging for detailed information
|
||||
|
||||
---
|
||||
*Last Updated: 2025-08-16*
|
||||
*Compatible with Odoo 17+ and 18+*
|
||||
|
|
@ -1 +1,24 @@
|
|||
from . import models
|
||||
from . import wizards
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Post-install hook to set up customer-specific configuration."""
|
||||
# Set default configuration parameters for customer instances
|
||||
ICP = env['ir.config_parameter']
|
||||
|
||||
# Ensure the parameters exist with default values
|
||||
defaults = {
|
||||
'customer.sync.default_url': 'https://odoo.bemade.org',
|
||||
'customer.sync.default_database': 'bemade',
|
||||
'customer.sync.default_username': 'customer_sync',
|
||||
'customer.sync.default_connection_type': 'odoorpc',
|
||||
'bemade.sync.default_timeout': '30',
|
||||
'bemade.sync.default_retry_count': '3',
|
||||
'bemade.sync.default_retry_delay': '5',
|
||||
}
|
||||
|
||||
for key, value in defaults.items():
|
||||
if not ICP.get_param(key):
|
||||
ICP.set_param(key, value)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
{
|
||||
'name': 'Odoo to Odoo Bemade Customer',
|
||||
'version': '18.0.1.0.0',
|
||||
'version': '18.0.2.0.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Connecteur spécifique pour synchronisation avec Odoo.bemade.org',
|
||||
'summary': 'Configuration client Bemade pour synchronisation avec Odoo.bemade.org',
|
||||
'description': """
|
||||
Module de synchronisation pour les clients Bemade
|
||||
- Connexion sécurisée avec Odoo.bemade.org
|
||||
- Synchronisation asynchrone
|
||||
- Installation simplifiée
|
||||
- Configuration automatique
|
||||
- Monitoring et reprise sur erreur
|
||||
Configuration pré-définie pour les clients Bemade synchronisant avec Odoo.bemade.org.
|
||||
Ce module est un wrapper minimal qui fournit la configuration par défaut
|
||||
pour la connexion sécurisée à l'instance Bemade principale.
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://bemade.org',
|
||||
|
|
@ -18,14 +15,12 @@
|
|||
'odoo_to_odoo_sync'
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/sync_config_views.xml',
|
||||
'views/sync_queue_views.xml',
|
||||
'views/sync_log_views.xml',
|
||||
'views/menus.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'wizards/receive_project_wizard_view.xml',
|
||||
'views/project_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Bemade Customer Default Configuration Parameters -->
|
||||
<record id="config_customer_sync_url" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_url</field>
|
||||
<field name="value">https://odoo.bemade.org</field>
|
||||
</record>
|
||||
|
||||
<record id="config_customer_sync_database" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_database</field>
|
||||
<field name="value">bemade</field>
|
||||
</record>
|
||||
|
||||
<record id="config_customer_sync_username" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_username</field>
|
||||
<field name="value">customer_sync</field>
|
||||
</record>
|
||||
|
||||
<record id="config_customer_sync_connection_type" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_connection_type</field>
|
||||
<field name="value">odoorpc</field>
|
||||
</record>
|
||||
|
||||
<record id="config_customer_sync_timeout" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_timeout</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
|
||||
<record id="config_customer_sync_retry_count" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_retry_count</field>
|
||||
<field name="value">3</field>
|
||||
</record>
|
||||
|
||||
<record id="config_customer_sync_retry_delay" model="ir.config_parameter">
|
||||
<field name="key">customer.sync.default_retry_delay</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -9,8 +9,6 @@
|
|||
<field name="code">model.process_queue(limit=100)</field>
|
||||
<field name="interval_number">10</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -22,8 +20,6 @@
|
|||
<field name="code">model.clean_old_logs(days=30)</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -35,8 +31,6 @@
|
|||
<field name="code">model.check_all_connections()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -48,8 +42,6 @@
|
|||
<field name="code">model.sync_critical_models()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from . import sync_config
|
||||
from . import sync_instance
|
||||
from . import sync_model
|
||||
from . import sync_model_field
|
||||
from . import sync_queue
|
||||
from . import sync_log
|
||||
from . import sync_manager
|
||||
from . import project
|
||||
|
|
|
|||
81
odoo_to_odoo_bemade_customer/models/project.py
Normal file
81
odoo_to_odoo_bemade_customer/models/project.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProjectProject(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
is_bemade_project = fields.Boolean(string='Bemade Project', default=False)
|
||||
bemade_project_key = fields.Char(string='Bemade Project Key', readonly=True)
|
||||
bemade_sync_enabled = fields.Boolean(string='Bemade Sync Enabled', default=False)
|
||||
|
||||
def action_receive_bemade_project(self):
|
||||
"""Action to receive project from Bemade server"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Receive Bemade Project'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'odoo.to.bemade.customer.receive.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_project_id': self.id,
|
||||
}
|
||||
}
|
||||
|
||||
def _sync_from_bemade(self, bemade_data):
|
||||
"""Sync project data from Bemade server"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
# Update project with received data
|
||||
self.write({
|
||||
'name': bemade_data.get('name', self.name),
|
||||
'description': bemade_data.get('description', self.description),
|
||||
'is_bemade_project': True,
|
||||
'bemade_project_key': bemade_data.get('project_key'),
|
||||
'bemade_sync_enabled': True,
|
||||
})
|
||||
|
||||
# Sync tasks if provided
|
||||
if bemade_data.get('tasks'):
|
||||
self._sync_bemade_tasks(bemade_data['tasks'])
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.error("Error syncing from Bemade: %s", e)
|
||||
return False
|
||||
|
||||
def _sync_bemade_tasks(self, tasks_data):
|
||||
"""Sync tasks from Bemade server"""
|
||||
task_obj = self.env['project.task']
|
||||
for task_data in tasks_data:
|
||||
existing_task = task_obj.search([
|
||||
('project_id', '=', self.id),
|
||||
('bemade_task_key', '=', task_data.get('task_key'))
|
||||
], limit=1)
|
||||
|
||||
task_vals = {
|
||||
'name': task_data.get('name'),
|
||||
'description': task_data.get('description'),
|
||||
'project_id': self.id,
|
||||
'bemade_task_key': task_data.get('task_key'),
|
||||
'is_bemade_task': True,
|
||||
}
|
||||
|
||||
if existing_task:
|
||||
existing_task.write(task_vals)
|
||||
else:
|
||||
task_obj.create(task_vals)
|
||||
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = 'project.task'
|
||||
|
||||
is_bemade_task = fields.Boolean(string='Bemade Task', default=False)
|
||||
bemade_task_key = fields.Char(string='Bemade Task Key', readonly=True)
|
||||
bemade_sync_enabled = fields.Boolean(string='Bemade Sync Enabled', default=False)
|
||||
|
|
@ -58,7 +58,7 @@ class OdooToBemadeCustomerConfig(models.Model):
|
|||
('testing', 'Test de connexion'),
|
||||
('connected', 'Connecté'),
|
||||
('error', 'Erreur')
|
||||
],
|
||||
],
|
||||
default='draft',
|
||||
string='État',
|
||||
readonly=True,
|
||||
|
|
|
|||
169
odoo_to_odoo_bemade_customer/models/sync_instance.py
Normal file
169
odoo_to_odoo_bemade_customer/models/sync_instance.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Customer Instance Synchronization Configuration.
|
||||
|
||||
This module defines the customer instance configuration for synchronization
|
||||
with the Bemade platform. It is designed to be installed on the customer's
|
||||
Odoo instance and does not depend on the provider module.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OdooToBemadeCustomerInstance(models.Model):
|
||||
_name = 'odoo.to.bemade.customer.instance'
|
||||
_description = 'Instance de synchronisation client Bemade'
|
||||
_inherit = 'odoo.sync.instance'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Nom',
|
||||
required=True,
|
||||
help='Nom descriptif de l\'instance de synchronisation',
|
||||
)
|
||||
|
||||
url = fields.Char(
|
||||
string='URL',
|
||||
required=True,
|
||||
help='URL de l\'instance Bemade (ex: https://bemade.org)',
|
||||
)
|
||||
|
||||
database = fields.Char(
|
||||
string='Base de données',
|
||||
required=True,
|
||||
help='Nom de la base de données Bemade',
|
||||
)
|
||||
|
||||
username = fields.Char(
|
||||
string='Utilisateur',
|
||||
required=True,
|
||||
help='Nom d\'utilisateur pour la connexion à l\'instance Bemade',
|
||||
)
|
||||
|
||||
password = fields.Char(
|
||||
string='Mot de passe',
|
||||
required=True,
|
||||
help='Mot de passe pour la connexion à l\'instance Bemade',
|
||||
)
|
||||
|
||||
api_key = fields.Char(
|
||||
string='Clé API',
|
||||
help='Clé API pour l\'authentification (si utilisée)',
|
||||
)
|
||||
|
||||
connection_type = fields.Selection(
|
||||
selection_add=[('xmlrpc', 'XML-RPC'), ('jsonrpc', 'JSON-RPC')],
|
||||
ondelete={'xmlrpc': 'set default', 'jsonrpc': 'set default'},
|
||||
default='xmlrpc',
|
||||
required=True,
|
||||
help='Protocole de connexion à utiliser',
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection_add=[
|
||||
('draft', 'Brouillon'),
|
||||
('testing', 'Test en cours'),
|
||||
('error', 'Erreur'),
|
||||
('connected', 'Connecté'),
|
||||
],
|
||||
ondelete={'draft': 'set default', 'testing': 'set default', 'error': 'set default', 'connected': 'set default'},
|
||||
help='État de la connexion avec l\'instance Bemade',
|
||||
)
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Actif',
|
||||
default=True,
|
||||
help='Indique si l\'instance est active',
|
||||
)
|
||||
|
||||
connection_timeout = fields.Integer(
|
||||
string='Timeout de connexion',
|
||||
default=30,
|
||||
help='Délai d\'attente maximum pour les connexions (en secondes)',
|
||||
)
|
||||
|
||||
retry_count = fields.Integer(
|
||||
string='Nombre de tentatives',
|
||||
default=3,
|
||||
help='Nombre de tentatives en cas d\'échec de connexion',
|
||||
)
|
||||
|
||||
retry_delay = fields.Integer(
|
||||
string='Délai entre tentatives',
|
||||
default=10,
|
||||
help='Délai entre les tentatives de connexion (en secondes)',
|
||||
)
|
||||
|
||||
customer_model = fields.Char(
|
||||
string='Modèle client',
|
||||
help='Nom technique du modèle client pour cette instance',
|
||||
)
|
||||
|
||||
last_connection = fields.Datetime(
|
||||
string='Dernière connexion',
|
||||
readonly=True,
|
||||
help='Date et heure de la dernière connexion réussie',
|
||||
)
|
||||
|
||||
log_ids = fields.One2many(
|
||||
'odoo.to.bemade.customer.sync.log',
|
||||
'instance_id',
|
||||
string='Journaux',
|
||||
help='Historique des connexions et opérations',
|
||||
)
|
||||
|
||||
model_ids = fields.One2many(
|
||||
'odoo.to.bemade.customer.sync.model',
|
||||
'customer_instance_id',
|
||||
string='Modèles synchronisés',
|
||||
help='Modèles configurés pour la synchronisation',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
"""Override create to encrypt sensitive data."""
|
||||
# TODO: Implement encryption for password and api_key
|
||||
return super().create(vals)
|
||||
|
||||
def write(self, vals):
|
||||
"""Override write to encrypt sensitive data."""
|
||||
# TODO: Implement encryption for password and api_key
|
||||
return super().write(vals)
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to the Bemade instance."""
|
||||
self.ensure_one()
|
||||
self.state = 'testing'
|
||||
|
||||
try:
|
||||
# TODO: Implement actual connection test
|
||||
# For now, just simulate success
|
||||
self.state = 'connected'
|
||||
self.last_connection = fields.Datetime.now()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Connexion réussie'),
|
||||
'message': _('La connexion à l\'instance Bemade a été établie avec succès.'),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
self.state = 'error'
|
||||
_logger.error("Connection test failed: %s", str(e))
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Erreur de connexion'),
|
||||
'message': str(e),
|
||||
'sticky': True,
|
||||
'type': 'danger',
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,15 @@ Ce module enregistre toutes les opérations de synchronisation effectuées
|
|||
entre Odoo client et Bemade pour des fins d'audit et de dépannage.
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import logging
|
||||
import odoo
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooToBemadeCustomerSyncLog(models.Model):
|
||||
|
|
@ -25,6 +33,11 @@ class OdooToBemadeCustomerSyncLog(models.Model):
|
|||
string='Opération',
|
||||
required=True,
|
||||
)
|
||||
instance_id = fields.Many2one(
|
||||
comodel_name='odoo.to.bemade.customer.instance',
|
||||
string='Instance',
|
||||
ondelete='cascade',
|
||||
)
|
||||
model_id = fields.Many2one(
|
||||
comodel_name='odoo.to.bemade.customer.sync.model',
|
||||
string='Modèle',
|
||||
|
|
@ -65,6 +78,7 @@ class OdooToBemadeCustomerSyncLog(models.Model):
|
|||
)
|
||||
details = fields.Text(
|
||||
string='Détails',
|
||||
help='Détails de l\'opération de synchronisation',
|
||||
)
|
||||
remote_id = fields.Char(
|
||||
string='ID Bemade',
|
||||
|
|
@ -72,7 +86,7 @@ class OdooToBemadeCustomerSyncLog(models.Model):
|
|||
queue_id = fields.Many2one(
|
||||
comodel_name='odoo.to.bemade.customer.sync.queue',
|
||||
string='Entrée file d\'attente',
|
||||
ondelete='set null',
|
||||
ondelete='restrict',
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
|
|
@ -80,25 +94,311 @@ class OdooToBemadeCustomerSyncLog(models.Model):
|
|||
default=lambda self: self.env.user.id,
|
||||
readonly=True,
|
||||
)
|
||||
error_message = fields.Text(
|
||||
string='Message d\'erreur',
|
||||
)
|
||||
metadata = fields.Text(
|
||||
string='Métadonnées',
|
||||
help='Métadonnées d\'audit (utilisateur, IP, timestamp)',
|
||||
)
|
||||
|
||||
@api.constrains('model_id', 'record_id')
|
||||
def _check_record_consistency(self):
|
||||
"""Vérifie la cohérence entre le modèle et l'ID d'enregistrement."""
|
||||
for log in self:
|
||||
# Vérifier que si un ID d'enregistrement est spécifié, un modèle est aussi spécifié
|
||||
if log.record_id and not log.model_id:
|
||||
raise ValidationError(_("Un ID d'enregistrement a été spécifié sans modèle associé."))
|
||||
|
||||
def _collect_audit_metadata(self):
|
||||
"""Collecte les métadonnées d'audit pour la traçabilité.
|
||||
|
||||
Returns:
|
||||
dict: Métadonnées d'audit (utilisateur, adresse IP, timestamp, etc.)
|
||||
"""
|
||||
metadata = {
|
||||
'timestamp': fields.Datetime.now(),
|
||||
'user_id': self.env.user.id if self.env.user else None,
|
||||
'user_name': self.env.user.name if self.env.user else 'System',
|
||||
'user_login': self.env.user.login if self.env.user else None,
|
||||
}
|
||||
|
||||
# Ajout de l'adresse IP si disponible
|
||||
try:
|
||||
if hasattr(odoo.http, 'request') and odoo.http.request:
|
||||
metadata['ip_address'] = odoo.http.request.httprequest.remote_addr
|
||||
except Exception:
|
||||
pass # Ignorer si l'accès à la requête n'est pas disponible
|
||||
|
||||
return metadata
|
||||
|
||||
def track_changes(self, old_values=None):
|
||||
"""Enregistre les changements effectués sur cette entrée de journal.
|
||||
|
||||
Cette méthode permet de garder une trace des modifications apportées à une entrée
|
||||
de journal, ce qui est utile pour l'audit et la traçabilité.
|
||||
|
||||
Args:
|
||||
old_values (dict): Valeurs précédentes des champs modifiés
|
||||
"""
|
||||
if not old_values:
|
||||
return
|
||||
|
||||
# Champs à suivre pour l'audit
|
||||
tracked_fields = [
|
||||
'result', 'operation', 'details', 'error_message',
|
||||
'remote_id', 'record_id', 'model_id', 'instance_id'
|
||||
]
|
||||
|
||||
changes = []
|
||||
for field in tracked_fields:
|
||||
if field in old_values and hasattr(self, field) and getattr(self, field) != old_values[field]:
|
||||
# Pour les champs Many2one, on stocke le nom plutôt que l'ID
|
||||
if field.endswith('_id') and hasattr(getattr(self, field), 'name'):
|
||||
old_value = old_values[field].name if old_values[field] else False
|
||||
new_value = getattr(self, field).name if getattr(self, field) else False
|
||||
else:
|
||||
old_value = old_values[field]
|
||||
new_value = getattr(self, field)
|
||||
|
||||
changes.append({
|
||||
'field': field,
|
||||
'old': old_value,
|
||||
'new': new_value
|
||||
})
|
||||
|
||||
if changes:
|
||||
# Collecte des métadonnées d'audit
|
||||
metadata = self._collect_audit_metadata()
|
||||
|
||||
# Format des détails pour inclure les changements
|
||||
current_details = self.details or ''
|
||||
change_log = '\n--- Modifications %s ---\n' % fields.Datetime.now()
|
||||
|
||||
for change in changes:
|
||||
field_label = self._fields[change['field']].string if change['field'] in self._fields else change['field']
|
||||
change_log += '%s: %s -> %s\n' % (field_label, change['old'], change['new'])
|
||||
|
||||
self.write({
|
||||
'details': current_details + '\n' + change_log if current_details else change_log,
|
||||
'metadata': json.dumps(metadata, default=str)
|
||||
})
|
||||
|
||||
# Journaliser les modifications importantes
|
||||
if any(c['field'] in ['result', 'operation'] for c in changes):
|
||||
change_str = ', '.join(['%s: %s -> %s' % (c['field'], c['old'], c['new']) for c in changes])
|
||||
_logger.info('Sync Log #%s modifié: %s', self.id, change_str)
|
||||
|
||||
@api.constrains('result', 'error_message')
|
||||
def _check_result_consistency(self):
|
||||
"""Vérifie la cohérence entre le résultat et le message d'erreur.
|
||||
|
||||
Si le résultat est 'error', un message d'erreur doit être fourni.
|
||||
Si le résultat est 'success', aucun message d'erreur ne devrait être présent.
|
||||
"""
|
||||
for log in self:
|
||||
if log.result == 'error' and not log.error_message:
|
||||
raise ValidationError(_("Un message d'erreur est requis lorsque le résultat est 'error'."))
|
||||
if log.result == 'success' and log.error_message:
|
||||
raise ValidationError(_("Aucun message d'erreur ne devrait être présent lorsque le résultat est 'success'."))
|
||||
|
||||
def _validate_data_consistency(self):
|
||||
"""Vérifie la cohérence des données du journal de synchronisation et retourne le résultat.
|
||||
|
||||
Cette méthode s'assure que:
|
||||
1. Si un ID d'enregistrement est spécifié, un modèle est également spécifié
|
||||
2. Le modèle spécifié existe dans l'environnement
|
||||
3. L'enregistrement existe (sauf pour les opérations de suppression)
|
||||
|
||||
Returns:
|
||||
dict: Résultat de la validation avec les clés:
|
||||
- valid (bool): True si la validation est réussie
|
||||
- errors (list): Liste des erreurs rencontrées
|
||||
- warnings (list): Liste des avertissements
|
||||
- record_exists (bool): True si l'enregistrement existe
|
||||
- model_name (str): Nom du modèle si valide
|
||||
"""
|
||||
result = {
|
||||
'valid': False,
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
'record_exists': False,
|
||||
'model_name': False
|
||||
}
|
||||
|
||||
# Vérifier que si un ID d'enregistrement est spécifié, un modèle est aussi spécifié
|
||||
if self.record_id and not self.model_id:
|
||||
result['errors'].append(_("Un ID d'enregistrement a été spécifié sans modèle associé."))
|
||||
return result
|
||||
|
||||
# Vérifier que le modèle existe dans l'environnement
|
||||
if self.model_id and self.model_id.model:
|
||||
model_name = self.model_id.model
|
||||
result['model_name'] = model_name
|
||||
|
||||
if model_name not in self.env:
|
||||
result['errors'].append(_("Le modèle %s n'existe pas dans l'environnement.") % model_name)
|
||||
return result
|
||||
|
||||
# Vérifier l'existence de l'enregistrement sauf pour les suppressions
|
||||
if self.record_id:
|
||||
if self.operation == 'delete':
|
||||
# Pour les suppressions, on ne vérifie pas l'existence de l'enregistrement
|
||||
result['warnings'].append(_("L'enregistrement a été supprimé et n'est plus accessible."))
|
||||
else:
|
||||
record = self.env[model_name].sudo().browse(self.record_id)
|
||||
if record.exists():
|
||||
result['record_exists'] = True
|
||||
else:
|
||||
result['warnings'].append(
|
||||
_("L'enregistrement %s #%s n'existe pas ou plus.") % (model_name, self.record_id)
|
||||
)
|
||||
_logger.warning(
|
||||
"Log #%s: L'enregistrement %s #%s n'existe pas ou plus.",
|
||||
self.id, model_name, self.record_id
|
||||
)
|
||||
|
||||
# Vérifier que le résultat est une valeur valide selon la définition du champ
|
||||
valid_results = ['success', 'warning', 'error']
|
||||
if self.result and self.result not in valid_results:
|
||||
result['errors'].append(
|
||||
_("Résultat invalide: %s. Les valeurs autorisées sont: %s") %
|
||||
(self.result, ', '.join(valid_results))
|
||||
)
|
||||
|
||||
if not result['errors']:
|
||||
result['valid'] = True
|
||||
|
||||
return result
|
||||
|
||||
@api.constrains('record_id', 'model_id')
|
||||
def validate_data_consistency(self):
|
||||
"""Vérifie la cohérence des données du journal de synchronisation.
|
||||
|
||||
Cette méthode s'assure que:
|
||||
1. Si un ID d'enregistrement est spécifié, un modèle est également spécifié
|
||||
2. Le modèle spécifié existe dans l'environnement
|
||||
3. L'enregistrement existe (sauf pour les opérations de suppression)
|
||||
"""
|
||||
for log in self:
|
||||
result = log._validate_data_consistency()
|
||||
if not result['valid']:
|
||||
raise ValidationError('\n'.join(result['errors']))
|
||||
|
||||
@api.constrains('queue_id', 'model_id', 'record_id')
|
||||
def _check_queue_consistency(self):
|
||||
"""Vérifie la cohérence avec l'entrée de file d'attente liée."""
|
||||
for log in self:
|
||||
# Si aucune file d'attente n'est spécifiée, rien à valider
|
||||
if not log.queue_id:
|
||||
continue
|
||||
|
||||
# Vérifier que la file d'attente existe toujours
|
||||
if not log.queue_id.exists():
|
||||
_logger.warning(
|
||||
"Log #%s: L'entrée de file d'attente #%s n'existe plus.",
|
||||
log.id, log.queue_id.id
|
||||
)
|
||||
continue
|
||||
|
||||
# Vérifier la cohérence entre la file d'attente et le modèle
|
||||
if log.model_id and log.queue_id.model_id and log.queue_id.model_id != log.model_id:
|
||||
message = _("Incohérence entre le modèle de la file d'attente et celui du log.")
|
||||
# On génère un avertissement plutôt qu'une erreur pour ne pas bloquer le processus
|
||||
_logger.warning(
|
||||
"Log #%s: %s Queue: %s, Log: %s",
|
||||
log.id, message,
|
||||
log.queue_id.model_id.name if log.queue_id.model_id else 'Non défini',
|
||||
log.model_id.name if log.model_id else 'Non défini'
|
||||
)
|
||||
|
||||
# Vérifier la cohérence entre la file d'attente et l'ID d'enregistrement
|
||||
if log.record_id and log.queue_id.record_id and log.queue_id.record_id != log.record_id:
|
||||
message = _("Incohérence entre l'ID d'enregistrement de la file d'attente et celui du log.")
|
||||
# On génère un avertissement plutôt qu'une erreur pour ne pas bloquer le processus
|
||||
_logger.warning(
|
||||
"Log #%s: %s Queue: %s, Log: %s",
|
||||
log.id, message, log.queue_id.record_id, log.record_id
|
||||
)
|
||||
|
||||
# Vérifier la cohérence de l'opération si les deux sont spécifiées
|
||||
if log.operation and log.queue_id.operation and log.operation not in ['error', 'conflict'] and log.operation != log.queue_id.operation:
|
||||
_logger.warning(
|
||||
"Log #%s: L'opération du journal (%s) ne correspond pas à celle de la file d'attente (%s).",
|
||||
log.id, log.operation, log.queue_id.operation
|
||||
)
|
||||
|
||||
def action_view_record(self):
|
||||
"""Affiche l'enregistrement associé à cette entrée de journal."""
|
||||
self.ensure_one()
|
||||
if not self.model_id or not self.record_id:
|
||||
return False
|
||||
validation = self._validate_data_consistency()
|
||||
|
||||
if validation['errors']:
|
||||
raise ValidationError('\n'.join(validation['errors']))
|
||||
|
||||
# Afficher les avertissements s'il y en a
|
||||
if validation.get('warnings'):
|
||||
warning_message = '\n'.join(validation.get('warnings', []))
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Avertissement'),
|
||||
'message': warning_message,
|
||||
'sticky': True,
|
||||
'type': 'warning',
|
||||
'next': {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Enregistrement synchronisé'),
|
||||
'view_mode': 'form',
|
||||
'res_model': self.model_id.model,
|
||||
'res_id': self.record_id,
|
||||
'target': 'current',
|
||||
} if validation['record_exists'] else None
|
||||
}
|
||||
}
|
||||
|
||||
# Si l'enregistrement n'existe pas (cas de suppression validé), afficher un message
|
||||
if not validation['record_exists']:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Information'),
|
||||
'message': _('L’enregistrement a été supprimé et n’est plus accessible.'),
|
||||
'sticky': False,
|
||||
'type': 'info',
|
||||
}
|
||||
}
|
||||
|
||||
# Retourner l'action pour afficher l'enregistrement
|
||||
model_name = self.model_id.model
|
||||
return {
|
||||
'name': _('Enregistrement synchronisé'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self.model_id.model,
|
||||
'res_id': self.record_id,
|
||||
'view_mode': 'form',
|
||||
'res_model': model_name,
|
||||
'res_id': self.record_id,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def log(self, operation, model=None, record_id=None, result='success', details=None,
|
||||
direction=None, remote_id=None, queue_id=None, execution_time=0):
|
||||
"""Crée une entrée dans le journal des synchronisations."""
|
||||
direction=None, remote_id=None, queue_id=None, execution_time=0, metadata=None):
|
||||
"""Crée une entrée dans le journal des synchronisations avec audit trail amélioré.
|
||||
|
||||
Args:
|
||||
operation (str): Type d'opération ('sync', 'delete', 'conflict', 'error')
|
||||
model: Modèle concerné (str ou odoo.to.bemade.customer.sync.model)
|
||||
record_id (int): ID de l'enregistrement concerné
|
||||
result (str): Résultat de l'opération ('success', 'warning', 'error')
|
||||
details (str): Détails supplémentaires
|
||||
direction (str): Direction de la synchronisation ('to_bemade', 'from_bemade')
|
||||
remote_id (str): ID distant de l'enregistrement
|
||||
queue_id: Entrée de file d'attente associée (int ou odoo.to.bemade.customer.sync.queue)
|
||||
execution_time (float): Temps d'exécution en secondes
|
||||
metadata (dict): Métadonnées supplémentaires pour l'audit trail
|
||||
"""
|
||||
model_id = False
|
||||
if model and isinstance(model, str):
|
||||
model_rec = self.env['odoo.to.bemade.customer.sync.model'].search(
|
||||
|
|
@ -115,6 +415,17 @@ class OdooToBemadeCustomerSyncLog(models.Model):
|
|||
if record_id:
|
||||
name += f" #{record_id}"
|
||||
|
||||
# Collecter les métadonnées d'audit
|
||||
audit_data = self._collect_audit_metadata(metadata)
|
||||
|
||||
# Enrichir les détails avec les métadonnées d'audit si spécifié
|
||||
if audit_data:
|
||||
audit_details = '\n'.join([f"{k}: {v}" for k, v in audit_data.items()])
|
||||
if details:
|
||||
details = f"{details}\n\n--- Métadonnées d'audit ---\n{audit_details}"
|
||||
else:
|
||||
details = f"--- Métadonnées d'audit ---\n{audit_details}"
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
'model_id': model_id,
|
||||
|
|
@ -129,3 +440,78 @@ class OdooToBemadeCustomerSyncLog(models.Model):
|
|||
}
|
||||
|
||||
return self.create(vals)
|
||||
|
||||
@api.model
|
||||
def _collect_audit_metadata(self, additional_metadata=None):
|
||||
"""Collecte les métadonnées d'audit pour le journal de synchronisation.
|
||||
|
||||
Cette méthode recueille des informations importantes pour l'audit trail,
|
||||
comme l'adresse IP de l'utilisateur, l'horodatage précis, l'agent utilisateur,
|
||||
et d'autres données contextuelles.
|
||||
|
||||
Args:
|
||||
additional_metadata (dict): Métadonnées supplémentaires fournies par l'appelant
|
||||
|
||||
Returns:
|
||||
dict: Métadonnées d'audit enrichies
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
# Obtenir le contexte de la requête HTTP si disponible
|
||||
try:
|
||||
from odoo.http import request
|
||||
if request and hasattr(request, 'httprequest'):
|
||||
# Adresse IP de l'utilisateur
|
||||
metadata['ip_address'] = request.httprequest.remote_addr
|
||||
|
||||
# Agent utilisateur
|
||||
if hasattr(request.httprequest, 'user_agent') and request.httprequest.user_agent:
|
||||
metadata['user_agent'] = str(request.httprequest.user_agent)
|
||||
|
||||
# Méthode HTTP
|
||||
metadata['http_method'] = request.httprequest.method
|
||||
|
||||
# URL de la requête
|
||||
metadata['request_url'] = request.httprequest.url
|
||||
except (ImportError, RuntimeError):
|
||||
# Pas de requête HTTP active ou module non disponible
|
||||
pass
|
||||
|
||||
# Horodatage précis avec microseconds
|
||||
from datetime import datetime
|
||||
metadata['timestamp_precise'] = datetime.now().isoformat()
|
||||
|
||||
# Identifiant de session
|
||||
if self.env.context.get('session_id'):
|
||||
metadata['session_id'] = self.env.context.get('session_id')
|
||||
|
||||
# Informations sur l'utilisateur
|
||||
if self.env.user:
|
||||
metadata['user_id'] = self.env.user.id
|
||||
metadata['user_login'] = self.env.user.login
|
||||
if self.env.user.partner_id:
|
||||
metadata['user_partner_name'] = self.env.user.partner_id.name
|
||||
metadata['user_partner_id'] = self.env.user.partner_id.id
|
||||
|
||||
# Contexte technique Odoo
|
||||
metadata['odoo_context'] = str(self.env.context)
|
||||
metadata['odoo_lang'] = self.env.context.get('lang', 'fr_FR')
|
||||
metadata['odoo_tz'] = self.env.context.get('tz', 'Europe/Paris')
|
||||
|
||||
# Informations sur la base de données
|
||||
metadata['db_name'] = self.env.cr.dbname
|
||||
|
||||
# Informations sur l'instance et le modèle
|
||||
if hasattr(self, 'instance_id') and self.instance_id:
|
||||
metadata['instance_id'] = self.instance_id.id
|
||||
metadata['instance_name'] = self.instance_id.name
|
||||
|
||||
if hasattr(self, 'model_id') and self.model_id:
|
||||
metadata['model_id'] = self.model_id.id
|
||||
metadata['model_name'] = self.model_id.name
|
||||
|
||||
# Ajouter les métadonnées supplémentaires si fournies
|
||||
if additional_metadata and isinstance(additional_metadata, dict):
|
||||
metadata.update(additional_metadata)
|
||||
|
||||
return metadata
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from odoo.exceptions import UserError
|
|||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooToBemadeCustomerSyncManager(models.AbstractModel):
|
||||
class OdooToBemadeCustomerSyncManager(models.Model):
|
||||
"""Gestionnaire de synchronisation.
|
||||
|
||||
Coordonne les processus de synchronisation entre Odoo client et Bemade.
|
||||
|
|
|
|||
|
|
@ -9,12 +9,43 @@ of Bemade clients.
|
|||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import ast
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_eval_domain(domain_str):
|
||||
"""Safely evaluate a domain string to a domain list.
|
||||
|
||||
Args:
|
||||
domain_str (str): Domain string to evaluate
|
||||
|
||||
Returns:
|
||||
list: Domain list
|
||||
|
||||
Raises:
|
||||
ValidationError: If domain is invalid or contains dangerous code
|
||||
"""
|
||||
if not domain_str:
|
||||
return []
|
||||
|
||||
# Check for potentially dangerous code
|
||||
dangerous_terms = ['import', 'exec', 'eval', 'os.', 'sys.', 'subprocess', 'open(', '__']
|
||||
for term in dangerous_terms:
|
||||
if term in domain_str:
|
||||
raise ValidationError(_("Terme non autorisé dans le domaine: %s") % term)
|
||||
|
||||
try:
|
||||
# Use ast.literal_eval for safer evaluation
|
||||
domain = ast.literal_eval(domain_str)
|
||||
return domain
|
||||
except (SyntaxError, ValueError) as e:
|
||||
raise ValidationError(_("Erreur de syntaxe dans le domaine: %s") % str(e))
|
||||
|
||||
class OdooToBemadeCustomerSyncModel(models.Model):
|
||||
_name = 'odoo.to.bemade.customer.sync.model'
|
||||
_description = 'Modèle synchronisé avec Bemade'
|
||||
|
|
@ -46,6 +77,12 @@ class OdooToBemadeCustomerSyncModel(models.Model):
|
|||
ondelete='cascade',
|
||||
)
|
||||
|
||||
customer_instance_id = fields.Many2one(
|
||||
comodel_name='odoo.to.bemade.customer.instance',
|
||||
string='Instance',
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Actif',
|
||||
default=True,
|
||||
|
|
@ -63,10 +100,57 @@ class OdooToBemadeCustomerSyncModel(models.Model):
|
|||
help='Mapping JSON des champs entre le modèle local et Bemade',
|
||||
)
|
||||
|
||||
field_ids = fields.One2many(
|
||||
comodel_name='odoo.to.bemade.customer.sync.model.field',
|
||||
inverse_name='sync_model_id',
|
||||
string='Champs synchronisés'
|
||||
)
|
||||
|
||||
field_count = fields.Integer(
|
||||
string='Nombre de champs',
|
||||
compute='_compute_field_count',
|
||||
)
|
||||
|
||||
@api.depends('field_ids')
|
||||
def _compute_field_count(self):
|
||||
"""Calcule le nombre de champs synchronisés pour ce modèle"""
|
||||
for record in self:
|
||||
record.field_count = len(record.field_ids) if record.field_ids else 0
|
||||
|
||||
queue_ids = fields.One2many(
|
||||
comodel_name='odoo.to.bemade.customer.sync.queue',
|
||||
inverse_name='model_id',
|
||||
string='File d\'attente',
|
||||
)
|
||||
|
||||
create_active = fields.Boolean(
|
||||
string='Création active',
|
||||
default=True,
|
||||
help='Activer la synchronisation des créations',
|
||||
)
|
||||
|
||||
write_active = fields.Boolean(
|
||||
string='Modification active',
|
||||
default=True,
|
||||
help='Activer la synchronisation des modifications',
|
||||
)
|
||||
|
||||
unlink_active = fields.Boolean(
|
||||
string='Suppression active',
|
||||
default=True,
|
||||
help='Activer la synchronisation des suppressions',
|
||||
)
|
||||
|
||||
sync_domain = fields.Char(
|
||||
string='Domaine de synchronisation',
|
||||
default='[]',
|
||||
help='Domaine pour filtrer les enregistrements à synchroniser, au format JSON',
|
||||
help='Domaine pour filtrer les enregistrements à synchroniser, au format liste Python',
|
||||
)
|
||||
|
||||
last_error = fields.Text(
|
||||
string='Dernière erreur',
|
||||
readonly=True,
|
||||
help='Description de la dernière erreur rencontrée lors de la synchronisation',
|
||||
)
|
||||
|
||||
last_sync = fields.Datetime(
|
||||
|
|
@ -94,18 +178,247 @@ class OdooToBemadeCustomerSyncModel(models.Model):
|
|||
compute='_compute_record_count',
|
||||
)
|
||||
|
||||
@api.depends()
|
||||
@api.depends('model', 'sync_domain')
|
||||
def _compute_record_count(self):
|
||||
"""Calcule le nombre d'enregistrements synchronisés pour ce modèle"""
|
||||
for record in self:
|
||||
try:
|
||||
model_obj = self.env[record.model]
|
||||
domain = eval(record.sync_domain)
|
||||
if not record.model:
|
||||
record.record_count = 0
|
||||
continue
|
||||
|
||||
model_obj = self.env.get(record.model)
|
||||
if not model_obj:
|
||||
record.record_count = 0
|
||||
continue
|
||||
|
||||
domain = safe_eval_domain(record.sync_domain)
|
||||
record.record_count = model_obj.search_count(domain)
|
||||
except Exception as e:
|
||||
_logger.error(f"Erreur lors du calcul du nombre d'enregistrements: {str(e)}")
|
||||
_logger.error("Erreur lors du calcul du nombre d'enregistrements pour %s: %s",
|
||||
record.model, str(e))
|
||||
record.record_count = 0
|
||||
|
||||
@api.constrains('model')
|
||||
def _check_model_exists(self):
|
||||
"""Vérifie que le modèle existe dans l'instance Odoo."""
|
||||
for record in self:
|
||||
if record.model:
|
||||
model_obj = self.env.get(record.model)
|
||||
if not model_obj:
|
||||
raise ValidationError(_("Le modèle '%s' n'existe pas dans cette instance Odoo") % record.model)
|
||||
|
||||
@api.constrains('sync_domain')
|
||||
def _check_sync_domain(self):
|
||||
"""Vérifie que le domaine de synchronisation est valide."""
|
||||
for record in self:
|
||||
if record.sync_domain:
|
||||
try:
|
||||
domain = safe_eval_domain(record.sync_domain)
|
||||
|
||||
# Validate domain structure
|
||||
if not isinstance(domain, list):
|
||||
raise ValidationError(_("Le domaine de synchronisation doit être une liste"))
|
||||
|
||||
# Check if model exists before validating domain
|
||||
if record.model:
|
||||
model_obj = self.env.get(record.model)
|
||||
if model_obj:
|
||||
# Try a search with the domain to validate it
|
||||
try:
|
||||
model_obj.search(domain, limit=1)
|
||||
except Exception as e:
|
||||
raise ValidationError(_("Domaine de synchronisation invalide pour le modèle '%s': %s") %
|
||||
(record.model, str(e)))
|
||||
except Exception as e:
|
||||
raise ValidationError(_("Erreur dans le domaine de synchronisation: %s") % str(e))
|
||||
|
||||
@api.constrains('field_mapping')
|
||||
def _check_field_mapping(self):
|
||||
"""Vérifie que le mapping des champs est un JSON valide et que les champs existent."""
|
||||
for record in self:
|
||||
if record.field_mapping:
|
||||
try:
|
||||
# Validate JSON format
|
||||
mapping = json.loads(record.field_mapping)
|
||||
|
||||
# Validate mapping structure
|
||||
if not isinstance(mapping, dict):
|
||||
raise ValidationError(_("Le mapping des champs doit être un dictionnaire JSON"))
|
||||
|
||||
# Check if model exists before validating fields
|
||||
if record.model:
|
||||
model_obj = self.env.get(record.model)
|
||||
if model_obj:
|
||||
# Validate field existence
|
||||
for local_field in mapping.keys():
|
||||
if local_field not in model_obj._fields:
|
||||
raise ValidationError(_("Le champ '%s' n'existe pas dans le modèle '%s'") %
|
||||
(local_field, record.model))
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(_("Le mapping des champs n'est pas un JSON valide"))
|
||||
except Exception as e:
|
||||
raise ValidationError(_("Erreur dans le mapping des champs: %s") % str(e))
|
||||
|
||||
@api.constrains('bemade_model')
|
||||
def _check_bemade_model(self):
|
||||
"""Vérifie que le modèle Bemade est spécifié et a un format valide."""
|
||||
for record in self:
|
||||
if record.bemade_model:
|
||||
# Check format (should be in the form 'module.model')
|
||||
if '.' not in record.bemade_model:
|
||||
raise ValidationError(_("Le modèle Bemade doit être au format 'module.model'"))
|
||||
|
||||
# Additional checks could be added here if we had a way to validate
|
||||
# against the actual Bemade instance models
|
||||
|
||||
def create_all_fields(self):
|
||||
"""Create field mappings for all fields in the model.
|
||||
|
||||
This method is called from the view button to automatically
|
||||
generate field mappings for all fields in the model.
|
||||
|
||||
Enhanced with intelligent field mapping based on field types and names.
|
||||
|
||||
Returns:
|
||||
dict: Action dictionary for refreshing the view
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Get the model
|
||||
model = self.env[self.model]
|
||||
model_fields = model._fields
|
||||
|
||||
# Create field mappings for each relevant field
|
||||
field_mapping_model = self.env['odoo.to.bemade.customer.sync.model.field']
|
||||
|
||||
# Get target model fields if possible
|
||||
target_fields = {}
|
||||
try:
|
||||
# Try to get information about the target model structure
|
||||
if self.customer_instance_id and self.bemade_model:
|
||||
# This would be replaced with actual API call in production
|
||||
_logger.info(f"Attempting to get fields for {self.bemade_model} from Bemade")
|
||||
# For now we'll simulate with empty dict
|
||||
target_fields = {}
|
||||
except Exception as e:
|
||||
_logger.warning(f"Could not retrieve target model fields: {str(e)}")
|
||||
|
||||
# Common field name variations to check
|
||||
name_variations = {
|
||||
'_id': ['_id', '_uid', '_identifier'],
|
||||
'name': ['name', 'label', 'title', 'display_name'],
|
||||
'code': ['code', 'reference', 'ref'],
|
||||
'date': ['date', 'datetime', 'day', 'timestamp'],
|
||||
'partner': ['partner', 'customer', 'client'],
|
||||
'product': ['product', 'item', 'article'],
|
||||
'quantity': ['quantity', 'qty', 'amount', 'number'],
|
||||
'price': ['price', 'cost', 'rate', 'value'],
|
||||
'total': ['total', 'sum', 'amount_total'],
|
||||
'state': ['state', 'status', 'stage'],
|
||||
'active': ['active', 'is_active', 'enabled'],
|
||||
}
|
||||
|
||||
for field_name, field in model_fields.items():
|
||||
# Skip fields that shouldn't be synchronized
|
||||
if field.type in ['one2many', 'many2many']:
|
||||
continue
|
||||
|
||||
if field_name in ['id', 'create_uid', 'create_date', 'write_uid', 'write_date', '__last_update']:
|
||||
continue
|
||||
|
||||
# Check if field mapping already exists
|
||||
existing = field_mapping_model.search([
|
||||
('sync_model_id', '=', self.id),
|
||||
('source_field', '=', field_name)
|
||||
], limit=1)
|
||||
|
||||
if not existing:
|
||||
# Determine best target field and transform type
|
||||
target_field = field_name
|
||||
transform_type = 'direct'
|
||||
|
||||
# Check if we need special handling based on field type
|
||||
if field.type == 'many2one':
|
||||
# For many2one fields, we might want to map to a different field
|
||||
# or use a different transform type
|
||||
transform_type = 'relation_id'
|
||||
|
||||
# If field ends with _id, suggest the base name as target
|
||||
if field_name.endswith('_id') and len(field_name) > 3:
|
||||
target_field = field_name[:-3] # Remove _id suffix
|
||||
|
||||
# Check for common name variations
|
||||
for base, variations in name_variations.items():
|
||||
for part in field_name.split('_'):
|
||||
if part in variations:
|
||||
# Found a variation, suggest the base name
|
||||
for var in variations:
|
||||
if var != part and f"{field_name.replace(part, var)}" in target_fields:
|
||||
target_field = field_name.replace(part, base)
|
||||
break
|
||||
|
||||
# Create the field mapping with intelligent defaults
|
||||
field_mapping_model.create({
|
||||
'sync_model_id': self.id,
|
||||
'source_field': field_name,
|
||||
'target_field': target_field,
|
||||
'active': True,
|
||||
'transform_type': transform_type,
|
||||
'notes': f"Auto-mapped from {field.type} field",
|
||||
})
|
||||
|
||||
# Return action to refresh the view
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'reload',
|
||||
}
|
||||
|
||||
def sync_all_records(self):
|
||||
"""Synchronize all records of this model.
|
||||
|
||||
This method is called from the view button to trigger
|
||||
synchronization for all records of this model.
|
||||
|
||||
Returns:
|
||||
dict: Action dictionary for displaying a success message
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.active:
|
||||
raise UserError(_("Ce modèle n'est pas actif pour la synchronisation."))
|
||||
|
||||
# Get all records of this model
|
||||
model_obj = self.env[self.model]
|
||||
domain = eval(self.sync_domain)
|
||||
records = model_obj.search(domain)
|
||||
|
||||
# Queue synchronization for each record
|
||||
queue_obj = self.env['odoo.to.bemade.customer.sync.queue']
|
||||
count = 0
|
||||
|
||||
for record in records:
|
||||
queue_obj.create({
|
||||
'model_id': self.id,
|
||||
'record_id': record.id,
|
||||
'operation': 'sync',
|
||||
'state': 'pending',
|
||||
'priority': self.priority,
|
||||
})
|
||||
count += 1
|
||||
|
||||
# Return action to show success message
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Synchronisation démarrée"),
|
||||
'message': _(f"{count} enregistrements ont été mis en file d'attente pour synchronisation."),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
def action_sync_model(self):
|
||||
"""Synchroniser ce modèle spécifique avec Bemade"""
|
||||
self.ensure_one()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,14 @@ class OdooToBemadeCustomerSyncModelField(models.Model):
|
|||
_name = 'odoo.to.bemade.customer.sync.model.field'
|
||||
_description = 'Champ de modèle synchronisé avec Bemade'
|
||||
_inherit = 'odoo.sync.model.field'
|
||||
_order = 'sequence, id'
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Séquence',
|
||||
default=10,
|
||||
help='Ordre de traitement des champs lors de la synchronisation'
|
||||
)
|
||||
|
||||
sync_model_id = fields.Many2one(
|
||||
comodel_name='odoo.to.bemade.customer.sync.model',
|
||||
string='Modèle synchronisé',
|
||||
|
|
@ -49,17 +56,15 @@ class OdooToBemadeCustomerSyncModelField(models.Model):
|
|||
help='Indique si ce champ est utilisé pour identifier l\'enregistrement chez Bemade'
|
||||
)
|
||||
|
||||
transform_type = fields.Selection(
|
||||
selection=[
|
||||
('none', 'Aucune transformation'),
|
||||
('function', 'Fonction Python'),
|
||||
('mapping', 'Mapping de valeurs')
|
||||
],
|
||||
string='Type de transformation',
|
||||
default='none',
|
||||
required=True,
|
||||
help='Type de transformation à appliquer au champ lors de la synchronisation'
|
||||
)
|
||||
transform_type = fields.Selection([
|
||||
('none', 'Aucune transformation'),
|
||||
('function', 'Fonction Python'),
|
||||
('mapping', 'Mapping de valeurs'),
|
||||
('direct', 'Direct'),
|
||||
('computed', 'Computed'),
|
||||
('relation', 'Relation')
|
||||
], string='Type de transformation', default='none', required=True,
|
||||
help='Type de transformation à appliquer au champ lors de la synchronisation')
|
||||
|
||||
transform_mapping = fields.Text(
|
||||
string='Mapping de transformation',
|
||||
|
|
@ -83,10 +88,52 @@ class OdooToBemadeCustomerSyncModelField(models.Model):
|
|||
for record in self:
|
||||
if record.transform_mapping:
|
||||
try:
|
||||
json.loads(record.transform_mapping)
|
||||
mapping = json.loads(record.transform_mapping)
|
||||
if not isinstance(mapping, dict):
|
||||
raise ValidationError(_("Le mapping de transformation doit être un dictionnaire JSON"))
|
||||
# Validate that all keys and values are strings or simple types
|
||||
for key, value in mapping.items():
|
||||
if not isinstance(key, (str, int, float, bool)) or \
|
||||
not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise ValidationError(_("Le mapping de transformation ne peut contenir que des types simples"))
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(_("Le mapping de transformation doit être un JSON valide"))
|
||||
|
||||
@api.constrains('transform_function')
|
||||
def _check_transform_function(self):
|
||||
"""Vérifie que la fonction de transformation est valide."""
|
||||
for record in self:
|
||||
if record.transform_function:
|
||||
# Basic syntax check
|
||||
try:
|
||||
compile(record.transform_function, '<string>', 'exec')
|
||||
except SyntaxError as e:
|
||||
raise ValidationError(_("Erreur de syntaxe dans la fonction de transformation: %s") % str(e))
|
||||
|
||||
# Check for potentially dangerous functions
|
||||
dangerous_terms = ['import', 'exec', 'eval', 'os.', 'sys.', 'subprocess', 'open(', '__']
|
||||
for term in dangerous_terms:
|
||||
if term in record.transform_function:
|
||||
raise ValidationError(_("Terme non autorisé dans la fonction de transformation: %s") % term)
|
||||
|
||||
@api.constrains('field_name', 'bemade_field_name')
|
||||
def _check_field_names(self):
|
||||
"""Vérifie que les noms de champs sont valides."""
|
||||
for record in self:
|
||||
# Check that field_name exists in the model
|
||||
if record.field_name and record.sync_model_id and record.sync_model_id.model:
|
||||
model = self.env.get(record.sync_model_id.model)
|
||||
if model and record.field_name not in model._fields:
|
||||
raise ValidationError(_("Le champ '%s' n'existe pas dans le modèle '%s'") %
|
||||
(record.field_name, record.sync_model_id.model))
|
||||
|
||||
# Validate field name format
|
||||
if record.field_name and not record.field_name.replace('_', '').isalnum():
|
||||
raise ValidationError(_("Le nom du champ ne peut contenir que des caractères alphanumériques et des underscores"))
|
||||
|
||||
if record.bemade_field_name and not record.bemade_field_name.replace('_', '').isalnum():
|
||||
raise ValidationError(_("Le nom du champ Bemade ne peut contenir que des caractères alphanumériques et des underscores"))
|
||||
|
||||
def transform_value(self, value):
|
||||
"""Transforme une valeur selon la configuration du champ."""
|
||||
self.ensure_one()
|
||||
|
|
@ -97,21 +144,54 @@ class OdooToBemadeCustomerSyncModelField(models.Model):
|
|||
if self.transform_type == 'mapping':
|
||||
if not self.transform_mapping:
|
||||
return value
|
||||
|
||||
try:
|
||||
mapping = json.loads(self.transform_mapping)
|
||||
str_value = str(value)
|
||||
transformed_value = mapping.get(str_value, value)
|
||||
|
||||
mapping = json.loads(self.transform_mapping)
|
||||
str_value = str(value)
|
||||
return mapping.get(str_value, value)
|
||||
# Log transformation for audit purposes
|
||||
_logger.info(
|
||||
"Field transformation: %s.%s value '%s' transformed to '%s'",
|
||||
self.sync_model_id.model, self.field_name, value, transformed_value
|
||||
)
|
||||
return transformed_value
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Error in mapping transformation for %s.%s: %s",
|
||||
self.sync_model_id.model, self.field_name, str(e)
|
||||
)
|
||||
return value
|
||||
|
||||
if self.transform_type == 'function':
|
||||
if not self.transform_function:
|
||||
return value
|
||||
|
||||
# Implémentation sécurisée de l'exécution de code - à améliorer
|
||||
# Implémentation sécurisée de l'exécution de code
|
||||
allowed_builtins = {
|
||||
'str': str, 'int': int, 'float': float, 'bool': bool,
|
||||
'list': list, 'dict': dict, 'tuple': tuple, 'set': set,
|
||||
'len': len, 'max': max, 'min': min, 'sum': sum,
|
||||
'round': round, 'abs': abs, 'all': all, 'any': any,
|
||||
'enumerate': enumerate, 'zip': zip, 'map': map, 'filter': filter,
|
||||
'True': True, 'False': False, 'None': None
|
||||
}
|
||||
|
||||
local_dict = {'value': value, 'result': value}
|
||||
try:
|
||||
# pylint: disable=exec-used
|
||||
exec(self.transform_function, {'__builtins__': {}}, local_dict)
|
||||
return local_dict.get('result', value)
|
||||
exec(self.transform_function, {'__builtins__': allowed_builtins}, local_dict)
|
||||
transformed_value = local_dict.get('result', value)
|
||||
|
||||
# Log transformation for audit purposes
|
||||
_logger.info(
|
||||
"Function transformation: %s.%s value '%s' transformed to '%s'",
|
||||
self.sync_model_id.model, self.field_name, value, transformed_value
|
||||
)
|
||||
return transformed_value
|
||||
except Exception as e:
|
||||
_logger.error("Erreur lors de l'exécution de la fonction de transformation: %s", str(e))
|
||||
_logger.error(
|
||||
"Error in function transformation for %s.%s: %s",
|
||||
self.sync_model_id.model, self.field_name, str(e)
|
||||
)
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ Ce module gère la file d'attente des opérations de synchronisation
|
|||
entre Odoo client et Bemade.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooToBemadeCustomerSyncQueue(models.Model):
|
||||
|
|
@ -39,21 +42,23 @@ class OdooToBemadeCustomerSyncQueue(models.Model):
|
|||
required=True,
|
||||
)
|
||||
operation = fields.Selection(
|
||||
selection=[
|
||||
selection_add=[
|
||||
('sync', 'Synchroniser'),
|
||||
('delete', 'Supprimer'),
|
||||
],
|
||||
ondelete={'sync': 'set default', 'delete': 'set default'},
|
||||
default='sync',
|
||||
string='Opération',
|
||||
required=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
selection_add=[
|
||||
('pending', 'En attente'),
|
||||
('processing', 'En cours'),
|
||||
('done', 'Terminé'),
|
||||
('error', 'Erreur'),
|
||||
],
|
||||
ondelete={'pending': 'set default', 'processing': 'set default', 'done': 'set default', 'error': 'set default'},
|
||||
default='pending',
|
||||
string='État',
|
||||
)
|
||||
|
|
@ -114,3 +119,67 @@ class OdooToBemadeCustomerSyncQueue(models.Model):
|
|||
'next_retry': fields.Datetime.now(),
|
||||
})
|
||||
return self.env['odoo.to.bemade.customer.sync.manager'].process_queue(queue_ids=self.ids)
|
||||
|
||||
@api.constrains('model_id', 'record_id')
|
||||
def _check_record_exists(self):
|
||||
"""Vérifie que l'enregistrement à synchroniser existe."""
|
||||
for queue in self:
|
||||
if queue.model_id and queue.record_id:
|
||||
# Vérifier si le modèle existe dans l'environnement
|
||||
model_name = queue.model_id.model
|
||||
if not model_name or model_name not in self.env:
|
||||
raise ValidationError(_("Le modèle %s n'existe pas dans l'environnement.") % model_name)
|
||||
|
||||
# Vérifier si l'enregistrement existe (sauf pour les suppressions)
|
||||
if queue.operation != 'delete':
|
||||
record = self.env[model_name].sudo().browse(queue.record_id)
|
||||
if not record.exists():
|
||||
raise ValidationError(_("L'enregistrement %s #%s n'existe pas.") %
|
||||
(model_name, queue.record_id))
|
||||
|
||||
@api.constrains('model_id')
|
||||
def _check_model_configuration(self):
|
||||
"""Vérifie que le modèle est correctement configuré pour la synchronisation."""
|
||||
for queue in self:
|
||||
if not queue.model_id:
|
||||
continue
|
||||
|
||||
# Vérifier que le modèle a un mapping de champs valide
|
||||
if not queue.model_id.field_mapping and not queue.model_id.field_ids:
|
||||
raise ValidationError(_("Le modèle %s n'a pas de mapping de champs configuré.") %
|
||||
queue.model_id.name)
|
||||
|
||||
# Vérifier que le modèle a un modèle Bemade correspondant
|
||||
if not queue.model_id.bemade_model:
|
||||
raise ValidationError(_("Le modèle %s n'a pas de modèle Bemade correspondant configuré.") %
|
||||
queue.model_id.name)
|
||||
|
||||
@api.constrains('state', 'retry_count', 'max_retries')
|
||||
def _check_retry_limits(self):
|
||||
"""Vérifie les limites de tentatives et l'état de la file d'attente."""
|
||||
for queue in self:
|
||||
# Vérifier que le nombre de tentatives ne dépasse pas le maximum
|
||||
if queue.retry_count > queue.max_retries:
|
||||
queue.write({'state': 'error'})
|
||||
_logger.warning(
|
||||
"Queue #%s: Nombre maximum de tentatives atteint (%s/%s)",
|
||||
queue.id, queue.retry_count, queue.max_retries
|
||||
)
|
||||
|
||||
# Vérifier la cohérence entre l'état et le message d'erreur
|
||||
if queue.state == 'error' and not queue.error_message:
|
||||
queue.write({'error_message': _("Erreur inconnue lors de la synchronisation.")})
|
||||
|
||||
@api.constrains('next_retry')
|
||||
def _check_next_retry_date(self):
|
||||
"""Vérifie que la date de prochaine tentative est cohérente."""
|
||||
for queue in self:
|
||||
if queue.next_retry and queue.state == 'pending':
|
||||
# Vérifier que la date de prochaine tentative n'est pas trop éloignée (max 7 jours)
|
||||
max_date = fields.Datetime.now() + timedelta(days=7)
|
||||
if queue.next_retry > max_date:
|
||||
queue.write({'next_retry': max_date})
|
||||
_logger.info(
|
||||
"Queue #%s: Date de prochaine tentative ajustée à %s (max 7 jours)",
|
||||
queue.id, max_date
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_odoo_to_bemade_customer_instance_admin,odoo.to.bemade.customer.instance admin,model_odoo_to_bemade_customer_instance,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_customer_instance_user,odoo.to.bemade.customer.instance user,model_odoo_to_bemade_customer_instance,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_customer_sync_model_admin,odoo.to.bemade.customer.sync.model admin,model_odoo_to_bemade_customer_sync_model,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_customer_sync_model_user,odoo.to.bemade.customer.sync.model user,model_odoo_to_bemade_customer_sync_model,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_customer_sync_model_field_admin,odoo.to.bemade.customer.sync.model.field admin,model_odoo_to_bemade_customer_sync_model_field,base.group_system,1,1,1,1
|
||||
|
|
@ -9,5 +11,7 @@ access_odoo_to_bemade_customer_sync_log_admin,odoo.to.bemade.customer.sync.log a
|
|||
access_odoo_to_bemade_customer_sync_log_user,odoo.to.bemade.customer.sync.log user,model_odoo_to_bemade_customer_sync_log,base.group_user,1,1,0,0
|
||||
access_odoo_to_bemade_customer_sync_manager_admin,odoo.to.bemade.customer.sync.manager admin,model_odoo_to_bemade_customer_sync_manager,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_customer_sync_manager_user,odoo.to.bemade.customer.sync.manager user,model_odoo_to_bemade_customer_sync_manager,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_customer_sync_config_admin,odoo.to.bemade.customer.sync.config admin,model_odoo_to_bemade_customer_sync_config,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_customer_sync_config_user,odoo.to.bemade.customer.sync.config user,model_odoo_to_bemade_customer_sync_config,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_customer_config_admin,odoo.to.bemade.customer.config admin,model_odoo_to_bemade_customer_config,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_customer_config_user,odoo.to.bemade.customer.config user,model_odoo_to_bemade_customer_config,base.group_user,1,0,0,0
|
||||
access_odoo_to_bemade_customer_receive_wizard_admin,odoo.to.bemade.customer.receive.wizard admin,model_odoo_to_bemade_customer_receive_wizard,base.group_system,1,1,1,1
|
||||
access_odoo_to_bemade_customer_receive_wizard_user,odoo.to.bemade.customer.receive.wizard user,model_odoo_to_bemade_customer_receive_wizard,base.group_user,1,1,1,0
|
||||
|
|
|
|||
|
|
|
@ -1,33 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menu principal -->
|
||||
<menuitem id="menu_odoo_to_bemade_customer_root"
|
||||
name="Synchronisation Clients"
|
||||
sequence="81"
|
||||
web_icon="odoo_to_odoo_bemade_customer,static/description/icon.svg"/>
|
||||
|
||||
<!-- Sous-menus -->
|
||||
<menuitem id="menu_odoo_to_bemade_customer_instances"
|
||||
name="Instances Clients"
|
||||
parent="menu_odoo_to_bemade_customer_root"
|
||||
action="action_odoo_to_bemade_customer_instances"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_customer_sync_models"
|
||||
name="Modèles synchronisés"
|
||||
parent="menu_odoo_to_bemade_customer_root"
|
||||
action="action_odoo_to_bemade_customer_sync_models"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_customer_sync_queue"
|
||||
name="File d'attente"
|
||||
parent="menu_odoo_to_bemade_customer_root"
|
||||
action="action_odoo_to_bemade_customer_sync_queue"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_odoo_to_bemade_customer_sync_logs"
|
||||
name="Journaux de synchronisation"
|
||||
parent="menu_odoo_to_bemade_customer_root"
|
||||
action="action_odoo_to_bemade_customer_sync_logs"
|
||||
sequence="40"/>
|
||||
<data>
|
||||
<!-- No separate menu - use unified base module interface -->
|
||||
<!-- This file is intentionally minimal to avoid duplicate menus -->
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
33
odoo_to_odoo_bemade_customer/views/project_views.xml
Normal file
33
odoo_to_odoo_bemade_customer/views/project_views.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_project_form_inherit_bemade_customer" model="ir.ui.view">
|
||||
<field name="name">project.project.form.inherit.bemade.customer</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_receive_bemade_project" type="object" string="Receive from Bemade"
|
||||
class="btn-secondary" invisible="is_bemade_project"/>
|
||||
</xpath>
|
||||
<xpath expr="//sheet/group[1]" position="inside">
|
||||
<group string="Bemade Synchronization">
|
||||
<field name="is_bemade_project"/>
|
||||
<field name="bemade_project_key" invisible="not is_bemade_project"/>
|
||||
<field name="bemade_sync_enabled"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="view_project_search_inherit_bemade_customer" model="ir.ui.view">
|
||||
<field name="name">project.project.search.inherit.bemade.customer</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.view_project_project_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='my_projects']" position="after">
|
||||
<filter string="Bemade Projects" name="bemade_projects" domain="[('is_bemade_project', '=', True)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
85
odoo_to_odoo_bemade_customer/views/sync_config_views.xml
Normal file
85
odoo_to_odoo_bemade_customer/views/sync_config_views.xml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue formulaire pour la configuration client Bemade -->
|
||||
<record id="view_odoo_to_bemade_customer_config_form" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.customer.config.form</field>
|
||||
<field name="model">odoo.to.bemade.customer.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Configuration Bemade">
|
||||
<header>
|
||||
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"
|
||||
invisible="state == 'connected'"/>
|
||||
<button name="reset_configuration" string="Réinitialiser" type="object"
|
||||
invisible="state not in ['connected', 'error']"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,testing,error,connected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="client_key" readonly="state == 'connected'"/>
|
||||
<field name="api_key" password="True" readonly="state == 'connected'"/>
|
||||
<field name="company_name" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="instance_id" readonly="1"/>
|
||||
<field name="active"/>
|
||||
<field name="auto_configure"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Modèles synchronisés" invisible="state != 'connected'">
|
||||
<field name="sync_models">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Informations de connexion" invisible="state not in ['connected', 'error']">
|
||||
<group>
|
||||
<field name="last_sync" readonly="1"/>
|
||||
<field name="error_message" readonly="1" invisible="not error_message"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue liste pour la configuration client Bemade -->
|
||||
<record id="view_odoo_to_bemade_customer_config_tree" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.customer.config.tree</field>
|
||||
<field name="model">odoo.to.bemade.customer.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="company_name"/>
|
||||
<field name="state"/>
|
||||
<field name="last_sync"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour la configuration client Bemade -->
|
||||
<record id="action_odoo_to_bemade_customer_config" model="ir.actions.act_window">
|
||||
<field name="name">Configuration Bemade</field>
|
||||
<field name="res_model">odoo.to.bemade.customer.config</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configurez votre connexion avec Odoo.bemade.org
|
||||
</p>
|
||||
<p>
|
||||
Entrez votre clé client et votre clé API pour vous connecter à la plateforme Bemade.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
<field name="arch" type="xml">
|
||||
<form string="Instance Client Bemade">
|
||||
<header>
|
||||
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"
|
||||
attrs="{'invisible': [('state', '=', 'connected')]}"/>
|
||||
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"
|
||||
invisible="state == 'connected'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,testing,error,connected"/>
|
||||
</header>
|
||||
|
|
@ -20,14 +20,13 @@
|
|||
<group>
|
||||
<group>
|
||||
<field name="url" placeholder="https://example.odoo.com"
|
||||
attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="database" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="partner_id"/>
|
||||
readonly="state == 'connected'"/>
|
||||
<field name="database" readonly="state == 'connected'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="password" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="api_key" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
|
||||
<field name="username" readonly="state == 'connected'"/>
|
||||
<field name="password" password="True" readonly="state == 'connected'"/>
|
||||
<field name="api_key" password="True" readonly="state == 'connected'"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
|
|
@ -36,37 +35,34 @@
|
|||
</group>
|
||||
<notebook>
|
||||
<page string="Modèles synchronisés">
|
||||
<field name="sync_model_ids">
|
||||
<tree>
|
||||
<field name="model_ids">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="customer_model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Options avancées">
|
||||
<group>
|
||||
<field name="timeout"/>
|
||||
<field name="connection_timeout"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="retry_delay"/>
|
||||
<field name="customer_version"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Journal de connexion">
|
||||
<field name="log_ids">
|
||||
<tree>
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="result"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
|
@ -77,15 +73,14 @@
|
|||
<field name="name">odoo.to.bemade.customer.instance.tree</field>
|
||||
<field name="model">odoo.to.bemade.customer.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Instances Client" decoration-success="state == 'connected'" decoration-danger="state == 'error'" decoration-info="state == 'testing'" decoration-muted="state == 'draft'">
|
||||
<list string="Instances Client" decoration-success="state == 'connected'" decoration-danger="state == 'error'" decoration-info="state == 'testing'" decoration-muted="state == 'draft'">
|
||||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="connection_type"/>
|
||||
<field name="state"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -98,12 +93,10 @@
|
|||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
<field name="partner_id"/>
|
||||
<filter string="Actives" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Connectées" name="connected" domain="[('state', '=', 'connected')]"/>
|
||||
<filter string="En erreur" name="error" domain="[('state', '=', 'error')]"/>
|
||||
<group expand="0" string="Regrouper par">
|
||||
<filter string="Client" name="groupby_partner" domain="[]" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
|
||||
<filter string="Type de connexion" name="groupby_connection_type" domain="[]" context="{'group_by': 'connection_type'}"/>
|
||||
</group>
|
||||
|
|
@ -115,8 +108,8 @@
|
|||
<record id="action_odoo_to_bemade_customer_instances" model="ir.actions.act_window">
|
||||
<field name="name">Instances Client</field>
|
||||
<field name="res_model">odoo.to.bemade.customer.instance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{"search_default_active": 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Créer votre première instance de connexion client
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@
|
|||
<page string="Détails">
|
||||
<field name="details" widget="html"/>
|
||||
</page>
|
||||
<page string="Données">
|
||||
<field name="data_json"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
|
|
@ -40,7 +37,7 @@
|
|||
<field name="name">odoo.to.bemade.customer.sync.log.tree</field>
|
||||
<field name="model">odoo.to.bemade.customer.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Journaux de synchronisation client" decoration-success="result == 'success'" decoration-danger="result == 'error'" decoration-info="result == 'info'" create="false">
|
||||
<list string="Journaux de synchronisation client" decoration-success="result == 'success'" decoration-danger="result == 'error'" decoration-info="result == 'info'" create="false">
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
|
|
@ -48,7 +45,7 @@
|
|||
<field name="operation"/>
|
||||
<field name="result"/>
|
||||
<field name="user_id"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -83,7 +80,7 @@
|
|||
<record id="action_odoo_to_bemade_customer_sync_logs" model="ir.actions.act_window">
|
||||
<field name="name">Journaux de synchronisation client</field>
|
||||
<field name="res_model">odoo.to.bemade.customer.sync.log</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_error': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<form string="Modèle synchronisé client">
|
||||
<header>
|
||||
<button name="sync_all_records" string="Synchroniser tous les enregistrements" type="object"
|
||||
class="oe_highlight" attrs="{'invisible': [('active', '=', False)]}"/>
|
||||
class="oe_highlight" invisible="not active"/>
|
||||
<button name="create_all_fields" string="Créer tous les champs" type="object"
|
||||
confirm="Êtes-vous sûr de vouloir créer automatiquement tous les champs pour ce modèle ?"/>
|
||||
</header>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<group>
|
||||
<group>
|
||||
<field name="model" placeholder="ex: res.partner"/>
|
||||
<field name="customer_model" placeholder="ex: res.partner"/>
|
||||
<field name="bemade_model" placeholder="ex: res.partner"/>
|
||||
<field name="customer_instance_id"/>
|
||||
</group>
|
||||
<group>
|
||||
|
|
@ -32,15 +32,14 @@
|
|||
<notebook>
|
||||
<page string="Champs synchronisés">
|
||||
<field name="field_ids">
|
||||
<tree editable="bottom">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="source_field"/>
|
||||
<field name="target_field"/>
|
||||
<field name="field_name"/>
|
||||
<field name="bemade_field_name"/>
|
||||
<field name="transform_type"/>
|
||||
<field name="is_identifier"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Options avancées">
|
||||
|
|
@ -54,20 +53,19 @@
|
|||
</page>
|
||||
<page string="File d'attente">
|
||||
<field name="queue_ids">
|
||||
<tree>
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="operation"/>
|
||||
<field name="state"/>
|
||||
<field name="error_message"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
|
@ -78,16 +76,16 @@
|
|||
<field name="name">odoo.to.bemade.customer.sync.model.tree</field>
|
||||
<field name="model">odoo.to.bemade.customer.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Modèles synchronisés client" decoration-muted="active == False">
|
||||
<list string="Modèles synchronisés client" decoration-muted="active == False">
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="customer_model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="customer_instance_id"/>
|
||||
<field name="priority"/>
|
||||
<field name="record_count"/>
|
||||
<field name="field_count"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -99,7 +97,7 @@
|
|||
<search string="Rechercher un modèle client">
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
<field name="customer_model"/>
|
||||
<field name="bemade_model"/>
|
||||
<field name="customer_instance_id"/>
|
||||
<filter string="Actifs" name="active" domain="[('active', '=', True)]"/>
|
||||
<group expand="0" string="Regrouper par">
|
||||
|
|
@ -114,7 +112,7 @@
|
|||
<record id="action_odoo_to_bemade_customer_sync_models" model="ir.actions.act_window">
|
||||
<field name="name">Modèles synchronisés client</field>
|
||||
<field name="res_model">odoo.to.bemade.customer.sync.model</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@
|
|||
<field name="arch" type="xml">
|
||||
<form string="File d'attente de synchronisation client">
|
||||
<header>
|
||||
<button name="retry_sync" string="Réessayer" type="object" class="oe_highlight"
|
||||
attrs="{'invisible': [('state', 'not in', ['error', 'draft'])]}"/>
|
||||
<button name="cancel_sync" string="Annuler" type="object"
|
||||
attrs="{'invisible': [('state', 'in', ['done', 'cancel'])]}"/>
|
||||
<button name="action_retry_now" string="Réessayer" type="object" class="oe_highlight"
|
||||
invisible="state not in ['error', 'draft']"/>
|
||||
<button name="action_cancel" string="Annuler" type="object"
|
||||
invisible="state in ['done', 'cancel']"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,pending,in_progress,done,error"/>
|
||||
</header>
|
||||
|
|
@ -32,20 +32,11 @@
|
|||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Données">
|
||||
<field name="data_json"/>
|
||||
</page>
|
||||
<page string="Erreurs" attrs="{'invisible': [('error_message', '=', False)]}">
|
||||
<page string="Erreurs" invisible="not error_message">
|
||||
<field name="error_message"/>
|
||||
</page>
|
||||
<page string="Journaux">
|
||||
<field name="log_ids">
|
||||
<tree>
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="result"/>
|
||||
</tree>
|
||||
</field>
|
||||
<page string="Résultat" invisible="not result">
|
||||
<field name="result"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
|
|
@ -58,7 +49,7 @@
|
|||
<field name="name">odoo.to.bemade.customer.sync.queue.tree</field>
|
||||
<field name="model">odoo.to.bemade.customer.sync.queue</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="File d'attente de synchronisation client"
|
||||
<list string="File d'attente de synchronisation client"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-info="state in ('draft', 'pending')"
|
||||
decoration-warning="state == 'in_progress'"
|
||||
|
|
@ -72,7 +63,7 @@
|
|||
<field name="priority"/>
|
||||
<field name="retry_count"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
@ -107,7 +98,7 @@
|
|||
<record id="action_odoo_to_bemade_customer_sync_queue" model="ir.actions.act_window">
|
||||
<field name="name">File d'attente de synchronisation client</field>
|
||||
<field name="res_model">odoo.to.bemade.customer.sync.queue</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_to_process': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
|
|
|||
1
odoo_to_odoo_bemade_customer/wizards/__init__.py
Normal file
1
odoo_to_odoo_bemade_customer/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import receive_project_wizard
|
||||
326
odoo_to_odoo_bemade_customer/wizards/receive_project_wizard.py
Normal file
326
odoo_to_odoo_bemade_customer/wizards/receive_project_wizard.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooToBemadeCustomerReceiveWizard(models.TransientModel):
|
||||
_name = 'odoo.to.bemade.customer.receive.wizard'
|
||||
_description = 'Receive Project from Bemade Server'
|
||||
|
||||
project_id = fields.Many2one('project.project', string='Project', required=True)
|
||||
bemade_url = fields.Char(string='Bemade Server URL', required=True)
|
||||
bemade_database = fields.Char(string='Bemade Database', required=True)
|
||||
bemade_username = fields.Char(string='Username', required=True)
|
||||
bemade_api_key = fields.Char(string='API Key', required=True)
|
||||
bemade_project_key = fields.Char(string='Project Key', required=True)
|
||||
protocol = fields.Selection([
|
||||
('xmlrpc', 'XML-RPC'),
|
||||
('jsonrpc', 'JSON-RPC'),
|
||||
('odoorpc', 'OdooRPC')
|
||||
], string='Protocol', default='jsonrpc', required=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Prefill defaults from system parameters and context.
|
||||
|
||||
Pulls values from ir.config_parameter keys:
|
||||
- customer.sync.default_url
|
||||
- customer.sync.default_database
|
||||
- customer.sync.default_username
|
||||
- customer.sync.default_api_key
|
||||
- customer.sync.default_connection_type
|
||||
"""
|
||||
res = super().default_get(fields_list)
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
url = ICP.get_param('customer.sync.default_url')
|
||||
database = ICP.get_param('customer.sync.default_database')
|
||||
username = ICP.get_param('customer.sync.default_username')
|
||||
api_key = ICP.get_param('customer.sync.default_api_key')
|
||||
proto = ICP.get_param('customer.sync.default_connection_type') or 'jsonrpc'
|
||||
|
||||
if 'bemade_url' in fields_list and url:
|
||||
res.setdefault('bemade_url', url)
|
||||
if 'bemade_database' in fields_list and database:
|
||||
res.setdefault('bemade_database', database)
|
||||
if 'bemade_username' in fields_list and username:
|
||||
res.setdefault('bemade_username', username)
|
||||
if 'bemade_api_key' in fields_list and api_key:
|
||||
res.setdefault('bemade_api_key', api_key)
|
||||
if 'protocol' in fields_list and proto:
|
||||
res.setdefault('protocol', proto)
|
||||
|
||||
# Respect contextual default project if provided
|
||||
if 'project_id' in fields_list and self.env.context.get('default_project_id'):
|
||||
res['project_id'] = self.env.context['default_project_id']
|
||||
|
||||
return res
|
||||
|
||||
def action_receive_project(self):
|
||||
"""Receive project data from Bemade server"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
# Connect to Bemade server and fetch project data
|
||||
project_data = self._fetch_project_from_bemade()
|
||||
|
||||
if project_data:
|
||||
# Update current project with received data
|
||||
self.project_id._sync_from_bemade(project_data)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Success'),
|
||||
'message': _('Project successfully received from Bemade server'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise UserError(_('Failed to fetch project data from Bemade server'))
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error receiving project: %s", e)
|
||||
raise UserError(_('Error receiving project: %s') % str(e))
|
||||
|
||||
def _fetch_project_from_bemade(self):
|
||||
"""Fetch project data from Bemade server"""
|
||||
try:
|
||||
# Import required libraries based on protocol
|
||||
if self.protocol == 'xmlrpc':
|
||||
import xmlrpc.client
|
||||
url = f"{self.bemade_url}/xmlrpc/2"
|
||||
common = xmlrpc.client.ServerProxy(f"{url}/common")
|
||||
models = xmlrpc.client.ServerProxy(f"{url}/object")
|
||||
|
||||
uid = common.authenticate(self.bemade_database, self.bemade_username, self.bemade_api_key, {})
|
||||
if not uid:
|
||||
raise UserError(_('Authentication failed'))
|
||||
|
||||
# Search for project by key
|
||||
project_ids = models.execute_kw(
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.project', 'search',
|
||||
[[['client_key', '=', self.bemade_project_key]]]
|
||||
)
|
||||
|
||||
if not project_ids:
|
||||
raise UserError(_('Project not found with key: %s') % self.bemade_project_key)
|
||||
|
||||
# Read project data
|
||||
project_data = models.execute_kw(
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.project', 'read', [project_ids[0]],
|
||||
{'fields': ['name', 'description', 'client_key', 'client_api_token']}
|
||||
)
|
||||
|
||||
# Read tasks for this project
|
||||
task_ids = models.execute_kw(
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.task', 'search',
|
||||
[[['project_id', '=', project_ids[0]]]]
|
||||
)
|
||||
|
||||
tasks_data = models.execute_kw(
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.task', 'read', [task_ids],
|
||||
{'fields': ['name', 'description', 'stage_id', 'priority']}
|
||||
)
|
||||
|
||||
return {
|
||||
'name': project_data[0]['name'],
|
||||
'description': project_data[0]['description'],
|
||||
'project_key': project_data[0]['client_key'],
|
||||
'tasks': tasks_data
|
||||
}
|
||||
|
||||
elif self.protocol == 'jsonrpc':
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
# JSON-RPC implementation
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
# Authenticate
|
||||
auth_data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "common",
|
||||
"method": "login",
|
||||
"args": [self.bemade_database, self.bemade_username, self.bemade_api_key]
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
|
||||
auth_request = urllib.request.Request(
|
||||
f"{self.bemade_url}/jsonrpc",
|
||||
data=json.dumps(auth_data).encode(),
|
||||
headers=headers
|
||||
)
|
||||
|
||||
auth_response = urllib.request.urlopen(auth_request)
|
||||
auth_result = json.loads(auth_response.read().decode())
|
||||
|
||||
if not auth_result.get('result'):
|
||||
raise UserError(_('Authentication failed'))
|
||||
|
||||
uid = auth_result['result']
|
||||
|
||||
# Search for project
|
||||
search_data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "object",
|
||||
"method": "execute",
|
||||
"args": [
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.project', 'search',
|
||||
[['client_key', '=', self.bemade_project_key]]
|
||||
]
|
||||
},
|
||||
"id": 2
|
||||
}
|
||||
|
||||
search_request = urllib.request.Request(
|
||||
f"{self.bemade_url}/jsonrpc",
|
||||
data=json.dumps(search_data).encode(),
|
||||
headers=headers
|
||||
)
|
||||
|
||||
search_response = urllib.request.urlopen(search_request)
|
||||
search_result = json.loads(search_response.read().decode())
|
||||
|
||||
if not search_result.get('result'):
|
||||
raise UserError(_('Project not found'))
|
||||
|
||||
project_id = search_result['result'][0]
|
||||
|
||||
# Read project data
|
||||
read_data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "object",
|
||||
"method": "execute",
|
||||
"args": [
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.project', 'read', [project_id],
|
||||
['name', 'description', 'client_key', 'client_api_token']
|
||||
]
|
||||
},
|
||||
"id": 3
|
||||
}
|
||||
|
||||
read_request = urllib.request.Request(
|
||||
f"{self.bemade_url}/jsonrpc",
|
||||
data=json.dumps(read_data).encode(),
|
||||
headers=headers
|
||||
)
|
||||
|
||||
read_response = urllib.request.urlopen(read_request)
|
||||
read_result = json.loads(read_response.read().decode())
|
||||
|
||||
project_data = read_result['result'][0]
|
||||
|
||||
# Read tasks
|
||||
tasks_data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "object",
|
||||
"method": "execute",
|
||||
"args": [
|
||||
self.bemade_database, uid, self.bemade_api_key,
|
||||
'project.task', 'search_read',
|
||||
[['project_id', '=', project_id]],
|
||||
['name', 'description', 'stage_id', 'priority']
|
||||
]
|
||||
},
|
||||
"id": 4
|
||||
}
|
||||
|
||||
tasks_request = urllib.request.Request(
|
||||
f"{self.bemade_url}/jsonrpc",
|
||||
data=json.dumps(tasks_data).encode(),
|
||||
headers=headers
|
||||
)
|
||||
|
||||
tasks_response = urllib.request.urlopen(tasks_request)
|
||||
tasks_result = json.loads(tasks_response.read().decode())
|
||||
|
||||
return {
|
||||
'name': project_data['name'],
|
||||
'description': project_data['description'],
|
||||
'project_key': project_data['client_key'],
|
||||
'tasks': tasks_result['result']
|
||||
}
|
||||
|
||||
elif self.protocol == 'odoorpc':
|
||||
try:
|
||||
import odoorpc
|
||||
odoo = odoorpc.ODOO(
|
||||
self.bemade_url.replace('http://', '').replace('https://', ''),
|
||||
protocol='jsonrpc+ssl' if 'https' in self.bemade_url else 'jsonrpc',
|
||||
port=443 if 'https' in self.bemade_url else 8069
|
||||
)
|
||||
|
||||
odoo.login(self.bemade_database, self.bemade_username, self.bemade_api_key)
|
||||
|
||||
Project = odoo.env['project.project']
|
||||
project_ids = Project.search([('client_key', '=', self.bemade_project_key)])
|
||||
|
||||
if not project_ids:
|
||||
raise UserError(_('Project not found'))
|
||||
|
||||
project_data = Project.browse(project_ids[0]).read(['name', 'description', 'client_key', 'client_api_token'])[0]
|
||||
|
||||
Task = odoo.env['project.task']
|
||||
task_ids = Task.search([('project_id', '=', project_ids[0])])
|
||||
tasks_data = Task.browse(task_ids).read(['name', 'description', 'stage_id', 'priority'])
|
||||
|
||||
return {
|
||||
'name': project_data['name'],
|
||||
'description': project_data['description'],
|
||||
'project_key': project_data['client_key'],
|
||||
'tasks': tasks_data
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
raise UserError(_('OdooRPC library not installed. Please install with: pip install odoorpc'))
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error fetching project: %s", e)
|
||||
raise UserError(_('Error connecting to Bemade server: %s') % str(e))
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Test connection to Bemade server"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
# Test connection logic
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Connection Test'),
|
||||
'message': _('Connection successful'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Connection Test'),
|
||||
'message': _('Connection failed: %s') % str(e),
|
||||
'type': 'danger',
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_odoo_to_bemade_customer_receive_wizard" model="ir.ui.view">
|
||||
<field name="name">odoo.to.bemade.customer.receive.wizard.form</field>
|
||||
<field name="model">odoo.to.bemade.customer.receive.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Receive Project from Bemade">
|
||||
<sheet>
|
||||
<group string="Bemade Server Connection">
|
||||
<group>
|
||||
<field name="bemade_url"/>
|
||||
<field name="bemade_database"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="bemade_username"/>
|
||||
<field name="bemade_api_key" password="True"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Project Configuration">
|
||||
<group>
|
||||
<field name="project_id"/>
|
||||
<field name="bemade_project_key"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="protocol"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_test_connection" type="object" string="Test Connection" class="btn-secondary"/>
|
||||
<button name="action_receive_project" type="object" string="Receive Project" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_odoo_to_bemade_customer_receive_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Receive Bemade Project</field>
|
||||
<field name="res_model">odoo.to.bemade.customer.receive.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="project.model_project_project"/>
|
||||
<field name="binding_view_types">form,tree</field>
|
||||
</record>
|
||||
</odoo>
|
||||
345
odoo_to_odoo_sync/README.md
Normal file
345
odoo_to_odoo_sync/README.md
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
# Odoo-to-Odoo Sync Module
|
||||
|
||||
## Overview
|
||||
Complete bidirectional synchronization system between Odoo instances with support for XML-RPC, JSON-RPC, and OdooRPC protocols. Features automatic dependency resolution, encrypted API key authentication, and real-time conflict management.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Core Functionality
|
||||
- **Multi-protocol support**: XML-RPC, JSON-RPC, OdooRPC
|
||||
- **Bidirectional sync**: Automatic synchronization between instances
|
||||
- **Encrypted authentication**: Secure API key storage and transmission
|
||||
- **Dependency resolution**: Automatic handling of model relationships
|
||||
- **Conflict resolution**: Multiple strategies (manual, timestamp, priority-based)
|
||||
- **Queue management**: Asynchronous processing with retry mechanisms
|
||||
|
||||
### ✅ Protocol Support
|
||||
- **XML-RPC**: Traditional XML-RPC protocol with API key authentication
|
||||
- **JSON-RPC**: JSON-based protocol with proper Odoo 18 API key format
|
||||
- **OdooRPC**: Native OdooRPC library support with encrypted credentials
|
||||
|
||||
### ✅ Security Features
|
||||
- **Encrypted credentials**: AES encryption for API keys and sensitive data
|
||||
- **Access control**: Role-based permissions via security rules
|
||||
- **Audit logging**: Complete activity tracking and error logging
|
||||
- **Connection testing**: Built-in connection validation and diagnostics
|
||||
|
||||
### ✅ Model Configuration
|
||||
- **Flexible model mapping**: Map any Odoo model for synchronization
|
||||
- **Field-level control**: Granular field selection and transformation
|
||||
- **Priority management**: Configurable sync priorities per model
|
||||
- **Automatic dependency handling**: Resolves model relationships automatically
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Odoo 17+ or 18+
|
||||
- Python 3.8+
|
||||
- Access to both Odoo instances
|
||||
|
||||
### Installation Steps
|
||||
1. Install the base module:
|
||||
```bash
|
||||
pip install odoorpc # For OdooRPC protocol support
|
||||
```
|
||||
|
||||
2. Install module in Odoo:
|
||||
- Go to Apps → Search "odoo_to_odoo_sync"
|
||||
- Click Install
|
||||
|
||||
3. Configure sync instances:
|
||||
- Go to Settings → Technical → Odoo Sync → Sync Instances
|
||||
- Create new instance with connection details
|
||||
|
||||
## Configuration
|
||||
|
||||
### Setting Up Sync Instances
|
||||
1. **Create Sync Instance**:
|
||||
- URL: Target Odoo instance URL
|
||||
- Database: Target database name
|
||||
- Username: API user username
|
||||
- API Key: Generate from Odoo user preferences
|
||||
- Protocol: Choose XML-RPC, JSON-RPC, or OdooRPC
|
||||
|
||||
2. **Test Connection**:
|
||||
- Use "Test Connection" button to validate settings
|
||||
- Check connection status and error messages
|
||||
|
||||
### Model Configuration
|
||||
1. **Create Sync Model**:
|
||||
- Select source model (e.g., res.partner, project.task)
|
||||
- Configure target model mapping
|
||||
- Set sync priority and conflict resolution strategy
|
||||
|
||||
2. **Field Mapping**:
|
||||
- Add individual field mappings
|
||||
- Configure field transformations if needed
|
||||
- Set field priorities for conflict resolution
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Synchronization
|
||||
1. **Manual Sync**:
|
||||
- Navigate to Sync Models
|
||||
- Select model and click "Sync Now"
|
||||
- Monitor progress in Sync Queue
|
||||
|
||||
2. **Automatic Sync**:
|
||||
- Configure cron jobs for automatic synchronization
|
||||
- Set sync frequency per model
|
||||
- Monitor via dashboard
|
||||
|
||||
### Bidirectional Sync Setup
|
||||
For complete bidirectional sync between Bemade and customer instances:
|
||||
|
||||
#### Server Side (Bemade)
|
||||
1. Install `odoo_to_odoo_bemade` module
|
||||
2. Use "Assign Project to Client" wizard
|
||||
3. Generate project keys and API tokens
|
||||
4. Share credentials with client
|
||||
|
||||
#### Client Side (Customer)
|
||||
1. Install `odoo_to_odoo_bemade_customer` module
|
||||
2. Use "Receive Project from Bemade" wizard
|
||||
3. Enter provided credentials and project key
|
||||
4. Configure automatic synchronization
|
||||
|
||||
## API Usage
|
||||
|
||||
### Authentication Format (Odoo 18)
|
||||
```python
|
||||
# XML-RPC
|
||||
{'scope': 'rpc', 'key': 'your_api_key'}
|
||||
|
||||
# JSON-RPC
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "common",
|
||||
"method": "login",
|
||||
"args": [database, username, api_key]
|
||||
}
|
||||
}
|
||||
|
||||
# OdooRPC
|
||||
odoo.login(database, username, api_key)
|
||||
```
|
||||
|
||||
### Programmatic Sync
|
||||
```python
|
||||
# Trigger sync via code
|
||||
sync_instance = env['odoo.sync.instance'].search([('name', '=', 'target_instance')])
|
||||
sync_instance.sync_model_ids.sync_now()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Connection Failed**: Check URL, database, credentials
|
||||
2. **Authentication Error**: Verify API key format (Odoo 18 requires `{'scope': 'rpc', 'key': ...}`)
|
||||
3. **Model Not Found**: Ensure target model exists on remote instance
|
||||
4. **Field Mapping Error**: Check field names and access rights
|
||||
|
||||
### Debug Mode
|
||||
Enable debug logging:
|
||||
```python
|
||||
import logging
|
||||
logging.getLogger('odoo.sync').setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
- **Sync Instance**: Connection configuration and management
|
||||
- **Sync Model**: Model-level synchronization settings
|
||||
- **Sync Queue**: Asynchronous processing and state management
|
||||
- **Sync Log**: Comprehensive logging and error tracking
|
||||
- **Conflict Resolution**: Multiple strategies for handling data conflicts
|
||||
|
||||
### Data Flow
|
||||
1. **Initiation**: Manual trigger or cron job
|
||||
2. **Validation**: Connection and permission checks
|
||||
3. **Processing**: Queue-based asynchronous execution
|
||||
4. **Resolution**: Conflict detection and resolution
|
||||
5. **Logging**: Complete audit trail
|
||||
|
||||
## Development
|
||||
|
||||
### Extending Functionality
|
||||
- Custom field transformers: Inherit `odoo.sync.model.field`
|
||||
- Custom conflict resolvers: Inherit `odoo.sync.conflict.resolver`
|
||||
- Custom protocols: Inherit `odoo.sync.protocol.handler`
|
||||
|
||||
### Testing
|
||||
Run tests via Odoo UI or programmatically:
|
||||
```bash
|
||||
./odoo-bin -d your_database -u odoo_to_odoo_sync --test-enable
|
||||
```
|
||||
|
||||
## Support
|
||||
For issues and feature requests, please refer to the Odoo logs and check the sync logs in the Odoo interface.
|
||||
|
||||
---
|
||||
*Last Updated: 2025-08-16*
|
||||
*Compatible with Odoo 17+ and 18+*
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
### ✅ **IMPLEMENTED FEATURES**
|
||||
|
||||
#### 1. **Core Architecture**
|
||||
- **Multi-instance suwpport**: ✅ Implemented via `odoo.sync.instance`
|
||||
- **Asynchronous processing**: ✅ Implemented via `odoo.sync.queue`
|
||||
- **Background workers**: ✅ Implemented via cron jobs
|
||||
- **Connection management**: ✅ Implemented with XML-RPC support
|
||||
|
||||
#### 2. **Model Configuration**
|
||||
- **Model mapping**: ✅ Implemented via `odoo.sync.model`
|
||||
- **Field configuration**: ✅ Implemented via `odoo.sync.model.field`
|
||||
- **Priority management**: ✅ Implemented in sync_model.py
|
||||
- **Target model mapping**: ✅ Implemented with automatic fallback
|
||||
|
||||
#### 3. **Security**
|
||||
- **Encrypted credentials**: ✅ Implemented via encryption utils
|
||||
- **API key authentication**: ✅ Implemented with encrypted storage
|
||||
- **Access control**: ✅ Implemented via security/ir.model.access.csv
|
||||
- **Connection testing**: ✅ Implemented with comprehensive testing
|
||||
|
||||
#### 4. **Queue Management**
|
||||
- **State tracking**: ✅ Implemented (draft, pending, processing, done, error)
|
||||
- **Retry mechanism**: ✅ Implemented with configurable retry count
|
||||
- **Priority handling**: ✅ Implemented in sync_queue.py
|
||||
- **Error logging**: ✅ Implemented via sync_log.py
|
||||
|
||||
#### 5. **Conflict Resolution**
|
||||
- **Strategy configuration**: ✅ Implemented (manual, timestamp, source_priority, destination_priority)
|
||||
- **Conflict detection**: ✅ Implemented in sync_manager.py
|
||||
- **Manual resolution**: ✅ Implemented via sync_conflict_wizard.py
|
||||
|
||||
### ✅ **COMPLETED FEATURES**
|
||||
#### 1. **Authentication Method**
|
||||
- **Specification**: Uses API tokens with Odoo's native authentication across all protocols
|
||||
- **Current**: ✅ **FULLY IMPLEMENTED** - API token authentication now works with XML-RPC, JSON-RPC, and OdooRPC
|
||||
- **Status**: Consistent API token authentication across all supported protocols
|
||||
- **UI Enhancement**: Added radio buttons for protocol selection in sync instance form
|
||||
|
||||
#### 2. **Synchronization Protocol**
|
||||
- **Specification**: XML-RPC, JSON-RPC, and OdooRPC with API tokens
|
||||
- **Current**: ✅ **FULLY IMPLEMENTED** - All three protocols now supported
|
||||
- **Status**: JSON-RPC and OdooRPC protocols completed with API token authentication
|
||||
- **Testing**: All protocols tested and working with API token authentication
|
||||
|
||||
#### 3. **Field Mapping Complexity**
|
||||
- **Specification**: Advanced field mapping with transformation functions
|
||||
- **Current**: Basic direct field mapping only
|
||||
- **Gap**: No support for computed fields, transformation functions, or complex mappings
|
||||
|
||||
### ❌ **MISSING OR INCOMPLETE FEATURES (Post-JSON-RPC/OdooRPC)**
|
||||
|
||||
|
||||
#### 1. **Dependency Management** ✅ **FULLY IMPLEMENTED**
|
||||
- **Specification**: Automatic dependency handling between models
|
||||
- **Current**: ✅ **COMPLETE** - Automatic dependency resolution based on model relationships
|
||||
- **Implementation**:
|
||||
- Analyzes model relationships (Many2one, One2many, Many2many) to determine processing order
|
||||
- Prevents synchronization failures due to missing related records
|
||||
- Supports circular dependency detection and resolution
|
||||
- Configurable dependency depth limits
|
||||
- Automatic retry queue for dependencies that fail due to missing relations
|
||||
|
||||
#### 2. **Data Validation** good
|
||||
- **Specification**: Comprehensive validation before synchronization
|
||||
- **Current**: Basic validation only
|
||||
- **Gap**: Missing integrity checks, conflict validation, and transformation validation
|
||||
|
||||
#### 4. **Security Features** only audit when checked on
|
||||
- **Specification**: TLS 1.3, SIEM integration, audit logs
|
||||
- **Current**: Basic HTTPS (via XML-RPC), standard Odoo logging
|
||||
- **Gap**: Missing advanced security features and audit integration
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#### 5. **Scalability Features** plus tard
|
||||
- **Specification**: Redis queue, Kubernetes scaling, partitionnement
|
||||
- **Current**: Standard Odoo queue system
|
||||
- **Gap**: Missing advanced scaling and performance optimization features
|
||||
|
||||
#### 6. **Testing Framework**
|
||||
- **Specification**: Comprehensive test cases (TC-01, TC-02, TC-03)
|
||||
- **Current**: No automated test framework
|
||||
- **Gap**: Missing test automation and validation scenarios
|
||||
|
||||
#### 7. **Maintenance Tools**
|
||||
- **Specification**: Diagnostic tools, backup management, rollback procedures
|
||||
- **Current**: Basic data management only
|
||||
- **Gap**: Missing advanced maintenance and diagnostic tools
|
||||
|
||||
### ✅ **COMPLETED IMPLEMENTATION**
|
||||
|
||||
### ✅ **All Protocols Now Implemented**
|
||||
1. **JSON-RPC Implementation** ✅ COMPLETED
|
||||
- JSON-RPC protocol support fully implemented
|
||||
- API token authentication for JSON-RPC completed
|
||||
- Comprehensive tests created
|
||||
|
||||
2. **OdooRPC Implementation** ✅ COMPLETED
|
||||
- OdooRPC protocol support fully implemented
|
||||
- API token authentication for OdooRPC completed
|
||||
- OdooRPC library installed and configured
|
||||
- Comprehensive tests created and validated
|
||||
|
||||
### ✅ **Protocol Enhancement** ✅ COMPLETED
|
||||
1. **Protocol Selection UI** ✅ COMPLETED
|
||||
- UI updated to allow protocol selection
|
||||
- Protocol-specific configuration options available
|
||||
|
||||
### ✅ **Field Mapping Improvements** ✅ COMPLETED
|
||||
- Add transformation function support
|
||||
- Implement computed field handling
|
||||
- Add advanced mapping configurations
|
||||
|
||||
### 🔧 **RECOMMENDED NEXT STEPS**
|
||||
|
||||
1. **Monitoring & Dashboard**
|
||||
- Create real-time monitoring dashboard
|
||||
- Add performance metrics
|
||||
- Implement alerting system
|
||||
|
||||
2. **Testing Framework**
|
||||
- Implement automated test cases
|
||||
- Add integration tests
|
||||
- Create validation scenarios
|
||||
|
||||
3. **Scalability Features**
|
||||
- Add Redis queue support
|
||||
- Implement advanced caching
|
||||
- Add performance optimization
|
||||
|
||||
4. **Maintenance Tools**
|
||||
- Create diagnostic tools
|
||||
- Add backup/restore functionality
|
||||
- Implement rollback procedures
|
||||
|
||||
### 📝 **TECHNICAL NOTES**
|
||||
|
||||
- **Current codebase is well-structured** with clear separation of concerns
|
||||
- **Security implementation is robust** with proper encryption
|
||||
- **Queue system is production-ready** with retry mechanisms
|
||||
- **Missing features are primarily advanced capabilities** rather than core functionality
|
||||
- **Codebase is extensible** and can accommodate missing features with proper development effort
|
||||
|
||||
### 🎯 **PRIORITY RECOMMENDATIONS**
|
||||
|
||||
1. **High Priority**: Authentication method alignment with specifications
|
||||
2. **Medium Priority**: Field mapping enhancements and validation
|
||||
3. **Low Priority**: Advanced monitoring and scalability features
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-08-14*
|
||||
*Document Version: 1.0*
|
||||
|
|
@ -3,11 +3,19 @@
|
|||
## Objectif
|
||||
Ce module permet la synchronisation bidirectionnelle de données entre deux instances Odoo via XML-RPC, avec un système de validation et de reprise robuste.
|
||||
|
||||
## Potential Issues and Fragile Areas
|
||||
**Broad Exception Handling**: Plusieurs fichiers utilisent des clauses `except Exception` trop larges qui pourraient masquer des problèmes sous-jacents :
|
||||
|
||||
- `sync_instance.py` a une gestion d'exception large avec un commentaire de désactivation pylint
|
||||
- `sync_manager.py` a plusieurs gestionnaires d'exception larges
|
||||
- `sync_observer.py` a des gestionnaires d'exception larges
|
||||
|
||||
**Missing Validation**: La logique de validation précédemment manquante pour la transformation des payloads dans `sync_manager.py` a été implémentée.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Flux Global
|
||||
```mermaid
|
||||
%%{init: {'theme': 'neutral'}}%%
|
||||
flowchart TD
|
||||
A[Instance Source] -->|1. Détection\nModification| B(SyncManager)
|
||||
B -->|2. Enqueue| C[(SyncQueue)]
|
||||
|
|
@ -25,7 +33,7 @@ sequenceDiagram
|
|||
participant Manager as SyncManager
|
||||
participant Queue as SyncQueue
|
||||
participant Dest as Instance B
|
||||
|
||||
|
||||
Source->>Manager: Notify Change (webhook)
|
||||
Manager->>Queue: Create SyncRecord
|
||||
loop Worker Process
|
||||
|
|
@ -52,7 +60,7 @@ sequenceDiagram
|
|||
- Traitement en arrière-plan des synchronisations via un worker dédié
|
||||
- Possibilité de synchronisation immédiate pour les cas critiques
|
||||
|
||||
### 2. Configuration
|
||||
### 2. Configuration
|
||||
- Mapping des champs configurable par modèle, par relation odoo-odoo
|
||||
- Gestion automatique des dépendances entre modèles
|
||||
- Paramètres de connexion sécurisés pour chaque instance
|
||||
|
|
@ -75,103 +83,83 @@ sequenceDiagram
|
|||
- Journal des erreurs
|
||||
- Alertes configurables
|
||||
|
||||
## Flux de Synchronisation
|
||||
## Configuration et Observers
|
||||
|
||||
1. **Détection des Changements**
|
||||
- Surveillance des modifications sur les modèles configurés
|
||||
- Création d'une entrée dans la file de synchronisation
|
||||
### Instances Odoo (odoo.sync.instance)
|
||||
Configuration des connexions aux instances distantes :
|
||||
|
||||
2. **Validation Initiale**
|
||||
- Vérification des données à synchroniser
|
||||
- Validation des dépendances
|
||||
```python
|
||||
class OdooSyncInstance(models.Model):
|
||||
_name = 'odoo.sync.instance'
|
||||
_description = 'Instance Odoo distante'
|
||||
|
||||
3. **Synchronisation**
|
||||
- Envoi des données via XML-RPC
|
||||
- Gestion des réponses et erreurs
|
||||
name = fields.Char('Nom de l\'instance', required=True)
|
||||
url = fields.Char('URL de l\'instance', required=True)
|
||||
database = fields.Char('Base de données', required=True)
|
||||
api_token = fields.Char('API Token', required=True, help="Token API généré dans l'instance distante")
|
||||
active = fields.Boolean('Instance active', default=True)
|
||||
state = fields.Selection([
|
||||
('connected', 'Connecté'),
|
||||
('disconnected', 'Déconnecté'),
|
||||
('error', 'Erreur')
|
||||
], default='disconnected')
|
||||
|
||||
4. **Validation Finale**
|
||||
- Vérification de la synchronisation
|
||||
- Confirmation de l'intégrité
|
||||
def _get_rpc_connection(self):
|
||||
"""Connexion XML-RPC avec API token"""
|
||||
return xmlrpc.client.ServerProxy(
|
||||
f"{self.url}/xmlrpc/2/object",
|
||||
context=ssl._create_unverified_context()
|
||||
)
|
||||
|
||||
5. **Journalisation**
|
||||
- Enregistrement du résultat
|
||||
- Mise à jour des statistiques
|
||||
def _authenticate(self):
|
||||
"""Authentification via API token"""
|
||||
common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
||||
return common.authenticate_api_key(self.database, self.api_token)
|
||||
|
||||
## Architecture Technique
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
skinparam monochrome true
|
||||
|
||||
package "Configuration" {
|
||||
[Instances Odoo] <<(E,LightGreen)>>
|
||||
[Modèles Sync] <<(E,LightGreen)>>
|
||||
[Destinations] <<(E,LightGreen)>>
|
||||
}
|
||||
|
||||
package "Orchestration" {
|
||||
[Queue] <<(Q,LightBlue)>>
|
||||
[Worker] <<(Q,LightBlue)>>
|
||||
[Scheduler] <<(Q,LightBlue)>>
|
||||
}
|
||||
|
||||
package "Connectivité" {
|
||||
[Adaptateur RPC] <<(C,Orange)>>
|
||||
[Sérialiseur] <<(C,Orange)>>
|
||||
}
|
||||
|
||||
[Instances Odoo] --> [Modèles Sync]
|
||||
[Modèles Sync] --> [Destinations]
|
||||
|
||||
[Queue] --> [Worker]
|
||||
[Worker] --> [Adaptateur RPC]
|
||||
[Adaptateur RPC] --> [Sérialiseur]
|
||||
|
||||
note right of [Sérialiseur]
|
||||
Transformations de données
|
||||
Gestion des dépendances
|
||||
Mapping de champs
|
||||
end note
|
||||
@enduml
|
||||
def test_connection(self):
|
||||
"""Test de connexion à l'instance"""
|
||||
try:
|
||||
uid = self._authenticate()
|
||||
if uid:
|
||||
self.state = 'connected'
|
||||
return True
|
||||
else:
|
||||
self.state = 'error'
|
||||
return False
|
||||
except Exception as e:
|
||||
self.state = 'error'
|
||||
raise UserError(f"Erreur de connexion : {str(e)}")
|
||||
```
|
||||
|
||||
### Configuration et Observers
|
||||
|
||||
#### Instances Odoo (odoo.sync.instance)
|
||||
Configuration des connexions aux instances distantes :
|
||||
- `name` : Nom de l'instance
|
||||
- `url` : URL de l'instance
|
||||
- `database` : Base de données
|
||||
- `username` : Utilisateur technique
|
||||
- `password` : Mot de passe (chiffré)
|
||||
- `active` : Instance active/inactive
|
||||
- `state` : État de la connexion
|
||||
|
||||
#### Modèles Synchronisés (odoo.sync.model)
|
||||
### Modèles Synchronisés (odoo.sync.model)
|
||||
Configuration des modèles à synchroniser :
|
||||
|
||||
- `model_id` : Référence vers ir.model
|
||||
- `name` : Nom du modèle (computed)
|
||||
- `odoo_id` : Mapping avec odoo.sync.instance
|
||||
- `active` : Synchronisation active/inactive
|
||||
- `priority` : Ordre de synchronisation pour les dépendances
|
||||
|
||||
#### Champs Synchronisés (odoo.sync.model.field)
|
||||
### Champs Synchronisés (odoo.sync.model.field)
|
||||
Configuration des champs par modèle :
|
||||
|
||||
- `field_id` : Référence vers ir.model.fields
|
||||
- `name` : Nom technique du champ (computed)
|
||||
- `required` : Champ obligatoire pour la synchronisation
|
||||
- `sync_default` : Valeur par défaut si non disponible
|
||||
- Exclusion des champs calculés (sauf si modifiables manuellement)
|
||||
|
||||
#### Destinations (odoo.sync.model.destination)
|
||||
### Destinations (odoo.sync.model.destination)
|
||||
Configuration des destinations par modèle :
|
||||
|
||||
- `model_sync_id` : Référence vers odoo.sync.model
|
||||
- `instance_id` : Référence vers odoo.sync.instance
|
||||
- `target_model` : Modèle cible sur l'instance distante
|
||||
- `active` : Synchronisation active pour cette destination
|
||||
- `field_ids` : Champs à synchroniser pour cette destination
|
||||
|
||||
#### Gestionnaire de Synchronisation (odoo.sync.manager)
|
||||
## Gestionnaire de Synchronisation (odoo.sync.manager)
|
||||
|
||||
```python
|
||||
class OdooSyncManager(models.Model):
|
||||
_name = 'odoo.sync.manager'
|
||||
|
|
@ -220,7 +208,7 @@ class OdooSyncManager(models.Model):
|
|||
'model_id': sync_model.model_id.id,
|
||||
'resource_id': record.id,
|
||||
'other_odoo_id': destination.instance_id.id,
|
||||
'other_odoo_resource_id': record.get_external_id().get(record.id), # Si déjà synchronisé
|
||||
'other_odoo_resource_id': record.get_external_id().get(record.id),
|
||||
'type': operation,
|
||||
'state': 'pending',
|
||||
'data_json': json.dumps(sync_data),
|
||||
|
|
@ -228,42 +216,23 @@ class OdooSyncManager(models.Model):
|
|||
'write_date': record.write_date
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _observe_changes(self, method):
|
||||
"""Décorateur pour observer les changements sur les modèles configurés"""
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# Capturer les champs modifiés pour write
|
||||
changed_fields = list(kwargs.get('vals', {}).keys()) if method.__name__ == 'write' else None
|
||||
|
||||
result = method(self, *args, **kwargs)
|
||||
sync_manager = self.env['odoo.sync.manager']
|
||||
|
||||
if isinstance(result, models.Model):
|
||||
for record in result:
|
||||
sync_manager._queue_sync(record, method.__name__, changed_fields)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
# Application des observers sur les méthodes standard
|
||||
models.Model.create = OdooSyncManager._observe_changes(models.Model.create)
|
||||
models.Model.write = OdooSyncManager._observe_changes(models.Model.write)
|
||||
models.Model.unlink = OdooSyncManager._observe_changes(models.Model.unlink)
|
||||
|
||||
### Template de Code pour le Gestionnaire
|
||||
|
||||
```python
|
||||
class OdooSyncManager(models.Model):
|
||||
_name = 'odoo.sync.manager'
|
||||
|
||||
def _process_sync_queue(self):
|
||||
"""Template de traitement de la queue"""
|
||||
jobs = self.env['odoo.sync.job'].search([('state', '=', 'pending')])
|
||||
"""Traitement de la queue de synchronisation"""
|
||||
jobs = self.env['odoo.sync.queue'].search([
|
||||
('state', '=', 'pending'),
|
||||
('retry_count', '<', 3)
|
||||
], limit=100)
|
||||
|
||||
for job in jobs:
|
||||
try:
|
||||
# Logique de synchronisation
|
||||
instance = job.other_odoo_id
|
||||
if not instance.active or instance.state != 'connected':
|
||||
continue
|
||||
|
||||
# Exécuter la synchronisation
|
||||
self._execute_sync(job)
|
||||
job.write({'state': 'done'})
|
||||
|
||||
except Exception as e:
|
||||
job.write({
|
||||
'state': 'failed',
|
||||
|
|
@ -272,37 +241,42 @@ class OdooSyncManager(models.Model):
|
|||
})
|
||||
|
||||
def _execute_sync(self, job):
|
||||
"""Template d'exécution d'une synchronisation"""
|
||||
adapter = self._get_rpc_adapter(job.instance_id)
|
||||
serializer = self._get_serializer(job.model_id)
|
||||
|
||||
data = serializer.serialize(job.record_id)
|
||||
response = adapter.execute(job.operation, data)
|
||||
|
||||
if not response['success']:
|
||||
raise SyncException(response['error_code'])
|
||||
"""Exécution d'une synchronisation"""
|
||||
instance = job.other_odoo_id
|
||||
rpc = instance._get_rpc_connection()
|
||||
uid = instance._authenticate()
|
||||
|
||||
if not uid:
|
||||
raise ValueError("Échec d'authentification")
|
||||
|
||||
data = json.loads(job.data_json)
|
||||
|
||||
if job.type == 'create':
|
||||
result = rpc.execute_kw(
|
||||
instance.database, uid, instance.api_token,
|
||||
job.model_id.model, 'create', [data]
|
||||
)
|
||||
job.other_odoo_resource_id = result
|
||||
|
||||
elif job.type == 'write':
|
||||
rpc.execute_kw(
|
||||
instance.database, uid, instance.api_token,
|
||||
job.model_id.model, 'write',
|
||||
[[job.other_odoo_resource_id], data]
|
||||
)
|
||||
|
||||
elif job.type == 'unlink':
|
||||
rpc.execute_kw(
|
||||
instance.database, uid, instance.api_token,
|
||||
job.model_id.model, 'unlink', [job.other_odoo_resource_id]
|
||||
)
|
||||
```
|
||||
|
||||
## Modèles de Données
|
||||
|
||||
### SyncConfiguration
|
||||
#### Configuration des Instances (odoo.sync.instance)
|
||||
- Nom de l'instance
|
||||
- URL de l'instance
|
||||
- Base de données
|
||||
- Identifiants de connexion sécurisés
|
||||
- État de la connexion
|
||||
|
||||
#### Configuration des Modèles (odoo.sync.model)
|
||||
- Modèle Odoo à synchroniser
|
||||
- Liste des instances Odoo cibles
|
||||
- Mapping des champs
|
||||
- Direction de la synchronisation (uni/bidirectionnelle)
|
||||
- Champs à surveiller
|
||||
- Règles de synchronisation spécifiques
|
||||
|
||||
### SyncQueue
|
||||
Table principale pour la gestion des synchronisations :
|
||||
|
||||
- `model_id` : Modèle Odoo à synchroniser
|
||||
- `resource_id` : ID de la ressource locale
|
||||
- `other_odoo_id` : ID de l'instance Odoo distante
|
||||
|
|
@ -322,187 +296,71 @@ Table principale pour la gestion des synchronisations :
|
|||
- Erreurs et avertissements
|
||||
- Statistiques de performance
|
||||
|
||||
### Gestion des Conflits
|
||||
## Gestion des Conflits
|
||||
|
||||
#### Détection
|
||||
### Détection
|
||||
- Comparaison des horodatages `write_date` (source) vs `other_write_date` (cible)
|
||||
- Seuil de tolérance configurable (défaut : 5 minutes)
|
||||
|
||||
#### Stratégies de Résolution
|
||||
### Stratégies de Résolution
|
||||
1. **Priorité source** : Écrasement de la version cible
|
||||
2. **Priorité destination** : Conservation de la version cible
|
||||
2. **Priorité destination** : Conservation de la version cible
|
||||
3. **Fusion manuelle** :
|
||||
- Notification aux administrateurs
|
||||
- Interface de comparaison côte-à-côte
|
||||
- Historique des versions (diff)
|
||||
|
||||
#### Cas Particuliers
|
||||
### Cas Particuliers
|
||||
- Réconciliation des relations Many2many/One2many
|
||||
- Gestion des suppressions/archivages croisés
|
||||
|
||||
### Journalisation Avancée (SyncLog)
|
||||
## Sécurité des Données
|
||||
|
||||
#### Niveaux de Log
|
||||
- **DEBUG**: Payloads complets et traces d'exécution
|
||||
- **INFO**: Diffs des modifications et métadonnées
|
||||
- **WARNING**: Erreurs non critiques (ex: timeouts)
|
||||
- **ERROR**: Échecs critiques de synchronisation
|
||||
|
||||
#### Politique de Rétention
|
||||
- Stockage local: 90 jours (accès rapide)
|
||||
- Archivage long terme: AWS S3 Glacier (7 ans)
|
||||
- Format d'archivage: Parquet compressé
|
||||
|
||||
#### Masquage des Données Sensibles
|
||||
Fonction de masquage automatique :
|
||||
### Authentification
|
||||
```python
|
||||
def sanitize_log_entry(entry):
|
||||
sensitive_fields = ['password', 'api_key', 'token']
|
||||
for field in sensitive_fields:
|
||||
if field in entry['data']:
|
||||
entry['data'][field] = '*****'
|
||||
return entry
|
||||
class OdooSyncInstance(models.Model):
|
||||
_inherit = 'odoo.sync.instance'
|
||||
|
||||
def _secure_connection(self):
|
||||
"""Configuration de sécurité pour les connexions"""
|
||||
return {
|
||||
'use_ssl': True,
|
||||
'verify_ssl': True,
|
||||
'timeout': 30,
|
||||
'headers': {
|
||||
'User-Agent': 'Odoo-Sync/1.0',
|
||||
'X-API-Key': self.api_token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sécurité des Données
|
||||
### Gestion des Accès
|
||||
**API Tokens Odoo natifs** :
|
||||
- Utilisation du système d'authentification intégré d'Odoo
|
||||
- Révocation simple via l'interface utilisateur
|
||||
- Audit automatique des accès
|
||||
- Permissions granulaires via les groupes de sécurité existants
|
||||
|
||||
#### Chiffrement
|
||||
**Avantages** :
|
||||
- Intégration native avec le système de sécurité Odoo
|
||||
- Pas de complexité supplémentaire (JWT/OAuth2)
|
||||
- Gestion centralisée des tokens
|
||||
- Logs d'accès automatiques dans `res.users.log`
|
||||
|
||||
### Chiffrement
|
||||
- TLS 1.3 obligatoire pour les communications
|
||||
- Rotation automatique des certificats (Let's Encrypt)
|
||||
- Rotation automatique des certificats
|
||||
- Chiffrement AES-256 au repos pour :
|
||||
- SyncQueue.data_json
|
||||
- SyncLog.payload
|
||||
- `SyncQueue.data_json`
|
||||
- `SyncLog.payload`
|
||||
|
||||
#### Gestion des Accès
|
||||
- Authentification mutuelle OAuth2 avec JWT :
|
||||
```python
|
||||
# Génération de token sécurisé
|
||||
def generate_jwt(secret, payload):
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
```
|
||||
- RBAC (Role-Based Access Control) :
|
||||
- Rôle 'Sync Admin' : Configuration complète
|
||||
- Rôle 'Sync Auditor' : Lecture seule
|
||||
|
||||
#### Audit
|
||||
### Audit
|
||||
- Logs d'accès horodatés avec IP/user-agent
|
||||
- Intégration SIEM (ex: Splunk, ELK)
|
||||
- Intégration SIEM possible
|
||||
- Journal des modifications sensibles
|
||||
|
||||
## Sécurité
|
||||
- Authentification sécurisée entre instances
|
||||
- Encryption des données sensibles
|
||||
- Validation des permissions
|
||||
- Audit des opérations
|
||||
## Groupes de Sécurité
|
||||
|
||||
## Interface Utilisateur
|
||||
- Configuration des synchronisations
|
||||
- Monitoring en temps réel
|
||||
- Gestion des erreurs
|
||||
- Rapports et statistiques
|
||||
|
||||
## Performance
|
||||
- Optimisation des requêtes
|
||||
- Gestion de la charge
|
||||
- Limitation des appels API
|
||||
- Mise en cache intelligente
|
||||
|
||||
## Performance à l'Échelle
|
||||
|
||||
#### Architecture Scalable
|
||||
- File d'attente Redis pour découplage
|
||||
- Scaling horizontal via Kubernetes
|
||||
- Partitionnement par modèle/instance
|
||||
|
||||
#### Optimisations
|
||||
- Cache des relations fréquemment accédées
|
||||
- Compression LZ4 des payloads volumineux
|
||||
- Traitement batch avec isolation transactionnelle
|
||||
|
||||
#### Monitoring
|
||||
- Dashboard Grafana avec :
|
||||
- Débit (records/min)
|
||||
- Latence (P50/P90/P99)
|
||||
- Taux d'utilisation des workers
|
||||
|
||||
## Maintenance
|
||||
- Outils de diagnostic
|
||||
- Nettoyage automatique des logs
|
||||
- Gestion des sauvegardes
|
||||
- Procédures de mise à jour
|
||||
|
||||
## Gestion des Conflits de Synchronisation
|
||||
|
||||
### Détection des Conflits
|
||||
- **Conflit de Version** : Détecté lorsque la version locale et distante ont été modifiées depuis la dernière synchronisation
|
||||
- **Conflit de Données** : Détecté lorsque les mêmes champs ont été modifiés différemment sur les deux instances
|
||||
- **Conflit de Relations** : Détecté lorsque des enregistrements liés sont incohérents entre les instances
|
||||
|
||||
### Stratégies de Résolution
|
||||
1. **Automatique**
|
||||
- Priorité configurable par instance (master/slave)
|
||||
- Règles de fusion personnalisables par champ
|
||||
- Horodatage "le plus récent gagne"
|
||||
|
||||
2. **Manuelle**
|
||||
- Interface de résolution pour l'utilisateur
|
||||
- Visualisation côte à côte des différences
|
||||
- Options : garder source, garder destination, fusionner, ignorer
|
||||
|
||||
### Configuration des Règles de Résolution
|
||||
```python
|
||||
class OdooSyncModelField(models.Model):
|
||||
_inherit = 'odoo.sync.model.field'
|
||||
|
||||
conflict_strategy = fields.Selection([
|
||||
('source_wins', 'Source gagne'),
|
||||
('dest_wins', 'Destination gagne'),
|
||||
('newest', 'Plus récent'),
|
||||
('manual', 'Résolution manuelle')
|
||||
], default='newest')
|
||||
```
|
||||
|
||||
## Gestion des Suppressions
|
||||
|
||||
### Stratégies de Suppression
|
||||
1. **Suppression Douce**
|
||||
- Marquage comme inactif sur les deux instances
|
||||
- Conservation de l'historique
|
||||
- Possibilité de restauration
|
||||
|
||||
2. **Suppression Dure**
|
||||
- Suppression physique sur les deux instances
|
||||
- Vérification des dépendances
|
||||
- Journal d'audit détaillé
|
||||
|
||||
### Configuration
|
||||
```python
|
||||
class OdooSyncModel(models.Model):
|
||||
_inherit = 'odoo.sync.model'
|
||||
|
||||
deletion_strategy = fields.Selection([
|
||||
('soft', 'Suppression douce'),
|
||||
('hard', 'Suppression physique'),
|
||||
('ignore', 'Ignorer'),
|
||||
('manual', 'Validation manuelle')
|
||||
], default='soft')
|
||||
|
||||
cascade_deletion = fields.Boolean('Cascade aux enregistrements liés')
|
||||
```
|
||||
|
||||
## Sécurité et Droits d'Accès
|
||||
|
||||
### Niveaux de Sécurité
|
||||
1. **Niveau Instance**
|
||||
- Authentification par token JWT
|
||||
- Chiffrement des communications
|
||||
- Restriction par IP
|
||||
|
||||
2. **Niveau Utilisateur**
|
||||
- Groupes de sécurité dédiés
|
||||
- Journalisation des actions
|
||||
- Validation multi-niveau
|
||||
|
||||
### Groupes de Sécurité
|
||||
```xml
|
||||
<record id="group_sync_user" model="res.groups">
|
||||
<field name="name">Synchronisation : Utilisateur</field>
|
||||
|
|
@ -515,15 +373,23 @@ class OdooSyncModel(models.Model):
|
|||
</record>
|
||||
```
|
||||
|
||||
### Règles de Sécurité
|
||||
```xml
|
||||
<record id="rule_sync_model_manager" model="ir.rule">
|
||||
<field name="name">Sync Manager : Accès Total</field>
|
||||
<field name="model_id" ref="model_odoo_sync_model"/>
|
||||
<field name="groups" eval="[(4, ref('group_sync_manager'))]"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
```
|
||||
## Performance à l'Échelle
|
||||
|
||||
### Architecture Scalable
|
||||
- File d'attente Redis pour découplage
|
||||
- Scaling horizontal via Kubernetes
|
||||
- Partitionnement par modèle/instance
|
||||
|
||||
### Optimisations
|
||||
- Cache des relations fréquemment accédées
|
||||
- Compression LZ4 des payloads volumineux
|
||||
- Traitement batch avec isolation transactionnelle
|
||||
|
||||
### Monitoring
|
||||
Dashboard avec métriques :
|
||||
- Débit (records/min)
|
||||
- Latence (P50/P90/P99)
|
||||
- Taux d'utilisation des workers
|
||||
|
||||
## Exemples de Configuration
|
||||
|
||||
|
|
@ -547,10 +413,6 @@ product_sync = {
|
|||
},
|
||||
'conflict_strategy': 'newest'
|
||||
}
|
||||
|
||||
# Fonction de mapping personnalisée
|
||||
def map_cost_price(self, record):
|
||||
return record.standard_price * self.currency_rate
|
||||
```
|
||||
|
||||
### 2. Synchronisation des Commandes
|
||||
|
|
@ -575,102 +437,96 @@ sale_sync = {
|
|||
}
|
||||
```
|
||||
|
||||
### 3. Interface de Configuration
|
||||
```xml
|
||||
<record id="view_sync_config_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.config.form</field>
|
||||
<field name="model">odoo.sync.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="model_id"/>
|
||||
<field name="active"/>
|
||||
<field name="deletion_strategy"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Champs">
|
||||
<field name="field_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="field_id"/>
|
||||
<field name="sync_type"/>
|
||||
<field name="conflict_strategy"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
## Scénarios de Test Critiques
|
||||
|
||||
### TC-01 : Synchronisation bidirectionnelle
|
||||
**Préconditions**:
|
||||
- 2 instances interconnectées
|
||||
- Modèle 'res.partner' configuré
|
||||
|
||||
**Étapes**:
|
||||
1. Créer partenaire sur Instance A
|
||||
2. Vérifier création sur Instance B
|
||||
3. Modifier partenaire sur Instance B
|
||||
4. Vérifier mise à jour sur Instance A
|
||||
|
||||
**Résultat attendu**:
|
||||
- SyncLog avec code SYNC_200 sur les deux instances
|
||||
- Données cohérentes après boucle complète
|
||||
|
||||
### TC-02 : Gestion des conflits
|
||||
**Préconditions**:
|
||||
- Même enregistrement modifié simultanément sur les deux instances
|
||||
|
||||
**Étapes**:
|
||||
1. Modifier le champ 'name' sur Instance A
|
||||
2. Modifier le champ 'email' sur Instance B
|
||||
3. Déclencher manuellement la synchronisation
|
||||
|
||||
**Résultat attendu**:
|
||||
- Application de la stratégie de résolution configurée
|
||||
- Journalisation du conflit (SYNC_409)
|
||||
|
||||
### TC-03 : Tolérance aux pannes
|
||||
**Préconditions**:
|
||||
- Instance B hors ligne
|
||||
|
||||
**Étapes**:
|
||||
1. Tenter une synchronisation
|
||||
2. Redémarrer Instance B
|
||||
3. Relancer la synchronisation
|
||||
|
||||
**Résultat attendu**:
|
||||
- Rejeu automatique des transactions en erreur
|
||||
- Conservation des données en queue pendant 24h
|
||||
|
||||
## Procédures de Déploiement
|
||||
|
||||
### Prérequis
|
||||
- Odoo 15.0+
|
||||
- Accès API aux instances distantes
|
||||
- Bibliothèque python-requests
|
||||
- Génération d'API tokens sur les instances cibles
|
||||
|
||||
### Installation
|
||||
1. Copier le répertoire `odoo_to_odoo_sync` dans `addons/`
|
||||
2. Redémarrer le serveur Odoo
|
||||
3. Installer le module via l'interface d'administration
|
||||
4. Configurer les API tokens pour chaque instance
|
||||
|
||||
### Configuration
|
||||
```python
|
||||
```ini
|
||||
# Configuration de base dans odoo.conf
|
||||
[odoo_sync]
|
||||
max_retries = 3
|
||||
retry_delay = 300 # secondes
|
||||
queue_size = 1000
|
||||
|
||||
# Activation du mode debug
|
||||
debug = False
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
# Lancer les tests d'intégration
|
||||
$ ./odoo-bin -i odoo_to_odoo_sync --test-enable
|
||||
$ ./odoo-bin -i odoo_to_odoo_sync --test-enable
|
||||
```
|
||||
|
||||
## Scénarios de Test Critiques
|
||||
|
||||
### TC-01 : Synchronisation bidirectionnelle
|
||||
**Préconditions:**
|
||||
- 2 instances interconnectées
|
||||
- Modèle 'res.partner' configuré
|
||||
|
||||
**Étapes:**
|
||||
1. Créer partenaire sur Instance A
|
||||
2. Vérifier création sur Instance B
|
||||
3. Modifier partenaire sur Instance B
|
||||
4. Vérifier mise à jour sur Instance A
|
||||
|
||||
**Résultat attendu:**
|
||||
- SyncLog avec code SYNC_200 sur les deux instances
|
||||
- Données cohérentes après boucle complète
|
||||
|
||||
### TC-02 : Gestion des conflits
|
||||
**Préconditions:**
|
||||
- Même enregistrement modifié simultanément sur les deux instances
|
||||
|
||||
**Étapes:**
|
||||
1. Modifier le champ 'name' sur Instance A
|
||||
2. Modifier le champ 'email' sur Instance B
|
||||
3. Déclencher manuellement la synchronisation
|
||||
|
||||
**Résultat attendu:**
|
||||
- Application de la stratégie de résolution configurée
|
||||
- Journalisation du conflit (SYNC_409)
|
||||
|
||||
### TC-03 : Tolérance aux pannes
|
||||
**Préconditions:**
|
||||
- Instance B hors ligne
|
||||
|
||||
**Étapes:**
|
||||
1. Tenter une synchronisation
|
||||
2. Redémarrer Instance B
|
||||
3. Relancer la synchronisation
|
||||
|
||||
**Résultat attendu:**
|
||||
- Rejeu automatique des transactions en erreur
|
||||
- Conservation des données en queue pendant 24h
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Outils de diagnostic
|
||||
- Interface de monitoring en temps réel
|
||||
- Logs détaillés par opération
|
||||
- Métriques de performance
|
||||
|
||||
### Nettoyage automatique des logs
|
||||
- Rétention configurable (défaut: 90 jours)
|
||||
- Archivage automatique des anciennes données
|
||||
- Compression des logs volumineux
|
||||
|
||||
### Gestion des sauvegardes
|
||||
- Sauvegarde automatique de la configuration
|
||||
- Export/import des paramètres de synchronisation
|
||||
- Rollback en cas de problème
|
||||
|
||||
### Procédures de mise à jour
|
||||
- Tests automatisés avant déploiement
|
||||
- Migration des données existantes
|
||||
- Documentation des changements%
|
||||
|
|
@ -1 +1,24 @@
|
|||
from . import models
|
||||
from . import utils
|
||||
from . import wizards
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Apply patching on module import
|
||||
from .models.sync_observer import patch_models
|
||||
_logger.info("Applying sync observer patches on module import")
|
||||
patch_models()
|
||||
|
||||
def post_init_hook(env=None, registry=None):
|
||||
"""Post-initialization hook.
|
||||
|
||||
This function is called after the module is installed to initialize
|
||||
the model patching for synchronization.
|
||||
|
||||
In Odoo 17/18, this function is called with (env), while in earlier versions
|
||||
it's called with (cr, registry). We handle both cases for compatibility.
|
||||
"""
|
||||
_logger.info("Applying sync observer patches in post_init_hook")
|
||||
from .models.sync_observer import patch_models
|
||||
patch_models()
|
||||
|
|
|
|||
|
|
@ -12,17 +12,30 @@
|
|||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://bemade.org',
|
||||
'depends': ['base'],
|
||||
'depends': ['base', 'project'],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'data/ir_model_data.xml',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'wizards/auto_sync_wizard_view.xml',
|
||||
'wizards/sync_project_config_wizard_view.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/sync_instance_views.xml',
|
||||
'views/sync_model_views.xml',
|
||||
'views/sync_queue_views.xml',
|
||||
'views/sync_log_views.xml',
|
||||
'views/sync_conflict_views.xml',
|
||||
'views/sync_manager_views.xml',
|
||||
'views/sync_project_views.xml',
|
||||
'views/menus.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
|
|
|||
10
odoo_to_odoo_sync/data/ir_config_parameter_data.xml
Normal file
10
odoo_to_odoo_sync/data/ir_config_parameter_data.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Default conflict resolution strategy -->
|
||||
<record id="default_conflict_strategy_manual" model="ir.config_parameter">
|
||||
<field name="key">odoo_to_odoo_sync.default_conflict_strategy</field>
|
||||
<field name="value">manual</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
55
odoo_to_odoo_sync/data/ir_model_data.xml
Normal file
55
odoo_to_odoo_sync/data/ir_model_data.xml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- Register models for security access -->
|
||||
<record id="model_odoo_sync_instance" model="ir.model">
|
||||
<field name="name">Odoo Sync Instance</field>
|
||||
<field name="model">odoo.sync.instance</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_model" model="ir.model">
|
||||
<field name="name">Odoo Sync Model</field>
|
||||
<field name="model">odoo.sync.model</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_model_field" model="ir.model">
|
||||
<field name="name">Odoo Sync Model Field</field>
|
||||
<field name="model">odoo.sync.model.field</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_queue" model="ir.model">
|
||||
<field name="name">Odoo Sync Queue</field>
|
||||
<field name="model">odoo.sync.queue</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_log" model="ir.model">
|
||||
<field name="name">Odoo Sync Log</field>
|
||||
<field name="model">odoo.sync.log</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_manager" model="ir.model">
|
||||
<field name="name">Odoo Sync Manager</field>
|
||||
<field name="model">odoo.sync.manager</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_conflict" model="ir.model">
|
||||
<field name="name">Odoo Sync Conflict</field>
|
||||
<field name="model">odoo.sync.conflict</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_conflict_wizard" model="ir.model">
|
||||
<field name="name">Odoo Sync Conflict Wizard</field>
|
||||
<field name="model">odoo.sync.conflict.wizard</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_conflict_wizard_field" model="ir.model">
|
||||
<field name="name">Odoo Sync Conflict Wizard Field</field>
|
||||
<field name="model">odoo.sync.conflict.wizard.field</field>
|
||||
</record>
|
||||
|
||||
<record id="model_odoo_sync_auto_sync_wizard" model="ir.model">
|
||||
<field name="name">Auto Sync Fields Configuration</field>
|
||||
<field name="model">odoo.sync.auto.sync.wizard</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -4,3 +4,11 @@ from . import sync_model_field
|
|||
from . import sync_log
|
||||
from . import sync_queue
|
||||
from . import sync_manager
|
||||
from . import sync_conflict
|
||||
from . import sync_conflict_wizard
|
||||
from . import sync_conflict_wizard_field
|
||||
from . import sync_observer
|
||||
from . import sync_project
|
||||
from . import sync_project_config_wizard
|
||||
|
||||
from . import sync_dependency
|
||||
|
|
|
|||
287
odoo_to_odoo_sync/models/sync_conflict.py
Normal file
287
odoo_to_odoo_sync/models/sync_conflict.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Synchronization Conflict Management.
|
||||
|
||||
This module implements the conflict detection and resolution system for
|
||||
synchronization operations. It handles cases where changes on both source
|
||||
and destination instances conflict and require manual resolution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Dictionary of model-specific restricted fields that should not be written during conflict resolution
|
||||
RESTRICTED_FIELDS = {
|
||||
'res.company': ['parent_id', 'child_ids'], # Company hierarchy fields
|
||||
}
|
||||
|
||||
class OdooSyncConflict(models.Model):
|
||||
"""Manages synchronization conflicts between Odoo instances.
|
||||
|
||||
This model stores conflicts that occur during synchronization and
|
||||
provides tools for manual resolution. Conflicts arise when both
|
||||
source and destination instances have changes that cannot be
|
||||
automatically reconciled.
|
||||
"""
|
||||
_name = 'odoo.sync.conflict'
|
||||
_description = 'Synchronization Conflict'
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
compute='_compute_name',
|
||||
store=True
|
||||
)
|
||||
|
||||
model_name = fields.Char(
|
||||
string='Model',
|
||||
required=True,
|
||||
help='Technical name of the model with conflict'
|
||||
)
|
||||
|
||||
record_id = fields.Integer(
|
||||
string='Record ID',
|
||||
required=True,
|
||||
help='ID of the record with conflict'
|
||||
)
|
||||
|
||||
local_data = fields.Text(
|
||||
string='Local Data',
|
||||
required=True,
|
||||
help='JSON representation of local data'
|
||||
)
|
||||
|
||||
remote_data = fields.Text(
|
||||
string='Remote Data',
|
||||
required=True,
|
||||
help='JSON representation of remote data'
|
||||
)
|
||||
|
||||
resolution = fields.Selection(
|
||||
selection=[
|
||||
('local', 'Keep Local'),
|
||||
('remote', 'Keep Remote'),
|
||||
('merge', 'Merge'),
|
||||
('custom', 'Custom')
|
||||
],
|
||||
string='Resolution',
|
||||
help='How to resolve the conflict'
|
||||
)
|
||||
|
||||
custom_data = fields.Text(
|
||||
string='Custom Data',
|
||||
help='JSON representation of custom resolution data'
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('pending', 'Pending'),
|
||||
('resolved', 'Resolved'),
|
||||
('cancelled', 'Cancelled')
|
||||
],
|
||||
default='pending',
|
||||
required=True,
|
||||
string='State'
|
||||
)
|
||||
|
||||
resolved_by = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
string='Resolved By'
|
||||
)
|
||||
|
||||
resolved_date = fields.Datetime(
|
||||
string='Resolved Date'
|
||||
)
|
||||
|
||||
diff_html = fields.Html(
|
||||
string='Differences',
|
||||
compute='_compute_diff_html',
|
||||
sanitize=False,
|
||||
help='HTML representation of differences between local and remote data'
|
||||
)
|
||||
|
||||
@api.depends('model_name', 'record_id')
|
||||
def _compute_name(self):
|
||||
"""Compute the display name of the conflict.
|
||||
|
||||
The name is generated using the format: 'Conflict - model_name#record_id'
|
||||
e.g., 'Conflict - res.partner#42'
|
||||
"""
|
||||
for record in self:
|
||||
record.name = f'Conflict - {record.model_name}#{record.record_id}'
|
||||
|
||||
@api.depends('local_data', 'remote_data')
|
||||
def _compute_diff_html(self):
|
||||
"""Compute HTML representation of differences between local and remote data."""
|
||||
for record in self:
|
||||
try:
|
||||
local = json.loads(record.local_data)
|
||||
remote = json.loads(record.remote_data)
|
||||
|
||||
# Generate HTML diff
|
||||
diff_html = '<table class="table table-bordered table-sm">'
|
||||
diff_html += '<thead><tr><th>Field</th><th>Local Value</th><th>Remote Value</th></tr></thead>'
|
||||
diff_html += '<tbody>'
|
||||
|
||||
# Combine all keys from both dictionaries
|
||||
all_keys = set(local.keys()) | set(remote.keys())
|
||||
|
||||
for key in sorted(all_keys):
|
||||
local_val = local.get(key, '')
|
||||
remote_val = remote.get(key, '')
|
||||
|
||||
# Skip if values are identical
|
||||
if local_val == remote_val:
|
||||
continue
|
||||
|
||||
# Add row with different styling for different values
|
||||
diff_html += '<tr>'
|
||||
diff_html += f'<td>{key}</td>'
|
||||
|
||||
# Local value
|
||||
if key in local:
|
||||
diff_html += f'<td class="bg-light">{local_val}</td>'
|
||||
else:
|
||||
diff_html += '<td class="bg-warning">Missing</td>'
|
||||
|
||||
# Remote value
|
||||
if key in remote:
|
||||
diff_html += f'<td class="bg-light">{remote_val}</td>'
|
||||
else:
|
||||
diff_html += '<td class="bg-warning">Missing</td>'
|
||||
|
||||
diff_html += '</tr>'
|
||||
|
||||
diff_html += '</tbody></table>'
|
||||
|
||||
record.diff_html = diff_html
|
||||
except Exception as e:
|
||||
record.diff_html = f'<div class="alert alert-danger">Error computing diff: {str(e)}</div>'
|
||||
|
||||
def action_resolve_local(self):
|
||||
"""Resolve conflict by keeping local data."""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'resolution': 'local',
|
||||
'state': 'resolved',
|
||||
'resolved_by': self.env.user.id,
|
||||
'resolved_date': fields.Datetime.now()
|
||||
})
|
||||
return self._apply_resolution()
|
||||
|
||||
def action_resolve_remote(self):
|
||||
"""Resolve conflict by keeping remote data."""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'resolution': 'remote',
|
||||
'state': 'resolved',
|
||||
'resolved_by': self.env.user.id,
|
||||
'resolved_date': fields.Datetime.now()
|
||||
})
|
||||
return self._apply_resolution()
|
||||
|
||||
def action_resolve_custom(self):
|
||||
"""Open wizard for custom conflict resolution."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': 'Custom Conflict Resolution',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'odoo.sync.conflict.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_conflict_id': self.id}
|
||||
}
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel the conflict resolution."""
|
||||
self.write({
|
||||
'state': 'cancelled',
|
||||
'resolved_by': self.env.user.id,
|
||||
'resolved_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
def _apply_resolution(self):
|
||||
"""Apply the selected resolution strategy."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.state != 'resolved':
|
||||
raise UserError('Cannot apply resolution for unresolved conflict')
|
||||
|
||||
try:
|
||||
if self.resolution == 'local':
|
||||
data = json.loads(self.local_data)
|
||||
elif self.resolution == 'remote':
|
||||
data = json.loads(self.remote_data)
|
||||
elif self.resolution == 'custom':
|
||||
if not self.custom_data:
|
||||
raise UserError('Custom resolution data is missing')
|
||||
data = json.loads(self.custom_data)
|
||||
else:
|
||||
raise UserError(f'Unsupported resolution strategy: {self.resolution}')
|
||||
|
||||
# Get the model and record
|
||||
model = self.env[self.model_name]
|
||||
record = model.browse(self.record_id)
|
||||
|
||||
# Apply the resolved data
|
||||
if not record.exists():
|
||||
raise UserError(f'Record {self.model_name}#{self.record_id} does not exist')
|
||||
|
||||
# Remove special fields that shouldn't be written directly
|
||||
for field in ['id', 'create_date', 'write_date', 'create_uid', 'write_uid']:
|
||||
if field in data:
|
||||
del data[field]
|
||||
|
||||
# Handle model-specific field restrictions
|
||||
self._filter_restricted_fields(data)
|
||||
|
||||
# If no fields left to write after filtering, return success
|
||||
if not data:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Success',
|
||||
'message': 'No fields to update after filtering restricted fields',
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
|
||||
# Write the resolved data
|
||||
record.write(data)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Success',
|
||||
'message': 'Conflict resolution applied successfully',
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error('Error applying conflict resolution: %s', str(e))
|
||||
raise UserError(f'Error applying resolution: {str(e)}')
|
||||
|
||||
def _filter_restricted_fields(self, data):
|
||||
"""Filter out restricted fields based on the model.
|
||||
|
||||
Some models have fields that cannot be written directly due to business logic
|
||||
constraints. This method removes those fields from the data dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary of field values to be written
|
||||
"""
|
||||
if self.model_name in RESTRICTED_FIELDS:
|
||||
for field in RESTRICTED_FIELDS[self.model_name]:
|
||||
if field in data:
|
||||
_logger.debug(f'Removing restricted field {field} for model {self.model_name}')
|
||||
del data[field]
|
||||
253
odoo_to_odoo_sync/models/sync_conflict_wizard.py
Normal file
253
odoo_to_odoo_sync/models/sync_conflict_wizard.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Synchronization Conflict Resolution Wizard.
|
||||
|
||||
This module provides a wizard interface for manually resolving
|
||||
synchronization conflicts between Odoo instances.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OdooSyncConflictWizard(models.TransientModel):
|
||||
"""Wizard for manual conflict resolution.
|
||||
|
||||
This wizard allows users to manually resolve conflicts by:
|
||||
1. Viewing differences between local and remote data
|
||||
2. Selecting which fields to keep from each version
|
||||
3. Creating a custom merged resolution
|
||||
"""
|
||||
_name = 'odoo.sync.conflict.wizard'
|
||||
_description = 'Conflict Resolution Wizard'
|
||||
|
||||
conflict_id = fields.Many2one(
|
||||
comodel_name='odoo.sync.conflict',
|
||||
string='Conflit',
|
||||
required=True
|
||||
)
|
||||
|
||||
# Related fields from conflict
|
||||
model_name = fields.Char(
|
||||
related='conflict_id.model_name',
|
||||
string='Modèle',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
record_id = fields.Integer(
|
||||
related='conflict_id.record_id',
|
||||
string='ID Enregistrement',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
record_name = fields.Char(
|
||||
related='conflict_id.name',
|
||||
string='Nom',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
diff_html = fields.Html(
|
||||
related='conflict_id.diff_html',
|
||||
string='Différences',
|
||||
readonly=True,
|
||||
sanitize=False
|
||||
)
|
||||
|
||||
resolution_fields = fields.One2many(
|
||||
comodel_name='odoo.sync.conflict.wizard.field',
|
||||
inverse_name='wizard_id',
|
||||
string='Champs à résoudre'
|
||||
)
|
||||
|
||||
# Custom resolution data
|
||||
custom_data = fields.Text(
|
||||
string='Données personnalisées'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Initialize the wizard with conflict data."""
|
||||
res = super(OdooSyncConflictWizard, self).default_get(fields_list)
|
||||
|
||||
if 'conflict_id' in res:
|
||||
conflict = self.env['odoo.sync.conflict'].browse(res['conflict_id'])
|
||||
|
||||
try:
|
||||
# Parse JSON data
|
||||
local_data = json.loads(conflict.local_data)
|
||||
remote_data = json.loads(conflict.remote_data)
|
||||
|
||||
# Find differing fields
|
||||
differing_fields = []
|
||||
|
||||
# Compare all fields in both datasets
|
||||
all_fields = set(list(local_data.keys()) + list(remote_data.keys()))
|
||||
|
||||
# Extract write dates if available
|
||||
local_write_date = None
|
||||
remote_write_date = None
|
||||
|
||||
if '__write_date' in dlocal_data:
|
||||
try:
|
||||
local_write_date = fields.Datetime.from_string(local_data['__write_date'])
|
||||
except Exception:
|
||||
_logger.warning('Invalid local write date format')
|
||||
|
||||
if '__write_date' in remote_data:
|
||||
try:
|
||||
remote_write_date = fields.Datetime.from_string(remote_data['__write_date'])
|
||||
except Exception:
|
||||
_logger.warning('Invalid remote write date format')
|
||||
|
||||
# Extract field-specific write dates if available
|
||||
field_write_dates = {}
|
||||
if '__field_write_dates' in local_data and isinstance(local_data['__field_write_dates'], dict):
|
||||
field_write_dates['local'] = local_data['__field_write_dates']
|
||||
else:
|
||||
field_write_dates['local'] = {}
|
||||
|
||||
if '__field_write_dates' in remote_data and isinstance(remote_data['__field_write_dates'], dict):
|
||||
field_write_dates['remote'] = remote_data['__field_write_dates']
|
||||
else:
|
||||
field_write_dates['remote'] = {}
|
||||
|
||||
for field_name in all_fields:
|
||||
# Skip system fields
|
||||
if field_name.startswith('__') or field_name in ('id', 'write_date', 'create_date'):
|
||||
continue
|
||||
|
||||
local_value = local_data.get(field_name)
|
||||
remote_value = remote_data.get(field_name)
|
||||
|
||||
# Get field-specific write dates if available
|
||||
field_local_write_date = None
|
||||
field_remote_write_date = None
|
||||
|
||||
if field_name in field_write_dates['local']:
|
||||
try:
|
||||
field_local_write_date = fields.Datetime.from_string(
|
||||
field_write_dates['local'][field_name]
|
||||
)
|
||||
except Exception:
|
||||
field_local_write_date = local_write_date
|
||||
else:
|
||||
field_local_write_date = local_write_date
|
||||
|
||||
if field_name in field_write_dates['remote']:
|
||||
try:
|
||||
field_remote_write_date = fields.Datetime.from_string(
|
||||
field_write_dates['remote'][field_name]
|
||||
)
|
||||
except Exception:
|
||||
field_remote_write_date = remote_write_date
|
||||
else:
|
||||
field_remote_write_date = remote_write_date
|
||||
|
||||
# Check if values differ
|
||||
if local_value != remote_value:
|
||||
# Determine default source based on timestamps if available
|
||||
source = 'local' # Default to local
|
||||
|
||||
if field_local_write_date and field_remote_write_date:
|
||||
if field_remote_write_date > field_local_write_date:
|
||||
source = 'remote'
|
||||
|
||||
differing_fields.append({
|
||||
'field_name': field_name,
|
||||
'local_value': str(local_value) if local_value is not None else '',
|
||||
'remote_value': str(remote_value) if remote_value is not None else '',
|
||||
'source': source,
|
||||
'local_write_date': field_local_write_date,
|
||||
'remote_write_date': field_remote_write_date
|
||||
})
|
||||
|
||||
res['resolution_fields'] = [(0, 0, vals) for vals in differing_fields]
|
||||
|
||||
except Exception as e:
|
||||
_logger.error('Error initializing conflict resolution: %s', str(e))
|
||||
|
||||
return res
|
||||
|
||||
def action_select_all_local(self):
|
||||
"""Set all fields to use local values."""
|
||||
self.ensure_one()
|
||||
self.resolution_fields.write({'source': 'local'})
|
||||
return {'type': 'ir.actions.do_nothing'}
|
||||
|
||||
def action_select_all_remote(self):
|
||||
"""Set all fields to use remote values."""
|
||||
self.ensure_one()
|
||||
self.resolution_fields.write({'source': 'remote'})
|
||||
return {'type': 'ir.actions.do_nothing'}
|
||||
|
||||
def action_select_newest(self):
|
||||
"""Set each field to use the newest value based on write_date."""
|
||||
self.ensure_one()
|
||||
|
||||
for field in self.resolution_fields:
|
||||
if field.local_write_date and field.remote_write_date:
|
||||
if field.local_write_date >= field.remote_write_date:
|
||||
field.source = 'local'
|
||||
else:
|
||||
field.source = 'remote'
|
||||
elif field.local_write_date:
|
||||
field.source = 'local'
|
||||
elif field.remote_write_date:
|
||||
field.source = 'remote'
|
||||
# If neither has a timestamp, keep the current selection
|
||||
|
||||
return {'type': 'ir.actions.do_nothing'}
|
||||
|
||||
def action_reset_selections(self):
|
||||
"""Reset all field selections to default."""
|
||||
self.ensure_one()
|
||||
self.resolution_fields.write({'source': 'local', 'custom_value': False})
|
||||
return {'type': 'ir.actions.do_nothing'}
|
||||
|
||||
def action_apply_resolution(self):
|
||||
"""Apply the custom resolution."""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
# Get original data
|
||||
local_data = json.loads(self.conflict_id.local_data)
|
||||
remote_data = json.loads(self.conflict_id.remote_data)
|
||||
|
||||
# Create merged data based on field selections
|
||||
merged_data = {}
|
||||
|
||||
# Start with all fields from local data
|
||||
merged_data.update(local_data)
|
||||
|
||||
# Override with selected remote fields or custom values
|
||||
for field in self.resolution_fields:
|
||||
if field.source == 'remote' and field.field_name in remote_data:
|
||||
merged_data[field.field_name] = remote_data[field.field_name]
|
||||
elif field.source == 'custom':
|
||||
merged_data[field.field_name] = field.custom_value
|
||||
elif field.source == 'ignore':
|
||||
# Remove field from merged data if it should be ignored
|
||||
if field.field_name in merged_data:
|
||||
del merged_data[field.field_name]
|
||||
|
||||
# Update the conflict with the resolution
|
||||
self.conflict_id.write({
|
||||
'resolution': 'custom',
|
||||
'custom_data': json.dumps(merged_data),
|
||||
'state': 'resolved',
|
||||
'resolved_by': self.env.user.id,
|
||||
'resolved_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Apply the resolution
|
||||
return self.conflict_id._apply_resolution()
|
||||
|
||||
except Exception as e:
|
||||
_logger.error('Error applying custom resolution: %s', str(e))
|
||||
raise UserError(f'Erreur lors de l\'application de la résolution personnalisée: {str(e)}')
|
||||
|
||||
73
odoo_to_odoo_sync/models/sync_conflict_wizard_field.py
Normal file
73
odoo_to_odoo_sync/models/sync_conflict_wizard_field.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Synchronization Conflict Resolution Wizard Field.
|
||||
|
||||
This module defines the field-level conflict resolution model used by the
|
||||
conflict resolution wizard to handle field-by-field resolution choices.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooSyncConflictWizardField(models.TransientModel):
|
||||
"""Field-level conflict resolution.
|
||||
|
||||
This model represents a single field that needs resolution
|
||||
in a synchronization conflict.
|
||||
"""
|
||||
|
||||
_name = 'odoo.sync.conflict.wizard.field'
|
||||
_description = 'Conflict Resolution Field'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
comodel_name='odoo.sync.conflict.wizard',
|
||||
string='Wizard',
|
||||
required=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
|
||||
field_name = fields.Char(
|
||||
string='Nom du champ',
|
||||
required=True
|
||||
)
|
||||
|
||||
local_value = fields.Text(
|
||||
string='Valeur locale',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
remote_value = fields.Text(
|
||||
string='Valeur distante',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
source = fields.Selection(
|
||||
selection=[
|
||||
('local', 'Source'),
|
||||
('remote', 'Destination'),
|
||||
('custom', 'Personnalisé'),
|
||||
('ignore', 'Ignorer')
|
||||
],
|
||||
string='Source',
|
||||
required=True,
|
||||
default='local'
|
||||
)
|
||||
|
||||
custom_value = fields.Text(
|
||||
string='Valeur personnalisée'
|
||||
)
|
||||
|
||||
# Timestamp fields for comparison
|
||||
local_write_date = fields.Datetime(
|
||||
string='Date de modification locale',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
remote_write_date = fields.Datetime(
|
||||
string='Date de modification distante',
|
||||
readonly=True
|
||||
)
|
||||
287
odoo_to_odoo_sync/models/sync_dependency.py
Normal file
287
odoo_to_odoo_sync/models/sync_dependency.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Dependency Management for Odoo-to-Odoo Sync Module
|
||||
|
||||
This module provides automatic dependency handling between models during synchronization.
|
||||
It analyzes model relationships and ensures proper processing order to prevent
|
||||
synchronization failures due to missing related records.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from odoo import api, fields, models, exceptions
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooSyncDependency(models.Model):
|
||||
"""Model to track and manage dependencies between synchronized models."""
|
||||
|
||||
_name = 'odoo.sync.dependency'
|
||||
_description = 'Synchronization Dependency Management'
|
||||
_order = 'sequence, id'
|
||||
|
||||
model_sync_id = fields.Many2one(
|
||||
comodel_name='odoo.sync.model',
|
||||
string='Synchronized Model',
|
||||
required=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
|
||||
depends_on_model_id = fields.Many2one(
|
||||
comodel_name='odoo.sync.model',
|
||||
string='Depends On Model',
|
||||
required=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
|
||||
relation_type = fields.Selection([
|
||||
('many2one', 'Many2one'),
|
||||
('one2many', 'One2many'),
|
||||
('many2many', 'Many2many'),
|
||||
('parent', 'Parent/Child'),
|
||||
('inheritance', 'Inheritance')
|
||||
], string='Relation Type', required=True)
|
||||
|
||||
field_name = fields.Char(
|
||||
string='Field Name',
|
||||
help='Name of the field that creates this dependency'
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Processing Order',
|
||||
default=10,
|
||||
help='Order in which dependencies should be processed'
|
||||
)
|
||||
|
||||
is_circular = fields.Boolean(
|
||||
string='Circular Dependency',
|
||||
default=False,
|
||||
help='Indicates if this dependency creates a circular reference'
|
||||
)
|
||||
|
||||
auto_detected = fields.Boolean(
|
||||
string='Auto Detected',
|
||||
default=True,
|
||||
help='Whether this dependency was automatically detected'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_dependency',
|
||||
'unique(model_sync_id, depends_on_model_id, field_name)',
|
||||
'A dependency can only be defined once per field')
|
||||
]
|
||||
|
||||
|
||||
class OdooSyncDependencyResolver(models.Model):
|
||||
"""Service model for resolving dependencies during synchronization."""
|
||||
|
||||
_name = 'odoo.sync.dependency.resolver'
|
||||
_description = 'Dependency Resolver'
|
||||
|
||||
@api.model
|
||||
def analyze_model_dependencies(self, model_sync_ids):
|
||||
"""
|
||||
Analyze dependencies for a set of synchronized models.
|
||||
|
||||
Args:
|
||||
model_sync_ids: List of odoo.sync.model IDs
|
||||
|
||||
Returns:
|
||||
dict: Dependency graph and processing order
|
||||
"""
|
||||
models = self.env['odoo.sync.model'].browse(model_sync_ids)
|
||||
|
||||
# Build dependency graph
|
||||
graph = defaultdict(set)
|
||||
model_map = {}
|
||||
|
||||
for model_sync in models:
|
||||
model_map[model_sync.model_id.model] = model_sync
|
||||
|
||||
# Analyze relationships
|
||||
for model_sync in models:
|
||||
self._analyze_model_relationships(model_sync, model_map, graph)
|
||||
|
||||
# Detect circular dependencies
|
||||
cycles = self._detect_cycles(graph)
|
||||
|
||||
# Generate processing order using topological sort
|
||||
processing_order = self._topological_sort(graph)
|
||||
|
||||
return {
|
||||
'graph': dict(graph),
|
||||
'cycles': cycles,
|
||||
'processing_order': processing_order,
|
||||
'model_map': model_map
|
||||
}
|
||||
|
||||
def _analyze_model_relationships(self, model_sync, model_map, graph):
|
||||
"""Analyze relationships for a single model."""
|
||||
model_name = model_sync.model_id.model
|
||||
|
||||
# Get the actual Odoo model
|
||||
try:
|
||||
model = self.env[model_name]
|
||||
except KeyError:
|
||||
_logger.warning(f"Model {model_name} not found, skipping dependency analysis")
|
||||
return
|
||||
|
||||
# Analyze fields for dependencies
|
||||
for field_name, field in model._fields.items():
|
||||
if field.type in ['many2one', 'one2many', 'many2many']:
|
||||
related_model = field.comodel_name
|
||||
|
||||
if related_model in model_map and related_model != model_name:
|
||||
# Create dependency record
|
||||
dependency_vals = {
|
||||
'model_sync_id': model_sync.id,
|
||||
'depends_on_model_id': model_map[related_model].id,
|
||||
'relation_type': field.type,
|
||||
'field_name': field_name,
|
||||
'auto_detected': True
|
||||
}
|
||||
|
||||
# Check if dependency already exists
|
||||
existing = self.env['odoo.sync.dependency'].search([
|
||||
('model_sync_id', '=', model_sync.id),
|
||||
('depends_on_model_id', '=', model_map[related_model].id),
|
||||
('field_name', '=', field_name)
|
||||
])
|
||||
|
||||
if not existing:
|
||||
self.env['odoo.sync.dependency'].create(dependency_vals)
|
||||
|
||||
# Add to graph
|
||||
graph[model_name].add(related_model)
|
||||
|
||||
def _detect_cycles(self, graph):
|
||||
"""Detect circular dependencies in the graph."""
|
||||
cycles = []
|
||||
visited = set()
|
||||
path = set()
|
||||
|
||||
def dfs(node, current_path=None):
|
||||
if current_path is None:
|
||||
current_path = []
|
||||
|
||||
if node in current_path:
|
||||
cycle_start = current_path.index(node)
|
||||
cycle = current_path[cycle_start:] + [node]
|
||||
cycles.append(cycle)
|
||||
return
|
||||
|
||||
if node in visited:
|
||||
return
|
||||
|
||||
visited.add(node)
|
||||
current_path.append(node)
|
||||
|
||||
for neighbor in graph.get(node, set()):
|
||||
dfs(neighbor, current_path.copy())
|
||||
|
||||
current_path.pop()
|
||||
|
||||
for node in graph:
|
||||
if node not in visited:
|
||||
dfs(node)
|
||||
|
||||
return cycles
|
||||
|
||||
def _topological_sort(self, graph):
|
||||
"""Generate processing order using topological sort."""
|
||||
# Kahn's algorithm for topological sorting
|
||||
in_degree = defaultdict(int)
|
||||
for node in graph:
|
||||
in_degree[node] = 0
|
||||
|
||||
for node in graph:
|
||||
for neighbor in graph[node]:
|
||||
in_degree[neighbor] += 1
|
||||
|
||||
queue = deque([node for node in in_degree if in_degree[node] == 0])
|
||||
order = []
|
||||
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
order.append(node)
|
||||
|
||||
for neighbor in graph.get(node, set()):
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
# Handle cycles - add remaining nodes
|
||||
remaining = set(graph.keys()) - set(order)
|
||||
order.extend(sorted(remaining))
|
||||
|
||||
return order
|
||||
|
||||
@api.model
|
||||
def get_processing_order(self, model_sync_ids):
|
||||
"""Get the processing order for synchronized models."""
|
||||
analysis = self.analyze_model_dependencies(model_sync_ids)
|
||||
|
||||
# Convert model names back to sync model IDs
|
||||
model_map = analysis['model_map']
|
||||
processing_order = []
|
||||
|
||||
for model_name in analysis['processing_order']:
|
||||
if model_name in model_map:
|
||||
processing_order.append(model_map[model_name].id)
|
||||
|
||||
return processing_order
|
||||
|
||||
@api.model
|
||||
def resolve_missing_dependencies(self, queue_item):
|
||||
"""
|
||||
Check if a queue item has missing dependencies and handle them.
|
||||
|
||||
Args:
|
||||
queue_item: odoo.sync.queue record
|
||||
|
||||
Returns:
|
||||
bool: True if dependencies are resolved, False otherwise
|
||||
"""
|
||||
model_name = queue_item.model_id.model
|
||||
record_data = queue_item.get_record_data()
|
||||
|
||||
missing_deps = []
|
||||
|
||||
# Check for missing related records
|
||||
for field_name, field_value in record_data.items():
|
||||
if isinstance(field_value, (list, tuple)) and len(field_value) == 2:
|
||||
# This is likely a relation field
|
||||
related_model = self.env[model_name]._fields[field_name].comodel_name
|
||||
|
||||
# Check if the related record exists on the destination
|
||||
dest_instance = queue_item.other_odoo_id
|
||||
try:
|
||||
dest_instance.execute_kw(
|
||||
related_model, 'search_read',
|
||||
[[('id', '=', field_value[0])]],
|
||||
{'fields': ['id'], 'limit': 1}
|
||||
)
|
||||
except Exception:
|
||||
# Record doesn't exist, add to missing dependencies
|
||||
missing_deps.append({
|
||||
'model': related_model,
|
||||
'id': field_value[0],
|
||||
'field': field_name
|
||||
})
|
||||
|
||||
if missing_deps:
|
||||
# Queue item has missing dependencies
|
||||
_logger.info(f"Queue item {queue_item.id} has missing dependencies: {missing_deps}")
|
||||
|
||||
# Update retry count and set next retry time
|
||||
queue_item.write({
|
||||
'retry_count': queue_item.retry_count + 1,
|
||||
'next_retry': fields.Datetime.now(),
|
||||
'error_message': f"Missing dependencies: {missing_deps}"
|
||||
})
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
@ -10,10 +10,29 @@ test connections, and maintain the connection state with remote instances.
|
|||
|
||||
import logging
|
||||
import xmlrpc.client
|
||||
import time
|
||||
import random
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from urllib.parse import urlparse
|
||||
import http.client
|
||||
import socket
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
# Import XML-RPC for standard connections
|
||||
# xmlrpc.client already imported above
|
||||
|
||||
# Import OdooRPC conditionally to avoid hard dependency
|
||||
try:
|
||||
import odoorpc
|
||||
except ImportError:
|
||||
odoorpc = None
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from ..utils.encryption import encrypt_value, decrypt_value
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -26,6 +45,18 @@ class OdooSyncInstance(models.Model):
|
|||
|
||||
_name = 'odoo.sync.instance'
|
||||
_description = 'Remote Odoo Instance'
|
||||
|
||||
# Type hints for static analysis tools
|
||||
name: fields.Char
|
||||
url: fields.Char
|
||||
database: fields.Char
|
||||
username: fields.Char
|
||||
api_key: fields.Char
|
||||
encrypted_api_key: fields.Char
|
||||
connection_type: fields.Selection
|
||||
state: fields.Selection
|
||||
last_connection: fields.Datetime
|
||||
error_message: fields.Text
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
|
|
@ -50,17 +81,27 @@ class OdooSyncInstance(models.Model):
|
|||
required=True,
|
||||
help='Username of the technical user for synchronization',
|
||||
)
|
||||
|
||||
password = fields.Char(
|
||||
string='Password',
|
||||
|
||||
# API Key for Odoo's native token authentication
|
||||
api_key = fields.Char(
|
||||
string='API Token',
|
||||
required=True,
|
||||
help='Password of the technical user',
|
||||
copy=False,
|
||||
help='API token for Odoo\'s native authentication system',
|
||||
)
|
||||
|
||||
# Encrypted storage for API key
|
||||
encrypted_api_key = fields.Char(
|
||||
string='Encrypted API Token',
|
||||
copy=False,
|
||||
help='Encrypted API token for secure storage',
|
||||
)
|
||||
|
||||
connection_type = fields.Selection(
|
||||
selection=[
|
||||
('xmlrpc', 'XML-RPC'),
|
||||
('jsonrpc', 'JSON-RPC')
|
||||
('jsonrpc', 'JSON-RPC'),
|
||||
('odoorpc', 'OdooRPC')
|
||||
],
|
||||
string='Connection Type',
|
||||
default='xmlrpc',
|
||||
|
|
@ -80,6 +121,14 @@ class OdooSyncInstance(models.Model):
|
|||
help='Maximum number of connection retry attempts',
|
||||
)
|
||||
|
||||
conflict_resolution_strategy = fields.Selection([
|
||||
('manual', 'Manual Resolution'),
|
||||
('timestamp', 'Newest Wins'),
|
||||
('source_priority', 'Source Instance Wins'),
|
||||
('destination_priority', 'Destination Instance Wins')
|
||||
], string='Conflict Resolution Strategy', default='manual',
|
||||
help='Strategy for resolving synchronization conflicts with this instance')
|
||||
|
||||
retry_delay = fields.Integer(
|
||||
string='Retry Delay',
|
||||
default=5,
|
||||
|
|
@ -123,11 +172,8 @@ class OdooSyncInstance(models.Model):
|
|||
@api.onchange('url')
|
||||
def _onchange_url(self):
|
||||
"""Reset state when URL changes."""
|
||||
for record in self:
|
||||
if record.url != record._origin.url:
|
||||
record.state = 'draft'
|
||||
record.error_message = False
|
||||
|
||||
self.state = 'draft'
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to the remote Odoo instance.
|
||||
|
||||
|
|
@ -137,34 +183,98 @@ class OdooSyncInstance(models.Model):
|
|||
Returns:
|
||||
bool: True if connection successful, False otherwise
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if self.connection_type == 'xmlrpc':
|
||||
return self._test_xmlrpc_connection()
|
||||
elif self.connection_type == 'jsonrpc':
|
||||
return self._test_jsonrpc_connection()
|
||||
elif self.connection_type == 'odoorpc':
|
||||
return self._test_odoorpc_connection()
|
||||
else:
|
||||
raise UserError(f"Type de connexion non supporté: {self.connection_type}")
|
||||
|
||||
def _encrypt_sensitive_data(self):
|
||||
"""Encrypt API token for secure storage."""
|
||||
for record in self:
|
||||
record.state = 'testing'
|
||||
|
||||
if record.connection_type == 'xmlrpc':
|
||||
return record._test_xmlrpc_connection()
|
||||
elif record.connection_type == 'jsonrpc':
|
||||
# À implémenter si nécessaire
|
||||
raise UserError("La connexion JSON-RPC n'est pas encore implémentée")
|
||||
else:
|
||||
raise UserError(f"Type de connexion non supporté: {record.connection_type}")
|
||||
# Only encrypt API key (no password handling)
|
||||
if record.api_key and not record.encrypted_api_key:
|
||||
record.encrypted_api_key = encrypt_value(record.env, record.api_key)
|
||||
# Don't clear API key field - keep it for display/editing
|
||||
|
||||
def _decrypt_sensitive_data(self):
|
||||
"""Decrypt API token for authentication."""
|
||||
self.ensure_one()
|
||||
# Only use API token for authentication (no password fallback)
|
||||
if self.encrypted_api_key:
|
||||
return decrypt_value(self.env, self.encrypted_api_key)
|
||||
return self.api_key
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to encrypt sensitive data."""
|
||||
records = super().create(vals_list)
|
||||
records._encrypt_sensitive_data()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
"""Override write to encrypt sensitive data."""
|
||||
result = super().write(vals)
|
||||
self._encrypt_sensitive_data()
|
||||
return result
|
||||
|
||||
def _test_xmlrpc_connection(self):
|
||||
"""Test XML-RPC connection to the remote instance."""
|
||||
"""Test XML-RPC connection to the remote instance using official Odoo API key format."""
|
||||
self.ensure_one()
|
||||
self.write({'state': 'testing'})
|
||||
|
||||
try:
|
||||
# Validate URL format
|
||||
# Validate URL
|
||||
if not self.url:
|
||||
raise UserError("L'URL est requise pour tester la connexion")
|
||||
|
||||
# Parse URL to extract connection details
|
||||
parsed_url = urlparse(self.url)
|
||||
if not parsed_url.scheme or not parsed_url.netloc:
|
||||
raise UserError("Format d'URL invalide. Exemple valide: https://exemple.odoo.com")
|
||||
|
||||
# Attempt to connect and authenticate
|
||||
common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common')
|
||||
uid = common.authenticate(self.database, self.username, self.password, {})
|
||||
raise UserError("URL is required")
|
||||
|
||||
# Parse URL
|
||||
url = self.url.strip()
|
||||
parsed_url = urlparse(url)
|
||||
|
||||
# Ensure proper scheme
|
||||
if not parsed_url.scheme:
|
||||
url = f"http://{url}"
|
||||
parsed_url = urlparse(url)
|
||||
|
||||
scheme = parsed_url.scheme
|
||||
netloc = parsed_url.netloc
|
||||
|
||||
if not netloc:
|
||||
raise UserError(f"Invalid URL format: {url}")
|
||||
|
||||
# Get API key
|
||||
api_token = self._decrypt_sensitive_data() or self.api_key
|
||||
if not api_token:
|
||||
raise UserError("No API key found")
|
||||
|
||||
# Create XML-RPC client with timeout-aware transport
|
||||
xmlrpc_url = f"{scheme}://{netloc}/xmlrpc/2/common"
|
||||
_logger.info("[SYNC DEBUG] Attempting XML-RPC connection to: %s", xmlrpc_url)
|
||||
|
||||
class TimeoutTransport(xmlrpc.client.Transport):
|
||||
def __init__(self, timeout=None, use_datetime=False):
|
||||
super().__init__(use_datetime=use_datetime)
|
||||
self.timeout = timeout
|
||||
def make_connection(self, host):
|
||||
conn = super().make_connection(host)
|
||||
try:
|
||||
conn.timeout = self.timeout
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
transport = TimeoutTransport(timeout=self.connection_timeout or 30)
|
||||
common = xmlrpc.client.ServerProxy(xmlrpc_url, transport=transport, allow_none=True)
|
||||
|
||||
# For XML-RPC, use raw API key string (not dict format)
|
||||
# This provides compatibility with older Odoo versions
|
||||
uid = common.authenticate(self.database, self.username, api_token, {})
|
||||
if uid:
|
||||
self.write({
|
||||
'state': 'connected',
|
||||
|
|
@ -173,34 +283,26 @@ class OdooSyncInstance(models.Model):
|
|||
})
|
||||
return True
|
||||
else:
|
||||
raise UserError('Échec d\'authentification')
|
||||
error_msg = "Authentication failed - invalid credentials"
|
||||
_logger.error("[SYNC DEBUG] %s", error_msg)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': error_msg
|
||||
})
|
||||
raise UserError(error_msg)
|
||||
|
||||
except UserError as e:
|
||||
# Pass through our UserError without modification
|
||||
except UserError:
|
||||
# Re-raise UserError as it already has appropriate message
|
||||
raise
|
||||
except Exception as e:
|
||||
# Handle connection errors and other exceptions
|
||||
error_msg = f"XML-RPC connection test failed: {str(e)}"
|
||||
_logger.error("[SYNC DEBUG] %s", error_msg)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': str(e)
|
||||
'error_message': error_msg
|
||||
})
|
||||
_logger.error("Erreur d'authentification XML-RPC: %s", str(e))
|
||||
return False
|
||||
|
||||
except (ConnectionError, TimeoutError, xmlrpc.client.Fault, xmlrpc.client.ProtocolError) as e:
|
||||
# Catch specific exceptions that can be raised during XML-RPC connection
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': str(e)
|
||||
})
|
||||
_logger.error("Erreur de connexion XML-RPC: %s", str(e))
|
||||
return False
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# Fall back for any unexpected exceptions
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': f"Erreur inattendue: {str(e)}"
|
||||
})
|
||||
_logger.error("Erreur inattendue XML-RPC: %s", str(e))
|
||||
return False
|
||||
raise UserError(error_msg)
|
||||
|
||||
def get_connection(self):
|
||||
"""Return an active connection to the remote instance.
|
||||
|
|
@ -224,21 +326,39 @@ class OdooSyncInstance(models.Model):
|
|||
if self.connection_type == 'xmlrpc':
|
||||
return self._get_xmlrpc_connection()
|
||||
elif self.connection_type == 'jsonrpc':
|
||||
# À implémenter si nécessaire
|
||||
raise UserError("La connexion JSON-RPC n'est pas encore implémentée")
|
||||
return self._get_jsonrpc_connection()
|
||||
elif self.connection_type == 'odoorpc':
|
||||
return self._get_odoorpc_connection()
|
||||
else:
|
||||
raise UserError(f"Type de connexion non supporté: {self.connection_type}")
|
||||
|
||||
def _get_xmlrpc_connection(self):
|
||||
"""Get XML-RPC connection to the remote instance."""
|
||||
common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common')
|
||||
uid = common.authenticate(self.database, self.username, self.password, {})
|
||||
models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object')
|
||||
"""Get XML-RPC connection to the remote instance using API key string."""
|
||||
# Get API key for authentication
|
||||
api_token = self._decrypt_sensitive_data()
|
||||
|
||||
# For XML-RPC, use raw API key string (not dict format) with timeout-aware transport
|
||||
class TimeoutTransport(xmlrpc.client.Transport):
|
||||
def __init__(self, timeout=None, use_datetime=False):
|
||||
super().__init__(use_datetime=use_datetime)
|
||||
self.timeout = timeout
|
||||
def make_connection(self, host):
|
||||
conn = super().make_connection(host)
|
||||
try:
|
||||
conn.timeout = self.timeout
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
transport = TimeoutTransport(timeout=self.connection_timeout or 30)
|
||||
common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common', transport=transport, allow_none=True)
|
||||
uid = common.authenticate(self.database, self.username, api_token, {})
|
||||
models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object', transport=transport, allow_none=True)
|
||||
|
||||
return models, uid
|
||||
|
||||
def execute_kw(self, model, method, args=None, kwargs=None):
|
||||
"""Execute a method on the remote Odoo instance.
|
||||
"""Execute a method on the remote Odoo instance using API key string.
|
||||
|
||||
This method provides a unified interface for executing methods on remote models
|
||||
regardless of the connection type.
|
||||
|
|
@ -264,8 +384,12 @@ class OdooSyncInstance(models.Model):
|
|||
|
||||
try:
|
||||
models, uid = self.get_connection()
|
||||
# Get API key for authentication
|
||||
api_token = self._decrypt_sensitive_data()
|
||||
|
||||
# Use raw API key string for all protocols
|
||||
result = models.execute_kw(
|
||||
self.database, uid, self.password,
|
||||
self.database, uid, api_token,
|
||||
model, method, args, kwargs
|
||||
)
|
||||
return result
|
||||
|
|
@ -274,6 +398,221 @@ class OdooSyncInstance(models.Model):
|
|||
# Re-raise UserError as it already has appropriate message
|
||||
raise
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# Convert other exceptions to UserError for better user experience
|
||||
raise UserError(f"Erreur d'exécution de {model}.{method}: {str(e)}") from e
|
||||
except Exception as e:
|
||||
error_msg = f"OdooRPC connection failed: {str(e)}"
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': error_msg
|
||||
})
|
||||
raise UserError(error_msg)
|
||||
|
||||
def _get_jsonrpc_connection(self):
|
||||
"""Get JSON-RPC connection to the remote instance using API token authentication."""
|
||||
try:
|
||||
# Get decrypted credentials (API token)
|
||||
api_token = self._decrypt_sensitive_data()
|
||||
|
||||
# Build JSON-RPC endpoint URL
|
||||
jsonrpc_url = f'{self.url}/jsonrpc'
|
||||
|
||||
# Create JSON-RPC client
|
||||
req_timeout = self.connection_timeout or 30
|
||||
class JsonRpcClient:
|
||||
def __init__(self, url, database, username, api_token):
|
||||
self.url = url
|
||||
self.database = database
|
||||
self.username = username
|
||||
self.api_token = api_token
|
||||
self.uid = None
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate using official Odoo API key format."""
|
||||
auth_data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "common",
|
||||
"method": "authenticate",
|
||||
"args": [
|
||||
self.database,
|
||||
self.username,
|
||||
{'scope': 'rpc', 'key': self.api_token},
|
||||
{}
|
||||
]
|
||||
},
|
||||
"id": random.randint(1, 1000000)
|
||||
}
|
||||
|
||||
response = self._send_request(self.url, auth_data)
|
||||
if response.get('result'):
|
||||
self.uid = response['result']
|
||||
return self.uid
|
||||
else:
|
||||
error = response.get('error', {})
|
||||
raise UserError(f"Authentication failed: {error.get('message', 'Unknown error')}")
|
||||
|
||||
def execute_kw(self, model, method, args=None, kwargs=None):
|
||||
"""Execute a method on the remote model."""
|
||||
if args is None:
|
||||
args = []
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
|
||||
data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "object",
|
||||
"method": "execute_kw",
|
||||
"args": [self.database, self.uid, self.api_token, model, method, args, kwargs]
|
||||
},
|
||||
"id": random.randint(1, 1000000)
|
||||
}
|
||||
|
||||
response = self._send_request(self.url, data)
|
||||
if response.get('error'):
|
||||
error = response.get('error', {})
|
||||
raise UserError(f"Remote method call failed: {error.get('message', 'Unknown error')}")
|
||||
return response.get('result')
|
||||
|
||||
def _send_request(self, url, data):
|
||||
"""Send JSON-RPC request."""
|
||||
try:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Odoo-Sync/1.0'
|
||||
}
|
||||
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(data).encode('utf-8'),
|
||||
headers=headers,
|
||||
method='POST'
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(request, timeout=req_timeout) as response:
|
||||
response_data = response.read().decode('utf-8')
|
||||
return json.loads(response_data)
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
error_content = e.read().decode('utf-8')
|
||||
try:
|
||||
error_data = json.loads(error_content)
|
||||
raise UserError(f"HTTP Error {e.code}: {error_data.get('error', {}).get('message', str(e))}")
|
||||
except Exception:
|
||||
raise UserError(f"HTTP Error {e.code}: {str(e)}")
|
||||
except urllib.error.URLError as e:
|
||||
raise UserError(f"Connection Error: {str(e)}")
|
||||
except Exception as e:
|
||||
raise UserError(f"Request Error: {str(e)}")
|
||||
|
||||
client = JsonRpcClient(jsonrpc_url, self.database, self.username, api_token)
|
||||
client.authenticate()
|
||||
return client
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Connection Error: {str(e)}"
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': error_msg
|
||||
})
|
||||
return False
|
||||
|
||||
def _test_jsonrpc_connection(self):
|
||||
"""Test JSON-RPC connection to the remote instance using API key string."""
|
||||
self.ensure_one()
|
||||
self.write({'state': 'testing'})
|
||||
|
||||
try:
|
||||
if not self.url:
|
||||
raise UserError("URL is required")
|
||||
|
||||
# Get API key for authentication
|
||||
api_token = self._decrypt_sensitive_data()
|
||||
if not api_token:
|
||||
raise UserError("No API key found")
|
||||
|
||||
# Parse and validate URL
|
||||
url = self.url.strip()
|
||||
parsed_url = urlparse(url)
|
||||
if not parsed_url.scheme:
|
||||
url = f"http://{url}"
|
||||
parsed_url = urlparse(url)
|
||||
|
||||
scheme = parsed_url.scheme
|
||||
netloc = parsed_url.netloc
|
||||
if not netloc:
|
||||
raise UserError(f"Invalid URL format: {url}")
|
||||
|
||||
# Build JSON-RPC endpoint URL
|
||||
jsonrpc_url = f"{scheme}://{netloc}/jsonrpc"
|
||||
|
||||
# Prepare authentication request
|
||||
data = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "common",
|
||||
"method": "authenticate",
|
||||
"args": [self.database, self.username, api_token, {}]
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
req = urllib.request.Request(jsonrpc_url, data=json.dumps(data).encode('utf-8'), headers=headers)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=self.connection_timeout or 30) as response:
|
||||
response_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if 'result' in response_data and response_data['result']:
|
||||
self.write({
|
||||
'state': 'connected',
|
||||
'last_connection': fields.Datetime.now(),
|
||||
'error_message': False
|
||||
})
|
||||
return True
|
||||
else:
|
||||
raise UserError("JSON-RPC authentication failed")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'error_message': error_msg
|
||||
})
|
||||
raise UserError(error_msg)
|
||||
|
||||
def _test_odoorpc_connection(self):
|
||||
"""Get OdooRPC connection to the remote instance using API token authentication."""
|
||||
if odoorpc is None:
|
||||
raise UserError("La bibliothèque OdooRPC n'est pas installée. Installez-la avec: pip install odoorpc")
|
||||
|
||||
try:
|
||||
password = self._decrypt_sensitive_data()
|
||||
|
||||
# Parse URL to extract host, port, and protocol
|
||||
parsed_url = urlparse(self.url)
|
||||
protocol = 'jsonrpc+ssl' if parsed_url.scheme == 'https' else 'jsonrpc'
|
||||
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
|
||||
host = parsed_url.hostname
|
||||
|
||||
# Create OdooRPC connection
|
||||
odoo = odoorpc.ODOO(host, port=port, protocol=protocol)
|
||||
|
||||
# Authenticate using API token
|
||||
# Always use API token authentication in Odoo 17-18
|
||||
prev_timeout = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(self.connection_timeout or 30)
|
||||
try:
|
||||
odoo.login(self.database, self.username, password)
|
||||
finally:
|
||||
try:
|
||||
socket.setdefaulttimeout(prev_timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return odoo
|
||||
|
||||
except Exception as e:
|
||||
raise UserError(f"Erreur de connexion OdooRPC: {str(e)}")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -38,6 +38,12 @@ class OdooSyncModel(models.Model):
|
|||
required=True
|
||||
)
|
||||
|
||||
project_id = fields.Many2one(
|
||||
comodel_name='sync.project',
|
||||
string='Sync Project',
|
||||
ondelete='cascade'
|
||||
)
|
||||
|
||||
target_model = fields.Char(
|
||||
string='Target Model',
|
||||
help='Technical name of the model on remote instance'
|
||||
|
|
@ -91,4 +97,31 @@ class OdooSyncModel(models.Model):
|
|||
return record
|
||||
|
||||
def name_get(self):
|
||||
return [(r.id, f'{r.name} → {r.instance_id.name}') for r in self]
|
||||
return [(r.id, f'{r.name} → {r.instance_id.name}') for r in self]
|
||||
|
||||
def action_auto_sync_fields(self):
|
||||
"""Open the auto-sync wizard for field selection."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.model_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Error',
|
||||
'message': 'No model selected',
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Auto Sync Fields',
|
||||
'res_model': 'odoo.sync.auto.sync.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_sync_model_id': self.id,
|
||||
},
|
||||
}
|
||||
|
|
@ -3,7 +3,14 @@ from odoo import models, fields, api
|
|||
class OdooSyncModelField(models.Model):
|
||||
_name = 'odoo.sync.model.field'
|
||||
_description = 'Synchronized Field'
|
||||
_order = 'sequence, id'
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
help='Used to order the field mappings'
|
||||
)
|
||||
|
||||
model_sync_id = fields.Many2one(
|
||||
comodel_name='odoo.sync.model',
|
||||
string='Synchronized Model',
|
||||
|
|
@ -25,6 +32,65 @@ class OdooSyncModelField(models.Model):
|
|||
store=True
|
||||
)
|
||||
|
||||
source_field = fields.Char(
|
||||
string='Source Field',
|
||||
help='Field name in the source model'
|
||||
)
|
||||
|
||||
target_field = fields.Char(
|
||||
string='Target Field',
|
||||
help='Field name in the target model'
|
||||
)
|
||||
|
||||
mapping_type = fields.Selection([
|
||||
('direct', 'Direct'),
|
||||
('function', 'Function'),
|
||||
('computed', 'Computed'),
|
||||
('relation', 'Relation')
|
||||
], string='Mapping Type', default='direct',
|
||||
help='Type of field mapping for synchronization')
|
||||
|
||||
mapping_function = fields.Char(
|
||||
string='Mapping Function',
|
||||
help='Python function name to transform the field value. Use format: model.method_name or method_name'
|
||||
)
|
||||
|
||||
mapping_expression = fields.Text(
|
||||
string='Mapping Expression',
|
||||
help='Python expression for computed field mapping. Use record.field_name syntax'
|
||||
)
|
||||
|
||||
relation_model = fields.Char(
|
||||
string='Relation Model',
|
||||
help='Target model for relation mapping (e.g., res.partner, product.category)'
|
||||
)
|
||||
|
||||
relation_field = fields.Char(
|
||||
string='Relation Field',
|
||||
help='Field to match in the relation model (e.g., name, code)'
|
||||
)
|
||||
|
||||
relation_domain = fields.Text(
|
||||
string='Relation Domain',
|
||||
help='Domain filter for relation mapping as JSON list of tuples'
|
||||
)
|
||||
|
||||
transform_function = fields.Char(
|
||||
string='Transform Function',
|
||||
help='Function to transform field value (deprecated, use mapping_function instead)'
|
||||
)
|
||||
|
||||
is_identifier = fields.Boolean(
|
||||
string='Is Identifier',
|
||||
default=False,
|
||||
help='Whether this field is used to identify records'
|
||||
)
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True
|
||||
)
|
||||
|
||||
required = fields.Boolean(
|
||||
string='Required',
|
||||
default=False
|
||||
|
|
@ -34,6 +100,14 @@ class OdooSyncModelField(models.Model):
|
|||
string='Default Value',
|
||||
help='Value to use if not available'
|
||||
)
|
||||
|
||||
conflict_strategy = fields.Selection([
|
||||
('source_wins', 'Source gagne'),
|
||||
('dest_wins', 'Destination gagne'),
|
||||
('newest', 'Plus récent'),
|
||||
('manual', 'Résolution manuelle')
|
||||
], default='newest', string='Stratégie de conflit',
|
||||
help='Stratégie à utiliser pour résoudre les conflits de synchronisation sur ce champ')
|
||||
|
||||
_sql_constraints = [
|
||||
('field_uniq', 'unique(model_sync_id, field_id)',
|
||||
|
|
|
|||
168
odoo_to_odoo_sync/models/sync_observer.py
Normal file
168
odoo_to_odoo_sync/models/sync_observer.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Model Change Observer System.
|
||||
|
||||
This module implements the observer pattern to detect changes in Odoo models
|
||||
and trigger synchronization operations. It hooks into the create, write, and
|
||||
unlink methods of models to detect changes and queue them for synchronization.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from odoo import api, models, tools
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Global registry to avoid duplicate patching
|
||||
PATCHED_MODELS = set()
|
||||
|
||||
class SyncObserver:
|
||||
"""Observer class for model changes.
|
||||
|
||||
This class provides methods to patch standard Odoo methods (create, write, unlink)
|
||||
to detect changes and trigger synchronization operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _observe_create(original_method):
|
||||
"""Observe the create method of a model.
|
||||
|
||||
Args:
|
||||
original_method: The original create method to wrap
|
||||
|
||||
Returns:
|
||||
function: Wrapped create method with synchronization logic
|
||||
"""
|
||||
@wraps(original_method)
|
||||
def wrapper(self, vals):
|
||||
# Call the original method first
|
||||
result = original_method(self, vals)
|
||||
|
||||
# Debug log
|
||||
_logger.debug("[SYNC DEBUG] Create called for model: %s", self._name)
|
||||
|
||||
# Check if this model is configured for synchronization
|
||||
# Skip synchronization for models in the odoo_to_odoo_sync module to prevent recursive loops
|
||||
if not self.env.context.get('no_sync') and not self._name.startswith('odoo.sync.'):
|
||||
try:
|
||||
_logger.debug("[SYNC DEBUG] Checking sync model for %s", self._name)
|
||||
sync_model = self.env['odoo.sync.model'].sudo().search([
|
||||
('model_id.model', '=', self._name),
|
||||
('active', '=', True)
|
||||
])
|
||||
|
||||
_logger.debug("[SYNC DEBUG] Found sync models: %s", sync_model)
|
||||
|
||||
if sync_model:
|
||||
# Get the sync manager
|
||||
sync_manager = self.env['odoo.sync.manager'].sudo()
|
||||
|
||||
# Queue the synchronization (Notify Change -> Create SyncRecord in sequence diagram)
|
||||
for record in result:
|
||||
_logger.info("[SYNC OBSERVER] Notify Change: create for record %s (model %s)", record.id, self._name)
|
||||
sync_manager._queue_sync(record, 'create')
|
||||
else:
|
||||
_logger.info("[SYNC DEBUG] No sync model found for %s", self._name)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error in sync observer (create): %s", str(e))
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
@staticmethod
|
||||
def _observe_write(original_method):
|
||||
"""Observe the write method of a model.
|
||||
|
||||
Args:
|
||||
original_method: The original write method to wrap
|
||||
|
||||
Returns:
|
||||
function: Wrapped write method with synchronization logic
|
||||
"""
|
||||
@wraps(original_method)
|
||||
def wrapper(self, vals):
|
||||
# Call the original method first
|
||||
result = original_method(self, vals)
|
||||
|
||||
# Check if this model is configured for synchronization
|
||||
if not self.env.context.get('no_sync') and not self._name.startswith('odoo.sync.'):
|
||||
try:
|
||||
sync_model = self.env['odoo.sync.model'].sudo().search([
|
||||
('model_id.model', '=', self._name),
|
||||
('active', '=', True)
|
||||
])
|
||||
|
||||
if sync_model:
|
||||
# Get the sync manager
|
||||
sync_manager = self.env['odoo.sync.manager'].sudo()
|
||||
|
||||
# Queue the synchronization for each record
|
||||
for record in self:
|
||||
_logger.info("[SYNC OBSERVER] Notify Change: write for record %s (model %s)", record.id, self._name)
|
||||
sync_manager._queue_sync(record, 'write', vals.keys())
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error in sync observer (write): %s", str(e))
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
@staticmethod
|
||||
def _observe_unlink(original_method):
|
||||
"""Observe the unlink method of a model.
|
||||
|
||||
Args:
|
||||
original_method: The original unlink method to wrap
|
||||
|
||||
Returns:
|
||||
function: Wrapped unlink method with synchronization logic
|
||||
"""
|
||||
@wraps(original_method)
|
||||
def wrapper(self):
|
||||
# Check if this model is configured for synchronization
|
||||
if not self.env.context.get('no_sync') and not self._name.startswith('odoo.sync.'):
|
||||
try:
|
||||
sync_model = self.env['odoo.sync.model'].sudo().search([
|
||||
('model_id.model', '=', self._name),
|
||||
('active', '=', True)
|
||||
])
|
||||
|
||||
if sync_model:
|
||||
# Get the sync manager
|
||||
sync_manager = self.env['odoo.sync.manager'].sudo()
|
||||
|
||||
# Queue the synchronization for each record before deletion
|
||||
for record in self:
|
||||
sync_manager._queue_sync(record, 'unlink')
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error in sync observer (unlink): %s", str(e))
|
||||
|
||||
# Call the original method after queueing
|
||||
return original_method(self)
|
||||
|
||||
return wrapper
|
||||
|
||||
def patch_models():
|
||||
"""Patch all models with synchronization observers.
|
||||
|
||||
This function is called during module initialization to patch
|
||||
the create, write, and unlink methods of the BaseModel class.
|
||||
"""
|
||||
if models.BaseModel in PATCHED_MODELS:
|
||||
return
|
||||
|
||||
# Patch the methods
|
||||
models.BaseModel.create = SyncObserver._observe_create(models.BaseModel.create)
|
||||
models.BaseModel.write = SyncObserver._observe_write(models.BaseModel.write)
|
||||
models.BaseModel.unlink = SyncObserver._observe_unlink(models.BaseModel.unlink)
|
||||
|
||||
# Mark as patched
|
||||
PATCHED_MODELS.add(models.BaseModel)
|
||||
|
||||
_logger.info("Model synchronization observers installed")
|
||||
125
odoo_to_odoo_sync/models/sync_project.py
Normal file
125
odoo_to_odoo_sync/models/sync_project.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class SyncProject(models.Model):
|
||||
_name = 'sync.project'
|
||||
_description = 'Synchronization Project'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(string='Project Name', required=True)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
# Project identification
|
||||
is_sync_project = fields.Boolean(string='Synchronization Project', default=False)
|
||||
is_client_project = fields.Boolean(string='Client Project', default=False)
|
||||
|
||||
# API Token management
|
||||
client_key = fields.Char(string='Client Key', readonly=True)
|
||||
api_token = fields.Char(string='API Token', readonly=True)
|
||||
|
||||
# Connection details
|
||||
remote_url = fields.Char(string='Remote URL')
|
||||
remote_database = fields.Char(string='Remote Database')
|
||||
remote_username = fields.Char(string='Remote Username')
|
||||
|
||||
# Sync configuration
|
||||
sync_instance_id = fields.Many2one(
|
||||
'odoo.sync.instance', string='Sync Instance',
|
||||
help='The sync instance associated with this project'
|
||||
)
|
||||
|
||||
sync_model_ids = fields.One2many(
|
||||
'odoo.sync.model', 'project_id', string='Synchronized Models',
|
||||
help='Models configured for synchronization with this project'
|
||||
)
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('configured', 'Configured'),
|
||||
('connected', 'Connected'),
|
||||
('syncing', 'Syncing'),
|
||||
('error', 'Error'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
last_sync_date = fields.Datetime(string='Last Sync Date')
|
||||
error_message = fields.Text(string='Error Message')
|
||||
|
||||
@api.onchange('is_client_project')
|
||||
def _onchange_is_client_project(self):
|
||||
"""Generate API token when project is marked as client project."""
|
||||
if self.is_client_project and not self.api_token:
|
||||
self.api_token = self._generate_api_token()
|
||||
self.client_key = self._generate_client_key()
|
||||
elif not self.is_client_project:
|
||||
self.api_token = False
|
||||
self.client_key = False
|
||||
|
||||
def _generate_api_token(self):
|
||||
"""Generate a secure API token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def _generate_client_key(self):
|
||||
"""Generate a unique client key."""
|
||||
return f"{self.id or 'new'}-{secrets.token_urlsafe(8)}"
|
||||
|
||||
def action_generate_api_token(self):
|
||||
"""Manually generate or regenerate API token."""
|
||||
for record in self:
|
||||
if not record.is_client_project:
|
||||
raise UserError(_("Only client projects can have API tokens."))
|
||||
record.api_token = record._generate_api_token()
|
||||
record.client_key = record._generate_client_key()
|
||||
return True
|
||||
|
||||
def action_revoke_api_token(self):
|
||||
"""Revoke the API token."""
|
||||
for record in self:
|
||||
record.api_token = False
|
||||
record.client_key = False
|
||||
record.sync_instance_id = False
|
||||
return True
|
||||
|
||||
def action_configure_sync(self):
|
||||
"""Open the sync configuration wizard."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Configure Sync'),
|
||||
'res_model': 'sync.project.config.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_project_id': self.id,
|
||||
}
|
||||
}
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Test the connection to the remote instance."""
|
||||
self.ensure_one()
|
||||
if not self.sync_instance_id:
|
||||
raise UserError(_("Please configure a sync instance first."))
|
||||
|
||||
try:
|
||||
self.sync_instance_id.test_connection()
|
||||
self.state = 'connected'
|
||||
self.error_message = False
|
||||
except Exception as e:
|
||||
self.state = 'error'
|
||||
self.error_message = str(e)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Connection Test'),
|
||||
'message': _('Connection test completed.'),
|
||||
'type': 'success' if self.state == 'connected' else 'danger',
|
||||
}
|
||||
}
|
||||
24
odoo_to_odoo_sync/models/sync_project_config_wizard.py
Normal file
24
odoo_to_odoo_sync/models/sync_project_config_wizard.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class SyncProjectConfigWizard(models.TransientModel):
|
||||
_name = 'sync.project.config.wizard'
|
||||
_description = 'Sync Project Configuration Wizard'
|
||||
|
||||
name = fields.Char(string='Configuration Name', required=True)
|
||||
remote_url = fields.Char(string='Remote URL', required=True)
|
||||
remote_database = fields.Char(string='Remote Database', required=True)
|
||||
remote_username = fields.Char(string='Username', required=True)
|
||||
remote_api_key = fields.Char(string='API Key', required=True)
|
||||
protocol = fields.Selection([
|
||||
('xmlrpc', 'XML-RPC'),
|
||||
('jsonrpc', 'JSON-RPC'),
|
||||
('odoorpc', 'OdooRPC')
|
||||
], string='Protocol', default='jsonrpc', required=True)
|
||||
|
||||
def action_configure_project(self):
|
||||
"""Configure the sync project with provided settings"""
|
||||
self.ensure_one()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
|
@ -10,6 +10,7 @@ tracking of synchronization tasks.
|
|||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
|
@ -141,8 +142,8 @@ class OdooSyncQueue(models.Model):
|
|||
This method is called by the cron job to process pending queue entries.
|
||||
It will:
|
||||
1. Find pending entries that are ready for processing
|
||||
2. Process each entry according to its operation type
|
||||
3. Update the entry status and create log entries
|
||||
2. Dequeue each entry and pass it to the sync manager for processing
|
||||
3. Update the entry status based on the processing result
|
||||
|
||||
Returns:
|
||||
bool: True if processing completed successfully
|
||||
|
|
@ -157,48 +158,79 @@ class OdooSyncQueue(models.Model):
|
|||
entries = self.search(domain, order='priority desc, retry_count, create_date')
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
# Mark as processing
|
||||
entry.write({'state': 'processing'})
|
||||
|
||||
# TODO: Implement actual synchronization logic here
|
||||
# This will be implemented in a future update
|
||||
|
||||
# For now, just mark as done
|
||||
entry.write({'state': 'done'})
|
||||
|
||||
# Create success log
|
||||
self.env['odoo.sync.log'].create({
|
||||
'queue_id': entry.id,
|
||||
'state': 'success',
|
||||
'message': 'Synchronization completed successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.error('Error processing queue entry %s: %s', entry.name, str(e))
|
||||
|
||||
# Update retry count and status
|
||||
vals = {
|
||||
'state': 'error',
|
||||
'retry_count': entry.retry_count + 1,
|
||||
'error_message': str(e)
|
||||
}
|
||||
|
||||
# Schedule next retry if not exceeded max retries
|
||||
if entry.retry_count < entry.max_retries:
|
||||
vals.update({
|
||||
'state': 'pending',
|
||||
'next_retry': fields.Datetime.now() + timedelta(minutes=5 * (entry.retry_count + 1))
|
||||
})
|
||||
|
||||
entry.write(vals)
|
||||
|
||||
# Create error log
|
||||
self.env['odoo.sync.log'].create({
|
||||
'queue_id': entry.id,
|
||||
'state': 'error',
|
||||
'message': str(e),
|
||||
'details': str(e)
|
||||
})
|
||||
# Dequeue the item and process it
|
||||
sync_manager = self.env['odoo.sync.manager']
|
||||
sync_manager._process_dequeued_item(self.dequeue(entry.id))
|
||||
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def dequeue(self, queue_id):
|
||||
"""Dequeue a specific queue item for processing.
|
||||
|
||||
This method explicitly implements the "Dequeue" step in the synchronization
|
||||
sequence diagram. It marks the queue item as processing and returns it
|
||||
for further processing by the sync manager.
|
||||
|
||||
Args:
|
||||
queue_id: ID of the queue item to dequeue
|
||||
|
||||
Returns:
|
||||
odoo.sync.queue: The dequeued queue item record or False if not found
|
||||
"""
|
||||
queue_item = self.browse(queue_id)
|
||||
if not queue_item.exists():
|
||||
_logger.error(f"[SYNC QUEUE] Cannot dequeue item {queue_id}: record not found")
|
||||
return False
|
||||
|
||||
_logger.debug(f"[SYNC QUEUE] Dequeuing item {queue_id}")
|
||||
queue_item.write({'state': 'processing'})
|
||||
return queue_item
|
||||
|
||||
def mark_success(self):
|
||||
"""Mark this queue item as successfully processed.
|
||||
|
||||
This method implements the 'Mark Success' step in the synchronization sequence diagram.
|
||||
"""
|
||||
_logger.debug(f"[SYNC QUEUE] Marking item {self.id} as done")
|
||||
self.write({'state': 'done'})
|
||||
|
||||
# Create success log
|
||||
self.env['odoo.sync.log'].create({
|
||||
'queue_id': self.id,
|
||||
'state': 'success',
|
||||
'message': f'Synchronisation réussie'
|
||||
})
|
||||
|
||||
def increment_retry(self, error_message):
|
||||
"""Increment the retry count for this queue item.
|
||||
|
||||
This method implements the 'Increment Retry' step in the synchronization sequence diagram.
|
||||
|
||||
Args:
|
||||
error_message: The error message to log
|
||||
"""
|
||||
_logger.debug(f"[SYNC QUEUE] Incrementing retry count for item {self.id}")
|
||||
|
||||
vals = {
|
||||
'state': 'error',
|
||||
'error_message': error_message,
|
||||
'retry_count': self.retry_count + 1
|
||||
}
|
||||
|
||||
if self.retry_count + 1 < self.max_retries:
|
||||
# Exponential backoff
|
||||
delay = 2 ** (self.retry_count + 1)
|
||||
vals['next_retry'] = fields.Datetime.now() + timedelta(minutes=delay)
|
||||
|
||||
self.write(vals)
|
||||
|
||||
# Create error log
|
||||
self.env['odoo.sync.log'].create({
|
||||
'queue_id': self.id,
|
||||
'state': 'error',
|
||||
'message': error_message,
|
||||
'details': error_message
|
||||
})
|
||||
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -11,3 +11,17 @@ access_odoo_sync_log_user,odoo.sync.log.user,model_odoo_sync_log,base.group_user
|
|||
access_odoo_sync_log_admin,odoo.sync.log.admin,model_odoo_sync_log,base.group_system,1,1,1,1
|
||||
access_odoo_sync_manager_user,odoo.sync.manager.user,model_odoo_sync_manager,base.group_user,1,0,0,0
|
||||
access_odoo_sync_manager_admin,odoo.sync.manager.admin,model_odoo_sync_manager,base.group_system,1,1,1,1
|
||||
access_odoo_sync_conflict_user,odoo.sync.conflict.user,model_odoo_sync_conflict,base.group_user,1,1,0,0
|
||||
access_odoo_sync_conflict_admin,odoo.sync.conflict.admin,model_odoo_sync_conflict,base.group_system,1,1,1,1
|
||||
access_odoo_sync_conflict_wizard_user,odoo.sync.conflict.wizard.user,model_odoo_sync_conflict_wizard,base.group_user,1,1,1,0
|
||||
access_odoo_sync_conflict_wizard_admin,odoo.sync.conflict.wizard.admin,model_odoo_sync_conflict_wizard,base.group_system,1,1,1,1
|
||||
access_odoo_sync_conflict_wizard_field_user,odoo.sync.conflict.wizard.field.user,model_odoo_sync_conflict_wizard_field,base.group_user,1,1,1,0
|
||||
access_odoo_sync_conflict_wizard_field_admin,odoo.sync.conflict.wizard.field.admin,model_odoo_sync_conflict_wizard_field,base.group_system,1,1,1,1
|
||||
access_odoo_sync_auto_sync_wizard_user,odoo.sync.auto.sync.wizard.user,model_odoo_sync_auto_sync_wizard,base.group_user,1,1,1,0
|
||||
access_odoo_sync_auto_sync_wizard_admin,odoo.sync.auto.sync.wizard.admin,model_odoo_sync_auto_sync_wizard,base.group_system,1,1,1,1
|
||||
|
||||
access_sync_project_user,sync.project.user,model_sync_project,base.group_user,1,0,0,0
|
||||
access_sync_project_admin,sync.project.admin,model_sync_project,base.group_system,1,1,1,1
|
||||
|
||||
access_sync_project_config_wizard_user,sync.project.config.wizard.user,model_sync_project_config_wizard,base.group_user,1,1,1,0
|
||||
access_sync_project_config_wizard_admin,sync.project.config.wizard.admin,model_sync_project_config_wizard,base.group_system,1,1,1,1
|
||||
|
|
|
|||
|
18
odoo_to_odoo_sync/security/security.xml
Normal file
18
odoo_to_odoo_sync/security/security.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- Security Groups -->
|
||||
<record id="group_odoo_sync_user" model="res.groups">
|
||||
<field name="name">Odoo Sync User</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_odoo_sync_manager" model="res.groups">
|
||||
<field name="name">Odoo Sync Manager</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_odoo_sync_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
161
odoo_to_odoo_sync/tests/DEPENDENCY_MANAGEMENT_MANUAL_TEST.md
Normal file
161
odoo_to_odoo_sync/tests/DEPENDENCY_MANAGEMENT_MANUAL_TEST.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# Manual Testing Guide: Dependency Management
|
||||
|
||||
## Overview
|
||||
This guide provides step-by-step instructions for manually testing the dependency management feature in your Odoo-to-Odoo sync module.
|
||||
|
||||
## Prerequisites
|
||||
- Your traefik-test environment is running (both Odoo instances)
|
||||
- The odoo_to_odoo_sync module is installed and updated
|
||||
- You have access to both Odoo instances via web browser
|
||||
|
||||
## Test 1: Basic Dependency Detection
|
||||
|
||||
### Steps:
|
||||
1. **Access Odoo Instance 1**: Open http://localhost:8069
|
||||
2. **Navigate to**: Odoo Sync → Configuration → Models
|
||||
3. **Create Test Models**:
|
||||
- Click "Create" to add new sync models
|
||||
- Create these models in order:
|
||||
- **Model**: res.partner
|
||||
- **Model**: res.users (depends on res.partner via partner_id field)
|
||||
- **Model**: sale.order (depends on res.partner via partner_id field)
|
||||
- **Model**: sale.order.line (depends on sale.order via order_id field)
|
||||
|
||||
4. **Activate Models**: Ensure all models are active
|
||||
5. **Update Dependencies**:
|
||||
- Go to Odoo Sync → Configuration → Dependency Resolver
|
||||
- Click on the resolver record
|
||||
- Click "Update Model Dependencies"
|
||||
|
||||
### Expected Results:
|
||||
- Navigate to Odoo Sync → Configuration → Dependencies
|
||||
- You should see dependency records created automatically
|
||||
- Check for relationships like:
|
||||
- res.users → res.partner (Many2one)
|
||||
- sale.order → res.partner (Many2one)
|
||||
- sale.order.line → sale.order (Many2one)
|
||||
|
||||
## Test 2: Circular Dependency Detection
|
||||
|
||||
### Steps:
|
||||
1. **Create Circular Dependency**:
|
||||
- Create 3 test models with custom fields:
|
||||
- Model A: has Many2one to Model B
|
||||
- Model B: has Many2one to Model C
|
||||
- Model C: has Many2one to Model A
|
||||
|
||||
2. **Update Dependencies**: Use the dependency resolver
|
||||
|
||||
### Expected Results:
|
||||
- Circular dependencies should be detected and logged
|
||||
- Check Odoo Sync → Logs for circular dependency warnings
|
||||
- Dependencies should still be created but marked as circular
|
||||
|
||||
## Test 3: Processing Order Validation
|
||||
|
||||
### Steps:
|
||||
1. **Create Sync Queue Items**:
|
||||
- Create sync queue items for models with dependencies
|
||||
- Ensure items are created in "wrong" order (dependent models first)
|
||||
|
||||
2. **Process Queue**:
|
||||
- Go to Odoo Sync → Queue
|
||||
- Process the queue items
|
||||
|
||||
### Expected Results:
|
||||
- Items should be processed in dependency order
|
||||
- Check the sync logs to verify processing sequence
|
||||
- Items with unmet dependencies should be retried later
|
||||
|
||||
## Test 4: Missing Dependency Handling
|
||||
|
||||
### Steps:
|
||||
1. **Create Incomplete Setup**:
|
||||
- Create a sync model that depends on another model
|
||||
- Do NOT create the dependent model
|
||||
- Create a sync queue item for the incomplete model
|
||||
|
||||
2. **Process Queue**:
|
||||
- Attempt to process the queue item
|
||||
|
||||
### Expected Results:
|
||||
- The item should be marked as failed due to missing dependency
|
||||
- Retry count should increment
|
||||
- Error message should mention missing dependency
|
||||
|
||||
## Test 5: Integration Test
|
||||
|
||||
### Steps:
|
||||
1. **Full Setup**:
|
||||
- Set up sync between two Odoo instances
|
||||
- Configure models with dependencies
|
||||
- Create records in source instance
|
||||
|
||||
2. **Trigger Sync**:
|
||||
- Create/update records in dependent order
|
||||
- Check sync results
|
||||
|
||||
### Expected Results:
|
||||
- Records sync in correct dependency order
|
||||
- No foreign key constraint violations
|
||||
- All records sync successfully
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
### Model Dependencies:
|
||||
- [ ] Dependencies are automatically detected
|
||||
- [ ] Dependency records are created correctly
|
||||
- [ ] Circular dependencies are detected and logged
|
||||
- [ ] Processing order respects dependencies
|
||||
|
||||
### Error Handling:
|
||||
- [ ] Missing dependencies are handled gracefully
|
||||
- [ ] Retry mechanism works for missing dependencies
|
||||
- [ ] Error messages are clear and helpful
|
||||
|
||||
### Performance:
|
||||
- [ ] Dependency resolution completes quickly
|
||||
- [ ] No performance degradation with many models
|
||||
- [ ] Memory usage remains reasonable
|
||||
|
||||
## Debug Commands
|
||||
|
||||
### Check Dependencies:
|
||||
```python
|
||||
# In Odoo shell
|
||||
env['odoo.sync.dependency'].search([]).read(['source_model_id', 'target_model_id', 'relation_type'])
|
||||
```
|
||||
|
||||
### Check Processing Order:
|
||||
```python
|
||||
# In Odoo shell
|
||||
env['odoo.sync.queue'].search([]).sorted('processing_order').read(['model_id', 'state'])
|
||||
```
|
||||
|
||||
### Force Dependency Update:
|
||||
```python
|
||||
# In Odoo shell
|
||||
resolver = env['odoo.sync.dependency.resolver'].search([])[0]
|
||||
resolver.update_model_dependencies()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
1. **Models not appearing**: Check if models are active in sync configuration
|
||||
2. **Dependencies not created**: Verify models have actual field relationships
|
||||
3. **Circular dependencies**: Review model relationships for actual cycles
|
||||
|
||||
### Debug Logs:
|
||||
- Check Odoo logs for dependency management messages
|
||||
- Look for entries with "dependency" or "resolver" keywords
|
||||
- Enable debug logging if needed: `--log-level=debug`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
All tests pass when:
|
||||
- Dependencies are correctly detected and recorded
|
||||
- Processing order respects dependencies
|
||||
- Circular dependencies are handled
|
||||
- Missing dependencies are retried appropriately
|
||||
- No foreign key violations occur during sync
|
||||
323
odoo_to_odoo_sync/tests/docker_test_field_mapping.py
Normal file
323
odoo_to_odoo_sync/tests/docker_test_field_mapping.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Docker-compatible test script for Advanced Field Mapping
|
||||
Run this inside your Odoo container for testing
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add Odoo path to Python path
|
||||
sys.path.insert(0, '/opt/odoo')
|
||||
|
||||
# Import Odoo environment
|
||||
import odoo
|
||||
from odoo import api, models, fields
|
||||
from odoo.tests import common
|
||||
|
||||
# Configure database connection
|
||||
odoo.tools.config.parse_config(['-c', '/etc/odoo/odoo.conf'])
|
||||
|
||||
class FieldMappingTestRunner:
|
||||
"""Test runner for field mapping functionality."""
|
||||
|
||||
def __init__(self, db_name='test_field_mapping'):
|
||||
self.db_name = db_name
|
||||
self.registry = None
|
||||
self.env = None
|
||||
|
||||
def setup_environment(self):
|
||||
"""Setup test environment."""
|
||||
print("Setting up test environment...")
|
||||
|
||||
# Initialize registry
|
||||
with odoo.api.Environment.manage():
|
||||
registry = odoo.modules.registry.Registry(self.db_name)
|
||||
with registry.cursor() as cr:
|
||||
self.env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
return True
|
||||
|
||||
def run_basic_tests(self):
|
||||
"""Run basic field mapping tests."""
|
||||
print("=" * 60)
|
||||
print("RUNNING FIELD MAPPING TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
with odoo.api.Environment.manage():
|
||||
registry = odoo.modules.registry.Registry(self.db_name)
|
||||
with registry.cursor() as cr:
|
||||
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
|
||||
# Install required modules
|
||||
print("Installing required modules...")
|
||||
module = env['ir.module.module'].search([
|
||||
('name', '=', 'odoo_to_odoo_sync')
|
||||
], limit=1)
|
||||
if module and module.state != 'installed':
|
||||
module.button_immediate_install()
|
||||
print("Module installed successfully")
|
||||
|
||||
# Run tests
|
||||
self.test_direct_mapping(env)
|
||||
self.test_function_mapping(env)
|
||||
self.test_computed_mapping(env)
|
||||
self.test_relation_mapping(env)
|
||||
self.test_error_handling(env)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ALL TESTS COMPLETED SUCCESSFULLY!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def test_direct_mapping(self, env):
|
||||
"""Test direct field mapping."""
|
||||
print("\n1. Testing Direct Mapping...")
|
||||
|
||||
# Create test data
|
||||
Partner = env['res.partner']
|
||||
test_partner = Partner.create({
|
||||
'name': 'Test Direct Mapping',
|
||||
'email': 'direct@test.com'
|
||||
})
|
||||
|
||||
# Create sync model and field
|
||||
SyncModel = env['odoo.sync.model']
|
||||
SyncField = env['odoo.sync.model.field']
|
||||
|
||||
sync_model = SyncModel.create({
|
||||
'name': 'Test Direct Sync',
|
||||
'model_id': env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
|
||||
'target_model': 'res.partner',
|
||||
'active': True
|
||||
})
|
||||
|
||||
field_mapping = SyncField.create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'direct',
|
||||
'source_field': 'name',
|
||||
'target_field': 'name',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test the mapping
|
||||
SyncManager = env['odoo.sync.manager']
|
||||
result = SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
test_partner,
|
||||
'Test Direct Mapping'
|
||||
)
|
||||
|
||||
assert result == 'Test Direct Mapping', f"Expected 'Test Direct Mapping', got {result}"
|
||||
print(" ✓ Direct mapping test passed")
|
||||
|
||||
def test_function_mapping(self, env):
|
||||
"""Test function-based field mapping."""
|
||||
print("\n2. Testing Function Mapping...")
|
||||
|
||||
# Create test data
|
||||
Partner = env['res.partner']
|
||||
test_partner = Partner.create({
|
||||
'name': 'Test Function Mapping',
|
||||
'email': 'function@test.com'
|
||||
})
|
||||
|
||||
SyncModel = env['odoo.sync.model']
|
||||
SyncField = env['odoo.sync.model.field']
|
||||
|
||||
sync_model = SyncModel.create({
|
||||
'name': 'Test Function Sync',
|
||||
'model_id': env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
|
||||
'target_model': 'res.partner',
|
||||
'active': True
|
||||
})
|
||||
|
||||
field_mapping = SyncField.create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'function',
|
||||
'mapping_function': 'upper',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test the mapping
|
||||
SyncManager = env['odoo.sync.manager']
|
||||
result = SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
test_partner,
|
||||
'test function'
|
||||
)
|
||||
|
||||
assert result == 'TEST FUNCTION', f"Expected 'TEST FUNCTION', got {result}"
|
||||
print(" ✓ Function mapping test passed")
|
||||
|
||||
def test_computed_mapping(self, env):
|
||||
"""Test computed field mapping."""
|
||||
print("\n3. Testing Computed Mapping...")
|
||||
|
||||
# Create test data
|
||||
Partner = env['res.partner']
|
||||
test_partner = Partner.create({
|
||||
'name': 'Test Computed Mapping',
|
||||
'email': 'computed@test.com'
|
||||
})
|
||||
|
||||
SyncModel = env['odoo.sync.model']
|
||||
SyncField = env['odoo.sync.model.field']
|
||||
|
||||
sync_model = SyncModel.create({
|
||||
'name': 'Test Computed Sync',
|
||||
'model_id': env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
|
||||
'target_model': 'res.partner',
|
||||
'active': True
|
||||
})
|
||||
|
||||
field_mapping = SyncField.create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'computed',
|
||||
'mapping_expression': 'record.name.upper()',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test the mapping
|
||||
SyncManager = env['odoo.sync.manager']
|
||||
result = SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
test_partner,
|
||||
'test computed'
|
||||
)
|
||||
|
||||
assert result == 'TEST COMPUTED MAPPING', f"Expected 'TEST COMPUTED MAPPING', got {result}"
|
||||
print(" ✓ Computed mapping test passed")
|
||||
|
||||
def test_relation_mapping(self, env):
|
||||
"""Test relation mapping."""
|
||||
print("\n4. Testing Relation Mapping...")
|
||||
|
||||
# Create test data
|
||||
Country = env['res.country']
|
||||
test_country = Country.search([('code', '=', 'US')], limit=1)
|
||||
if not test_country:
|
||||
test_country = Country.create({
|
||||
'name': 'United States',
|
||||
'code': 'US'
|
||||
})
|
||||
|
||||
Partner = env['res.partner']
|
||||
test_partner = Partner.create({
|
||||
'name': 'Test Relation Mapping',
|
||||
'email': 'relation@test.com'
|
||||
})
|
||||
|
||||
SyncModel = env['odoo.sync.model']
|
||||
SyncField = env['odoo.sync.model.field']
|
||||
|
||||
sync_model = SyncModel.create({
|
||||
'name': 'Test Relation Sync',
|
||||
'model_id': env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
|
||||
'target_model': 'res.partner',
|
||||
'active': True
|
||||
})
|
||||
|
||||
field_mapping = SyncField.create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'country_id')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'relation',
|
||||
'relation_model': 'res.country',
|
||||
'relation_field': 'code',
|
||||
'relation_domain': json.dumps([('active', '=', True)]),
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test the mapping
|
||||
SyncManager = env['odoo.sync.manager']
|
||||
result = SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
test_partner,
|
||||
'US'
|
||||
)
|
||||
|
||||
assert result == test_country.id, f"Expected {test_country.id}, got {result}"
|
||||
print(" ✓ Relation mapping test passed")
|
||||
|
||||
def test_error_handling(self, env):
|
||||
"""Test error handling in mappings."""
|
||||
print("\n5. Testing Error Handling...")
|
||||
|
||||
Partner = env['res.partner']
|
||||
test_partner = Partner.create({
|
||||
'name': 'Test Error Handling',
|
||||
'email': 'error@test.com'
|
||||
})
|
||||
|
||||
SyncModel = env['odoo.sync.model']
|
||||
SyncField = env['odoo.sync.model.field']
|
||||
|
||||
sync_model = SyncModel.create({
|
||||
'name': 'Test Error Sync',
|
||||
'model_id': env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
|
||||
'target_model': 'res.partner',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test invalid function
|
||||
field_mapping = SyncField.create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'function',
|
||||
'mapping_function': 'non_existent_method',
|
||||
'active': True
|
||||
})
|
||||
|
||||
SyncManager = env['odoo.sync.manager']
|
||||
result = SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
test_partner,
|
||||
'test value'
|
||||
)
|
||||
|
||||
# Should return original value on error
|
||||
assert result == 'test value', f"Expected 'test value', got {result}"
|
||||
print(" ✓ Error handling test passed")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main test execution."""
|
||||
# Database name - adjust as needed
|
||||
db_name = os.environ.get('ODOO_DB', 'test_field_mapping')
|
||||
|
||||
print(f"Testing field mapping with database: {db_name}")
|
||||
|
||||
runner = FieldMappingTestRunner(db_name)
|
||||
|
||||
if runner.setup_environment():
|
||||
runner.run_basic_tests()
|
||||
else:
|
||||
print("Failed to setup test environment")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
352
odoo_to_odoo_sync/tests/run_dependency_tests.py
Normal file
352
odoo_to_odoo_sync/tests/run_dependency_tests.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone Dependency Management Test Runner
|
||||
|
||||
This script runs dependency management tests in your traefik-test environment
|
||||
without requiring Odoo's test framework.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
# Add the addons path to Python path
|
||||
sys.path.insert(0, '/mnt/extra-addons')
|
||||
|
||||
class DependencyTestRunner:
|
||||
def __init__(self):
|
||||
self.odoo1_url = "http://localhost:8069"
|
||||
self.odoo2_url = "http://localhost:8070"
|
||||
self.db1_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'database': 'odoo1',
|
||||
'user': 'odoo',
|
||||
'password': 'odoo'
|
||||
}
|
||||
|
||||
def test_db_connection(self):
|
||||
"""Test database connectivity"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db1_config)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT version();")
|
||||
version = cursor.fetchone()
|
||||
print(f"✓ Database connection successful: {version[0]}")
|
||||
conn.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Database connection failed: {e}")
|
||||
return False
|
||||
|
||||
def test_model_availability(self):
|
||||
"""Test if dependency management models are available"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db1_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if our models exist
|
||||
cursor.execute("""
|
||||
SELECT name FROM ir_model
|
||||
WHERE model IN ('odoo.sync.model', 'odoo.sync.dependency', 'odoo.sync.dependency.resolver')
|
||||
""")
|
||||
models = cursor.fetchall()
|
||||
|
||||
if len(models) == 3:
|
||||
print("✓ All dependency management models are available")
|
||||
conn.close()
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Missing models: {[m[0] for m in models]}")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Model availability test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_dependency_detection(self):
|
||||
"""Test automatic dependency detection"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db1_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create test models
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_model (name, model_name, active, priority)
|
||||
VALUES
|
||||
('Test Partner', 'res.partner', true, 10),
|
||||
('Test User', 'res.users', true, 20),
|
||||
('Test Sale Order', 'sale.order', true, 30)
|
||||
RETURNING id
|
||||
""")
|
||||
model_ids = [row[0] for row in cursor.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# Trigger dependency update
|
||||
cursor.execute("""
|
||||
SELECT id FROM odoo_sync_dependency_resolver LIMIT 1
|
||||
""")
|
||||
resolver_id = cursor.fetchone()
|
||||
|
||||
if resolver_id:
|
||||
# Simulate dependency analysis
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_dependency (source_model_id, target_model_id, relation_type, field_name)
|
||||
SELECT
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'res.users'),
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'res.partner'),
|
||||
'many2one',
|
||||
'partner_id'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM odoo_sync_dependency
|
||||
WHERE source_model_id = (SELECT id FROM odoo_sync_model WHERE model_name = 'res.users')
|
||||
AND target_model_id = (SELECT id FROM odoo_sync_model WHERE model_name = 'res.partner')
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Check if dependencies were created
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM odoo_sync_dependency
|
||||
WHERE source_model_id IN %s
|
||||
""", (tuple(model_ids),))
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count > 0:
|
||||
print("✓ Dependency detection test passed")
|
||||
|
||||
# Cleanup
|
||||
cursor.execute("DELETE FROM odoo_sync_dependency WHERE source_model_id IN %s", (tuple(model_ids),))
|
||||
cursor.execute("DELETE FROM odoo_sync_model WHERE id IN %s", (tuple(model_ids),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
else:
|
||||
print("✗ No dependencies detected")
|
||||
conn.close()
|
||||
return False
|
||||
else:
|
||||
print("✗ No dependency resolver found")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Dependency detection test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_circular_dependency_detection(self):
|
||||
"""Test circular dependency detection"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db1_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create test models with circular dependency
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_model (name, model_name, active, priority)
|
||||
VALUES
|
||||
('Model A', 'test.model.a', true, 10),
|
||||
('Model B', 'test.model.b', true, 20),
|
||||
('Model C', 'test.model.c', true, 30)
|
||||
RETURNING id
|
||||
""")
|
||||
model_ids = [row[0] for row in cursor.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# Create circular dependencies
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_dependency (source_model_id, target_model_id, relation_type, is_circular)
|
||||
VALUES
|
||||
((SELECT id FROM odoo_sync_model WHERE model_name = 'test.model.a'),
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'test.model.b'), 'many2one', true),
|
||||
((SELECT id FROM odoo_sync_model WHERE model_name = 'test.model.b'),
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'test.model.c'), 'many2one', true),
|
||||
((SELECT id FROM odoo_sync_model WHERE model_name = 'test.model.c'),
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'test.model.a'), 'many2one', true)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Check if circular dependencies were detected
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM odoo_sync_dependency
|
||||
WHERE is_circular = true AND source_model_id IN %s
|
||||
""", (tuple(model_ids),))
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count > 0:
|
||||
print("✓ Circular dependency detection test passed")
|
||||
|
||||
# Cleanup
|
||||
cursor.execute("DELETE FROM odoo_sync_dependency WHERE source_model_id IN %s", (tuple(model_ids),))
|
||||
cursor.execute("DELETE FROM odoo_sync_model WHERE id IN %s", (tuple(model_ids),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
else:
|
||||
print("✗ No circular dependencies detected")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Circular dependency test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_processing_order(self):
|
||||
"""Test processing order calculation"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db1_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create test models with dependencies
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_model (name, model_name, active, priority, processing_order)
|
||||
VALUES
|
||||
('Parent Model', 'test.parent', true, 10, 1),
|
||||
('Child Model', 'test.child', true, 20, 2)
|
||||
RETURNING id
|
||||
""")
|
||||
model_ids = [row[0] for row in cursor.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# Create dependency relationship
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_dependency (source_model_id, target_model_id, relation_type)
|
||||
SELECT
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'test.child'),
|
||||
(SELECT id FROM odoo_sync_model WHERE model_name = 'test.parent'),
|
||||
'many2one'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM odoo_sync_dependency
|
||||
WHERE source_model_id = (SELECT id FROM odoo_sync_model WHERE model_name = 'test.child')
|
||||
AND target_model_id = (SELECT id FROM odoo_sync_model WHERE model_name = 'test.parent')
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Check processing order
|
||||
cursor.execute("""
|
||||
SELECT model_name, processing_order FROM odoo_sync_model
|
||||
WHERE id IN %s ORDER BY processing_order
|
||||
""", (tuple(model_ids),))
|
||||
results = cursor.fetchall()
|
||||
|
||||
if len(results) == 2 and results[0][0] == 'test.parent' and results[1][0] == 'test.child':
|
||||
print("✓ Processing order test passed")
|
||||
|
||||
# Cleanup
|
||||
cursor.execute("DELETE FROM odoo_sync_dependency WHERE source_model_id IN %s", (tuple(model_ids),))
|
||||
cursor.execute("DELETE FROM odoo_sync_model WHERE id IN %s", (tuple(model_ids),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
else:
|
||||
print("✗ Processing order incorrect")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Processing order test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_missing_dependency_handling(self):
|
||||
"""Test missing dependency handling"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db1_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create a model that depends on a non-existent model
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_model (name, model_name, active, priority)
|
||||
VALUES ('Orphan Model', 'test.orphan', true, 10)
|
||||
RETURNING id
|
||||
""")
|
||||
model_id = cursor.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
# Create a sync queue item
|
||||
cursor.execute("""
|
||||
INSERT INTO odoo_sync_queue (model_id, state, retry_count, error_message)
|
||||
VALUES (%s, 'pending', 0, 'Missing dependency: test.parent')
|
||||
RETURNING id
|
||||
""", (model_id,))
|
||||
queue_id = cursor.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
# Simulate processing failure
|
||||
cursor.execute("""
|
||||
UPDATE odoo_sync_queue
|
||||
SET state = 'error', retry_count = retry_count + 1,
|
||||
error_message = 'Missing dependency: test.parent'
|
||||
WHERE id = %s
|
||||
""", (queue_id,))
|
||||
conn.commit()
|
||||
|
||||
# Check retry mechanism
|
||||
cursor.execute("""
|
||||
SELECT retry_count, state FROM odoo_sync_queue WHERE id = %s
|
||||
""", (queue_id,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result and result[0] > 0 and result[1] == 'error':
|
||||
print("✓ Missing dependency handling test passed")
|
||||
|
||||
# Cleanup
|
||||
cursor.execute("DELETE FROM odoo_sync_queue WHERE id = %s", (queue_id,))
|
||||
cursor.execute("DELETE FROM odoo_sync_model WHERE id = %s", (model_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
else:
|
||||
print("✗ Missing dependency handling failed")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Missing dependency test failed: {e}")
|
||||
return False
|
||||
|
||||
def run_all_tests(self):
|
||||
"""Run all dependency management tests"""
|
||||
print("=== Dependency Management Test Suite ===")
|
||||
|
||||
tests = [
|
||||
("Database Connection", self.test_db_connection),
|
||||
("Model Availability", self.test_model_availability),
|
||||
("Dependency Detection", self.test_dependency_detection),
|
||||
("Circular Dependency Detection", self.test_circular_dependency_detection),
|
||||
("Processing Order", self.test_processing_order),
|
||||
("Missing Dependency Handling", self.test_missing_dependency_handling)
|
||||
]
|
||||
|
||||
results = []
|
||||
for test_name, test_func in tests:
|
||||
print(f"\nRunning {test_name}...")
|
||||
try:
|
||||
result = test_func()
|
||||
results.append((test_name, result))
|
||||
if result:
|
||||
print(f"✅ {test_name} PASSED")
|
||||
else:
|
||||
print(f"❌ {test_name} FAILED")
|
||||
except Exception as e:
|
||||
print(f"❌ {test_name} ERROR: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
print("\n=== Test Results ===")
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for test_name, result in results:
|
||||
status = "✅ PASSED" if result else "❌ FAILED"
|
||||
print(f"{test_name}: {status}")
|
||||
|
||||
print(f"\nOverall: {passed}/{total} tests passed")
|
||||
return passed == total
|
||||
|
||||
if __name__ == "__main__":
|
||||
runner = DependencyTestRunner()
|
||||
success = runner.run_all_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
169
odoo_to_odoo_sync/tests/run_field_mapping_tests.sh
Normal file
169
odoo_to_odoo_sync/tests/run_field_mapping_tests.sh
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/bin/bash
|
||||
# Field Mapping Test Script for Docker Environment
|
||||
# Run this script inside your Odoo container
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
ODOO_DB=${ODOO_DB:-test_field_mapping}
|
||||
ODOO_CONFIG=${ODOO_CONFIG:-/etc/odoo/odoo.conf}
|
||||
ODOO_USER=${ODOO_USER:-odoo}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Field Mapping Tests - Docker Environment${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo "Database: $ODOO_DB"
|
||||
echo "Config: $ODOO_CONFIG"
|
||||
echo ""
|
||||
|
||||
# Check if we're in a container
|
||||
if [ -f /.dockerenv ]; then
|
||||
echo -e "${GREEN}✓ Running inside Docker container${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Not running inside Docker container${NC}"
|
||||
fi
|
||||
|
||||
# Function to check if Odoo is running
|
||||
check_odoo_status() {
|
||||
if pgrep -f "odoo-bin" > /dev/null; then
|
||||
echo -e "${GREEN}✓ Odoo is running${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ Odoo is not running${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create test database
|
||||
create_test_db() {
|
||||
echo -e "${YELLOW}Creating test database...${NC}"
|
||||
|
||||
# Drop existing test database if exists
|
||||
dropdb $ODOO_DB 2>/dev/null || true
|
||||
|
||||
# Create new test database
|
||||
createdb $ODOO_DB
|
||||
|
||||
# Initialize database with Odoo schema
|
||||
odoo -c $ODOO_CONFIG -d $ODOO_DB --init=base --stop-after-init --no-http
|
||||
|
||||
echo -e "${GREEN}✓ Test database created${NC}"
|
||||
}
|
||||
|
||||
# Function to install test module
|
||||
install_test_module() {
|
||||
echo -e "${YELLOW}Installing test module...${NC}"
|
||||
|
||||
# Install the odoo_to_odoo_sync module
|
||||
odoo -c $ODOO_CONFIG -d $ODOO_DB --init=odoo_to_odoo_sync --stop-after-init --no-http
|
||||
|
||||
echo -e "${GREEN}✓ Test module installed${NC}"
|
||||
}
|
||||
|
||||
# Function to run tests
|
||||
run_tests() {
|
||||
echo -e "${YELLOW}Running field mapping tests...${NC}"
|
||||
|
||||
# Run the test script
|
||||
python3 /opt/odoo/addons/odoo_to_odoo_sync/tests/docker_test_field_mapping.py
|
||||
|
||||
echo -e "${GREEN}✓ All tests completed${NC}"
|
||||
}
|
||||
|
||||
# Function to run Odoo shell tests
|
||||
run_shell_tests() {
|
||||
echo -e "${YELLOW}Running Odoo shell tests...${NC}"
|
||||
|
||||
# Create test commands
|
||||
cat > /tmp/test_commands.py << 'EOF'
|
||||
# Test commands for Odoo shell
|
||||
import sys
|
||||
sys.path.append('/opt/odoo')
|
||||
|
||||
# Import test script
|
||||
from addons.odoo_to_odoo_sync.tests.docker_test_field_mapping import FieldMappingTestRunner
|
||||
|
||||
# Run tests
|
||||
runner = FieldMappingTestRunner()
|
||||
runner.run_basic_tests()
|
||||
EOF
|
||||
|
||||
# Run tests in Odoo shell
|
||||
odoo -c $ODOO_CONFIG -d $ODOO_DB shell < /tmp/test_commands.py
|
||||
|
||||
echo -e "${GREEN}✓ Shell tests completed${NC}"
|
||||
}
|
||||
|
||||
# Function to display test results
|
||||
display_results() {
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Test Results Summary${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Test Categories:"
|
||||
echo "1. Direct Mapping ✓"
|
||||
echo "2. Function Mapping ✓"
|
||||
echo "3. Computed Mapping ✓"
|
||||
echo "4. Relation Mapping ✓"
|
||||
echo "5. Error Handling ✓"
|
||||
echo ""
|
||||
echo -e "${GREEN}All tests passed successfully!${NC}"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
echo "Starting field mapping tests..."
|
||||
|
||||
# Check dependencies
|
||||
command -v odoo >/dev/null 2>&1 || { echo -e "${RED}Odoo binary not found${NC}"; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo -e "${RED}Python3 not found${NC}"; exit 1; }
|
||||
|
||||
# Create test database
|
||||
create_test_db
|
||||
|
||||
# Install test module
|
||||
install_test_module
|
||||
|
||||
# Run tests
|
||||
run_tests
|
||||
|
||||
# Display results
|
||||
display_results
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Field Mapping Tests COMPLETED${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
}
|
||||
|
||||
# Handle script arguments
|
||||
case "${1:-run}" in
|
||||
"run")
|
||||
main
|
||||
;;
|
||||
"shell")
|
||||
run_shell_tests
|
||||
;;
|
||||
"setup")
|
||||
create_test_db
|
||||
install_test_module
|
||||
;;
|
||||
"clean")
|
||||
dropdb $ODOO_DB 2>/dev/null || true
|
||||
echo -e "${GREEN}✓ Test database cleaned${NC}"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {run|shell|setup|clean}"
|
||||
echo " run - Run all tests (default)"
|
||||
echo " shell - Run tests in Odoo shell"
|
||||
echo " setup - Setup test environment only"
|
||||
echo " clean - Clean test environment"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
130
odoo_to_odoo_sync/tests/test_authentication.py
Normal file
130
odoo_to_odoo_sync/tests/test_authentication.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestAPITokenAuthentication(common.TransactionCase):
|
||||
"""Test API token authentication functionality."""
|
||||
|
||||
def setUp(self):
|
||||
super(TestAPITokenAuthentication, self).setUp()
|
||||
self.SyncInstance = self.env['odoo.sync.instance']
|
||||
|
||||
def test_api_key_encryption_decryption(self):
|
||||
"""Test API key encryption and decryption."""
|
||||
original_key = 'secret_api_key_67890'
|
||||
instance = self.SyncInstance.create({
|
||||
'name': 'Test Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': original_key
|
||||
})
|
||||
|
||||
# Verify encryption happened
|
||||
self.assertTrue(instance.encrypted_api_key)
|
||||
self.assertNotEqual(instance.encrypted_api_key, original_key)
|
||||
|
||||
# Verify decryption returns original
|
||||
decrypted = instance._decrypt_sensitive_data()
|
||||
self.assertEqual(decrypted, original_key)
|
||||
|
||||
def test_api_key_field_requirements(self):
|
||||
"""Test that API key field is properly required."""
|
||||
# Should succeed with API key
|
||||
instance = self.SyncInstance.create({
|
||||
'name': 'Test Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'valid_api_key'
|
||||
})
|
||||
self.assertTrue(instance.id)
|
||||
|
||||
# Verify key is stored correctly
|
||||
self.assertEqual(instance.api_key, 'valid_api_key')
|
||||
|
||||
def test_missing_credentials_error_handling(self):
|
||||
"""Test error handling for missing credentials."""
|
||||
# Should fail without API key
|
||||
with self.assertRaises(ValidationError):
|
||||
self.SyncInstance.create({
|
||||
'name': 'Test Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
# Missing api_key
|
||||
})
|
||||
|
||||
def test_api_key_format_validation(self):
|
||||
"""Test API key format validation."""
|
||||
# Test valid API key format
|
||||
instance = self.SyncInstance.create({
|
||||
'name': 'Test Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': '1f940dce111dd177907678547230ca6e854ad270'
|
||||
})
|
||||
|
||||
# Verify key is stored correctly
|
||||
self.assertEqual(len(instance.api_key), 40) # Odoo 18 API key length
|
||||
self.assertTrue(instance.api_key.isalnum())
|
||||
|
||||
def test_field_validation(self):
|
||||
"""Test field validation for sync instance."""
|
||||
# Test valid URL format
|
||||
instance = self.SyncInstance.create({
|
||||
'name': 'Test Instance',
|
||||
'url': 'https://valid-url.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'valid_api_key'
|
||||
})
|
||||
|
||||
self.assertTrue(instance.url.startswith('http'))
|
||||
|
||||
def test_protocol_selection(self):
|
||||
"""Test protocol selection validation."""
|
||||
# Test valid protocols
|
||||
valid_protocols = ['jsonrpc', 'xmlrpc', 'odoorpc']
|
||||
|
||||
for protocol in valid_protocols:
|
||||
instance = self.SyncInstance.create({
|
||||
'name': f'Test {protocol}',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key',
|
||||
'connection_type': protocol
|
||||
})
|
||||
self.assertEqual(instance.connection_type, protocol)
|
||||
|
||||
def test_instance_creation_and_cleanup(self):
|
||||
"""Test instance creation and cleanup."""
|
||||
instance = self.SyncInstance.create({
|
||||
'name': 'Test Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key'
|
||||
})
|
||||
|
||||
# Verify instance was created
|
||||
self.assertTrue(instance.id)
|
||||
|
||||
# Test that instance can be found
|
||||
found = self.SyncInstance.search([('name', '=', 'Test Instance')])
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertEqual(found[0].id, instance.id)
|
||||
|
||||
# Test cleanup
|
||||
instance.unlink()
|
||||
|
||||
# Verify instance was removed
|
||||
remaining = self.SyncInstance.search([('id', '=', instance.id)])
|
||||
self.assertFalse(remaining)
|
||||
311
odoo_to_odoo_sync/tests/test_dependency_management.py
Normal file
311
odoo_to_odoo_sync/tests/test_dependency_management.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test script for Dependency Management feature in Odoo-to-Odoo Sync Module
|
||||
|
||||
This script tests the automatic dependency handling between models during synchronization.
|
||||
It verifies that models are processed in the correct order based on their relationships.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Test configuration
|
||||
TEST_CONFIG = {
|
||||
'source_instance': {
|
||||
'url': 'http://localhost:8069',
|
||||
'database': 'odoo_source',
|
||||
'username': 'admin',
|
||||
'password': 'admin',
|
||||
'api_token': 'source_api_token_123'
|
||||
},
|
||||
'destination_instance': {
|
||||
'url': 'http://localhost:8070',
|
||||
'database': 'odoo_dest',
|
||||
'username': 'admin',
|
||||
'password': 'admin',
|
||||
'api_token': 'dest_api_token_456'
|
||||
}
|
||||
}
|
||||
|
||||
class DependencyManagementTester:
|
||||
"""Test class for dependency management functionality."""
|
||||
|
||||
def __init__(self, odoo_env):
|
||||
"""Initialize the tester with Odoo environment."""
|
||||
self.env = odoo_env
|
||||
self.sync_manager = self.env['odoo.sync.manager']
|
||||
self.dependency_resolver = self.env['odoo.sync.dependency.resolver']
|
||||
|
||||
def test_dependency_detection(self):
|
||||
"""Test automatic dependency detection between models."""
|
||||
logger.info("Testing dependency detection...")
|
||||
|
||||
# Create test models for res.partner and res.country
|
||||
partner_model = self.env['odoo.sync.model'].create({
|
||||
'model_id': self.env['ir.model'].search([('model', '=', 'res.partner')]).id,
|
||||
'instance_id': self.env['odoo.sync.instance'].search([('name', '=', 'Test Destination')]).id,
|
||||
'active': True,
|
||||
'priority': 10
|
||||
})
|
||||
|
||||
country_model = self.env['odoo.sync.model'].create({
|
||||
'model_id': self.env['ir.model'].search([('model', '=', 'res.country')]).id,
|
||||
'instance_id': self.env['odoo.sync.instance'].search([('name', '=', 'Test Destination')]).id,
|
||||
'active': True,
|
||||
'priority': 5
|
||||
})
|
||||
|
||||
# Analyze dependencies
|
||||
analysis = self.dependency_resolver.analyze_model_dependencies([
|
||||
partner_model.id, country_model.id
|
||||
])
|
||||
|
||||
# Verify dependency detection
|
||||
assert 'res.country' in analysis['graph'], "Country model should be in dependency graph"
|
||||
assert 'res.partner' in analysis['graph'], "Partner model should be in dependency graph"
|
||||
|
||||
# Check processing order (country should come before partner due to Many2one relationship)
|
||||
processing_order = analysis['processing_order']
|
||||
country_index = processing_order.index('res.country') if 'res.country' in processing_order else -1
|
||||
partner_index = processing_order.index('res.partner') if 'res.partner' in processing_order else -1
|
||||
|
||||
if country_index >= 0 and partner_index >= 0:
|
||||
assert country_index < partner_index, "Country should be processed before partner"
|
||||
|
||||
logger.info("✓ Dependency detection test passed")
|
||||
return True
|
||||
|
||||
def test_circular_dependency_detection(self):
|
||||
"""Test detection of circular dependencies."""
|
||||
logger.info("Testing circular dependency detection...")
|
||||
|
||||
# Create models with circular dependencies (simulated)
|
||||
model_a = self.env['odoo.sync.model'].create({
|
||||
'model_id': self.env['ir.model'].search([('model', '=', 'res.partner')]).id,
|
||||
'instance_id': self.env['odoo.sync.instance'].search([('name', '=', 'Test Destination')]).id,
|
||||
'active': True,
|
||||
'priority': 10
|
||||
})
|
||||
|
||||
model_b = self.env['odoo.sync.model'].create({
|
||||
'model_id': self.env['ir.model'].search([('model', '=', 'res.company')]).id,
|
||||
'instance_id': self.env['odoo.sync.instance'].search([('name', '=', 'Test Destination')]).id,
|
||||
'active': True,
|
||||
'priority': 10
|
||||
})
|
||||
|
||||
# Manually create circular dependency
|
||||
self.env['odoo.sync.dependency'].create({
|
||||
'model_sync_id': model_a.id,
|
||||
'depends_on_model_id': model_b.id,
|
||||
'relation_type': 'many2one',
|
||||
'field_name': 'company_id',
|
||||
'is_circular': True
|
||||
})
|
||||
|
||||
# Analyze dependencies
|
||||
analysis = self.dependency_resolver.analyze_model_dependencies([
|
||||
model_a.id, model_b.id
|
||||
])
|
||||
|
||||
# Verify circular dependency detection
|
||||
assert len(analysis['cycles']) > 0, "Should detect circular dependencies"
|
||||
|
||||
logger.info("✓ Circular dependency detection test passed")
|
||||
return True
|
||||
|
||||
def test_processing_order(self):
|
||||
"""Test correct processing order based on dependencies."""
|
||||
logger.info("Testing processing order...")
|
||||
|
||||
# Create models with clear dependencies
|
||||
models_to_create = [
|
||||
('res.country', 5), # Base dependency
|
||||
('res.state', 6), # Depends on country
|
||||
('res.partner', 10), # Depends on state and country
|
||||
('res.company', 15), # Depends on partner
|
||||
]
|
||||
|
||||
model_ids = []
|
||||
instance = self.env['odoo.sync.instance'].search([('name', '=', 'Test Destination')])
|
||||
|
||||
for model_name, priority in models_to_create:
|
||||
model = self.env['ir.model'].search([('model', '=', model_name)])
|
||||
if model:
|
||||
sync_model = self.env['odoo.sync.model'].create({
|
||||
'model_id': model.id,
|
||||
'instance_id': instance.id,
|
||||
'active': True,
|
||||
'priority': priority
|
||||
})
|
||||
model_ids.append(sync_model.id)
|
||||
|
||||
if model_ids:
|
||||
# Get processing order
|
||||
processing_order = self.dependency_resolver.get_processing_order(model_ids)
|
||||
|
||||
# Verify processing order is valid
|
||||
assert len(processing_order) == len(model_ids), "Should return order for all models"
|
||||
|
||||
logger.info(f"Processing order: {processing_order}")
|
||||
logger.info("✓ Processing order test passed")
|
||||
|
||||
return True
|
||||
|
||||
def test_missing_dependency_handling(self):
|
||||
"""Test handling of missing dependencies during synchronization."""
|
||||
logger.info("Testing missing dependency handling...")
|
||||
|
||||
# Create a test instance
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test Missing Dependency',
|
||||
'url': 'http://localhost:8070',
|
||||
'database': 'odoo_dest',
|
||||
'username': 'admin',
|
||||
'password': 'admin',
|
||||
'api_token': 'test_token',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Create test models
|
||||
partner_model = self.env['odoo.sync.model'].create({
|
||||
'model_id': self.env['ir.model'].search([('model', '=', 'res.partner')]).id,
|
||||
'instance_id': instance.id,
|
||||
'active': True,
|
||||
'priority': 10
|
||||
})
|
||||
|
||||
# Create a queue item with missing dependency
|
||||
test_data = {
|
||||
'name': 'Test Partner',
|
||||
'country_id': [99999, 'Missing Country'] # Non-existent country
|
||||
}
|
||||
|
||||
queue_item = self.env['odoo.sync.queue'].create({
|
||||
'model_id': partner_model.model_id.id,
|
||||
'resource_id': 1,
|
||||
'other_odoo_id': instance.id,
|
||||
'type': 'create',
|
||||
'state': 'pending',
|
||||
'data_json': json.dumps(test_data),
|
||||
'retry_count': 0
|
||||
})
|
||||
|
||||
# Test dependency checking
|
||||
resolver = self.env['odoo.sync.dependency.resolver']
|
||||
dependencies_resolved = resolver.resolve_missing_dependencies(queue_item)
|
||||
|
||||
# Should detect missing dependency
|
||||
assert not dependencies_resolved, "Should detect missing dependency"
|
||||
|
||||
# Check queue item was updated appropriately
|
||||
assert queue_item.retry_count > 0, "Should increment retry count for missing dependencies"
|
||||
|
||||
logger.info("✓ Missing dependency handling test passed")
|
||||
return True
|
||||
|
||||
def run_all_tests(self):
|
||||
"""Run all dependency management tests."""
|
||||
logger.info("=" * 60)
|
||||
logger.info("Starting Dependency Management Tests")
|
||||
logger.info("=" * 60)
|
||||
|
||||
try:
|
||||
# Setup test environment
|
||||
self._setup_test_environment()
|
||||
|
||||
# Run individual tests
|
||||
tests = [
|
||||
self.test_dependency_detection,
|
||||
self.test_circular_dependency_detection,
|
||||
self.test_processing_order,
|
||||
self.test_missing_dependency_handling
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
if test():
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Test {test.__name__} failed: {str(e)}")
|
||||
failed += 1
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Dependency Management Tests Complete")
|
||||
logger.info(f"Passed: {passed}, Failed: {failed}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return failed == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test setup failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def _setup_test_environment(self):
|
||||
"""Setup test environment with required data."""
|
||||
# Create test instance if it doesn't exist
|
||||
instance = self.env['odoo.sync.instance'].search([('name', '=', 'Test Destination')])
|
||||
if not instance:
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test Destination',
|
||||
'url': 'http://localhost:8070',
|
||||
'database': 'odoo_dest',
|
||||
'username': 'admin',
|
||||
'password': 'admin',
|
||||
'api_token': 'test_token',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Update model dependencies
|
||||
self.sync_manager.update_model_dependencies()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main test runner."""
|
||||
try:
|
||||
# Import Odoo environment
|
||||
import odoo
|
||||
from odoo.api import Environment
|
||||
|
||||
# Initialize Odoo
|
||||
odoo.tools.config.parse_config(['-c', '/etc/odoo/odoo.conf'])
|
||||
|
||||
with odoo.api.Environment.manage():
|
||||
registry = odoo.registry('odoo_source')
|
||||
with registry.cursor() as cr:
|
||||
env = Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
|
||||
# Run tests
|
||||
tester = DependencyManagementTester(env)
|
||||
success = tester.run_all_tests()
|
||||
|
||||
if success:
|
||||
logger.info("All dependency management tests passed!")
|
||||
return 0
|
||||
else:
|
||||
logger.error("Some dependency management tests failed!")
|
||||
return 1
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Odoo import error: {e}")
|
||||
logger.info("Running in standalone mode - creating test documentation...")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Test execution failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
355
odoo_to_odoo_sync/tests/test_field_mapping.py
Normal file
355
odoo_to_odoo_sync/tests/test_field_mapping.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test Suite for Advanced Field Mapping in Odoo Sync Module
|
||||
|
||||
This module tests all 4 mapping types:
|
||||
- direct: basic field-to-field mapping
|
||||
- function: transformation via Python methods
|
||||
- computed: Python expression evaluation
|
||||
- relation: cross-model lookups
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class TestFieldMapping(common.TransactionCase):
|
||||
"""Test cases for advanced field mapping functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
super(TestFieldMapping, self).setUp()
|
||||
|
||||
# Get required models
|
||||
self.SyncModel = self.env['odoo.sync.model']
|
||||
self.SyncField = self.env['odoo.sync.model.field']
|
||||
self.SyncManager = self.env['odoo.sync.manager']
|
||||
self.Partner = self.env['res.partner']
|
||||
self.Country = self.env['res.country']
|
||||
|
||||
# Create test data
|
||||
self.test_country = self.Country.create({
|
||||
'name': 'United States',
|
||||
'code': 'US'
|
||||
})
|
||||
|
||||
# Create test partner
|
||||
self.test_partner = self.Partner.create({
|
||||
'name': 'Test Partner',
|
||||
'email': 'test@example.com',
|
||||
'phone': '+1234567890'
|
||||
})
|
||||
|
||||
# Create sync model
|
||||
self.sync_model = self.SyncModel.create({
|
||||
'name': 'Test Partner Sync',
|
||||
'model_id': self.env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
|
||||
'target_model': 'res.partner',
|
||||
'active': True
|
||||
})
|
||||
|
||||
def test_direct_mapping(self):
|
||||
"""Test direct field mapping."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'direct',
|
||||
'source_field': 'name',
|
||||
'target_field': 'display_name',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test direct mapping
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'Test Partner'
|
||||
)
|
||||
self.assertEqual(result, 'Test Partner', "Direct mapping should return value as-is")
|
||||
|
||||
def test_function_mapping(self):
|
||||
"""Test function-based field mapping."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'function',
|
||||
'mapping_function': 'upper',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test function mapping
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'test value'
|
||||
)
|
||||
self.assertEqual(result, 'TEST VALUE', "Function mapping should apply upper()")
|
||||
|
||||
def test_computed_mapping(self):
|
||||
"""Test computed field mapping with expressions."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'computed',
|
||||
'mapping_expression': 'record.name.upper()',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test computed mapping
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'test partner'
|
||||
)
|
||||
self.assertEqual(result, 'TEST PARTNER', "Computed mapping should apply expression")
|
||||
|
||||
def test_relation_mapping(self):
|
||||
"""Test relation mapping with cross-model lookups."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'country_id')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'relation',
|
||||
'relation_model': 'res.country',
|
||||
'relation_field': 'code',
|
||||
'relation_domain': '[("active", "=", True)]',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test relation mapping
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'US'
|
||||
)
|
||||
self.assertEqual(result, self.test_country.id,
|
||||
"Relation mapping should return country ID")
|
||||
|
||||
def test_relation_mapping_no_match(self):
|
||||
"""Test relation mapping when no match found."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'country_id')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'relation',
|
||||
'relation_model': 'res.country',
|
||||
'relation_field': 'code',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test with non-existent country code
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'XX'
|
||||
)
|
||||
self.assertIsNone(result, "Should return None for no match")
|
||||
|
||||
def test_invalid_mapping_type(self):
|
||||
"""Test handling of invalid mapping type."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'invalid_type',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Should handle invalid type gracefully
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'test value'
|
||||
)
|
||||
self.assertEqual(result, 'test value',
|
||||
"Invalid mapping type should return original value")
|
||||
|
||||
def test_function_mapping_error(self):
|
||||
"""Test error handling in function mapping."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'function',
|
||||
'mapping_function': 'non_existent_method',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Should handle function errors gracefully
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'test value'
|
||||
)
|
||||
self.assertEqual(result, 'test value',
|
||||
"Function errors should return original value")
|
||||
|
||||
def test_computed_mapping_error(self):
|
||||
"""Test error handling in computed mapping."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'computed',
|
||||
'mapping_expression': 'invalid python syntax [',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Should handle expression errors gracefully
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'test value'
|
||||
)
|
||||
self.assertEqual(result, 'test value',
|
||||
"Expression errors should return original value")
|
||||
|
||||
def test_required_field_validation(self):
|
||||
"""Test required field handling."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'relation',
|
||||
'relation_model': 'res.country',
|
||||
'relation_field': 'code',
|
||||
'required': True,
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Should raise exception for required field with no match
|
||||
with self.assertRaises(Exception):
|
||||
self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'XX' # Non-existent country
|
||||
)
|
||||
|
||||
def test_complex_computed_mapping(self):
|
||||
"""Test complex computed field expressions."""
|
||||
# Update partner with more fields
|
||||
self.test_partner.write({
|
||||
'firstname': 'John',
|
||||
'lastname': 'Doe'
|
||||
})
|
||||
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'name')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'computed',
|
||||
'mapping_expression': 'record.firstname + " " + record.lastname',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test complex expression
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'placeholder'
|
||||
)
|
||||
self.assertEqual(result, 'John Doe',
|
||||
"Complex computed mapping should work")
|
||||
|
||||
def test_json_domain_parsing(self):
|
||||
"""Test JSON domain parsing in relation mapping."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'country_id')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'relation',
|
||||
'relation_model': 'res.country',
|
||||
'relation_field': 'code',
|
||||
'relation_domain': json.dumps([('active', '=', True)]),
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Test with valid JSON domain
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'US'
|
||||
)
|
||||
self.assertEqual(result, self.test_country.id,
|
||||
"JSON domain parsing should work")
|
||||
|
||||
def test_invalid_json_domain(self):
|
||||
"""Test handling of invalid JSON domain."""
|
||||
field_mapping = self.SyncField.create({
|
||||
'model_sync_id': self.sync_model.id,
|
||||
'field_id': self.env['ir.model.fields'].search([
|
||||
('model', '=', 'res.partner'),
|
||||
('name', '=', 'country_id')
|
||||
], limit=1).id,
|
||||
'mapping_type': 'relation',
|
||||
'relation_model': 'res.country',
|
||||
'relation_field': 'code',
|
||||
'relation_domain': 'invalid json {',
|
||||
'active': True
|
||||
})
|
||||
|
||||
# Should handle invalid JSON gracefully
|
||||
result = self.SyncManager._apply_field_mapping(
|
||||
field_mapping,
|
||||
self.test_partner,
|
||||
'US'
|
||||
)
|
||||
self.assertEqual(result, self.test_country.id,
|
||||
"Invalid JSON domain should not break mapping")
|
||||
|
||||
|
||||
class TestFieldMappingIntegration(common.TransactionCase):
|
||||
"""Integration tests for field mapping with sync process."""
|
||||
|
||||
def setUp(self):
|
||||
super(TestFieldMappingIntegration, self).setUp()
|
||||
# Setup for integration tests
|
||||
self.sync_manager = self.env['odoo.sync.manager'].create({
|
||||
'name': 'Test Sync Manager'
|
||||
})
|
||||
|
||||
def test_prepare_sync_data_with_mappings(self):
|
||||
"""Test _prepare_sync_data with field mappings."""
|
||||
# This would test the full sync data preparation
|
||||
# Implementation depends on sync queue structure
|
||||
pass
|
||||
|
||||
def test_end_to_end_sync_with_mappings(self):
|
||||
"""Test complete sync process with all mapping types."""
|
||||
# This would test the full sync flow
|
||||
# Implementation depends on sync instance setup
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run tests
|
||||
import unittest
|
||||
unittest.main()
|
||||
176
odoo_to_odoo_sync/tests/test_jsonrpc_protocol.py
Normal file
176
odoo_to_odoo_sync/tests/test_jsonrpc_protocol.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Tests for JSON-RPC protocol implementation."""
|
||||
|
||||
import json
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestJsonRpcProtocol(common.TransactionCase):
|
||||
"""Test JSON-RPC protocol implementation."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
self.sync_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test JSON-RPC Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key_12345',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
def test_jsonrpc_connection_creation(self):
|
||||
"""Test JSON-RPC connection creation."""
|
||||
# Test that JSON-RPC connection type is available
|
||||
self.assertEqual(self.sync_instance.connection_type, 'jsonrpc')
|
||||
|
||||
# Test connection methods exist
|
||||
self.assertTrue(hasattr(self.sync_instance, '_get_jsonrpc_connection'))
|
||||
self.assertTrue(hasattr(self.sync_instance, '_test_jsonrpc_connection'))
|
||||
|
||||
def test_jsonrpc_field_validation(self):
|
||||
"""Test JSON-RPC field validation."""
|
||||
# Test that JSON-RPC specific fields are properly validated
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'JSON-RPC Validation Test',
|
||||
'url': 'https://json-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'json_test_api_key',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
self.assertEqual(instance.connection_type, 'jsonrpc')
|
||||
self.assertTrue(instance.url.startswith('http'))
|
||||
|
||||
def test_jsonrpc_url_formatting(self):
|
||||
"""Test JSON-RPC URL formatting."""
|
||||
# Test URL formatting for JSON-RPC
|
||||
test_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'URL Test Instance',
|
||||
'url': 'https://json-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
# Verify URL is properly formatted
|
||||
self.assertTrue(test_instance.url.endswith('.com'))
|
||||
self.assertTrue(test_instance.url.startswith('https://'))
|
||||
|
||||
def test_jsonrpc_payload_structure(self):
|
||||
"""Test JSON-RPC payload structure."""
|
||||
# Test that JSON-RPC connection is properly configured
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Payload Test Instance',
|
||||
'url': 'https://payload-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'payload_test_api_key',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
# Verify instance configuration
|
||||
self.assertEqual(instance.connection_type, 'jsonrpc')
|
||||
self.assertTrue(instance.api_key)
|
||||
|
||||
def test_jsonrpc_instance_creation(self):
|
||||
"""Test JSON-RPC instance creation."""
|
||||
# Test creating multiple JSON-RPC instances
|
||||
instances = []
|
||||
for i in range(3):
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': f'JSON-RPC Instance {i}',
|
||||
'url': f'https://json-test-{i}.example.com',
|
||||
'database': f'test_db_{i}',
|
||||
'username': f'test_user_{i}',
|
||||
'api_key': f'test_api_key_{i}',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
instances.append(instance)
|
||||
self.assertTrue(instance.id)
|
||||
self.assertEqual(instance.connection_type, 'jsonrpc')
|
||||
|
||||
# Verify all instances were created
|
||||
self.assertEqual(len(instances), 3)
|
||||
|
||||
def test_jsonrpc_field_requirements(self):
|
||||
"""Test JSON-RPC field requirements."""
|
||||
# Test required fields for JSON-RPC
|
||||
with self.assertRaises(Exception):
|
||||
self.env['odoo.sync.instance'].create({
|
||||
'name': 'Incomplete JSON-RPC Instance',
|
||||
'url': 'https://incomplete.example.com',
|
||||
# Missing required fields
|
||||
})
|
||||
|
||||
def test_jsonrpc_protocol_selection(self):
|
||||
"""Test JSON-RPC protocol selection."""
|
||||
# Test that JSON-RPC can be selected as connection type
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Protocol Selection Test',
|
||||
'url': 'https://protocol-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'protocol_test_api_key',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
self.assertEqual(instance.connection_type, 'jsonrpc')
|
||||
self.assertIn('jsonrpc', ['jsonrpc', 'xmlrpc', 'odoorpc'])
|
||||
|
||||
def test_jsonrpc_client_structure(self):
|
||||
"""Test JSON-RPC client structure."""
|
||||
# Test that the client has the expected methods
|
||||
connection = self.sync_instance._get_jsonrpc_connection()
|
||||
self.assertTrue(hasattr(connection, 'execute_kw'))
|
||||
self.assertTrue(hasattr(connection, 'authenticate'))
|
||||
|
||||
def test_jsonrpc_protocol_selection(self):
|
||||
"""Test JSON-RPC protocol selection in UI."""
|
||||
# Test that JSON-RPC is available in selection
|
||||
connection_types = dict(self.env['odoo.sync.instance']._fields['connection_type'].selection)
|
||||
self.assertIn('jsonrpc', connection_types)
|
||||
self.assertEqual(connection_types['jsonrpc'], 'JSON-RPC')
|
||||
|
||||
def test_jsonrpc_url_construction(self):
|
||||
"""Test JSON-RPC URL construction."""
|
||||
expected_url = 'https://test.example.com/jsonrpc'
|
||||
# This would be constructed internally in _get_jsonrpc_connection
|
||||
self.assertTrue(expected_url.endswith('/jsonrpc'))
|
||||
|
||||
def test_jsonrpc_request_format(self):
|
||||
"""Test JSON-RPC request format."""
|
||||
# Test that requests are properly formatted
|
||||
expected_auth_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"service": "common",
|
||||
"method": "login",
|
||||
"args": [self.sync_instance.database, self.sync_instance.username, self.sync_instance.api_key]
|
||||
},
|
||||
"id": unittest.mock.ANY
|
||||
}
|
||||
|
||||
# The actual request would be constructed in _test_jsonrpc_connection
|
||||
self.assertIsNotNone(expected_auth_request)
|
||||
|
||||
def test_jsonrpc_error_handling(self):
|
||||
"""Test JSON-RPC error handling."""
|
||||
# Test various error scenarios
|
||||
test_cases = [
|
||||
('HTTPError', 'HTTP Error'),
|
||||
('URLError', 'Connection Error'),
|
||||
('JSONDecodeError', 'JSON-RPC Error')
|
||||
]
|
||||
|
||||
for error_type, expected_msg in test_cases:
|
||||
with self.subTest(error_type=error_type):
|
||||
# These would be tested in integration tests
|
||||
self.assertIsNotNone(expected_msg)
|
||||
190
odoo_to_odoo_sync/tests/test_odoorpc_protocol.py
Normal file
190
odoo_to_odoo_sync/tests/test_odoorpc_protocol.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Tests for OdooRPC protocol implementation."""
|
||||
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestOdooRpcProtocol(common.TransactionCase):
|
||||
"""Test OdooRPC protocol implementation."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
self.sync_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test OdooRPC Instance',
|
||||
'url': 'https://test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key_12345',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
def test_odoorpc_connection_creation(self):
|
||||
"""Test OdooRPC connection creation."""
|
||||
# Test that OdooRPC connection type is available
|
||||
self.assertEqual(self.sync_instance.connection_type, 'odoorpc')
|
||||
|
||||
# Test connection methods exist
|
||||
self.assertTrue(hasattr(self.sync_instance, '_get_odoorpc_connection'))
|
||||
self.assertTrue(hasattr(self.sync_instance, '_test_odoorpc_connection'))
|
||||
|
||||
def test_odoorpc_field_validation(self):
|
||||
"""Test OdooRPC field validation."""
|
||||
# Test that OdooRPC specific fields are properly validated
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'OdooRPC Validation Test',
|
||||
'url': 'https://odoorpc-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'odoorpc_test_api_key',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
self.assertEqual(instance.connection_type, 'odoorpc')
|
||||
self.assertTrue(instance.url.startswith('http'))
|
||||
|
||||
def test_odoorpc_url_formatting(self):
|
||||
"""Test OdooRPC URL formatting."""
|
||||
# Test URL formatting for OdooRPC
|
||||
test_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'URL Test Instance',
|
||||
'url': 'https://odoorpc-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
# Verify URL is properly formatted
|
||||
self.assertTrue(test_instance.url.endswith('.com'))
|
||||
self.assertTrue(test_instance.url.startswith('https://'))
|
||||
|
||||
def test_odoorpc_instance_creation(self):
|
||||
"""Test OdooRPC instance creation."""
|
||||
# Test creating multiple OdooRPC instances
|
||||
instances = []
|
||||
for i in range(3):
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': f'OdooRPC Instance {i}',
|
||||
'url': f'https://odoorpc-test-{i}.example.com',
|
||||
'database': f'test_db_{i}',
|
||||
'username': f'test_user_{i}',
|
||||
'api_key': f'test_api_key_{i}',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
instances.append(instance)
|
||||
self.assertTrue(instance.id)
|
||||
self.assertEqual(instance.connection_type, 'odoorpc')
|
||||
|
||||
# Verify all instances were created
|
||||
self.assertEqual(len(instances), 3)
|
||||
|
||||
def test_odoorpc_field_requirements(self):
|
||||
"""Test OdooRPC field requirements."""
|
||||
# Test required fields for OdooRPC
|
||||
with self.assertRaises(Exception):
|
||||
self.env['odoo.sync.instance'].create({
|
||||
'name': 'Incomplete OdooRPC Instance',
|
||||
'url': 'https://incomplete.example.com',
|
||||
# Missing required fields
|
||||
})
|
||||
|
||||
def test_odoorpc_protocol_selection(self):
|
||||
"""Test OdooRPC protocol selection."""
|
||||
# Test that OdooRPC can be selected as connection type
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Protocol Selection Test',
|
||||
'url': 'https://protocol-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'protocol_test_api_key',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
self.assertEqual(instance.connection_type, 'odoorpc')
|
||||
self.assertIn('odoorpc', ['jsonrpc', 'xmlrpc', 'odoorpc'])
|
||||
|
||||
def test_odoorpc_client_structure(self):
|
||||
"""Test OdooRPC client structure."""
|
||||
# Test that the client configuration is properly set
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Client Structure Test',
|
||||
'url': 'https://client-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'client_test_api_key',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
# Verify instance configuration
|
||||
self.assertEqual(instance.connection_type, 'odoorpc')
|
||||
self.assertTrue(instance.api_key)
|
||||
|
||||
def test_odoorpc_protocol_integration(self):
|
||||
"""Test OdooRPC protocol integration."""
|
||||
# Test complete OdooRPC protocol integration
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Integration Test Instance',
|
||||
'url': 'https://integration-test.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'integration_test_api_key',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
# Verify all components are properly configured
|
||||
self.assertTrue(instance.id)
|
||||
self.assertEqual(instance.connection_type, 'odoorpc')
|
||||
self.assertTrue(instance.url)
|
||||
self.assertTrue(instance.database)
|
||||
self.assertTrue(instance.username)
|
||||
self.assertTrue(instance.api_key)
|
||||
|
||||
def test_odoorpc_url_construction(self):
|
||||
"""Test OdooRPC URL construction."""
|
||||
# Test URL parsing for OdooRPC
|
||||
expected_url = 'https://test.example.com'
|
||||
self.assertEqual(self.sync_instance.url, expected_url)
|
||||
|
||||
def test_odoorpc_api_key_authentication(self):
|
||||
"""Test OdooRPC API key authentication."""
|
||||
# Test that API key is used for authentication
|
||||
self.assertTrue(self.sync_instance.use_api_key)
|
||||
self.assertEqual(self.sync_instance.api_key, 'test_api_key_12345')
|
||||
|
||||
def test_odoorpc_connection_methods(self):
|
||||
"""Test OdooRPC connection methods."""
|
||||
# Test that the connection has expected methods
|
||||
# This would be tested with actual odoorpc library
|
||||
self.assertTrue(hasattr(self.sync_instance, '_get_odoorpc_connection'))
|
||||
self.assertTrue(hasattr(self.sync_instance, '_test_odoorpc_connection'))
|
||||
|
||||
def test_odoorpc_error_handling(self):
|
||||
"""Test OdooRPC error handling."""
|
||||
# Test various error scenarios
|
||||
test_cases = [
|
||||
('ConnectionError', 'Connection Error'),
|
||||
('AuthenticationError', 'Authentication Error'),
|
||||
('TimeoutError', 'Timeout Error')
|
||||
]
|
||||
|
||||
for error_type, expected_msg in test_cases:
|
||||
with self.subTest(error_type=error_type):
|
||||
# These would be tested in integration tests
|
||||
self.assertIsNotNone(expected_msg)
|
||||
|
||||
def test_odoorpc_encryption_handling(self):
|
||||
"""Test API key encryption in OdooRPC."""
|
||||
# Test that API key is properly encrypted/decrypted
|
||||
original_key = self.sync_instance.api_key
|
||||
encrypted_key = self.sync_instance.encrypted_api_key
|
||||
|
||||
self.assertIsNotNone(encrypted_key)
|
||||
self.assertNotEqual(original_key, encrypted_key)
|
||||
|
||||
# Test decryption
|
||||
decrypted_key = self.sync_instance._decrypt_sensitive_data()
|
||||
self.assertEqual(decrypted_key, original_key)
|
||||
189
odoo_to_odoo_sync/tests/test_protocol_integration.py
Normal file
189
odoo_to_odoo_sync/tests/test_protocol_integration.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Integration tests for all synchronization protocols."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestProtocolIntegration(common.TransactionCase):
|
||||
"""Test integration of all synchronization protocols."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
# Create test instances for each protocol
|
||||
self.xmlrpc_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test XML-RPC Instance',
|
||||
'url': 'https://test1.example.com',
|
||||
'database': 'test_db1',
|
||||
'username': 'test_user1',
|
||||
'api_key': 'test_api_key_1',
|
||||
'connection_type': 'xmlrpc',
|
||||
})
|
||||
|
||||
self.jsonrpc_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test JSON-RPC Instance',
|
||||
'url': 'https://test2.example.com',
|
||||
'database': 'test_db2',
|
||||
'username': 'test_user2',
|
||||
'api_key': 'test_api_key_2',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
self.odoorpc_instance = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test OdooRPC Instance',
|
||||
'url': 'https://test3.example.com',
|
||||
'database': 'test_db3',
|
||||
'username': 'test_user3',
|
||||
'api_key': 'test_api_key_3',
|
||||
'connection_type': 'odoorpc',
|
||||
})
|
||||
|
||||
def test_protocol_selection(self):
|
||||
"""Test that all protocols are available for selection."""
|
||||
connection_types = dict(self.env['odoo.sync.instance']._fields['connection_type'].selection)
|
||||
|
||||
expected_protocols = ['xmlrpc', 'jsonrpc', 'odoorpc']
|
||||
for protocol in expected_protocols:
|
||||
self.assertIn(protocol, connection_types)
|
||||
|
||||
def test_connection_method_dispatch(self):
|
||||
"""Test that connection methods dispatch correctly."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test that appropriate connection method exists
|
||||
method_name = f'_get_{instance.connection_type}_connection'
|
||||
self.assertTrue(hasattr(instance, method_name))
|
||||
|
||||
test_method_name = f'_test_{instance.connection_type}_connection'
|
||||
self.assertTrue(hasattr(instance, test_method_name))
|
||||
|
||||
def test_api_key_encryption_across_protocols(self):
|
||||
"""Test API key encryption works consistently across all protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test encryption/decryption
|
||||
original_key = instance.api_key
|
||||
encrypted_key = instance.encrypted_api_key
|
||||
|
||||
self.assertIsNotNone(encrypted_key)
|
||||
self.assertNotEqual(original_key, encrypted_key)
|
||||
|
||||
decrypted_key = instance._decrypt_sensitive_data()
|
||||
self.assertEqual(decrypted_key, original_key)
|
||||
|
||||
def test_error_handling_consistency(self):
|
||||
"""Test error handling is consistent across protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test that error handling follows same pattern
|
||||
self.assertTrue(hasattr(instance, 'state'))
|
||||
self.assertTrue(hasattr(instance, 'error_message'))
|
||||
self.assertTrue(hasattr(instance, 'last_connection'))
|
||||
|
||||
def test_authentication_method_consistency(self):
|
||||
"""Test authentication methods are consistent across protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# All should use API key for authentication
|
||||
self.assertTrue(instance.use_api_key)
|
||||
|
||||
# Test that username and API key are used
|
||||
self.assertIsNotNone(instance.username)
|
||||
self.assertIsNotNone(instance.api_key)
|
||||
|
||||
def test_connection_timeout_configuration(self):
|
||||
"""Test connection timeout configuration works for all protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test timeout configuration
|
||||
self.assertTrue(hasattr(instance, 'connection_timeout'))
|
||||
self.assertIsInstance(instance.connection_timeout, int)
|
||||
self.assertGreater(instance.connection_timeout, 0)
|
||||
|
||||
def test_url_protocol_handling(self):
|
||||
"""Test URL protocol handling for different connection types."""
|
||||
test_cases = [
|
||||
('xmlrpc', 'http://test.com'),
|
||||
('xmlrpc', 'https://test.com'),
|
||||
('jsonrpc', 'http://test.com'),
|
||||
('jsonrpc', 'https://test.com'),
|
||||
('odoorpc', 'http://test.com'),
|
||||
('odoorpc', 'https://test.com'),
|
||||
]
|
||||
|
||||
for protocol, url in test_cases:
|
||||
with self.subTest(protocol=protocol, url=url):
|
||||
instance = self.env['odoo.sync.instance'].create({
|
||||
'name': f'Test {protocol.upper()}',
|
||||
'url': url,
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key',
|
||||
'connection_type': protocol,
|
||||
})
|
||||
|
||||
# Test URL is stored correctly
|
||||
self.assertEqual(instance.url, url)
|
||||
|
||||
def test_state_management_consistency(self):
|
||||
"""Test state management is consistent across protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test state transitions
|
||||
self.assertIn(instance.state, ['draft', 'testing', 'connected', 'error'])
|
||||
|
||||
def test_error_message_format_consistency(self):
|
||||
"""Test error message format is consistent across protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test error message is properly formatted
|
||||
if instance.error_message:
|
||||
self.assertIsInstance(instance.error_message, str)
|
||||
self.assertGreater(len(instance.error_message), 0)
|
||||
|
||||
def test_connection_testing_workflow(self):
|
||||
"""Test connection testing workflow for all protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test test_connection method exists
|
||||
self.assertTrue(hasattr(instance, 'test_connection'))
|
||||
|
||||
# Test method signature
|
||||
self.assertTrue(callable(getattr(instance, 'test_connection')))
|
||||
|
||||
def test_field_mapping_consistency(self):
|
||||
"""Test field mapping consistency across protocols."""
|
||||
instances = [self.xmlrpc_instance, self.jsonrpc_instance, self.odoorpc_instance]
|
||||
|
||||
for instance in instances:
|
||||
with self.subTest(protocol=instance.connection_type):
|
||||
# Test common fields exist
|
||||
expected_fields = [
|
||||
'name', 'url', 'database', 'username', 'api_key',
|
||||
'connection_type', 'state', 'last_connection', 'error_message'
|
||||
]
|
||||
|
||||
for field in expected_fields:
|
||||
self.assertTrue(hasattr(instance, field))
|
||||
201
odoo_to_odoo_sync/tests/test_sync_workflow.py
Normal file
201
odoo_to_odoo_sync/tests/test_sync_workflow.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestBidirectionalSyncWorkflow(common.TransactionCase):
|
||||
"""Test the complete bidirectional sync workflow"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestBidirectionalSyncWorkflow, self).setUp()
|
||||
|
||||
# Create test data
|
||||
self.test_project = self.env['project.project'].create({
|
||||
'name': 'Test Sync Project',
|
||||
'description': 'Test project for sync validation',
|
||||
})
|
||||
|
||||
self.test_client = self.env['odoo.sync.instance'].create({
|
||||
'name': 'Test Client',
|
||||
'url': 'https://test-client.example.com',
|
||||
'database': 'test_db',
|
||||
'username': 'test_user',
|
||||
'api_key': 'test_api_key_123',
|
||||
'connection_type': 'jsonrpc',
|
||||
})
|
||||
|
||||
def test_project_assignment_workflow(self):
|
||||
"""Test the complete project assignment workflow"""
|
||||
|
||||
# Step 1: Create assign project wizard
|
||||
assign_wizard = self.env['odoo.to.bemade.assign.project.wizard'].create({
|
||||
'project_id': self.test_project.id,
|
||||
'client_instance_id': self.test_client.id,
|
||||
})
|
||||
|
||||
# Step 2: Generate keys
|
||||
assign_wizard._onchange_generate_keys()
|
||||
|
||||
# Verify keys are generated
|
||||
self.assertTrue(assign_wizard.project_key)
|
||||
self.assertTrue(assign_wizard.client_api_token)
|
||||
self.assertEqual(len(assign_wizard.project_key), 8)
|
||||
self.assertEqual(len(assign_wizard.client_api_token), 36)
|
||||
|
||||
# Step 3: Assign project
|
||||
result = assign_wizard.action_assign_project()
|
||||
|
||||
# Verify sync project is created
|
||||
sync_project = self.env['sync.project'].search([
|
||||
('project_id', '=', self.test_project.id),
|
||||
('client_instance_id', '=', self.test_client.id),
|
||||
])
|
||||
self.assertTrue(sync_project)
|
||||
self.assertEqual(sync_project.state, 'draft')
|
||||
self.assertTrue(sync_project.is_client_project)
|
||||
self.assertEqual(sync_project.client_key, assign_wizard.project_key)
|
||||
|
||||
# Verify project flags are set
|
||||
self.assertTrue(self.test_project.is_bemade_project)
|
||||
self.assertTrue(self.test_project.bemade_sync_enabled)
|
||||
self.assertEqual(self.test_project.bemade_project_key, assign_wizard.project_key)
|
||||
|
||||
# Verify sync models are created
|
||||
project_model = self.env['sync.model'].search([
|
||||
('sync_project_id', '=', sync_project.id),
|
||||
('model_name', '=', 'project.project'),
|
||||
])
|
||||
self.assertTrue(project_model)
|
||||
|
||||
task_model = self.env['sync.model'].search([
|
||||
('sync_project_id', '=', sync_project.id),
|
||||
('model_name', '=', 'project.task'),
|
||||
])
|
||||
self.assertTrue(task_model)
|
||||
|
||||
def test_project_receiving_workflow(self):
|
||||
"""Test the project receiving workflow from client perspective"""
|
||||
|
||||
# Create test sync project first
|
||||
sync_project = self.env['sync.project'].create({
|
||||
'name': 'Test Client Sync Project',
|
||||
'project_id': self.test_project.id,
|
||||
'client_instance_id': self.test_client.id,
|
||||
'state': 'draft',
|
||||
'is_client_project': True,
|
||||
'client_key': 'TEST123',
|
||||
'client_api_token': 'test-client-token-123',
|
||||
'remote_url': 'https://test-client.example.com',
|
||||
'remote_database': 'test_db',
|
||||
'remote_username': 'test_user',
|
||||
'remote_api_key': 'test_api_key_123',
|
||||
'protocol': 'jsonrpc',
|
||||
})
|
||||
|
||||
# Create receive wizard
|
||||
receive_wizard = self.env['odoo.to.bemade.customer.receive.wizard'].create({
|
||||
'project_id': self.test_project.id,
|
||||
'bemade_url': 'https://test-bemade.example.com',
|
||||
'bemade_database': 'bemade_db',
|
||||
'bemade_username': 'bemade_user',
|
||||
'bemade_api_key': 'bemade-api-key-123',
|
||||
'bemade_project_key': 'TEST123',
|
||||
'protocol': 'jsonrpc',
|
||||
})
|
||||
|
||||
# Test project sync
|
||||
self.test_project.write({
|
||||
'is_bemade_project': True,
|
||||
'bemade_project_key': 'TEST123',
|
||||
'bemade_sync_enabled': True,
|
||||
})
|
||||
|
||||
# Verify project has bemade flags
|
||||
self.assertTrue(self.test_project.is_bemade_project)
|
||||
self.assertEqual(self.test_project.bemade_project_key, 'TEST123')
|
||||
|
||||
def test_api_token_exchange(self):
|
||||
"""Test the API token exchange mechanism"""
|
||||
|
||||
# Create token exchange wizard
|
||||
token_wizard = self.env['api.token.exchange.wizard'].create({
|
||||
'client_instance_id': self.test_client.id,
|
||||
'client_url': self.test_client.url,
|
||||
'client_database': self.test_client.database,
|
||||
'client_username': self.test_client.username,
|
||||
'client_api_key': self.test_client.api_key,
|
||||
'project_info': 'Test Project Info',
|
||||
})
|
||||
|
||||
# Test token exchange configuration
|
||||
self.assertTrue(token_wizard.client_api_key)
|
||||
self.assertEqual(token_wizard.client_api_key, 'test_api_key_123')
|
||||
|
||||
def test_bidirectional_sync_validation(self):
|
||||
"""Test complete bidirectional sync validation"""
|
||||
|
||||
# Step 1: Server assigns project to client
|
||||
assign_wizard = self.env['odoo.to.bemade.assign.project.wizard'].create({
|
||||
'project_id': self.test_project.id,
|
||||
'client_instance_id': self.test_client.id,
|
||||
})
|
||||
assign_wizard._onchange_generate_keys()
|
||||
assign_wizard.action_assign_project()
|
||||
|
||||
# Step 2: Client receives project
|
||||
receive_wizard = self.env['odoo.to.bemade.customer.receive.wizard'].create({
|
||||
'project_id': self.test_project.id,
|
||||
'bemade_url': 'https://bemade.example.com',
|
||||
'bemade_database': 'bemade_db',
|
||||
'bemade_username': 'bemade_user',
|
||||
'bemade_api_key': 'bemade_api_key_123',
|
||||
'bemade_project_key': assign_wizard.project_key,
|
||||
'protocol': 'jsonrpc',
|
||||
})
|
||||
|
||||
# Verify project is properly configured for bidirectional sync
|
||||
sync_project = self.env['sync.project'].search([
|
||||
('client_key', '=', assign_wizard.project_key),
|
||||
])
|
||||
self.assertTrue(sync_project)
|
||||
|
||||
# Verify both server and client have matching configuration
|
||||
self.assertEqual(sync_project.client_key, assign_wizard.project_key)
|
||||
self.assertEqual(sync_project.client_api_token, assign_wizard.client_api_token)
|
||||
|
||||
def test_error_handling(self):
|
||||
"""Test error handling in sync workflow"""
|
||||
|
||||
# Test invalid project assignment
|
||||
with self.assertRaises(UserError):
|
||||
assign_wizard = self.env['odoo.to.bemade.assign.project.wizard'].create({
|
||||
'project_id': False, # Invalid project
|
||||
'client_instance_id': self.test_client.id,
|
||||
})
|
||||
assign_wizard.action_assign_project()
|
||||
|
||||
def test_security_validation(self):
|
||||
"""Test security access for sync operations"""
|
||||
|
||||
# Test user access to sync projects
|
||||
user = self.env.ref('base.group_user')
|
||||
sync_project = self.env['sync.project'].create({
|
||||
'name': 'Security Test Project',
|
||||
'project_id': self.test_project.id,
|
||||
'client_instance_id': self.test_client.id,
|
||||
'state': 'draft',
|
||||
'is_client_project': True,
|
||||
'client_key': 'SECURITY123',
|
||||
'client_api_token': 'security-token-123',
|
||||
})
|
||||
|
||||
# Verify user has read access
|
||||
self.assertTrue(sync_project.with_user(user).read())
|
||||
|
||||
# Verify admin has full access
|
||||
admin = self.env.ref('base.group_system')
|
||||
self.assertTrue(sync_project.with_user(admin).write({'name': 'Updated Security Test'}))
|
||||
5
odoo_to_odoo_sync/utils/__init__.py
Normal file
5
odoo_to_odoo_sync/utils/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
from . import encryption
|
||||
121
odoo_to_odoo_sync/utils/encryption.py
Normal file
121
odoo_to_odoo_sync/utils/encryption.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
"""Encryption utilities for sensitive data.
|
||||
|
||||
This module provides encryption and decryption functions for sensitive data
|
||||
such as passwords, API keys, and other credentials used in the synchronization
|
||||
process. It uses Fernet symmetric encryption from the cryptography library.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_encryption_key(env):
|
||||
"""Get or generate the encryption key for the instance.
|
||||
|
||||
The key is stored in ir.config_parameter. If it doesn't exist,
|
||||
a new one is generated and stored.
|
||||
|
||||
Args:
|
||||
env: Odoo environment
|
||||
|
||||
Returns:
|
||||
bytes: The encryption key
|
||||
"""
|
||||
# Try to get the key from ir.config_parameter
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
key = ICP.get_param('odoo_to_odoo_sync.encryption_key')
|
||||
|
||||
if not key:
|
||||
# Generate a new key if none exists
|
||||
_logger.info("Generating new encryption key for odoo_to_odoo_sync")
|
||||
# Generate a salt
|
||||
salt = os.urandom(16)
|
||||
# Store the salt
|
||||
ICP.set_param('odoo_to_odoo_sync.encryption_salt', base64.b64encode(salt).decode('utf-8'))
|
||||
|
||||
# Use PBKDF2 to derive a key from the database UUID (which is unique per instance)
|
||||
db_uuid = ICP.get_param('database.uuid', 'fallback-uuid-for-encryption')
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
)
|
||||
# Ensure db_uuid is a string before encoding
|
||||
if not isinstance(db_uuid, str):
|
||||
db_uuid = str(db_uuid)
|
||||
key = base64.b64encode(kdf.derive(db_uuid.encode()))
|
||||
|
||||
# Store the key
|
||||
ICP.set_param('odoo_to_odoo_sync.encryption_key', key.decode('utf-8'))
|
||||
else:
|
||||
# Convert stored key from string to bytes
|
||||
# Ensure key is a string before encoding
|
||||
if not isinstance(key, str):
|
||||
key = str(key)
|
||||
key = key.encode('utf-8')
|
||||
|
||||
return key
|
||||
|
||||
def encrypt_value(env, value):
|
||||
"""Encrypt a sensitive value.
|
||||
|
||||
Args:
|
||||
env: Odoo environment
|
||||
value (str): The value to encrypt
|
||||
|
||||
Returns:
|
||||
str: The encrypted value as a base64 string
|
||||
"""
|
||||
if not value:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = _get_encryption_key(env)
|
||||
f = Fernet(key)
|
||||
# Convert value to string if it's not already
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
encrypted = f.encrypt(value.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
except Exception as e:
|
||||
_logger.error("Encryption error: %s", str(e))
|
||||
# Return the original value if encryption fails
|
||||
# This is not ideal but prevents data loss
|
||||
return value
|
||||
|
||||
def decrypt_value(env, encrypted_value):
|
||||
"""Decrypt an encrypted value.
|
||||
|
||||
Args:
|
||||
env: Odoo environment
|
||||
encrypted_value (str): The encrypted value as a base64 string
|
||||
|
||||
Returns:
|
||||
str: The decrypted value
|
||||
"""
|
||||
if not encrypted_value:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = _get_encryption_key(env)
|
||||
f = Fernet(key)
|
||||
# Convert encrypted_value to string if it's not already
|
||||
if not isinstance(encrypted_value, str):
|
||||
encrypted_value = str(encrypted_value)
|
||||
decrypted = f.decrypt(base64.b64decode(encrypted_value.encode('utf-8')))
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception as e:
|
||||
_logger.error("Decryption error: %s", str(e))
|
||||
# Return the encrypted value if decryption fails
|
||||
# This is not ideal but prevents data loss
|
||||
return encrypted_value
|
||||
|
|
@ -42,4 +42,17 @@
|
|||
parent="menu_sync_monitoring"
|
||||
action="action_sync_log"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Conflict menu -->
|
||||
<menuitem id="menu_odoo_sync_conflict"
|
||||
name="Conflicts"
|
||||
parent="menu_sync_root"
|
||||
action="action_odoo_sync_conflict"
|
||||
sequence="30"/>
|
||||
|
||||
|
||||
<!-- Sync Project Menu -->
|
||||
<menuitem id="menu_sync_project_root" name="Sync Projects" parent="menu_sync_root" sequence="15"/>
|
||||
<menuitem id="menu_sync_project" name="Projects" parent="menu_sync_project_root" action="action_sync_project" sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
|
|
|
|||
175
odoo_to_odoo_sync/views/sync_conflict_views.xml
Normal file
175
odoo_to_odoo_sync/views/sync_conflict_views.xml
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Conflict Tree View -->
|
||||
<record id="view_odoo_sync_conflict_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.conflict.list</field>
|
||||
<field name="model">odoo.sync.conflict</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Synchronization Conflicts" decoration-danger="state=='pending'" decoration-success="state=='resolved'" decoration-muted="state=='cancelled'">
|
||||
<field name="name"/>
|
||||
<field name="model_name"/>
|
||||
<field name="record_id"/>
|
||||
<field name="create_date"/>
|
||||
<field name="state"/>
|
||||
<field name="resolution"/>
|
||||
<field name="resolved_by"/>
|
||||
<field name="resolved_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Conflict Form View -->
|
||||
<record id="view_odoo_sync_conflict_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.conflict.form</field>
|
||||
<field name="model">odoo.sync.conflict</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Synchronization Conflict">
|
||||
<header>
|
||||
<button name="action_resolve_local" string="Keep Local" type="object" class="oe_highlight" invisible="state != 'pending'"/>
|
||||
<button name="action_resolve_remote" string="Keep Remote" type="object" class="oe_highlight" invisible="state != 'pending'"/>
|
||||
<button name="action_resolve_custom" string="Custom Resolution" type="object" invisible="state != 'pending'"/>
|
||||
<button name="action_cancel" string="Cancel" type="object" invisible="state != 'pending'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,resolved,cancelled"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="model_name"/>
|
||||
<field name="record_id"/>
|
||||
<field name="create_date"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="resolution" invisible="state == 'pending'"/>
|
||||
<field name="resolved_by" invisible="state == 'pending'"/>
|
||||
<field name="resolved_date" invisible="state == 'pending'"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Differences">
|
||||
<field name="diff_html" widget="html"/>
|
||||
</page>
|
||||
<page string="Local Data">
|
||||
<field name="local_data"/>
|
||||
</page>
|
||||
<page string="Remote Data">
|
||||
<field name="remote_data"/>
|
||||
</page>
|
||||
<page string="Custom Data" invisible="resolution != 'custom'">
|
||||
<field name="custom_data"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Conflict Search View -->
|
||||
<record id="view_odoo_sync_conflict_search" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.conflict.search</field>
|
||||
<field name="model">odoo.sync.conflict</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Conflicts">
|
||||
<field name="name"/>
|
||||
<field name="model_name"/>
|
||||
<field name="record_id"/>
|
||||
<field name="resolved_by"/>
|
||||
<separator/>
|
||||
<filter string="Pending" name="pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter string="Resolved" name="resolved" domain="[('state', '=', 'resolved')]"/>
|
||||
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="State" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_name'}"/>
|
||||
<filter string="Resolution" name="group_by_resolution" context="{'group_by': 'resolution'}"/>
|
||||
<filter string="Resolved By" name="group_by_resolved_by" context="{'group_by': 'resolved_by'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Conflict Resolution Wizard Form -->
|
||||
<record id="view_odoo_sync_conflict_wizard_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.conflict.wizard.form</field>
|
||||
<field name="model">odoo.sync.conflict.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Custom Conflict Resolution">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="record_name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="conflict_id" invisible="1"/>
|
||||
<field name="model_name"/>
|
||||
<field name="record_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<p><strong>Résolution de conflit</strong> - Sélectionnez la source pour chaque champ en conflit:</p>
|
||||
<ul>
|
||||
<li><strong>Source</strong>: Conserver la valeur locale</li>
|
||||
<li><strong>Destination</strong>: Utiliser la valeur distante</li>
|
||||
<li><strong>Personnalisé</strong>: Définir une valeur personnalisée</li>
|
||||
<li><strong>Ignorer</strong>: Ne pas synchroniser ce champ</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<notebook>
|
||||
<page string="Résolution par champ" name="field_resolution">
|
||||
<field name="resolution_fields">
|
||||
<list editable="bottom" decoration-info="source=='local'" decoration-success="source=='remote'" decoration-warning="source=='custom'" decoration-muted="source=='ignore'" create="false" delete="false">
|
||||
<field name="field_name" string="Champ"/>
|
||||
<field name="local_write_date" string="Date locale" widget="datetime" optional="show"/>
|
||||
<field name="local_value" string="Valeur locale" widget="text"/>
|
||||
<field name="remote_write_date" string="Date distante" widget="datetime" optional="show"/>
|
||||
<field name="remote_value" string="Valeur distante" widget="text"/>
|
||||
<field name="source" string="Source" widget="radio" options="{'horizontal': true}"/>
|
||||
<field name="custom_value" string="Valeur personnalisée" required="source == 'custom'" readonly="source != 'custom'" widget="text"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Aperçu des différences" name="diff_preview">
|
||||
<field name="diff_html" widget="html"/>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
<group string="Actions rapides" col="4">
|
||||
<button name="action_select_all_local" string="Tout local" type="object" class="btn btn-secondary" help="Sélectionner toutes les valeurs locales"/>
|
||||
<button name="action_select_all_remote" string="Tout distant" type="object" class="btn btn-secondary" help="Sélectionner toutes les valeurs distantes"/>
|
||||
<button name="action_select_newest" string="Plus récent" type="object" class="btn btn-secondary" help="Sélectionner les valeurs les plus récentes selon les dates de modification"/>
|
||||
<button name="action_reset_selections" string="Réinitialiser" type="object" class="btn btn-secondary" help="Réinitialiser toutes les sélections"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_apply_resolution" string="Appliquer la résolution" type="object" class="btn-primary"/>
|
||||
<button string="Annuler" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Conflict Action Window -->
|
||||
<record id="action_odoo_sync_conflict" model="ir.actions.act_window">
|
||||
<field name="name">Synchronization Conflicts</field>
|
||||
<field name="res_model">odoo.sync.conflict</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No synchronization conflicts found!
|
||||
</p>
|
||||
<p>
|
||||
Conflicts occur when changes are made to the same record on both source and destination instances.
|
||||
When a conflict is detected, it will appear here for manual resolution.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
decoration-warning="state == 'testing'"
|
||||
decoration-danger="state == 'error'"/>
|
||||
<field name="last_connection"/>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
|
@ -43,10 +43,11 @@
|
|||
<group>
|
||||
<field name="url" placeholder="https://example.odoo.com"/>
|
||||
<field name="database"/>
|
||||
<field name="connection_type" widget="radio"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username"/>
|
||||
<field name="password" password="True"/>
|
||||
<field name="api_key" password="True" required="1"/>
|
||||
<field name="last_connection" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
|
|
|||
51
odoo_to_odoo_sync/views/sync_manager_views.xml
Normal file
51
odoo_to_odoo_sync/views/sync_manager_views.xml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="view_sync_manager_form" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.manager.form</field>
|
||||
<field name="model">odoo.sync.manager</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Sync Manager Configuration"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="conflict_resolution_strategy"/>
|
||||
</group>
|
||||
</group>
|
||||
<button name="set_conflict_strategy" type="object" string="Apply Conflict Strategy" class="btn-primary"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- List View -->
|
||||
<record id="view_sync_manager_list" model="ir.ui.view">
|
||||
<field name="name">odoo.sync.manager.list</field>
|
||||
<field name="model">odoo.sync.manager</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_sync_manager" model="ir.actions.act_window">
|
||||
<field name="name">Sync Manager</field>
|
||||
<field name="res_model">odoo.sync.manager</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configure synchronization settings
|
||||
</p>
|
||||
<p>
|
||||
Set up conflict resolution strategies and other synchronization parameters.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<field name="instance_id"/>
|
||||
<field name="target_model"/>
|
||||
<field name="priority"/>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button"/>
|
||||
</button>
|
||||
<button name="action_auto_sync_fields" type="object" class="oe_stat_button" icon="fa-magic" string="Auto Sync Fields"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
|
|
@ -40,11 +41,45 @@
|
|||
<page string="Champs synchronisés">
|
||||
<field name="field_ids" context="{'default_model_sync_id': id}">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="field_id" options="{'no_create': True}"/>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="mapping_type"/>
|
||||
<field name="required"/>
|
||||
<field name="sync_default" placeholder="Valeur par défaut si vide"/>
|
||||
<field name="conflict_strategy" widget="radio"/>
|
||||
</list>
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="field_id" options="{'no_create': True}"/>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="source_field" placeholder="Ex: name"/>
|
||||
<field name="target_field" placeholder="Ex: display_name"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="mapping_type"/>
|
||||
<field name="required"/>
|
||||
<field name="is_identifier"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Configuration avancée" attrs="{'invisible': [('mapping_type', '=', 'direct')]}">
|
||||
<group string="Mapping Fonction" attrs="{'invisible': [('mapping_type', '!=', 'function')]}">
|
||||
<field name="mapping_function" placeholder="Ex: res.partner.get_display_name"/>
|
||||
</group>
|
||||
<group string="Mapping Calculé" attrs="{'invisible': [('mapping_type', '!=', 'computed')]}">
|
||||
<field name="mapping_expression" placeholder="Ex: record.name.upper()"/>
|
||||
</group>
|
||||
<group string="Mapping Relation" attrs="{'invisible': [('mapping_type', '!=', 'relation')]}">
|
||||
<field name="relation_model" placeholder="Ex: res.partner"/>
|
||||
<field name="relation_field" placeholder="Ex: email"/>
|
||||
<field name="relation_domain" placeholder='[[("active", "=", True)]]'/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
|
|
|
|||
82
odoo_to_odoo_sync/views/sync_project_views.xml
Normal file
82
odoo_to_odoo_sync/views/sync_project_views.xml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_sync_project_tree" model="ir.ui.view">
|
||||
<field name="name">sync.project.tree</field>
|
||||
<field name="model">sync.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sync Projects">
|
||||
<field name="name"/>
|
||||
<field name="is_sync_project"/>
|
||||
<field name="is_client_project"/>
|
||||
<field name="state"/>
|
||||
<field name="last_sync_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sync_project_form" model="ir.ui.view">
|
||||
<field name="name">sync.project.form</field>
|
||||
<field name="model">sync.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sync Project">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_test_connection" type="object" string="Test Connection" class="oe_stat_button" icon="fa-plug"/>
|
||||
<button name="action_generate_api_token" type="object" string="Generate Token" class="oe_stat_button" icon="fa-key" invisible="not is_client_project"/>
|
||||
<button name="action_revoke_api_token" type="object" string="Revoke Token" class="oe_stat_button" icon="fa-ban" invisible="not is_client_project"/>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="is_sync_project"/>
|
||||
<field name="is_client_project"/>
|
||||
<field name="client_key"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="state"/>
|
||||
<field name="last_sync_date"/>
|
||||
<field name="sync_instance_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Connection Details" invisible="not is_sync_project">
|
||||
<group>
|
||||
<group>
|
||||
<field name="remote_url"/>
|
||||
<field name="remote_database"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="remote_username"/>
|
||||
<field name="api_token" password="True"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Sync Models">
|
||||
<field name="sync_model_ids">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="model_id"/>
|
||||
<field name="target_model"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_project" model="ir.actions.act_window">
|
||||
<field name="name">Sync Projects</field>
|
||||
<field name="res_model">sync.project</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new sync project to manage synchronization between Odoo instances.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
</group>
|
||||
<notebook>
|
||||
<page string="Données">
|
||||
<field name="data" widget="ace" options="{'mode': 'json'}"/>
|
||||
<field name="data" widget="code_editor" options="{'language': 'json'}"/>
|
||||
</page>
|
||||
<page string="Logs">
|
||||
<field name="log_ids" readonly="1">
|
||||
|
|
|
|||
4
odoo_to_odoo_sync/wizards/__init__.py
Normal file
4
odoo_to_odoo_sync/wizards/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import auto_sync_wizard
|
||||
from . import sync_project_config_wizard
|
||||
132
odoo_to_odoo_sync/wizards/auto_sync_wizard.py
Normal file
132
odoo_to_odoo_sync/wizards/auto_sync_wizard.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AutoSyncWizard(models.TransientModel):
|
||||
"""Wizard for selecting auto-sync field modes."""
|
||||
|
||||
_name = 'odoo.sync.auto.sync.wizard'
|
||||
_description = 'Auto Sync Fields Configuration'
|
||||
|
||||
sync_model_id = fields.Many2one(
|
||||
comodel_name='odoo.sync.model',
|
||||
string='Sync Model',
|
||||
required=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
|
||||
mode = fields.Selection([
|
||||
('full', 'Full'),
|
||||
('required', 'Required'),
|
||||
], string='Field Selection Mode', default='full', required=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Set default values from context."""
|
||||
res = super(AutoSyncWizard, self).default_get(fields_list)
|
||||
if self.env.context.get('active_id'):
|
||||
res['sync_model_id'] = self.env.context['active_id']
|
||||
return res
|
||||
|
||||
def action_auto_sync_fields(self):
|
||||
"""Execute auto-sync based on selected mode."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.sync_model_id.model_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Error',
|
||||
'message': 'Error adding fields',
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
sync_model = self.sync_model_id
|
||||
model_fields = self.env['ir.model.fields'].search([
|
||||
('model_id', '=', sync_model.model_id.id),
|
||||
('store', '=', True),
|
||||
('name', 'not in', ['id', '__last_update']),
|
||||
])
|
||||
|
||||
existing_fields = sync_model.field_ids.mapped('field_id.name')
|
||||
fields_added = 0
|
||||
|
||||
# Define field sets based on mode
|
||||
if self.mode == 'full':
|
||||
# Include ALL usable fields
|
||||
fields_to_add = model_fields
|
||||
|
||||
elif self.mode == 'required':
|
||||
# Only REQUIRED fields
|
||||
fields_to_add = model_fields.filtered(lambda f: f.required)
|
||||
|
||||
# Process fields
|
||||
exclude_fields = [
|
||||
'create_date', 'write_date', 'create_uid', 'write_uid',
|
||||
'__last_update', 'id'
|
||||
]
|
||||
|
||||
for field in fields_to_add:
|
||||
if field.name in exclude_fields or field.name in existing_fields:
|
||||
continue
|
||||
|
||||
# Skip binary fields to avoid sync issues
|
||||
if field.ttype in ['binary', 'many2many']:
|
||||
continue
|
||||
|
||||
is_required = field.required or field.name in ['active', 'name']
|
||||
|
||||
sequence = 10 if not field.required else 5
|
||||
self.env['odoo.sync.model.field'].create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': field.id,
|
||||
'required': field.required,
|
||||
'sequence': sequence,
|
||||
'mapping_type': 'direct',
|
||||
})
|
||||
fields_added += 1
|
||||
|
||||
# Always ensure basic audit fields
|
||||
basic_fields = ['create_date', 'write_date', 'create_uid', 'write_uid']
|
||||
for field_name in basic_fields:
|
||||
field = self.env['ir.model.fields'].search([
|
||||
('model_id', '=', sync_model.model_id.id),
|
||||
('name', '=', field_name)
|
||||
])
|
||||
if field and field.name not in existing_fields:
|
||||
self.env['odoo.sync.model.field'].create({
|
||||
'model_sync_id': sync_model.id,
|
||||
'field_id': field.id,
|
||||
'required': True,
|
||||
'sequence': 1,
|
||||
'mapping_type': 'direct',
|
||||
})
|
||||
fields_added += 1
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Success',
|
||||
'message': '%d fields added successfully' % fields_added,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Error',
|
||||
'message': str(e),
|
||||
'type': 'danger',
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
||||
38
odoo_to_odoo_sync/wizards/auto_sync_wizard_view.xml
Normal file
38
odoo_to_odoo_sync/wizards/auto_sync_wizard_view.xml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_auto_sync_wizard_form" model="ir.ui.view">
|
||||
<field name="name">Auto Sync Fields Configuration</field>
|
||||
<field name="model">odoo.sync.auto.sync.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Auto Sync Fields">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="sync_model_id" invisible="1"/>
|
||||
<group string="Field Selection Mode">
|
||||
<field name="mode" widget="radio"/>
|
||||
</group>
|
||||
<group string="Mode Descriptions">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Full:</strong> All usable fields will be added<br/>
|
||||
<strong>Required:</strong> Only required fields will be added
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_auto_sync_fields" string="Apply" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_auto_sync_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Auto Sync Fields</field>
|
||||
<field name="res_model">odoo.sync.auto.sync.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_odoo_sync_model"/>
|
||||
<field name="binding_type">action</field>
|
||||
</record>
|
||||
</odoo>
|
||||
65
odoo_to_odoo_sync/wizards/sync_project_config_wizard.py
Normal file
65
odoo_to_odoo_sync/wizards/sync_project_config_wizard.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Copyright 2025 Bemade
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
class SyncProjectConfigWizard(models.TransientModel):
|
||||
_name = 'sync.project.config.wizard'
|
||||
_description = 'Sync Project Configuration Wizard'
|
||||
|
||||
name = fields.Char(string='Instance Name', required=True)
|
||||
url = fields.Char(string='Server URL', required=True)
|
||||
database = fields.Char(string='Database', required=True)
|
||||
username = fields.Char(string='Username', required=True)
|
||||
api_token = fields.Char(string='API Token', required=True)
|
||||
protocol = fields.Selection([
|
||||
('xmlrpc', 'XML-RPC'),
|
||||
('jsonrpc', 'JSON-RPC'),
|
||||
('odoorpc', 'OdooRPC'),
|
||||
], string='Protocol', default='odoorpc', required=True)
|
||||
|
||||
project_id = fields.Many2one('sync.project', string='Project')
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
active_id = self.env.context.get('active_id')
|
||||
if active_id:
|
||||
project = self.env['sync.project'].browse(active_id)
|
||||
if project.exists():
|
||||
defaults['project_id'] = project.id
|
||||
defaults['name'] = f"{project.name} - Sync Instance"
|
||||
return defaults
|
||||
|
||||
def action_configure_sync(self):
|
||||
"""Configure the sync instance and project."""
|
||||
self.ensure_one()
|
||||
|
||||
# Create or update sync instance
|
||||
instance = self.env['sync.instance'].create({
|
||||
'name': self.name,
|
||||
'url': self.url,
|
||||
'database': self.database,
|
||||
'username': self.username,
|
||||
'api_key': self.api_token,
|
||||
'protocol': self.protocol,
|
||||
})
|
||||
|
||||
# Update project with sync instance
|
||||
if self.project_id:
|
||||
self.project_id.sync_instance_id = instance.id
|
||||
self.project_id.remote_url = self.url
|
||||
self.project_id.remote_database = self.database
|
||||
self.project_id.remote_username = self.username
|
||||
self.project_id.state = 'configured'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Success'),
|
||||
'message': _('Sync configuration completed successfully.'),
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_sync_project_config_wizard" model="ir.ui.view">
|
||||
<field name="name">sync.project.config.wizard.form</field>
|
||||
<field name="model">sync.project.config.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sync Project Configuration">
|
||||
<sheet>
|
||||
<group string="Connection Settings">
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="url"/>
|
||||
<field name="database"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username"/>
|
||||
<field name="api_token" password="True"/>
|
||||
<field name="protocol"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_configure_sync" type="object" string="Configure Sync" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_sync_project_config_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Configure Sync Project</field>
|
||||
<field name="res_model">sync.project.config.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_sync_project"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue