Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:50:04 +08:00
commit 3e809e35ad
41 changed files with 10010 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,558 @@
# API Integration Guide for Odoo Connectors
## REST API Integration
### Standard REST Pattern
**Adapter Structure**:
```python
class RESTAdapter(GenericAdapter):
def get_resource(self, resource_id):
"""GET /resources/{id}"""
return self.get(f'/{self.resource_name}/{resource_id}')
def list_resources(self, filters=None):
"""GET /resources"""
return self.get(f'/{self.resource_name}', params=filters)
def create_resource(self, data):
"""POST /resources"""
return self.post(f'/{self.resource_name}', data=data)
def update_resource(self, resource_id, data):
"""PUT /resources/{id}"""
return self.put(f'/{self.resource_name}/{resource_id}', data=data)
def delete_resource(self, resource_id):
"""DELETE /resources/{id}"""
return self.delete(f'/{self.resource_name}/{resource_id}')
```
### Pagination Handling
**Offset-Based Pagination**:
```python
def get_all_resources(self, filters=None):
"""Fetch all resources with pagination."""
all_resources = []
page = 1
per_page = 100
while True:
params = filters.copy() if filters else {}
params.update({'page': page, 'per_page': per_page})
response = self.get('/resources', params=params)
resources = response.get('data', [])
if not resources:
break
all_resources.extend(resources)
# Check if more pages exist
total = response.get('total', 0)
if len(all_resources) >= total:
break
page += 1
return all_resources
```
**Cursor-Based Pagination**:
```python
def get_all_resources(self, filters=None):
"""Fetch all resources with cursor pagination."""
all_resources = []
cursor = None
while True:
params = filters.copy() if filters else {}
if cursor:
params['cursor'] = cursor
response = self.get('/resources', params=params)
resources = response.get('data', [])
if not resources:
break
all_resources.extend(resources)
# Get next cursor
cursor = response.get('next_cursor')
if not cursor:
break
return all_resources
```
**Link Header Pagination**:
```python
def get_all_resources(self):
"""Follow Link headers for pagination."""
all_resources = []
url = '/resources'
while url:
response = requests.get(self.build_url(url), headers=self.get_api_headers())
response.raise_for_status()
all_resources.extend(response.json())
# Parse Link header
link_header = response.headers.get('Link', '')
url = self._extract_next_url(link_header)
return all_resources
def _extract_next_url(self, link_header):
"""Extract next URL from Link header."""
import re
match = re.search(r'<([^>]+)>; rel="next"', link_header)
return match.group(1) if match else None
```
### Response Envelope Handling
**Wrapped Response**:
```python
def get_products(self):
"""Handle wrapped API response."""
response = self.get('/products')
# Response: {"status": "success", "data": {"products": [...]}}
if response.get('status') == 'success':
return response.get('data', {}).get('products', [])
raise ValueError(f"API error: {response.get('message')}")
```
**Nested Data**:
```python
def extract_data(self, response):
"""Extract data from nested structure."""
# Response: {"response": {"result": {"items": [...]}}}
return response.get('response', {}).get('result', {}).get('items', [])
```
## GraphQL API Integration
**GraphQL Adapter**:
```python
class GraphQLAdapter(GenericAdapter):
def query(self, query, variables=None):
"""Execute GraphQL query."""
payload = {'query': query}
if variables:
payload['variables'] = variables
response = self.post('/graphql', data=payload)
if 'errors' in response:
raise ValueError(f"GraphQL errors: {response['errors']}")
return response.get('data')
def get_products(self, first=100, after=None):
"""Fetch products using GraphQL."""
query = """
query GetProducts($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
node {
id
title
description
variants {
edges {
node {
id
price
sku
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
variables = {'first': first}
if after:
variables['after'] = after
return self.query(query, variables)
def get_all_products(self):
"""Fetch all products with pagination."""
all_products = []
has_next_page = True
cursor = None
while has_next_page:
data = self.get_products(after=cursor)
products_data = data.get('products', {})
edges = products_data.get('edges', [])
all_products.extend([edge['node'] for edge in edges])
page_info = products_data.get('pageInfo', {})
has_next_page = page_info.get('hasNextPage', False)
cursor = page_info.get('endCursor')
return all_products
```
## SOAP API Integration
**SOAP Adapter**:
```python
from zeep import Client
from zeep.transports import Transport
class SOAPAdapter(GenericAdapter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = self._create_client()
def _create_client(self):
"""Create SOAP client."""
wsdl = f'{self.backend_record.api_url}?wsdl'
# Configure transport
session = requests.Session()
session.auth = (
self.backend_record.api_username,
self.backend_record.api_password
)
transport = Transport(session=session)
return Client(wsdl, transport=transport)
def get_products(self):
"""Call SOAP method."""
try:
response = self.client.service.GetProducts()
return response
except Exception as e:
_logger.error("SOAP call failed: %s", str(e))
raise
```
## Webhook Integration
### Webhook Controller
```python
from odoo import http
from odoo.http import request
import json
import hmac
import hashlib
class MyConnectorWebhookController(http.Controller):
@http.route('/myconnector/webhook', type='json', auth='none', csrf=False)
def webhook(self):
"""Handle incoming webhooks."""
try:
# Get raw payload
payload = request.httprequest.get_data(as_text=True)
# Get headers
signature = request.httprequest.headers.get('X-Signature')
event_type = request.httprequest.headers.get('X-Event-Type')
# Find backend (by API key or other identifier)
api_key = request.httprequest.headers.get('X-API-Key')
backend = request.env['myconnector.backend'].sudo().search([
('api_key', '=', api_key)
], limit=1)
if not backend:
return {'error': 'Invalid API key'}, 401
# Verify signature
if not self._verify_signature(payload, signature, backend.webhook_secret):
return {'error': 'Invalid signature'}, 401
# Create webhook record
webhook = request.env['generic.webhook'].sudo().create({
'backend_id': backend.id,
'event_type': event_type,
'payload': payload,
'signature': signature,
'processing_status': 'pending',
})
# Process asynchronously
webhook.with_delay().process_webhook()
return {'status': 'accepted', 'webhook_id': webhook.id}
except Exception as e:
_logger.exception("Webhook processing failed")
return {'error': str(e)}, 500
def _verify_signature(self, payload, signature, secret):
"""Verify HMAC signature."""
expected = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
```
### Webhook Processing
```python
class MyBackend(models.Model):
def process_webhook(self, webhook):
"""Process webhook by event type."""
handlers = {
'order.created': self._handle_order_created,
'order.updated': self._handle_order_updated,
'product.updated': self._handle_product_updated,
'inventory.updated': self._handle_inventory_updated,
}
handler = handlers.get(webhook.event_type)
if handler:
try:
handler(webhook)
webhook.mark_as_processed()
except Exception as e:
_logger.exception("Webhook handler failed")
webhook.mark_as_failed(str(e))
else:
webhook.mark_as_ignored(f"No handler for {webhook.event_type}")
def _handle_order_created(self, webhook):
"""Handle order.created event."""
payload = json.loads(webhook.payload)
order_id = payload['order']['id']
# Import the order
self.env['myconnector.sale.order'].import_record(
backend=self,
external_id=str(order_id)
)
```
## Rate Limiting
### Token Bucket Implementation
```python
from datetime import datetime, timedelta
from collections import defaultdict
class RateLimiter:
def __init__(self, rate_limit=100, window=60):
"""
Args:
rate_limit: Number of requests allowed
window: Time window in seconds
"""
self.rate_limit = rate_limit
self.window = window
self.buckets = defaultdict(list)
def allow_request(self, key):
"""Check if request is allowed."""
now = datetime.now()
window_start = now - timedelta(seconds=self.window)
# Clean old requests
self.buckets[key] = [
req_time for req_time in self.buckets[key]
if req_time > window_start
]
# Check limit
if len(self.buckets[key]) >= self.rate_limit:
return False
# Add current request
self.buckets[key].append(now)
return True
class RateLimitedAdapter(GenericAdapter):
_rate_limiter = None
@classmethod
def get_rate_limiter(cls):
if cls._rate_limiter is None:
cls._rate_limiter = RateLimiter(rate_limit=100, window=60)
return cls._rate_limiter
def make_request(self, method, endpoint, **kwargs):
"""Make request with rate limiting."""
limiter = self.get_rate_limiter()
key = f"{self.backend_record.id}"
if not limiter.allow_request(key):
# Wait and retry
import time
time.sleep(1)
return self.make_request(method, endpoint, **kwargs)
return super().make_request(method, endpoint, **kwargs)
```
### Response Header Rate Limiting
```python
def make_request(self, method, endpoint, **kwargs):
"""Check rate limit from response headers."""
response = super().make_request(method, endpoint, **kwargs)
# Check rate limit headers
remaining = response.headers.get('X-RateLimit-Remaining')
reset_time = response.headers.get('X-RateLimit-Reset')
if remaining and int(remaining) < 10:
_logger.warning(
"Rate limit nearly exceeded. Remaining: %s, Resets at: %s",
remaining,
reset_time
)
# Optionally delay next request
if int(remaining) == 0:
import time
reset_timestamp = int(reset_time)
wait_time = reset_timestamp - time.time()
if wait_time > 0:
time.sleep(wait_time)
return response
```
## Error Handling
### Retry with Exponential Backoff
```python
import time
from requests.exceptions import RequestException
class ResilientAdapter(GenericAdapter):
def make_request(self, method, endpoint, max_retries=3, **kwargs):
"""Make request with retry logic."""
for attempt in range(max_retries):
try:
return super().make_request(method, endpoint, **kwargs)
except RequestException as e:
if attempt == max_retries - 1:
# Last attempt, re-raise
raise
# Calculate backoff
wait_time = (2 ** attempt) + (random.random() * 0.1)
_logger.warning(
"Request failed (attempt %d/%d): %s. Retrying in %.2fs",
attempt + 1,
max_retries,
str(e),
wait_time
)
time.sleep(wait_time)
```
### Status Code Handling
```python
def make_request(self, method, endpoint, **kwargs):
"""Handle different HTTP status codes."""
response = requests.request(
method=method,
url=self.build_url(endpoint),
headers=self.get_api_headers(),
**kwargs
)
if response.status_code == 200:
return response.json()
elif response.status_code == 201:
return response.json()
elif response.status_code == 204:
return None # No content
elif response.status_code == 400:
raise ValueError(f"Bad request: {response.text}")
elif response.status_code == 401:
raise PermissionError("Unauthorized. Check API credentials.")
elif response.status_code == 403:
raise PermissionError("Forbidden. Insufficient permissions.")
elif response.status_code == 404:
return None # Resource not found
elif response.status_code == 429:
# Rate limited
retry_after = response.headers.get('Retry-After', 60)
raise RateLimitExceeded(f"Rate limited. Retry after {retry_after}s")
elif response.status_code >= 500:
raise ServerError(f"Server error: {response.status_code}")
else:
response.raise_for_status()
```
## Testing APIs
### Mock Adapter for Testing
```python
class MockAdapter(GenericAdapter):
"""Mock adapter for testing."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.mock_data = {}
def set_mock_response(self, endpoint, data):
"""Set mock response for endpoint."""
self.mock_data[endpoint] = data
def get(self, endpoint, **kwargs):
"""Return mock data instead of making real request."""
return self.mock_data.get(endpoint, {})
# In tests
def test_product_import(self):
backend = self.env['myconnector.backend'].create({...})
# Use mock adapter
adapter = MockAdapter(self.env, backend)
adapter.set_mock_response('/products/123', {
'id': 123,
'title': 'Test Product',
'price': 99.99
})
# Test import
importer = ProductImporter(...)
result = importer.run(external_id='123')
self.assertEqual(result.name, 'Test Product')
```

View File

@@ -0,0 +1,451 @@
# Generic Connector Architecture Reference
## Overview
The `generic_connector` module provides a reusable framework for building connectors to external systems. It follows a component-based architecture with clear separation of concerns.
## Core Components
### 1. Backend Model
**Purpose**: Configuration and orchestration center for connector operations.
**Key Responsibilities**:
- Store API credentials and configuration
- Manage connection status
- Orchestrate synchronization operations
- Configure webhooks
- Define business logic (warehouse, pricelist, etc.)
**Implementation Pattern**:
```python
class MyConnectorBackend(models.Model):
_name = 'myconnector.backend'
_inherit = 'generic.backend'
_description = 'My Connector Backend'
_backend_type = 'myconnector' # Unique identifier
# API Configuration fields
api_url = fields.Char(required=True, default='https://api.example.com')
api_key = fields.Char(required=True)
api_secret = fields.Char()
# Override template methods
def _test_connection_implementation(self):
"""Implement connection testing logic."""
adapter = self.get_adapter('myconnector.adapter')
return adapter.test_connection()
def _sync_orders_implementation(self):
"""Implement order import logic."""
# Import orders from external system
pass
```
**Template Methods** (override these):
- `_test_connection_implementation()` - Test API connection
- `_sync_orders_implementation()` - Import orders
- `_sync_products_implementation()` - Export/import products
- `_sync_inventory_implementation()` - Export inventory
- `_sync_customers_implementation()` - Import/export customers
### 2. Binding Models
**Purpose**: Link Odoo records to external system entities.
**Key Characteristics**:
- Uses `_inherits` to extend Odoo models
- Stores external ID and sync metadata
- Tracks sync status and retry count
**Implementation Pattern**:
```python
class MyConnectorProductBinding(models.Model):
_name = 'myconnector.product.template'
_inherit = 'generic.binding'
_inherits = {'product.template': 'odoo_id'}
_description = 'My Connector Product Binding'
odoo_id = fields.Many2one(
'product.template',
required=True,
ondelete='cascade'
)
# External system fields
external_sku = fields.Char(readonly=True)
external_price = fields.Float(readonly=True)
_sql_constraints = [
('backend_external_uniq',
'unique(backend_id, external_id)',
'Product binding must be unique per backend')
]
```
**Generic Binding Fields** (automatically inherited):
- `backend_id` - Link to backend
- `external_id` - ID in external system
- `sync_date` - Last sync timestamp
- `sync_status` - pending/in_progress/success/failed/skipped
- `retry_count` - Number of retry attempts
- `last_error` - Last error message
**Generic Binding Methods**:
- `mark_sync_success()` - Mark record as successfully synced
- `mark_sync_failed(error_msg)` - Mark record as failed with error
- `can_retry_sync()` - Check if retry is allowed
### 3. Adapter Component
**Purpose**: HTTP client for API communication.
**Key Responsibilities**:
- Make HTTP requests (GET, POST, PUT, DELETE)
- Handle authentication
- Build URLs
- Manage headers and timeouts
- Transform API responses
**Implementation Pattern**:
```python
from odoo.addons.generic_connector.components.adapter import GenericAdapter
class MyConnectorAdapter(GenericAdapter):
_name = 'myconnector.adapter'
_inherit = 'generic.adapter'
_usage = 'backend.adapter'
def get_api_headers(self):
"""Build API request headers."""
headers = super().get_api_headers()
headers.update({
'Authorization': f'Bearer {self.backend_record.api_key}',
'X-API-Version': '2.0'
})
return headers
# CRUD operations
def get_product(self, external_id):
"""Get single product."""
return self.get(f'/products/{external_id}')
def get_products(self, filters=None):
"""Get list of products."""
return self.get('/products', params=filters)
def create_product(self, data):
"""Create product."""
return self.post('/products', data=data)
def update_product(self, external_id, data):
"""Update product."""
return self.put(f'/products/{external_id}', data=data)
```
**Available HTTP Methods** (from GenericAdapter):
- `get(endpoint, params=None, **kwargs)` - GET request
- `post(endpoint, data=None, **kwargs)` - POST request
- `put(endpoint, data=None, **kwargs)` - PUT request
- `delete(endpoint, **kwargs)` - DELETE request
- `make_request(method, endpoint, **kwargs)` - Generic request
**Helper Methods**:
- `build_url(endpoint)` - Construct full URL
- `get_api_headers()` - Get request headers
- `get_api_auth()` - Get authentication tuple
### 4. Mapper Components
**Purpose**: Transform data between Odoo and external system formats.
**Implementation Pattern**:
```python
from odoo.addons.generic_connector.components.mapper import GenericImportMapper
class ProductImportMapper(GenericImportMapper):
_name = 'myconnector.product.import.mapper'
_inherit = 'generic.import.mapper'
_apply_on = 'myconnector.product.template'
direct = [
('name', 'name'), # Simple field mapping
('sku', 'default_code'),
('price', 'list_price'),
]
@mapping
def backend_id(self, record):
"""Map backend."""
return {'backend_id': self.backend_record.id}
@mapping
def external_id(self, record):
"""Map external ID."""
return {'external_id': str(record['id'])}
@mapping
def category_id(self, record):
"""Map category with lookup."""
external_cat_id = record.get('category_id')
if external_cat_id:
category = self.env['product.category'].search([
('name', '=', record.get('category_name'))
], limit=1)
return {'categ_id': category.id if category else False}
return {}
```
**Mapping Decorators**:
- `@mapping` - Define a custom mapping method
- `@only_create` - Apply only when creating records
- `@changed_by('field1', 'field2')` - Apply only when specified fields change
**Direct Mappings**:
```python
direct = [
('external_field', 'odoo_field'), # Simple mapping
(transform('external_field'), 'odoo_field'), # With transformation
]
```
### 5. Importer Components
**Purpose**: Import data from external system to Odoo.
**Implementation Pattern**:
```python
from odoo.addons.generic_connector.components.importer import GenericImporter
class ProductImporter(GenericImporter):
_name = 'myconnector.product.importer'
_inherit = 'generic.importer'
_apply_on = 'myconnector.product.template'
def _import_record(self, external_id, force=False):
"""Import a single product."""
# 1. Fetch from external system
adapter = self.component(usage='backend.adapter')
external_data = adapter.get_product(external_id)
# 2. Transform data
mapper = self.component(usage='import.mapper')
mapped_data = mapper.map_record(external_data).values()
# 3. Create or update binding
binding = self._get_binding()
if binding:
binding.write(mapped_data)
else:
binding = self.model.create(mapped_data)
return binding
def _get_binding(self):
"""Get existing binding by external_id."""
return self.env[self.model._name].search([
('backend_id', '=', self.backend_record.id),
('external_id', '=', self.external_id),
], limit=1)
```
**Batch Importer**:
```python
class ProductBatchImporter(GenericBatchImporter):
_name = 'myconnector.product.batch.importer'
_inherit = 'generic.batch.importer'
_apply_on = 'myconnector.product.template'
def run(self, filters=None):
"""Import products in batch."""
adapter = self.component(usage='backend.adapter')
products = adapter.get_products(filters=filters)
for product in products:
external_id = str(product['id'])
self._import_record(external_id, force=False)
```
### 6. Exporter Components
**Purpose**: Export data from Odoo to external system.
**Implementation Pattern**:
```python
from odoo.addons.generic_connector.components.exporter import GenericExporter
class ProductExporter(GenericExporter):
_name = 'myconnector.product.exporter'
_inherit = 'generic.exporter'
_apply_on = 'myconnector.product.template'
def _export_record(self, binding):
"""Export a single product."""
# 1. Transform data
mapper = self.component(usage='export.mapper')
mapped_data = mapper.map_record(binding).values()
# 2. Send to external system
adapter = self.component(usage='backend.adapter')
if binding.external_id:
# Update existing
adapter.update_product(binding.external_id, mapped_data)
else:
# Create new
result = adapter.create_product(mapped_data)
binding.write({
'external_id': str(result['id']),
'sync_date': fields.Datetime.now()
})
```
### 7. Webhook Model
**Purpose**: Receive and process webhooks from external systems.
**Features**:
- Store raw webhook payloads
- Verify webhook signatures
- Queue async processing
- Track processing status
- Retry failed webhooks
**Usage Pattern**:
```python
# In controller (receive webhook)
webhook = request.env['generic.webhook'].sudo().create({
'backend_id': backend.id,
'event_type': 'order.created',
'payload': json.dumps(payload),
'signature': request.httprequest.headers.get('X-Webhook-Signature'),
'processing_status': 'pending'
})
# Process with delay
webhook.with_delay().process_webhook()
# In backend model (handle webhook)
def _handle_webhook_order_created(self, webhook):
"""Handle order.created webhook event."""
payload = json.loads(webhook.payload)
order_id = payload['order']['id']
# Import the order
self.env['myconnector.sale.order'].import_record(
backend=self,
external_id=str(order_id)
)
```
## Component Registration
Components must be registered with specific attributes:
- `_name` - Unique component identifier
- `_inherit` - Parent component(s)
- `_apply_on` - Model(s) this component applies to
- `_usage` - Usage context (e.g., 'backend.adapter', 'import.mapper')
**Example**:
```python
class MyAdapter(GenericAdapter):
_name = 'myconnector.product.adapter'
_inherit = 'generic.adapter'
_apply_on = 'myconnector.product.template'
_usage = 'backend.adapter'
```
## Queue Job Integration
Use `with_delay()` for async operations:
```python
# Queue a sync job
backend.with_delay().sync_orders()
# Queue with custom settings
backend.with_delay(priority=5, eta=60).sync_products()
# Queue from binding
binding.with_delay().export_record()
```
## Security Model
### Groups (from generic_connector):
- `group_generic_connector_user` - Basic access
- `group_generic_connector_manager` - Configuration access
- `group_generic_connector_admin` - Full control
### Access Rules Pattern:
```csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_backend_user,myconnector.backend user,model_myconnector_backend,group_generic_connector_user,1,0,0,0
access_backend_manager,myconnector.backend manager,model_myconnector_backend,group_generic_connector_manager,1,1,1,1
```
## View Pattern
### Backend Form View Structure:
```xml
<form>
<header>
<button name="action_test_connection" type="object" string="Test Connection"/>
<button name="action_sync_all" type="object" string="Sync All"/>
</header>
<sheet>
<group name="api_config" string="API Configuration">
<field name="api_url"/>
<field name="api_key" password="True"/>
</group>
<notebook>
<page name="sync" string="Synchronization">
<!-- Sync settings -->
</page>
<page name="advanced" string="Advanced">
<!-- Advanced settings -->
</page>
</notebook>
</sheet>
</form>
```
## Odoo Model File Locations Reference
When building connectors, you'll interact with these core Odoo models:
### Product Models
- `product.template` - Product template (variants container)
- `product.product` - Product variant
- `product.category` - Product categories
### Sales Models
- `sale.order` - Sales orders
- `sale.order.line` - Order lines
- `res.partner` - Customers/contacts
### Inventory Models
- `stock.picking` - Stock transfers
- `stock.move` - Stock movements
- `stock.quant` - Inventory quantities
- `stock.warehouse` - Warehouses
- `stock.location` - Stock locations
### Accounting Models
- `account.move` - Invoices/bills
- `account.payment` - Payments
- `account.tax` - Taxes
## Best Practices
1. **Always use bindings** - Never directly modify Odoo records from external data
2. **Use queue jobs** - For any operation that might take >2 seconds
3. **Implement retry logic** - Use binding's retry_count and max_retries
4. **Log extensively** - Use `_logger` for debugging
5. **Handle API errors** - Wrap adapter calls in try/except
6. **Validate data** - Check required fields before creating/updating
7. **Use transactions** - Leverage Odoo's automatic transaction management
8. **Test connection** - Always implement `_test_connection_implementation()`
9. **Document API** - Add docstrings to all adapter methods
10. **Follow naming conventions** - Use consistent model/component names

View File

@@ -0,0 +1,441 @@
# Authentication Patterns for Odoo Connectors
## 1. API Key Authentication
**Usage**: Simple, static authentication
**Backend Fields**:
```python
class MyBackend(models.Model):
api_key = fields.Char(string='API Key', required=True)
api_secret = fields.Char(string='API Secret') # Optional
```
**Adapter Implementation**:
```python
class MyAdapter(GenericAdapter):
def get_api_headers(self):
headers = super().get_api_headers()
headers['X-API-Key'] = self.backend_record.api_key
return headers
```
**Variants**:
- Header-based: `Authorization: ApiKey YOUR_KEY`
- Query parameter: `?api_key=YOUR_KEY`
- Custom header: `X-API-Key: YOUR_KEY`
## 2. Bearer Token Authentication
**Usage**: Token-based auth (common in modern APIs)
**Backend Fields**:
```python
class MyBackend(models.Model):
access_token = fields.Char(string='Access Token')
```
**Adapter Implementation**:
```python
class MyAdapter(GenericAdapter):
def get_api_headers(self):
headers = super().get_api_headers()
headers['Authorization'] = f'Bearer {self.backend_record.access_token}'
return headers
```
## 3. OAuth 2.0 Authentication
**Usage**: Delegated authorization (Shopify, Google, etc.)
### Authorization Code Flow
**Backend Fields**:
```python
class MyBackend(models.Model):
oauth_client_id = fields.Char(string='Client ID', required=True)
oauth_client_secret = fields.Char(string='Client Secret', required=True)
oauth_redirect_uri = fields.Char(string='Redirect URI', compute='_compute_redirect_uri')
access_token = fields.Char(string='Access Token', readonly=True)
refresh_token = fields.Char(string='Refresh Token', readonly=True)
token_expires_at = fields.Datetime(string='Token Expires At', readonly=True)
token_type = fields.Char(string='Token Type', readonly=True, default='Bearer')
@api.depends()
def _compute_redirect_uri(self):
"""Compute OAuth redirect URI."""
for backend in self:
base_url = backend.env['ir.config_parameter'].sudo().get_param('web.base.url')
backend.oauth_redirect_uri = f'{base_url}/myconnector/oauth/callback'
def action_start_oauth_flow(self):
"""Start OAuth authorization flow."""
self.ensure_one()
auth_url = self._build_authorization_url()
return {
'type': 'ir.actions.act_url',
'url': auth_url,
'target': 'new',
}
def _build_authorization_url(self):
"""Build OAuth authorization URL."""
from urllib.parse import urlencode
params = {
'client_id': self.oauth_client_id,
'redirect_uri': self.oauth_redirect_uri,
'response_type': 'code',
'scope': 'read_products write_orders', # Adjust scopes
'state': self._generate_oauth_state(),
}
return f'{self.api_url}/oauth/authorize?{urlencode(params)}'
def _generate_oauth_state(self):
"""Generate OAuth state parameter for CSRF protection."""
import secrets
state = secrets.token_urlsafe(32)
# Store state in session or database for validation
self.env['ir.config_parameter'].sudo().set_param(
f'oauth_state_{self.id}',
state
)
return state
def exchange_code_for_token(self, code, state):
"""Exchange authorization code for access token."""
self.ensure_one()
# Validate state
stored_state = self.env['ir.config_parameter'].sudo().get_param(
f'oauth_state_{self.id}'
)
if state != stored_state:
raise ValueError('Invalid OAuth state')
# Exchange code for token
token_url = f'{self.api_url}/oauth/token'
data = {
'client_id': self.oauth_client_id,
'client_secret': self.oauth_client_secret,
'code': code,
'redirect_uri': self.oauth_redirect_uri,
'grant_type': 'authorization_code',
}
response = requests.post(token_url, data=data)
response.raise_for_status()
token_data = response.json()
self._save_token_data(token_data)
def _save_token_data(self, token_data):
"""Save OAuth token data."""
from datetime import datetime, timedelta
expires_in = token_data.get('expires_in', 3600)
expires_at = datetime.now() + timedelta(seconds=expires_in)
self.write({
'access_token': token_data['access_token'],
'refresh_token': token_data.get('refresh_token'),
'token_expires_at': expires_at,
'token_type': token_data.get('token_type', 'Bearer'),
})
def refresh_access_token(self):
"""Refresh expired access token."""
self.ensure_one()
if not self.refresh_token:
raise ValueError('No refresh token available')
token_url = f'{self.api_url}/oauth/token'
data = {
'client_id': self.oauth_client_id,
'client_secret': self.oauth_client_secret,
'refresh_token': self.refresh_token,
'grant_type': 'refresh_token',
}
response = requests.post(token_url, data=data)
response.raise_for_status()
token_data = response.json()
self._save_token_data(token_data)
```
**OAuth Callback Controller**:
```python
from odoo import http
from odoo.http import request
class MyConnectorOAuthController(http.Controller):
@http.route('/myconnector/oauth/callback', type='http', auth='user', csrf=False)
def oauth_callback(self, code=None, state=None, error=None):
"""Handle OAuth callback."""
if error:
return request.render('myconnector.oauth_error', {'error': error})
if not code or not state:
return request.render('myconnector.oauth_error',
{'error': 'Missing code or state'})
# Find backend by state or use session
backend_id = request.session.get('oauth_backend_id')
if not backend_id:
return request.render('myconnector.oauth_error',
{'error': 'Invalid session'})
backend = request.env['myconnector.backend'].sudo().browse(backend_id)
try:
backend.exchange_code_for_token(code, state)
return request.render('myconnector.oauth_success')
except Exception as e:
return request.render('myconnector.oauth_error', {'error': str(e)})
```
**Adapter with Token Refresh**:
```python
class MyAdapter(GenericAdapter):
def make_request(self, method, endpoint, **kwargs):
"""Make request with automatic token refresh."""
# Check if token is expired
if self._is_token_expired():
self.backend_record.refresh_access_token()
return super().make_request(method, endpoint, **kwargs)
def _is_token_expired(self):
"""Check if access token is expired."""
from datetime import datetime, timedelta
if not self.backend_record.token_expires_at:
return False
# Refresh 5 minutes before expiry
buffer = timedelta(minutes=5)
return datetime.now() + buffer >= self.backend_record.token_expires_at
def get_api_headers(self):
headers = super().get_api_headers()
headers['Authorization'] = (
f'{self.backend_record.token_type} {self.backend_record.access_token}'
)
return headers
```
## 4. Basic Authentication
**Usage**: Username/password (less common, less secure)
**Backend Fields**:
```python
class MyBackend(models.Model):
api_username = fields.Char(string='Username', required=True)
api_password = fields.Char(string='Password', required=True)
```
**Adapter Implementation**:
```python
class MyAdapter(GenericAdapter):
def get_api_auth(self):
"""Return (username, password) tuple for requests."""
return (
self.backend_record.api_username,
self.backend_record.api_password
)
def make_request(self, method, endpoint, **kwargs):
"""Add basic auth to requests."""
kwargs['auth'] = self.get_api_auth()
return super().make_request(method, endpoint, **kwargs)
```
## 5. HMAC Signature Authentication
**Usage**: Signed requests (high security)
**Backend Fields**:
```python
class MyBackend(models.Model):
api_key = fields.Char(string='API Key', required=True)
api_secret = fields.Char(string='API Secret', required=True)
```
**Adapter Implementation**:
```python
import hmac
import hashlib
import base64
from datetime import datetime
class MyAdapter(GenericAdapter):
def make_request(self, method, endpoint, **kwargs):
"""Add HMAC signature to request."""
# Generate signature
timestamp = str(int(datetime.now().timestamp()))
signature = self._generate_signature(method, endpoint, timestamp, kwargs.get('data'))
# Add to headers
headers = kwargs.get('headers', {})
headers.update({
'X-API-Key': self.backend_record.api_key,
'X-Signature': signature,
'X-Timestamp': timestamp,
})
kwargs['headers'] = headers
return super().make_request(method, endpoint, **kwargs)
def _generate_signature(self, method, endpoint, timestamp, data=None):
"""Generate HMAC signature."""
# Build signature string
message_parts = [
method.upper(),
endpoint,
timestamp,
]
if data:
import json
message_parts.append(json.dumps(data, sort_keys=True))
message = '\n'.join(message_parts)
# Generate HMAC
secret = self.backend_record.api_secret.encode('utf-8')
signature = hmac.new(
secret,
message.encode('utf-8'),
hashlib.sha256
).digest()
# Return base64-encoded signature
return base64.b64encode(signature).decode('utf-8')
```
## 6. JWT Authentication
**Usage**: JSON Web Tokens (stateless auth)
**Backend Fields**:
```python
class MyBackend(models.Model):
jwt_secret = fields.Char(string='JWT Secret', required=True)
jwt_algorithm = fields.Selection([
('HS256', 'HMAC SHA-256'),
('RS256', 'RSA SHA-256'),
], default='HS256', string='Algorithm')
jwt_expiration = fields.Integer(string='Token Expiration (seconds)', default=3600)
```
**Adapter Implementation**:
```python
import jwt
from datetime import datetime, timedelta
class MyAdapter(GenericAdapter):
def get_api_headers(self):
headers = super().get_api_headers()
token = self._generate_jwt()
headers['Authorization'] = f'Bearer {token}'
return headers
def _generate_jwt(self):
"""Generate JWT token."""
payload = {
'iss': self.backend_record.api_key, # Issuer
'iat': datetime.utcnow(), # Issued at
'exp': datetime.utcnow() + timedelta(
seconds=self.backend_record.jwt_expiration
),
}
return jwt.encode(
payload,
self.backend_record.jwt_secret,
algorithm=self.backend_record.jwt_algorithm
)
```
## 7. Store-Specific Headers (ZID Pattern)
**Usage**: Multi-tenant systems requiring store identification
**Backend Fields**:
```python
class MyBackend(models.Model):
store_id = fields.Char(string='Store ID', required=True)
api_key = fields.Char(string='API Key', required=True)
```
**Adapter Implementation**:
```python
class MyAdapter(GenericAdapter):
def get_api_headers(self):
headers = super().get_api_headers()
headers.update({
'X-Manager-Token': self.backend_record.api_key,
'X-Store-Id': self.backend_record.store_id,
'Accept': 'application/json',
})
return headers
```
## Webhook Signature Verification
### HMAC-SHA256 Verification
```python
import hmac
import hashlib
class GenericWebhook(models.Model):
def verify_signature(self, payload, signature, secret):
"""Verify webhook signature."""
expected_signature = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
# In controller
class WebhookController(http.Controller):
@http.route('/myconnector/webhook', type='json', auth='none', csrf=False)
def webhook(self):
payload = request.httprequest.get_data(as_text=True)
signature = request.httprequest.headers.get('X-Signature')
backend = self._find_backend()
webhook_model = request.env['generic.webhook'].sudo()
if not webhook_model.verify_signature(payload, signature, backend.webhook_secret):
return {'error': 'Invalid signature'}, 401
# Process webhook
...
```
## Security Best Practices
1. **Never log credentials** - Mask API keys/secrets in logs
2. **Use password fields** - Set `password=True` for sensitive fields
3. **Rotate tokens** - Implement token refresh before expiry
4. **Validate signatures** - Always verify webhook signatures
5. **Use HTTPS** - Never send credentials over HTTP
6. **Store securely** - Consider using `ir.config_parameter` for secrets
7. **Limit scopes** - Request minimum required OAuth scopes
8. **Handle expiry** - Implement token refresh logic
9. **CSRF protection** - Use state parameter in OAuth
10. **Rate limit** - Implement rate limiting to prevent abuse

View File

@@ -0,0 +1,562 @@
# Design Patterns in Odoo Connectors
## 1. Template Method Pattern
**Usage**: Backend model orchestration
**Implementation**:
```python
# generic_connector provides template methods
class GenericBackend(models.Model):
def sync_orders(self):
"""Template method."""
self._pre_sync_validation()
result = self._sync_orders_implementation() # Hook
self._post_sync_actions()
return result
def _sync_orders_implementation(self):
"""Override this in concrete implementations."""
raise NotImplementedError()
# Concrete connector overrides the hook
class ShopifyBackend(models.Model):
_inherit = 'generic.backend'
def _sync_orders_implementation(self):
"""Shopify-specific implementation."""
# Actual sync logic here
pass
```
**Benefits**:
- Enforces consistent workflow
- Allows customization at specific points
- Reduces code duplication
## 2. Adapter Pattern
**Usage**: API communication abstraction
**Implementation**:
```python
class ShopifyAdapter(GenericAdapter):
"""Adapts Shopify API to generic interface."""
def get_orders(self, filters=None):
"""Translate to Shopify API call."""
# Shopify uses /admin/api/2024-01/orders.json
endpoint = '/admin/api/2024-01/orders.json'
response = self.get(endpoint, params=self._build_params(filters))
# Shopify wraps response in 'orders' key
return response.get('orders', [])
def _build_params(self, filters):
"""Transform generic filters to Shopify params."""
params = {}
if filters and 'created_after' in filters:
params['created_at_min'] = filters['created_after']
return params
```
**Benefits**:
- Hides API differences
- Provides consistent interface
- Simplifies testing (mock adapter)
## 3. Strategy Pattern
**Usage**: Different sync strategies per backend
**Implementation**:
```python
class GenericImporter(Component):
_name = 'generic.importer'
def run(self, external_id, force=False):
"""Import strategy can vary."""
if self._should_skip_import(external_id, force):
return None
# Different strategies:
# - Direct import
# - Delayed import
# - Batch import
return self._import_record(external_id)
class RealtimeImporter(GenericImporter):
"""Strategy: Import immediately."""
_name = 'shopify.realtime.importer'
def _import_record(self, external_id):
# Import synchronously
pass
class BatchImporter(GenericImporter):
"""Strategy: Queue for batch processing."""
_name = 'shopify.batch.importer'
def _import_record(self, external_id):
# Queue for later
self.with_delay().import_record(external_id)
```
## 4. Factory Pattern
**Usage**: Component selection based on context
**Implementation**:
```python
# Component framework acts as factory
with backend.work_on('shopify.product.template') as work:
# Factory automatically selects appropriate components
adapter = work.component(usage='backend.adapter')
# Returns ShopifyProductAdapter
importer = work.component(usage='record.importer')
# Returns ShopifyProductImporter
mapper = work.component(usage='import.mapper')
# Returns ShopifyProductImportMapper
```
**Benefits**:
- Automatic component selection
- Decoupled component creation
- Easy to extend with new components
## 5. Observer Pattern
**Usage**: Webhook event handling
**Implementation**:
```python
# Event source (webhook controller)
class WebhookController(http.Controller):
@http.route('/shopify/webhook', type='json', auth='none')
def webhook_handler(self):
payload = request.jsonrequest
event_type = request.httprequest.headers.get('X-Shopify-Topic')
# Notify observers
webhook = request.env['generic.webhook'].sudo().create({
'event_type': event_type,
'payload': json.dumps(payload),
'processing_status': 'pending'
})
webhook.with_delay().process_webhook()
# Observer (backend model)
class ShopifyBackend(models.Model):
def process_webhook(self, webhook):
"""Observe and handle webhook events."""
handlers = {
'orders/create': self._handle_order_created,
'products/update': self._handle_product_updated,
}
handler = handlers.get(webhook.event_type)
if handler:
handler(webhook)
```
## 6. Delegation Pattern
**Usage**: Multi-level model inheritance
**Implementation**:
```python
class GenericBackend(models.Model):
_name = 'generic.backend'
_inherits = {'connector.base.backend': 'connector_backend_id'}
connector_backend_id = fields.Many2one(
'connector.base.backend',
required=True,
ondelete='cascade'
)
# Delegates fields: name, version, etc.
# Accessing backend.name actually accesses backend.connector_backend_id.name
```
**Benefits**:
- Reuse existing model functionality
- Avoid deep inheritance hierarchies
- Maintain database normalization
## 7. Mapper Pattern (Data Transfer Object)
**Usage**: Data transformation between systems
**Implementation**:
```python
class ProductImportMapper(GenericImportMapper):
_name = 'shopify.product.import.mapper'
# Simple mappings
direct = [
('title', 'name'),
('vendor', 'manufacturer'),
]
# Complex mapping
@mapping
def description(self, record):
"""Transform HTML description to plain text."""
html_desc = record.get('body_html', '')
return {'description': self._strip_html(html_desc)}
@mapping
def price(self, record):
"""Extract price from variants."""
variants = record.get('variants', [])
if variants:
return {'list_price': float(variants[0].get('price', 0))}
return {}
@only_create
def default_code(self, record):
"""Set SKU only when creating."""
return {'default_code': record.get('sku')}
```
**Benefits**:
- Centralized data transformation
- Declarative mapping definitions
- Reusable transformations
## 8. Retry Pattern
**Usage**: Handling transient failures
**Implementation**:
```python
class GenericBinding(models.AbstractModel):
retry_count = fields.Integer(default=0)
max_retries = fields.Integer(default=3)
def can_retry_sync(self):
"""Check if retry is allowed."""
return self.retry_count < self.max_retries
def export_with_retry(self):
"""Export with automatic retry."""
try:
self.export_record()
self.mark_sync_success()
except Exception as e:
self.retry_count += 1
self.last_error = str(e)
if self.can_retry_sync():
# Retry with exponential backoff
delay = 60 * (2 ** self.retry_count)
self.with_delay(eta=delay).export_with_retry()
else:
self.mark_sync_failed(str(e))
```
**Benefits**:
- Resilient to temporary failures
- Configurable retry behavior
- Exponential backoff prevents API overload
## 9. Rate Limiting Pattern
**Usage**: Respect API rate limits
**Implementation**:
```python
from datetime import datetime, timedelta
from cachetools import TTLCache
class RateLimitedAdapter(GenericAdapter):
# Class-level cache (shared across instances)
_rate_limit_cache = TTLCache(maxsize=100, ttl=60)
def make_request(self, method, endpoint, **kwargs):
"""Make request with rate limiting."""
cache_key = f"{self.backend_record.id}:requests"
# Get request count in current window
request_count = self._rate_limit_cache.get(cache_key, 0)
max_requests = self.backend_record.rate_limit_calls or 100
if request_count >= max_requests:
# Wait for next window
raise RateLimitExceeded(f"Rate limit of {max_requests}/min exceeded")
# Make request
response = super().make_request(method, endpoint, **kwargs)
# Increment counter
self._rate_limit_cache[cache_key] = request_count + 1
return response
```
**Variants**:
- **Token Bucket**: For bursty traffic
- **Leaky Bucket**: For steady rate
- **Sliding Window**: For precise limits
## 10. Circuit Breaker Pattern
**Usage**: Prevent cascading failures
**Implementation**:
```python
class CircuitBreaker:
def __init__(self, failure_threshold=5, timeout=60):
self.failure_threshold = failure_threshold
self.timeout = timeout
self.failures = 0
self.last_failure_time = None
self.state = 'CLOSED' # CLOSED, OPEN, HALF_OPEN
def call(self, func, *args, **kwargs):
if self.state == 'OPEN':
if datetime.now() - self.last_failure_time > timedelta(seconds=self.timeout):
self.state = 'HALF_OPEN'
else:
raise CircuitBreakerOpen("Circuit breaker is OPEN")
try:
result = func(*args, **kwargs)
if self.state == 'HALF_OPEN':
self.state = 'CLOSED'
self.failures = 0
return result
except Exception as e:
self.failures += 1
self.last_failure_time = datetime.now()
if self.failures >= self.failure_threshold:
self.state = 'OPEN'
raise
class ResilientAdapter(GenericAdapter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.circuit_breaker = CircuitBreaker()
def make_request(self, *args, **kwargs):
return self.circuit_breaker.call(
super().make_request,
*args,
**kwargs
)
```
## 11. Saga Pattern
**Usage**: Distributed transaction management
**Implementation**:
```python
class OrderImportSaga:
"""Multi-step order import with compensation."""
def __init__(self, backend, external_order_id):
self.backend = backend
self.external_order_id = external_order_id
self.steps_completed = []
def execute(self):
"""Execute saga steps."""
try:
# Step 1: Import customer
customer = self._import_customer()
self.steps_completed.append(('customer', customer.id))
# Step 2: Import order
order = self._import_order(customer)
self.steps_completed.append(('order', order.id))
# Step 3: Import order lines
lines = self._import_order_lines(order)
self.steps_completed.append(('lines', [l.id for l in lines]))
# Step 4: Confirm order
order.action_confirm()
return order
except Exception as e:
# Compensate (rollback completed steps)
self._compensate()
raise
def _compensate(self):
"""Rollback completed steps."""
for step_type, record_ids in reversed(self.steps_completed):
if step_type == 'order':
self.env['sale.order'].browse(record_ids).action_cancel()
elif step_type == 'customer':
# Mark as not synced (don't delete)
partner = self.env['res.partner'].browse(record_ids)
partner.write({'active': False})
```
## 12. Repository Pattern
**Usage**: Centralize data access logic
**Implementation**:
```python
class ProductBindingRepository:
"""Repository for product bindings."""
def __init__(self, env, backend):
self.env = env
self.backend = backend
self.model = env['shopify.product.template']
def find_by_external_id(self, external_id):
"""Find binding by external ID."""
return self.model.search([
('backend_id', '=', self.backend.id),
('external_id', '=', str(external_id))
], limit=1)
def find_by_sku(self, sku):
"""Find binding by SKU."""
return self.model.search([
('backend_id', '=', self.backend.id),
('default_code', '=', sku)
], limit=1)
def find_or_create(self, external_id, defaults=None):
"""Find existing or create new binding."""
binding = self.find_by_external_id(external_id)
if not binding:
values = {'backend_id': self.backend.id, 'external_id': str(external_id)}
if defaults:
values.update(defaults)
binding = self.model.create(values)
return binding
def find_pending_export(self, limit=100):
"""Find bindings pending export."""
return self.model.search([
('backend_id', '=', self.backend.id),
('sync_status', '=', 'pending'),
('external_id', '=', False)
], limit=limit)
```
## Pattern Selection Guidelines
| Use Case | Pattern | Reason |
|----------|---------|--------|
| Define sync workflow | Template Method | Consistent process, extensible hooks |
| API communication | Adapter | Abstract API differences |
| Different import modes | Strategy | Pluggable algorithms |
| Select components | Factory | Automatic selection based on context |
| Handle webhooks | Observer | Event-driven architecture |
| Extend core models | Delegation | Reuse without deep inheritance |
| Transform data | Mapper | Declarative transformations |
| Handle failures | Retry + Circuit Breaker | Resilient operations |
| Respect API limits | Rate Limiting | Prevent API throttling |
| Multi-step operations | Saga | Rollback on failure |
| Data access | Repository | Centralized queries |
## Anti-Patterns to Avoid
### ❌ Direct Odoo Record Modification
```python
# BAD: Directly modify product
product = self.env['product.template'].browse(product_id)
product.write({'name': external_data['title']})
```
```python
# GOOD: Use binding
binding = self.env['shopify.product.template'].search([
('odoo_id', '=', product_id)
])
binding.write({'name': external_data['title']})
```
### ❌ Synchronous Long Operations
```python
# BAD: Block user while importing 1000 products
def import_all_products(self):
for product_id in range(1, 1000):
self.import_product(product_id) # Takes 30 minutes!
```
```python
# GOOD: Queue async job
def import_all_products(self):
self.with_delay().import_products_batch()
```
### ❌ No Error Handling
```python
# BAD: Unhandled API errors crash sync
def sync_orders(self):
response = adapter.get_orders() # What if API is down?
for order in response:
self.import_order(order)
```
```python
# GOOD: Graceful error handling
def sync_orders(self):
try:
response = adapter.get_orders()
except requests.HTTPError as e:
_logger.error("Failed to fetch orders: %s", e)
return False
for order in response:
try:
self.import_order(order)
except Exception as e:
_logger.error("Failed to import order %s: %s", order['id'], e)
continue # Continue with next order
```
### ❌ Hardcoded Configuration
```python
# BAD: Hardcoded values
API_URL = 'https://api.shopify.com'
API_KEY = 'hardcoded-key-123'
```
```python
# GOOD: Backend configuration
api_url = self.backend_record.api_url
api_key = self.backend_record.api_key
```
### ❌ God Object
```python
# BAD: Backend does everything
class ShopifyBackend(models.Model):
def sync_orders(self):
# 500 lines of code doing:
# - API calls
# - Data transformation
# - Validation
# - Record creation
# - Email notifications
# etc.
```
```python
# GOOD: Separated concerns
class ShopifyBackend(models.Model):
def sync_orders(self):
# Orchestration only
with self.work_on('shopify.sale.order') as work:
importer = work.component(usage='batch.importer')
return importer.run()
# Adapter handles API
# Mapper handles transformation
# Importer handles record creation
```

View File

@@ -0,0 +1,576 @@
# Troubleshooting Guide for Odoo Connectors
## Common Issues and Solutions
### 1. Connection Issues
#### Problem: "Connection test failed" or timeout errors
**Possible Causes**:
- Incorrect API URL
- Invalid API credentials
- Network/firewall blocking requests
- API endpoint not accessible
**Solutions**:
```python
# 1. Verify API URL format
api_url = backend.api_url
print(f"Testing connection to: {api_url}")
# 2. Test with curl/requests directly
import requests
response = requests.get(f"{api_url}/health", timeout=10)
print(f"Status: {response.status_code}")
# 3. Check credentials
adapter = backend.get_adapter('backend.adapter')
headers = adapter.get_api_headers()
print(f"Headers: {headers}") # Don't log in production!
# 4. Add detailed logging
import logging
logging.getLogger('requests').setLevel(logging.DEBUG)
```
#### Problem: SSL Certificate verification failed
**Solution**:
```python
# Temporary: Disable SSL verification (NOT for production!)
def make_request(self, method, endpoint, **kwargs):
kwargs['verify'] = False
return super().make_request(method, endpoint, **kwargs)
# Production: Add CA certificate
import certifi
kwargs['verify'] = certifi.where()
```
### 2. Authentication Issues
#### Problem: 401 Unauthorized
**Diagnosis**:
```python
# Check token expiry
if backend.token_expires_at:
from datetime import datetime
is_expired = datetime.now() >= backend.token_expires_at
print(f"Token expired: {is_expired}")
# Check authentication header
adapter = backend.get_adapter('backend.adapter')
headers = adapter.get_api_headers()
print(f"Auth header: {headers.get('Authorization', 'MISSING')}")
```
**Solutions**:
```python
# 1. Refresh OAuth token
backend.refresh_access_token()
# 2. Re-authenticate
backend.action_start_oauth_flow()
# 3. Verify API key is correct
# Go to backend form and re-enter API key
```
#### Problem: OAuth callback not working
**Common Issues**:
- Redirect URI mismatch
- State parameter validation failed
- CORS issues
**Solutions**:
```python
# 1. Check redirect URI matches exactly
print(f"Configured: {backend.oauth_redirect_uri}")
print(f"Expected: https://yourodoo.com/myconnector/oauth/callback")
# 2. Disable state validation temporarily for debugging
def exchange_code_for_token(self, code, state):
# Skip state validation
# if state != stored_state:
# raise ValueError('Invalid OAuth state')
...
# 3. Add CORS headers in controller
@http.route('/myconnector/oauth/callback', cors='*')
```
### 3. Import/Sync Issues
#### Problem: Records not importing
**Diagnosis**:
```python
# 1. Check if importer is registered
with backend.work_on('myconnector.product.template') as work:
try:
importer = work.component(usage='record.importer')
print(f"Importer found: {importer._name}")
except ComponentNotFound:
print("ERROR: Importer component not registered!")
# 2. Check adapter methods
adapter = work.component(usage='backend.adapter')
products = adapter.get_products()
print(f"Fetched {len(products)} products from API")
# 3. Test mapper
mapper = work.component(usage='import.mapper')
if products:
mapped = mapper.map_record(products[0])
print(f"Mapped data: {mapped.values()}")
```
**Solutions**:
```python
# 1. Register component properly
class ProductImporter(GenericImporter):
_name = 'myconnector.product.importer'
_inherit = 'generic.importer'
_apply_on = 'myconnector.product.template' # Must match model!
_usage = 'record.importer' # Required!
# 2. Check model name consistency
# Backend: myconnector.backend
# Binding: myconnector.product.template
# Component: _apply_on = 'myconnector.product.template'
# 3. Add logging
def _import_record(self, external_id, force=False):
_logger.info("Importing product %s", external_id)
# ... import logic
_logger.info("Successfully imported product %s", external_id)
```
#### Problem: Duplicate records created
**Cause**: External ID not properly set or constraint not working
**Solution**:
```python
# 1. Verify SQL constraint
class ProductBinding(models.Model):
_sql_constraints = [
('backend_external_uniq',
'unique(backend_id, external_id)',
'Product must be unique per backend')
]
# 2. Check external ID is set
def _import_record(self, external_id, force=False):
# Always set external_id in mapped data
mapped_data = mapper.map_record(external_data).values()
if 'external_id' not in mapped_data:
mapped_data['external_id'] = str(external_id)
# 3. Search for existing binding before creating
binding = self.env['myconnector.product.template'].search([
('backend_id', '=', backend.id),
('external_id', '=', str(external_id))
], limit=1)
if binding:
binding.write(mapped_data)
else:
binding = self.env['myconnector.product.template'].create(mapped_data)
```
### 4. Export Issues
#### Problem: Records not exporting to external system
**Diagnosis**:
```python
# 1. Check exporter is registered
with backend.work_on('myconnector.product.template') as work:
exporter = work.component(usage='record.exporter')
# 2. Test export mapper
mapper = work.component(usage='export.mapper')
external_data = mapper.map_record(binding).values()
print(f"Export data: {json.dumps(external_data, indent=2)}")
# 3. Test adapter create method
adapter = work.component(usage='backend.adapter')
result = adapter.create_product(external_data)
print(f"Created external ID: {result.get('id')}")
```
**Solutions**:
```python
# 1. Check no_export flag
binding.write({'no_export': False})
# 2. Ensure export mapper returns correct format
class ProductExportMapper(GenericExportMapper):
direct = [
('name', 'title'), # Odoo field -> External field
('list_price', 'price'),
]
# 3. Handle API response correctly
def _export_record(self, binding):
mapper = self.component(usage='export.mapper')
data = mapper.map_record(binding).values()
adapter = self.component(usage='backend.adapter')
if binding.external_id:
adapter.update_product(binding.external_id, data)
else:
result = adapter.create_product(data)
# Save external ID!
binding.write({'external_id': str(result['id'])})
```
### 5. Queue Job Issues
#### Problem: Queue jobs not running
**Diagnosis**:
```bash
# 1. Check queue job workers are running
ps aux | grep odoo
# 2. Check queued jobs
# Go to Queue > Jobs in Odoo UI
# 3. Check job configuration
# Settings > Technical > Queue Jobs > Functions
```
**Solutions**:
```python
# 1. Ensure queue_job is installed and loaded
# In odoo.conf:
# server_wide_modules = base,web,queue_job
# 2. Start job runner
# odoo-bin -c odoo.conf --workers=2
# 3. Register job functions
# In data/queue_job_function_data.xml
<record id="queue_job_function_sync_products" model="queue.job.function">
<field name="name">myconnector.backend.sync_products</field>
<field name="channel_id" ref="queue_job.channel_root"/>
</record>
# 4. Use with_delay correctly
backend.with_delay().sync_products() # Correct
backend.sync_products() # Wrong - runs synchronously
```
#### Problem: Jobs failing silently
**Solution**:
```python
# 1. Check job logs
# Queue > Jobs > Failed
# Click on job to see error details
# 2. Add try/except with logging
@job
def sync_products(self):
try:
# Sync logic
_logger.info("Product sync completed successfully")
except Exception as e:
_logger.exception("Product sync failed")
raise # Re-raise to mark job as failed
# 3. Configure retry pattern
<record id="queue_job_function_sync_products" model="queue.job.function">
<field name="retry_pattern">{1: 60, 5: 300, 10: 600}</field>
<!-- Retry after 60s, 300s, 600s -->
</record>
```
### 6. Webhook Issues
#### Problem: Webhooks not received
**Diagnosis**:
```bash
# 1. Check route is registered
# In Odoo shell:
routes = request.env['ir.http']._get_routes()
webhook_routes = [r for r in routes if 'webhook' in r]
print(webhook_routes)
# 2. Test webhook endpoint manually
curl -X POST https://yourodoo.com/myconnector/webhook \
-H "Content-Type: application/json" \
-H "X-API-Key: your_key" \
-d '{"test": "data"}'
# 3. Check webhook URL configuration
print(f"Webhook URL: {backend.webhook_url}")
# Ensure this matches the URL configured in external system
```
**Solutions**:
```python
# 1. Ensure controller is registered
class WebhookController(http.Controller):
@http.route('/myconnector/webhook', type='json', auth='none', csrf=False)
def webhook(self):
# IMPORTANT: auth='none', csrf=False for external calls
...
# 2. Update module to load controllers
# In __init__.py:
from . import controllers
# 3. Check firewall/reverse proxy allows POST to webhook URL
# 4. Add debug logging
@http.route('/myconnector/webhook', type='json', auth='none', csrf=False)
def webhook(self):
_logger.info("Webhook received: %s", request.jsonrequest)
...
```
#### Problem: Webhook signature verification failing
**Diagnosis**:
```python
# In controller:
payload = request.httprequest.get_data(as_text=True)
signature = request.httprequest.headers.get('X-Signature')
secret = backend.webhook_secret
expected = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
print(f"Received signature: {signature}")
print(f"Expected signature: {expected}")
print(f"Match: {signature == expected}")
```
**Solutions**:
```python
# 1. Ensure secret matches between Odoo and external system
# 2. Check signature algorithm matches
# Some systems use base64, others hex
# 3. Verify payload encoding
# Use raw payload, not parsed JSON
# 4. Check header name
# Could be X-Signature, X-Webhook-Signature, etc.
# 5. Temporarily disable verification for debugging
if not self._verify_signature(...):
_logger.warning("Signature verification failed, but processing anyway")
# return {'error': 'Invalid signature'}, 401
```
### 7. Data Mapping Issues
#### Problem: Fields not mapping correctly
**Diagnosis**:
```python
# Test mapper in isolation
mapper = ProductImportMapper(work)
external_data = {
'id': 123,
'title': 'Test Product',
'price': 99.99,
}
mapped = mapper.map_record(external_data)
print(f"Mapped values: {mapped.values()}")
# Check each mapping method
for method_name in dir(mapper):
if hasattr(getattr(mapper, method_name), '_mapping'):
result = getattr(mapper, method_name)(external_data)
print(f"{method_name}: {result}")
```
**Solutions**:
```python
# 1. Use @mapping decorator
@mapping
def product_name(self, record):
return {'name': record['title']} # Must return dict!
# 2. Handle missing fields
@mapping
def category(self, record):
category_name = record.get('category', {}).get('name')
if not category_name:
return {} # Return empty dict, not None
# 3. Use only_create for default values
@only_create
def default_code(self, record):
return {'default_code': record.get('sku', 'SKU_MISSING')}
```
### 8. Performance Issues
#### Problem: Sync takes too long
**Diagnosis**:
```python
import time
def sync_products(self):
start = time.time()
# Time each step
t1 = time.time()
products = adapter.get_products()
print(f"Fetch: {time.time() - t1:.2f}s for {len(products)} products")
t2 = time.time()
for product in products:
self.import_product(product['id'])
print(f"Import: {time.time() - t2:.2f}s")
print(f"Total: {time.time() - start:.2f}s")
```
**Solutions**:
```python
# 1. Use batch operations
def sync_products(self):
# Fetch all products at once (if API supports)
products = adapter.get_all_products()
# Process in batches
batch_size = 100
for i in range(0, len(products), batch_size):
batch = products[i:i+batch_size]
self.with_delay().import_product_batch(batch)
# 2. Reduce database queries
# Use search_read instead of browse
products = env['product.template'].search_read(
[('id', 'in', product_ids)],
['name', 'list_price']
)
# 3. Use SQL for bulk operations
self.env.cr.execute("""
UPDATE myconnector_product_template
SET sync_status = 'success'
WHERE backend_id = %s
""", (backend.id,))
# 4. Disable expensive computations during import
# Use context flags
binding.with_context(skip_compute=True).write(values)
```
### 9. Module Installation Issues
#### Problem: Module won't install/upgrade
**Common Errors**:
```
ParseError: Invalid XML
SyntaxError: Invalid Python syntax
ProgrammingError: relation does not exist
```
**Solutions**:
```bash
# 1. Check logs
tail -f /var/log/odoo/odoo.log
# 2. Validate XML syntax
xmllint --noout views/*.xml
# 3. Check Python syntax
python3 -m py_compile models/*.py
# 4. Drop and recreate database (dev only!)
dropdb test_db
createdb test_db
odoo-bin -c odoo.conf -d test_db -i myconnector
# 5. Update with stop-after-init to see errors
odoo-bin -c odoo.conf -d test_db -u myconnector --stop-after-init
# 6. Check dependencies
# In __manifest__.py, ensure all 'depends' modules are installed
```
### 10. Debugging Tips
#### Enable Debug Logging
```python
# In code:
import logging
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.DEBUG)
# In odoo.conf:
log_level = debug
log_handler = :DEBUG
```
#### Use Odoo Shell
```bash
odoo-bin shell -c odoo.conf -d your_db
>>> backend = env['myconnector.backend'].browse(1)
>>> backend.sync_products()
>>> env.cr.rollback() # Rollback changes
```
#### Use pdb Debugger
```python
def sync_products(self):
import pdb; pdb.set_trace() # Debugger will pause here
products = adapter.get_products()
...
```
#### Monitor API Calls
```python
# Add request/response logging
def make_request(self, method, endpoint, **kwargs):
_logger.debug("Request: %s %s", method, endpoint)
_logger.debug("Params: %s", kwargs.get('params'))
_logger.debug("Data: %s", kwargs.get('data'))
response = super().make_request(method, endpoint, **kwargs)
_logger.debug("Response status: %s", response.status_code if hasattr(response, 'status_code') else 'N/A')
_logger.debug("Response data: %s", response[:500] if isinstance(response, str) else str(response)[:500])
return response
```
## Error Reference
| Error | Cause | Solution |
|-------|-------|----------|
| ComponentNotFound | Component not registered | Check `_name`, `_apply_on`, `_usage` |
| MissingError | Record deleted | Check `exists()` before operations |
| AccessError | Permission denied | Check security rules and groups |
| ValidationError | Constraint violated | Check required fields and constraints |
| HTTPError 401 | Invalid credentials | Refresh tokens or re-authenticate |
| HTTPError 429 | Rate limited | Implement rate limiting and backoff |
| HTTPError 500 | Server error | Retry with exponential backoff |
| TypeError in mapper | Wrong return type | Mappers must return dict |
| IntegrityError | Duplicate key | Check SQL constraints |
| JSONDecodeError | Invalid JSON | Check API response format |

View File

@@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""
Add a new binding model to an existing Odoo connector module.
Usage:
python3 add_binding.py <connector_module_path> <entity_name> [--odoo-model <model>]
Arguments:
connector_module_path: Path to existing connector module
entity_name: Name of the entity (e.g., 'order', 'customer', 'invoice')
--odoo-model: Odoo model to bind (default: inferred from entity name)
Examples:
python3 add_binding.py ~/addons/shopify_connector order
python3 add_binding.py ~/addons/shopify_connector customer --odoo-model res.partner
python3 add_binding.py ~/addons/shopify_connector invoice --odoo-model account.move
"""
import argparse
import os
import sys
from pathlib import Path
# Map common entity names to Odoo models
ENTITY_MODEL_MAP = {
'product': 'product.template',
'variant': 'product.product',
'order': 'sale.order',
'customer': 'res.partner',
'invoice': 'account.move',
'payment': 'account.payment',
'picking': 'stock.picking',
'inventory': 'stock.quant',
'category': 'product.category',
'pricelist': 'product.pricelist',
'tax': 'account.tax',
}
def sanitize_name(name):
"""Convert name to valid Python identifier."""
return name.lower().replace('-', '_').replace(' ', '_')
def get_connector_info(module_path):
"""Extract connector name and module name from path."""
manifest_path = module_path / '__manifest__.py'
if not manifest_path.exists():
raise ValueError(f"No __manifest__.py found at {module_path}")
with open(manifest_path, 'r') as f:
manifest_content = f.read()
# Try to extract module name from path
module_name = module_path.name
if module_name.endswith('_connector'):
connector_name = module_name.replace('_connector', '')
else:
connector_name = module_name
return {
'module_name': module_name,
'connector_name': connector_name,
'module_path': module_path
}
def generate_binding_model(connector_info, entity_name, odoo_model):
"""Generate binding model Python code."""
module_name = connector_info['module_name']
connector_name = connector_info['connector_name']
entity_lower = sanitize_name(entity_name)
# Generate class name
class_name_parts = [word.capitalize() for word in connector_name.split('_')]
class_name_parts.append(entity_name.capitalize())
class_name = ''.join(class_name_parts) + 'Binding'
# Determine binding model name
binding_model_name = f"{connector_name}.{odoo_model.replace('.', '.')}"
# Extract Odoo model information
odoo_model_parts = odoo_model.split('.')
inherits_field_name = odoo_model_parts[-1] + '_id'
code = f'''# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class {class_name}(models.Model):
"""
Binding between Odoo {odoo_model} and {connector_name.title()} {entity_name}s.
This model links Odoo {entity_name} records with their counterparts
in the {connector_name.title()} system.
"""
_name = '{binding_model_name}'
_inherit = 'generic.binding'
_inherits = {{'{odoo_model}': 'odoo_id'}}
_description = '{connector_name.title()} {entity_name.title()} Binding'
odoo_id = fields.Many2one(
comodel_name='{odoo_model}',
string='{entity_name.title()}',
required=True,
ondelete='cascade',
help='Odoo {entity_name} record'
)
# External system fields
external_status = fields.Char(
string='External Status',
readonly=True,
help='Status in {connector_name.title()}'
)
external_number = fields.Char(
string='External Number',
readonly=True,
help='Reference number in {connector_name.title()}'
)
# Synchronization metadata
sync_date = fields.Datetime(
string='Last Sync Date',
readonly=True,
help='Last successful synchronization date'
)
external_created_at = fields.Datetime(
string='Created in {connector_name.title()}',
readonly=True
)
external_updated_at = fields.Datetime(
string='Updated in {connector_name.title()}',
readonly=True
)
# Sync control flags
no_export = fields.Boolean(
string='No Export',
help='Prevent automatic export to {connector_name.title()}'
)
_sql_constraints = [
('backend_external_uniq', 'unique(backend_id, external_id)',
'A {entity_name} binding with the same external ID already exists for this backend.')
]
@api.model
def import_record(self, backend, external_id):
"""
Import a single {entity_name} from {connector_name.title()}.
Args:
backend: Backend record
external_id: External ID of the {entity_name}
Returns:
Binding record
"""
_logger.info('Importing {entity_name} %s from backend %s', external_id, backend.name)
with backend.work_on(self._name) as work:
importer = work.component(usage='record.importer')
return importer.run(external_id)
@api.model
def import_batch(self, backend, filters=None):
"""
Import {entity_name}s in batch from {connector_name.title()}.
Args:
backend: Backend record
filters: Optional filters for the import
Returns:
List of imported binding records
"""
_logger.info('Starting batch import of {entity_name}s for backend %s', backend.name)
with backend.work_on(self._name) as work:
importer = work.component(usage='batch.importer')
return importer.run(filters=filters)
def export_record(self):
"""
Export {entity_name} to {connector_name.title()}.
Returns:
External ID of the exported record
"""
self.ensure_one()
if self.no_export:
_logger.info('{entity_name.title()} %s marked as no_export, skipping', self.odoo_id.display_name)
return False
_logger.info('Exporting {entity_name} %s to backend %s', self.odoo_id.display_name, self.backend_id.name)
with self.backend_id.work_on(self._name) as work:
exporter = work.component(usage='record.exporter')
return exporter.run(self)
@api.model
def export_batch(self, backend, domain=None):
"""
Export multiple {entity_name}s to {connector_name.title()}.
Args:
backend: Backend record
domain: Optional domain to filter records
Returns:
Number of exported records
"""
if domain is None:
domain = []
domain.append(('backend_id', '=', backend.id))
domain.append(('no_export', '=', False))
bindings = self.search(domain)
_logger.info('Exporting %d {entity_name}s to backend %s', len(bindings), backend.name)
for binding in bindings:
binding.with_delay().export_record()
return len(bindings)
def resync_record(self):
"""Re-import the record from {connector_name.title()}."""
self.ensure_one()
if not self.external_id:
raise UserError(_('Cannot resync: No external ID found'))
return self.import_record(self.backend_id, self.external_id)
'''
return code
def generate_binding_view(connector_info, entity_name, odoo_model):
"""Generate binding view XML."""
module_name = connector_info['module_name']
connector_name = connector_info['connector_name']
entity_lower = sanitize_name(entity_name)
binding_model_name = f"{connector_name}.{odoo_model.replace('.', '.')}"
view_id_prefix = f"view_{connector_name}_{entity_lower}"
xml = f'''<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- {entity_name.title()} Binding Form View -->
<record id="{view_id_prefix}_form" model="ir.ui.view">
<field name="name">{binding_model_name}.form</field>
<field name="model">{binding_model_name}</field>
<field name="arch" type="xml">
<form string="{connector_name.title()} {entity_name.title()}">
<header>
<button name="export_record"
type="object"
string="Export to {connector_name.title()}"
class="oe_highlight"
attrs="{{'invisible': [('external_id', '!=', False)]}}"/>
<button name="resync_record"
type="object"
string="Resync"
attrs="{{'invisible': [('external_id', '=', False)]}}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<!-- Add smart buttons here -->
</div>
<group>
<group name="odoo_info" string="Odoo Information">
<field name="odoo_id"/>
<field name="backend_id"/>
</group>
<group name="external_info" string="{connector_name.title()} Information">
<field name="external_id" readonly="1"/>
<field name="external_number" readonly="1"/>
<field name="external_status" readonly="1"/>
</group>
</group>
<group>
<group name="sync_info" string="Synchronization">
<field name="sync_date" readonly="1"/>
<field name="external_created_at" readonly="1"/>
<field name="external_updated_at" readonly="1"/>
</group>
<group name="sync_control" string="Control">
<field name="no_export"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- {entity_name.title()} Binding Tree View -->
<record id="{view_id_prefix}_tree" model="ir.ui.view">
<field name="name">{binding_model_name}.tree</field>
<field name="model">{binding_model_name}</field>
<field name="arch" type="xml">
<tree string="{connector_name.title()} {entity_name.title()}s">
<field name="odoo_id"/>
<field name="external_id"/>
<field name="external_status"/>
<field name="backend_id"/>
<field name="sync_date"/>
</tree>
</field>
</record>
<!-- {entity_name.title()} Binding Search View -->
<record id="{view_id_prefix}_search" model="ir.ui.view">
<field name="name">{binding_model_name}.search</field>
<field name="model">{binding_model_name}</field>
<field name="arch" type="xml">
<search string="{connector_name.title()} {entity_name.title()}s">
<field name="odoo_id"/>
<field name="external_id"/>
<field name="backend_id"/>
<filter string="Not Exported" name="not_exported"
domain="[('external_id', '=', False)]"/>
<filter string="No Export Flag" name="no_export"
domain="[('no_export', '=', True)]"/>
<group expand="0" string="Group By">
<filter string="Backend" name="group_backend"
context="{{'group_by': 'backend_id'}}"/>
<filter string="Status" name="group_status"
context="{{'group_by': 'external_status'}}"/>
</group>
</search>
</field>
</record>
<!-- {entity_name.title()} Binding Action -->
<record id="action_{connector_name}_{entity_lower}" model="ir.actions.act_window">
<field name="name">{connector_name.title()} {entity_name.title()}s</field>
<field name="res_model">{binding_model_name}</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="{view_id_prefix}_search"/>
</record>
<!-- Menu Item (add under existing Data menu) -->
<menuitem id="menu_{connector_name}_{entity_lower}s"
name="{entity_name.title()}s"
parent="menu_{connector_name}_data"
action="action_{connector_name}_{entity_lower}"
sequence="20"/>
</odoo>
'''
return xml
def generate_adapter_methods(connector_info, entity_name):
"""Generate adapter methods to add to existing adapter."""
entity_lower = sanitize_name(entity_name)
entity_plural = entity_lower + 's' # Simple pluralization
code = f'''
# CRUD operations for {entity_plural}
def get_{entity_lower}(self, external_id):
"""Get {entity_lower} by external ID."""
return self._make_request('GET', f'/{entity_plural}/{{external_id}}')
def get_{entity_plural}(self, filters=None):
"""Get list of {entity_plural}."""
return self._make_request('GET', '/{entity_plural}', params=filters)
def create_{entity_lower}(self, data):
"""Create {entity_lower}."""
return self._make_request('POST', '/{entity_plural}', data=data)
def update_{entity_lower}(self, external_id, data):
"""Update {entity_lower}."""
return self._make_request('PUT', f'/{entity_plural}/{{external_id}}', data=data)
def delete_{entity_lower}(self, external_id):
"""Delete {entity_lower}."""
return self._make_request('DELETE', f'/{entity_plural}/{{external_id}}')
'''
return code
def generate_security_entries(connector_info, entity_name, odoo_model):
"""Generate security entries for ir.model.access.csv."""
module_name = connector_info['module_name']
connector_name = connector_info['connector_name']
entity_lower = sanitize_name(entity_name)
model_name_sanitized = odoo_model.replace('.', '_')
entries = f'''access_{connector_name}_{model_name_sanitized}_user,{connector_name}.{odoo_model} user,model_{connector_name}_{model_name_sanitized},group_{connector_name}_user,1,0,0,0
access_{connector_name}_{model_name_sanitized}_manager,{connector_name}.{odoo_model} manager,model_{connector_name}_{model_name_sanitized},group_{connector_name}_manager,1,1,1,1
'''
return entries
def update_module_files(connector_info, entity_name, odoo_model):
"""Update module files to include new binding."""
module_path = connector_info['module_path']
entity_lower = sanitize_name(entity_name)
print(f"\\n📝 Updating module files...")
# 1. Create binding model file
binding_file = module_path / 'models' / f'{entity_lower}_binding.py'
binding_code = generate_binding_model(connector_info, entity_name, odoo_model)
with open(binding_file, 'w') as f:
f.write(binding_code)
print(f"✅ Created: models/{entity_lower}_binding.py")
# 2. Update models/__init__.py
models_init = module_path / 'models' / '__init__.py'
with open(models_init, 'r') as f:
init_content = f.read()
import_line = f"from . import {entity_lower}_binding\\n"
if import_line not in init_content:
with open(models_init, 'a') as f:
f.write(import_line)
print(f"✅ Updated: models/__init__.py")
# 3. Create view file
view_file = module_path / 'views' / f'{entity_lower}_views.xml'
view_xml = generate_binding_view(connector_info, entity_name, odoo_model)
with open(view_file, 'w') as f:
f.write(view_xml)
print(f"✅ Created: views/{entity_lower}_views.xml")
# 4. Update __manifest__.py data list
manifest_file = module_path / '__manifest__.py'
with open(manifest_file, 'r') as f:
manifest_content = f.read()
# Add view file to manifest if not already there
view_entry = f" 'views/{entity_lower}_views.xml',\\n"
if view_entry not in manifest_content:
# Find the views section and add
manifest_content = manifest_content.replace(
" 'views/binding_views.xml',",
f" 'views/binding_views.xml',\\n{view_entry}"
)
with open(manifest_file, 'w') as f:
f.write(manifest_content)
print(f"✅ Updated: __manifest__.py (added view reference)")
# 5. Update security file
security_file = module_path / 'security' / 'ir.model.access.csv'
security_entries = generate_security_entries(connector_info, entity_name, odoo_model)
with open(security_file, 'a') as f:
f.write(security_entries)
print(f"✅ Updated: security/ir.model.access.csv")
# 6. Show adapter methods to add manually
print(f"\\n📋 Add these methods to models/adapter.py:")
print("=" * 60)
print(generate_adapter_methods(connector_info, entity_name))
print("=" * 60)
def main():
parser = argparse.ArgumentParser(
description='Add a new binding model to an existing Odoo connector module'
)
parser.add_argument(
'connector_module_path',
help='Path to existing connector module'
)
parser.add_argument(
'entity_name',
help='Name of the entity (e.g., order, customer, invoice)'
)
parser.add_argument(
'--odoo-model',
help='Odoo model to bind (default: auto-detected from entity name)'
)
args = parser.parse_args()
try:
# Validate module path
module_path = Path(args.connector_module_path)
if not module_path.exists():
raise ValueError(f"Module path does not exist: {module_path}")
# Get connector info
connector_info = get_connector_info(module_path)
# Determine Odoo model
odoo_model = args.odoo_model
if not odoo_model:
entity_lower = sanitize_name(args.entity_name)
odoo_model = ENTITY_MODEL_MAP.get(entity_lower)
if not odoo_model:
print(f"\\n⚠ Could not auto-detect Odoo model for '{args.entity_name}'")
print(f"Please specify using --odoo-model option")
print(f"\\nCommon mappings:")
for entity, model in ENTITY_MODEL_MAP.items():
print(f" {entity}: {model}")
sys.exit(1)
print(f"\\n🚀 Adding {args.entity_name.title()} Binding")
print(f" Module: {connector_info['module_name']}")
print(f" Odoo Model: {odoo_model}")
print(f" Location: {module_path}\\n")
# Update module files
update_module_files(connector_info, args.entity_name, odoo_model)
print(f"\\n✅ Binding for '{args.entity_name}' added successfully!")
print(f"\\nNext steps:")
print(f"1. Add adapter methods to models/adapter.py (see output above)")
print(f"2. Implement importer component for {args.entity_name}")
print(f"3. Implement exporter component for {args.entity_name}")
print(f"4. Update module and test: odoo-bin -u {connector_info['module_name']}")
except Exception as e:
print(f"\\n❌ Error: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
Validate Odoo connector module structure and dependencies.
Usage:
python3 validate_connector.py <connector_module_path>
Example:
python3 validate_connector.py ~/addons/shopify_connector
"""
import argparse
import sys
from pathlib import Path
import ast
class ConnectorValidator:
"""Validator for Odoo connector modules."""
def __init__(self, module_path):
self.module_path = Path(module_path)
self.errors = []
self.warnings = []
self.info = []
def error(self, message):
"""Add error message."""
self.errors.append(f"❌ ERROR: {message}")
def warning(self, message):
"""Add warning message."""
self.warnings.append(f"⚠️ WARNING: {message}")
def info_msg(self, message):
"""Add info message."""
self.info.append(f" INFO: {message}")
def validate_structure(self):
"""Validate basic directory structure."""
print("\\n🔍 Validating module structure...")
required_files = [
'__manifest__.py',
'__init__.py',
'models/__init__.py',
'security/ir.model.access.csv',
]
for file_path in required_files:
full_path = self.module_path / file_path
if not full_path.exists():
self.error(f"Required file missing: {file_path}")
else:
self.info_msg(f"Found: {file_path}")
recommended_dirs = ['views', 'security', 'models', 'wizards', 'data']
for dir_name in recommended_dirs:
dir_path = self.module_path / dir_name
if not dir_path.exists():
self.warning(f"Recommended directory missing: {dir_name}/")
def validate_manifest(self):
"""Validate __manifest__.py."""
print("\\n🔍 Validating __manifest__.py...")
manifest_path = self.module_path / '__manifest__.py'
if not manifest_path.exists():
return # Already reported in structure validation
try:
with open(manifest_path, 'r') as f:
content = f.read()
manifest = ast.literal_eval(content)
# Check required fields
required_fields = ['name', 'version', 'depends', 'data']
for field in required_fields:
if field not in manifest:
self.error(f"__manifest__.py missing required field: {field}")
else:
self.info_msg(f"Found manifest field: {field}")
# Check dependencies
if 'depends' in manifest:
depends = manifest['depends']
if 'generic_connector' not in depends:
self.error("Missing dependency: generic_connector")
else:
self.info_msg("Found dependency: generic_connector")
if 'queue_job' not in depends:
self.warning("Missing recommended dependency: queue_job")
else:
self.info_msg("Found dependency: queue_job")
# Check version format
if 'version' in manifest:
version = manifest['version']
if not version.startswith('16.0.'):
self.warning(f"Version should start with '16.0.' for Odoo 16: {version}")
except Exception as e:
self.error(f"Failed to parse __manifest__.py: {str(e)}")
def validate_models(self):
"""Validate model files."""
print("\\n🔍 Validating models...")
models_dir = self.module_path / 'models'
if not models_dir.exists():
return
# Check for backend model
backend_files = list(models_dir.glob('*backend*.py'))
if not backend_files:
self.error("No backend model found (should have a file like 'backend.py')")
else:
self.info_msg(f"Found backend model: {backend_files[0].name}")
self._validate_backend_model(backend_files[0])
# Check for adapter
adapter_files = list(models_dir.glob('*adapter*.py'))
if not adapter_files:
self.warning("No adapter model found (recommended: 'adapter.py')")
else:
self.info_msg(f"Found adapter model: {adapter_files[0].name}")
# Check for binding models
binding_files = list(models_dir.glob('*binding*.py'))
if not binding_files:
self.warning("No binding models found")
else:
self.info_msg(f"Found {len(binding_files)} binding model(s)")
def _validate_backend_model(self, backend_file):
"""Validate backend model content."""
try:
with open(backend_file, 'r') as f:
content = f.read()
# Check for inheritance from generic.backend
if 'generic.backend' not in content:
self.error("Backend model should inherit from 'generic.backend'")
# Check for _backend_type
if '_backend_type' not in content:
self.warning("Backend model should define '_backend_type'")
# Check for API configuration fields
recommended_fields = ['api_url', 'api_key']
for field in recommended_fields:
if field not in content:
self.warning(f"Backend model missing recommended field: {field}")
except Exception as e:
self.error(f"Failed to validate backend model: {str(e)}")
def validate_security(self):
"""Validate security configuration."""
print("\\n🔍 Validating security...")
access_file = self.module_path / 'security' / 'ir.model.access.csv'
if not access_file.exists():
return
try:
with open(access_file, 'r') as f:
lines = f.readlines()
if len(lines) < 2:
self.warning("Security file seems empty (should have header + access rules)")
return
# Check header
header = lines[0].strip()
expected_header = 'id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink'
if header != expected_header:
self.error("Invalid security file header")
# Count rules
num_rules = len(lines) - 1
self.info_msg(f"Found {num_rules} access rule(s)")
if num_rules == 0:
self.warning("No access rules defined")
except Exception as e:
self.error(f"Failed to validate security file: {str(e)}")
def validate_views(self):
"""Validate view files."""
print("\\n🔍 Validating views...")
views_dir = self.module_path / 'views'
if not views_dir.exists():
self.warning("No views directory found")
return
view_files = list(views_dir.glob('*.xml'))
if not view_files:
self.warning("No view XML files found")
return
self.info_msg(f"Found {len(view_files)} view file(s)")
# Check for backend views
backend_view_files = [f for f in view_files if 'backend' in f.name]
if not backend_view_files:
self.warning("No backend views found")
else:
self.info_msg("Found backend views")
def print_results(self):
"""Print validation results."""
print("\\n" + "=" * 70)
print("VALIDATION RESULTS")
print("=" * 70)
if self.errors:
print(f"\\n🔴 ERRORS ({len(self.errors)}):")
for error in self.errors:
print(f" {error}")
if self.warnings:
print(f"\\n🟡 WARNINGS ({len(self.warnings)}):")
for warning in self.warnings:
print(f" {warning}")
if self.info:
print(f"\\n🟢 INFO ({len(self.info)}):")
for info in self.info:
print(f" {info}")
print("\\n" + "=" * 70)
if self.errors:
print("\\n❌ VALIDATION FAILED: Please fix errors above")
return False
elif self.warnings:
print("\\n⚠ VALIDATION PASSED WITH WARNINGS: Consider addressing warnings")
return True
else:
print("\\n✅ VALIDATION PASSED: Module structure looks good!")
return True
def validate(self):
"""Run all validations."""
self.validate_structure()
self.validate_manifest()
self.validate_models()
self.validate_security()
self.validate_views()
return self.print_results()
def main():
parser = argparse.ArgumentParser(
description='Validate Odoo connector module structure and dependencies'
)
parser.add_argument(
'connector_module_path',
help='Path to connector module to validate'
)
args = parser.parse_args()
module_path = Path(args.connector_module_path)
if not module_path.exists():
print(f"\\n❌ Error: Module path does not exist: {module_path}", file=sys.stderr)
sys.exit(1)
if not module_path.is_dir():
print(f"\\n❌ Error: Path is not a directory: {module_path}", file=sys.stderr)
sys.exit(1)
print(f"\\n🚀 Validating Connector Module")
print(f" Location: {module_path}\\n")
validator = ConnectorValidator(module_path)
success = validator.validate()
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()