Initial commit
This commit is contained in:
1285
skills/odoo-connector-module-creator/SKILL.md
Normal file
1285
skills/odoo-connector-module-creator/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
```
|
||||
451
skills/odoo-connector-module-creator/references/architecture.md
Normal file
451
skills/odoo-connector-module-creator/references/architecture.md
Normal 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
|
||||
@@ -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
|
||||
562
skills/odoo-connector-module-creator/references/patterns.md
Normal file
562
skills/odoo-connector-module-creator/references/patterns.md
Normal 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
|
||||
```
|
||||
@@ -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 |
|
||||
537
skills/odoo-connector-module-creator/scripts/add_binding.py
Normal file
537
skills/odoo-connector-module-creator/scripts/add_binding.py
Normal 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()
|
||||
1171
skills/odoo-connector-module-creator/scripts/init_connector.py
Normal file
1171
skills/odoo-connector-module-creator/scripts/init_connector.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
Reference in New Issue
Block a user