Initial commit
This commit is contained in:
357
skills/odoo-code-reviewer/SKILL.md
Normal file
357
skills/odoo-code-reviewer/SKILL.md
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
name: odoo-code-reviewer
|
||||
description: Reviews Odoo 16.0 code for best practices, security issues, performance problems, and OCA guidelines compliance. This skill should be used when the user requests code review, such as "Review this code" or "Check this module for issues" or "Is this code optimized?" or "Security review needed for this module".
|
||||
---
|
||||
|
||||
# Odoo Code Reviewer
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive code review for Odoo 16.0 modules, checking for security vulnerabilities, performance issues, OCA guideline compliance, and general best practices.
|
||||
|
||||
## Review Categories
|
||||
|
||||
### 1. Security Issues
|
||||
SQL injection, XSS vulnerabilities, improper sudo() usage, missing input validation.
|
||||
|
||||
### 2. Performance Problems
|
||||
N+1 queries, inefficient searches, unnecessary database operations.
|
||||
|
||||
### 3. OCA Guidelines Compliance
|
||||
Code style, structure, naming conventions, documentation.
|
||||
|
||||
### 4. Best Practices
|
||||
Proper API usage, error handling, logging, testing.
|
||||
|
||||
### 5. Maintainability
|
||||
Code organization, readability, documentation, modularity.
|
||||
|
||||
## Review Process
|
||||
|
||||
### Step 1: Identify Review Scope
|
||||
|
||||
Determine what to review:
|
||||
- Complete module
|
||||
- Specific model files
|
||||
- View files
|
||||
- Security configuration
|
||||
- Specific functionality
|
||||
|
||||
### Step 2: Systematic Review
|
||||
|
||||
Check each category systematically following the patterns below.
|
||||
|
||||
## Review Patterns
|
||||
|
||||
### Security Review Checklist
|
||||
|
||||
**1. SQL Injection Risk**
|
||||
```python
|
||||
# BAD - SQL injection vulnerability
|
||||
self.env.cr.execute("SELECT * FROM table WHERE id = %s" % record_id)
|
||||
|
||||
# GOOD - Parameterized query
|
||||
self.env.cr.execute("SELECT * FROM table WHERE id = %s", (record_id,))
|
||||
```
|
||||
|
||||
**2. XSS Vulnerabilities**
|
||||
```python
|
||||
# BAD - Unescaped HTML field
|
||||
description = fields.Char(string='Description')
|
||||
|
||||
# GOOD - Use Text or Html field with sanitization
|
||||
description = fields.Html(string='Description', sanitize=True)
|
||||
```
|
||||
|
||||
**3. Improper sudo() Usage**
|
||||
```python
|
||||
# BAD - sudo() without justification
|
||||
records = self.env['model'].sudo().search([])
|
||||
|
||||
# GOOD - Check permissions properly
|
||||
if self.env.user.has_group('base.group_system'):
|
||||
records = self.env['model'].search([])
|
||||
```
|
||||
|
||||
**4. Missing Input Validation**
|
||||
```python
|
||||
# BAD - No validation
|
||||
def process(self, value):
|
||||
return int(value)
|
||||
|
||||
# GOOD - Proper validation
|
||||
def process(self, value):
|
||||
if not value or not isinstance(value, (int, str)):
|
||||
raise ValueError('Invalid value')
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValidationError('Value must be a valid integer')
|
||||
```
|
||||
|
||||
### Performance Review Checklist
|
||||
|
||||
**1. N+1 Query Problem**
|
||||
```python
|
||||
# BAD - N+1 queries
|
||||
for order in orders:
|
||||
print(order.partner_id.name) # Database query for each iteration
|
||||
|
||||
# GOOD - Prefetch
|
||||
for order in orders:
|
||||
pass # partner_id prefetched automatically
|
||||
print([o.partner_id.name for o in orders])
|
||||
|
||||
# EVEN BETTER - Explicit prefetch
|
||||
orders = orders.with_prefetch(['partner_id'])
|
||||
```
|
||||
|
||||
**2. Inefficient Searches**
|
||||
```python
|
||||
# BAD - Search in loop
|
||||
for partner in partners:
|
||||
orders = self.env['sale.order'].search([('partner_id', '=', partner.id)])
|
||||
|
||||
# GOOD - Single search
|
||||
orders = self.env['sale.order'].search([('partner_id', 'in', partners.ids)])
|
||||
```
|
||||
|
||||
**3. Unnecessary Database Operations**
|
||||
```python
|
||||
# BAD - Multiple writes
|
||||
for line in lines:
|
||||
line.write({'processed': True})
|
||||
|
||||
# GOOD - Batch write
|
||||
lines.write({'processed': True})
|
||||
```
|
||||
|
||||
**4. Inefficient Computed Fields**
|
||||
```python
|
||||
# BAD - Not stored, recalculated every time
|
||||
total = fields.Float(compute='_compute_total')
|
||||
|
||||
# GOOD - Stored with proper depends
|
||||
total = fields.Float(compute='_compute_total', store=True)
|
||||
|
||||
@api.depends('line_ids.amount')
|
||||
def _compute_total(self):
|
||||
for record in self:
|
||||
record.total = sum(record.line_ids.mapped('amount'))
|
||||
```
|
||||
|
||||
### OCA Guidelines Checklist
|
||||
|
||||
**1. Naming Conventions**
|
||||
- Module: `snake_case` (e.g., `stock_batch_tracking`)
|
||||
- Model: `model.name` (e.g., `stock.batch`)
|
||||
- Fields: `snake_case`
|
||||
- Methods: `snake_case` with verb prefix
|
||||
- Private methods: `_method_name`
|
||||
|
||||
**2. Import Order**
|
||||
```python
|
||||
# Standard library
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# Third-party
|
||||
from lxml import etree
|
||||
|
||||
# Odoo
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import float_compare
|
||||
```
|
||||
|
||||
**3. Docstrings**
|
||||
```python
|
||||
class Model(models.Model):
|
||||
"""Brief description of model."""
|
||||
|
||||
_name = 'model.name'
|
||||
_description = 'Model Description'
|
||||
|
||||
def method(self, param):
|
||||
"""Brief description of method.
|
||||
|
||||
Args:
|
||||
param: Description of parameter
|
||||
|
||||
Returns:
|
||||
Description of return value
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
**4. Field Attributes**
|
||||
```python
|
||||
# GOOD - Complete field definition
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
index=True,
|
||||
tracking=True,
|
||||
help='Detailed help text'
|
||||
)
|
||||
```
|
||||
|
||||
### Best Practices Checklist
|
||||
|
||||
**1. Error Handling**
|
||||
```python
|
||||
# BAD - Generic exception
|
||||
try:
|
||||
value = int(data)
|
||||
except:
|
||||
pass
|
||||
|
||||
# GOOD - Specific exception with logging
|
||||
try:
|
||||
value = int(data)
|
||||
except ValueError as e:
|
||||
_logger.error('Invalid data: %s', e)
|
||||
raise ValidationError('Please provide a valid number')
|
||||
```
|
||||
|
||||
**2. Logging**
|
||||
```python
|
||||
# BAD - Print statements
|
||||
print("Processing record", record.id)
|
||||
|
||||
# GOOD - Proper logging
|
||||
_logger.info('Processing record %s', record.id)
|
||||
_logger.debug('Record data: %s', record.read())
|
||||
```
|
||||
|
||||
**3. Method Decorators**
|
||||
```python
|
||||
# Ensure proper decorator usage
|
||||
@api.depends('field1', 'field2') # For computed fields
|
||||
def _compute_field(self): pass
|
||||
|
||||
@api.onchange('field1') # For onchange methods
|
||||
def _onchange_field(self): pass
|
||||
|
||||
@api.constrains('field1') # For constraints
|
||||
def _check_field(self): pass
|
||||
|
||||
@api.model # For class-level methods
|
||||
def create_from_ui(self, vals): pass
|
||||
```
|
||||
|
||||
**4. Transaction Safety**
|
||||
```python
|
||||
# BAD - Commit in method
|
||||
def method(self):
|
||||
self.process()
|
||||
self.env.cr.commit() # Don't do this!
|
||||
|
||||
# GOOD - Let Odoo handle transactions
|
||||
def method(self):
|
||||
self.process()
|
||||
# Transaction committed automatically
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Provide review results in this format:
|
||||
|
||||
### Critical Issues
|
||||
- **Security**: List any security vulnerabilities
|
||||
- **Data Loss Risk**: Operations that could cause data loss
|
||||
|
||||
### High Priority Issues
|
||||
- **Performance**: Major performance problems
|
||||
- **Incorrect Logic**: Business logic errors
|
||||
|
||||
### Medium Priority Issues
|
||||
- **OCA Compliance**: Guideline violations
|
||||
- **Code Quality**: Maintainability issues
|
||||
|
||||
### Low Priority Issues
|
||||
- **Style**: Minor style issues
|
||||
- **Documentation**: Missing or incomplete docs
|
||||
|
||||
### Recommendations
|
||||
- Suggested improvements
|
||||
- Best practice suggestions
|
||||
- Refactoring opportunities
|
||||
|
||||
## Common Anti-Patterns
|
||||
|
||||
1. **Using search() in loops**
|
||||
2. **Not using prefetch**
|
||||
3. **Missing translations** (`string` without `_()` for translatable text)
|
||||
4. **Hardcoded values** instead of configuration
|
||||
5. **Incorrect sudo() usage**
|
||||
6. **Missing input validation**
|
||||
7. **Poor error messages**
|
||||
8. **Inefficient computed fields**
|
||||
9. **Missing access rights**
|
||||
10. **No unit tests**
|
||||
|
||||
## Example Review
|
||||
|
||||
```python
|
||||
# CODE BEING REVIEWED
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
total_weight = fields.Float(compute='_compute_weight')
|
||||
|
||||
def _compute_weight(self):
|
||||
for order in self:
|
||||
weight = 0
|
||||
for line in order.order_line:
|
||||
product = self.env['product.product'].search([('id', '=', line.product_id.id)])
|
||||
weight += product.weight * line.product_uom_qty
|
||||
order.total_weight = weight
|
||||
```
|
||||
|
||||
**Review Findings:**
|
||||
|
||||
**HIGH - Performance Issues:**
|
||||
1. Unnecessary search in loop (line 10)
|
||||
- FIX: Use `line.product_id.weight` directly
|
||||
2. Not storing computed field
|
||||
- FIX: Add `store=True` and `@api.depends` decorator
|
||||
|
||||
**MEDIUM - Best Practices:**
|
||||
1. Missing `@api.depends` decorator
|
||||
- FIX: Add `@api.depends('order_line.product_id.weight', 'order_line.product_uom_qty')`
|
||||
2. Variable could be clearer
|
||||
- FIX: Rename `weight` to `total_weight`
|
||||
|
||||
**Improved Code:**
|
||||
```python
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
total_weight = fields.Float(
|
||||
string='Total Weight',
|
||||
compute='_compute_weight',
|
||||
store=True,
|
||||
help='Total weight of all order lines'
|
||||
)
|
||||
|
||||
@api.depends('order_line.product_id.weight', 'order_line.product_uom_qty')
|
||||
def _compute_weight(self):
|
||||
"""Compute total weight from order lines."""
|
||||
for order in self:
|
||||
order.total_weight = sum(
|
||||
line.product_id.weight * line.product_uom_qty
|
||||
for line in order.order_line
|
||||
)
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### references/oca_guidelines.md
|
||||
Complete OCA (Odoo Community Association) coding guidelines for Odoo modules.
|
||||
|
||||
### references/security_checklist.md
|
||||
Comprehensive security checklist for Odoo development.
|
||||
|
||||
### references/performance_patterns.md
|
||||
Common performance patterns and anti-patterns with examples and fixes.
|
||||
1
skills/odoo-code-reviewer/references/oca_guidelines.md
Normal file
1
skills/odoo-code-reviewer/references/oca_guidelines.md
Normal file
@@ -0,0 +1 @@
|
||||
OCA Guidelines reference
|
||||
@@ -0,0 +1 @@
|
||||
Performance patterns
|
||||
@@ -0,0 +1 @@
|
||||
Security checklist
|
||||
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()
|
||||
356
skills/odoo-debugger/SKILL.md
Normal file
356
skills/odoo-debugger/SKILL.md
Normal file
@@ -0,0 +1,356 @@
|
||||
---
|
||||
name: odoo-debugger
|
||||
description: Analyzes and resolves Odoo 16.0 issues including SVL linking problems, queue job failures, view errors, and business logic bugs. This skill should be used when the user reports problems such as "Debug this SVL linking issue" or "Queue job is failing" or "View not showing correctly" or "Figure out why this vendor bill isn't linking to stock moves".
|
||||
---
|
||||
|
||||
# Odoo Debugger & Issue Resolver
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides systematic debugging approaches for common Odoo 16.0 issues, with specialized knowledge of SVL (Stock Valuation Layer) linking, queue jobs, view inheritance problems, and business logic errors specific to the Siafa project.
|
||||
|
||||
## Issue Categories
|
||||
|
||||
### 1. SVL (Stock Valuation Layer) Issues
|
||||
Stock valuation layers not linking properly to vendor bills or account moves.
|
||||
|
||||
### 2. Queue Job Failures
|
||||
Background jobs failing or getting stuck in queue_job system.
|
||||
|
||||
### 3. View/XML Errors
|
||||
Views not rendering, XPath inheritance issues, missing fields.
|
||||
|
||||
### 4. Business Logic Bugs
|
||||
Computed fields not calculating, onchange not triggering, constraints failing.
|
||||
|
||||
### 5. Data Integrity Issues
|
||||
Orphaned records, inconsistent data, broken relationships.
|
||||
|
||||
### 6. Performance Problems
|
||||
Slow queries, N+1 problems, inefficient computed fields.
|
||||
|
||||
### 7. Access Rights Issues
|
||||
Permission errors, record rules blocking access.
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
### Step 1: Gather Information
|
||||
|
||||
Ask for:
|
||||
- Error message or traceback (full text)
|
||||
- Steps to reproduce the issue
|
||||
- Which module/model is affected
|
||||
- Recent changes or updates
|
||||
- Database name and Odoo version
|
||||
|
||||
### Step 2: Categorize the Issue
|
||||
|
||||
Identify which category the issue falls into and follow the specialized workflow.
|
||||
|
||||
## Debugging Patterns by Category
|
||||
|
||||
### Pattern 1: SVL Linking Issues
|
||||
|
||||
**Common Symptoms:**
|
||||
- Stock valuation layers exist but not linked to vendor bills
|
||||
- Account move lines don't reference SVL
|
||||
- Quantity mismatch between stock moves and SVL
|
||||
- Missing SVL for stock moves
|
||||
|
||||
**Investigation Script:**
|
||||
|
||||
```python
|
||||
# In Odoo shell (python3 src/odoo-bin shell -c src/odoo.conf -d DATABASE_NAME)
|
||||
|
||||
# Get SVL record
|
||||
svl = env['stock.valuation.layer'].browse(SVL_ID)
|
||||
|
||||
# Check SVL details
|
||||
print(f"SVL ID: {svl.id}")
|
||||
print(f"Product: {svl.product_id.name}")
|
||||
print(f"Quantity: {svl.quantity}")
|
||||
print(f"Value: {svl.value}")
|
||||
print(f"Unit Cost: {svl.unit_cost}")
|
||||
print(f"Stock Move: {svl.stock_move_id.name if svl.stock_move_id else 'NONE'}")
|
||||
print(f"Account Move: {svl.account_move_id.name if svl.account_move_id else 'NONE'}")
|
||||
|
||||
# Check stock move linkage
|
||||
if svl.stock_move_id:
|
||||
move = svl.stock_move_id
|
||||
print(f"\nStock Move Details:")
|
||||
print(f" Name: {move.name}")
|
||||
print(f" State: {move.state}")
|
||||
print(f" Picking: {move.picking_id.name if move.picking_id else 'NONE'}")
|
||||
print(f" Purchase Line: {move.purchase_line_id.id if move.purchase_line_id else 'NONE'}")
|
||||
|
||||
# Check for vendor bill
|
||||
if move.purchase_line_id:
|
||||
po_line = move.purchase_line_id
|
||||
print(f"\nPurchase Order Line:")
|
||||
print(f" Order: {po_line.order_id.name}")
|
||||
print(f" Invoice Lines: {po_line.invoice_lines}")
|
||||
|
||||
for inv_line in po_line.invoice_lines:
|
||||
print(f" Invoice: {inv_line.move_id.name}, State: {inv_line.move_id.state}")
|
||||
|
||||
# Check account move lines
|
||||
if svl.account_move_id:
|
||||
print(f"\nAccount Move Lines:")
|
||||
for line in svl.account_move_id.line_ids:
|
||||
print(f" Account: {line.account_id.code} - {line.account_id.name}")
|
||||
print(f" Debit: {line.debit}, Credit: {line.credit}")
|
||||
print(f" SVL ID in context: {line.stock_valuation_layer_id.id if line.stock_valuation_layer_id else 'NONE'}")
|
||||
|
||||
# Find orphaned SVLs (SQL)
|
||||
env.cr.execute("""
|
||||
SELECT svl.id, svl.product_id, svl.quantity, svl.value
|
||||
FROM stock_valuation_layer svl
|
||||
WHERE svl.stock_move_id IS NULL
|
||||
OR svl.account_move_id IS NULL
|
||||
LIMIT 100
|
||||
""")
|
||||
orphaned = env.cr.dictfetchall()
|
||||
print(f"\nFound {len(orphaned)} potentially orphaned SVLs")
|
||||
```
|
||||
|
||||
**Common Fixes:**
|
||||
|
||||
1. **Re-link SVL to Account Move:**
|
||||
```python
|
||||
svl = env['stock.valuation.layer'].browse(SVL_ID)
|
||||
account_move = env['account.move'].browse(ACCOUNT_MOVE_ID)
|
||||
|
||||
# Update SVL
|
||||
svl.write({'account_move_id': account_move.id})
|
||||
|
||||
# Update account move lines
|
||||
for line in account_move.line_ids:
|
||||
if line.account_id == svl.product_id.categ_id.property_stock_valuation_account_id:
|
||||
line.write({'stock_valuation_layer_id': svl.id})
|
||||
```
|
||||
|
||||
2. **Regenerate SVL:**
|
||||
```python
|
||||
stock_move = env['stock.move'].browse(MOVE_ID)
|
||||
stock_move._create_stock_valuation_layers()
|
||||
```
|
||||
|
||||
### Pattern 2: Queue Job Failures
|
||||
|
||||
**Investigation:**
|
||||
|
||||
```python
|
||||
# Find failed jobs
|
||||
failed_jobs = env['queue.job'].search([
|
||||
('state', '=', 'failed'),
|
||||
('date_created', '>=', '2025-01-01')
|
||||
])
|
||||
|
||||
for job in failed_jobs:
|
||||
print(f"\nJob: {job.name}")
|
||||
print(f" UUID: {job.uuid}")
|
||||
print(f" State: {job.state}")
|
||||
print(f" Date Failed: {job.date_done}")
|
||||
print(f" Exception:\n{job.exc_info}")
|
||||
|
||||
# Retry the job
|
||||
# job.requeue()
|
||||
```
|
||||
|
||||
**Common Fixes:**
|
||||
|
||||
1. **Retry failed job:**
|
||||
```python
|
||||
job = env['queue.job'].browse(JOB_ID)
|
||||
job.requeue()
|
||||
```
|
||||
|
||||
2. **Cancel stuck job:**
|
||||
```python
|
||||
job.write({'state': 'done'}) # or 'cancelled'
|
||||
```
|
||||
|
||||
### Pattern 3: View/XML Errors
|
||||
|
||||
**Common Issues:**
|
||||
- XPath not finding target element
|
||||
- Field doesn't exist in model
|
||||
- View inheritance loop
|
||||
- Incorrect XML ID reference
|
||||
|
||||
**Investigation:**
|
||||
|
||||
1. **Check if view exists:**
|
||||
```python
|
||||
view = env.ref('module_name.view_id')
|
||||
print(view.arch_db) # Print XML
|
||||
```
|
||||
|
||||
2. **Find view by model:**
|
||||
```python
|
||||
views = env['ir.ui.view'].search([('model', '=', 'stock.picking')])
|
||||
for v in views:
|
||||
print(f"{v.name}: {v.xml_id}")
|
||||
```
|
||||
|
||||
3. **Test XPath expression:**
|
||||
```python
|
||||
from lxml import etree
|
||||
view = env.ref('stock.view_picking_form')
|
||||
arch = etree.fromstring(view.arch_db)
|
||||
|
||||
# Test xpath
|
||||
result = arch.xpath("//field[@name='partner_id']")
|
||||
print(f"Found {len(result)} elements")
|
||||
```
|
||||
|
||||
**Common Fixes:**
|
||||
|
||||
1. **Fix XPath - Use Developer Mode to inspect actual view structure**
|
||||
2. **Ensure field exists in model before adding to view**
|
||||
3. **Check view priority if inheritance not working**
|
||||
|
||||
### Pattern 4: Business Logic Bugs
|
||||
|
||||
**Computed Field Not Updating:**
|
||||
|
||||
```python
|
||||
# Force recompute
|
||||
record = env['model.name'].browse(RECORD_ID)
|
||||
record._recompute_field('field_name')
|
||||
|
||||
# Check dependencies
|
||||
field = env['model.name']._fields['field_name']
|
||||
print(f"Depends: {field.depends}")
|
||||
|
||||
# Test compute method directly
|
||||
record._compute_field_name()
|
||||
```
|
||||
|
||||
**Onchange Not Triggering:**
|
||||
|
||||
```python
|
||||
# Onchange methods only work in UI
|
||||
# Test via form:
|
||||
record.onchange('field_name', 'partner_id')
|
||||
```
|
||||
|
||||
**Constraint Failing:**
|
||||
|
||||
```python
|
||||
# Test constraint
|
||||
try:
|
||||
record._check_constraint_name()
|
||||
print("Constraint passed")
|
||||
except ValidationError as e:
|
||||
print(f"Constraint failed: {e}")
|
||||
```
|
||||
|
||||
### Pattern 5: Data Investigation (SQL)
|
||||
|
||||
**Finding Data Inconsistencies:**
|
||||
|
||||
```python
|
||||
# Stock moves without SVL
|
||||
env.cr.execute("""
|
||||
SELECT sm.id, sm.name, sm.product_id, sm.state
|
||||
FROM stock_move sm
|
||||
LEFT JOIN stock_valuation_layer svl ON svl.stock_move_id = sm.id
|
||||
WHERE sm.state = 'done'
|
||||
AND svl.id IS NULL
|
||||
AND sm.product_id IN (
|
||||
SELECT id FROM product_product WHERE type = 'product'
|
||||
)
|
||||
LIMIT 50
|
||||
""")
|
||||
print(env.cr.dictfetchall())
|
||||
|
||||
# Vendor bills with no SVL link
|
||||
env.cr.execute("""
|
||||
SELECT am.id, am.name, am.partner_id, am.amount_total
|
||||
FROM account_move am
|
||||
WHERE am.move_type = 'in_invoice'
|
||||
AND am.state = 'posted'
|
||||
AND am.id NOT IN (
|
||||
SELECT DISTINCT account_move_id
|
||||
FROM stock_valuation_layer
|
||||
WHERE account_move_id IS NOT NULL
|
||||
)
|
||||
LIMIT 50
|
||||
""")
|
||||
print(env.cr.dictfetchall())
|
||||
```
|
||||
|
||||
## Debugging Tools
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
In `odoo.conf`:
|
||||
```ini
|
||||
log_level = debug
|
||||
log_handler = :DEBUG
|
||||
```
|
||||
|
||||
Or via command line:
|
||||
```bash
|
||||
python3 src/odoo-bin -c src/odoo.conf --log-level=debug --log-handler=:DEBUG
|
||||
```
|
||||
|
||||
### Add Logging to Code
|
||||
|
||||
```python
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def method(self):
|
||||
_logger.info('Method called with %s', self)
|
||||
_logger.debug('Detailed debug info: %s', self.read())
|
||||
_logger.warning('Something unusual: %s', issue)
|
||||
_logger.error('Error occurred: %s', error)
|
||||
```
|
||||
|
||||
### Use pdb for Debugging
|
||||
|
||||
```python
|
||||
import pdb; pdb.set_trace()
|
||||
```
|
||||
|
||||
## Common Error Patterns
|
||||
|
||||
### AccessError
|
||||
```
|
||||
User does not have access rights
|
||||
```
|
||||
**Fix:** Check `ir.model.access.csv` and record rules
|
||||
|
||||
### ValidationError
|
||||
```
|
||||
Constraint validation failed
|
||||
```
|
||||
**Fix:** Check `@api.constrains` methods
|
||||
|
||||
### MissingError
|
||||
```
|
||||
Record does not exist
|
||||
```
|
||||
**Fix:** Check if record was deleted, use `exists()` method
|
||||
|
||||
### SQL Errors
|
||||
```
|
||||
column does not exist
|
||||
```
|
||||
**Fix:** Update module to create/modify fields
|
||||
|
||||
## Resources
|
||||
|
||||
### scripts/investigate_svl.py
|
||||
Python script for automated SVL investigation - checks linkage, finds orphaned records, validates data consistency.
|
||||
|
||||
### scripts/check_queue_jobs.py
|
||||
Script to analyze queue job status, identify stuck jobs, and generate repair commands.
|
||||
|
||||
### references/debugging_queries.md
|
||||
Collection of useful SQL queries for data investigation and debugging common issues.
|
||||
|
||||
### references/common_issues.md
|
||||
Database of known issues specific to the Siafa project with solutions and workarounds.
|
||||
1
skills/odoo-debugger/references/common_issues.md
Normal file
1
skills/odoo-debugger/references/common_issues.md
Normal file
@@ -0,0 +1 @@
|
||||
Created common issues database
|
||||
1
skills/odoo-debugger/references/debugging_queries.md
Normal file
1
skills/odoo-debugger/references/debugging_queries.md
Normal file
@@ -0,0 +1 @@
|
||||
Created basic debugging query reference
|
||||
19
skills/odoo-debugger/scripts/example.py
Executable file
19
skills/odoo-debugger/scripts/example.py
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example helper script for odoo-debugger
|
||||
|
||||
This is a placeholder script that can be executed directly.
|
||||
Replace with actual implementation or delete if not needed.
|
||||
|
||||
Example real scripts from other skills:
|
||||
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
|
||||
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("This is an example script for odoo-debugger")
|
||||
# TODO: Add actual script logic here
|
||||
# This could be data processing, file conversion, API calls, etc.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
440
skills/odoo-feature-enhancer/SKILL.md
Normal file
440
skills/odoo-feature-enhancer/SKILL.md
Normal file
@@ -0,0 +1,440 @@
|
||||
---
|
||||
name: odoo-feature-enhancer
|
||||
description: Extends existing Odoo 16.0 modules with new features, fields, views, business logic, wizards, and reports. This skill should be used when the user requests enhancements to existing functionality, such as "Add a field to track serial numbers in stock.picking" or "Create a wizard for bulk invoice generation" or "Add a report for vendor bill analysis".
|
||||
---
|
||||
|
||||
# Odoo Feature Enhancer
|
||||
|
||||
## Overview
|
||||
|
||||
This skill enables extension of existing Odoo 16.0 modules by adding new fields, views, business logic, wizards, reports, and automated actions. It follows module inheritance patterns and ensures proper integration with existing functionality.
|
||||
|
||||
## Enhancement Categories
|
||||
|
||||
When a user requests a feature enhancement, identify the category and follow the appropriate workflow:
|
||||
|
||||
### 1. Field Additions
|
||||
Add new fields to existing models (stored, computed, related).
|
||||
|
||||
### 2. View Modifications
|
||||
Extend existing views (tree, form, kanban, calendar, pivot, graph) using XML inheritance.
|
||||
|
||||
### 3. Business Logic
|
||||
Add computed methods, onchange handlers, constraints, and custom business rules.
|
||||
|
||||
### 4. Wizards
|
||||
Create transient models for user interactions and batch operations.
|
||||
|
||||
### 5. Reports
|
||||
Generate PDF (QWeb) or Excel reports with custom data aggregation.
|
||||
|
||||
### 6. Server Actions
|
||||
Create automated actions, scheduled actions (cron jobs), and workflow automations.
|
||||
|
||||
### 7. Buttons and Actions
|
||||
Add action buttons to forms and tree views.
|
||||
|
||||
## Enhancement Workflow
|
||||
|
||||
### Step 1: Identify the Enhancement Type
|
||||
|
||||
Ask clarifying questions based on the request:
|
||||
|
||||
**For Field Additions:**
|
||||
- Field technical name (snake_case)
|
||||
- Field type (Char, Integer, Float, Boolean, Selection, Many2one, One2many, Many2many, Date, Datetime, Text, Html, Binary, Monetary)
|
||||
- Field label and help text
|
||||
- Required or optional?
|
||||
- Computed field or stored?
|
||||
- Should it appear in specific views?
|
||||
|
||||
**For View Modifications:**
|
||||
- Which view(s) to modify? (form, tree, search, kanban)
|
||||
- Which model?
|
||||
- Where to add the element? (header, group, notebook page, after specific field)
|
||||
- Any conditional visibility?
|
||||
|
||||
**For Business Logic:**
|
||||
- Trigger condition (onchange, compute, constraint, button click)
|
||||
- Dependencies (which fields trigger the logic)
|
||||
- Expected behavior
|
||||
|
||||
**For Wizards:**
|
||||
- Wizard purpose (batch update, data export, configuration, etc.)
|
||||
- Input fields needed
|
||||
- Target models to affect
|
||||
- Where to trigger (menu, button on form, action)
|
||||
|
||||
**For Reports:**
|
||||
- Report format (PDF or Excel)
|
||||
- Data to include
|
||||
- Grouping and aggregation
|
||||
- Filters needed
|
||||
|
||||
### Step 2: Create or Identify Extension Module
|
||||
|
||||
Determine if enhancement goes in:
|
||||
- New extension module (e.g., `stock_picking_serial_tracking`)
|
||||
- Existing extension module
|
||||
|
||||
If creating new module, provide:
|
||||
- Module name: `[base_module]_[feature]` (e.g., `stock_picking_enhancements`)
|
||||
- Dependencies: base module being extended
|
||||
- Target directory: appropriate `addons-*` folder
|
||||
|
||||
### Step 3: Implement the Enhancement
|
||||
|
||||
Follow the appropriate implementation pattern below.
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Pattern 1: Adding Fields to Existing Models
|
||||
|
||||
Create model inheritance file:
|
||||
|
||||
```python
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockPickingInherit(models.Model):
|
||||
"""Extend stock.picking with additional fields."""
|
||||
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
# Simple stored field
|
||||
serial_number = fields.Char(
|
||||
string='Serial Number',
|
||||
index=True,
|
||||
tracking=True,
|
||||
help='Serial number for tracking purposes'
|
||||
)
|
||||
|
||||
# Selection field
|
||||
priority_level = fields.Selection([
|
||||
('low', 'Low'),
|
||||
('medium', 'Medium'),
|
||||
('high', 'High'),
|
||||
('urgent', 'Urgent'),
|
||||
], string='Priority Level', default='medium', required=True)
|
||||
|
||||
# Many2one field
|
||||
responsible_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Responsible Person',
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
# Computed field (stored)
|
||||
total_weight = fields.Float(
|
||||
string='Total Weight',
|
||||
compute='_compute_total_weight',
|
||||
store=True,
|
||||
digits=(10, 2)
|
||||
)
|
||||
|
||||
# Computed field (non-stored, real-time)
|
||||
is_urgent = fields.Boolean(
|
||||
string='Is Urgent',
|
||||
compute='_compute_is_urgent'
|
||||
)
|
||||
|
||||
# Related field (from related record)
|
||||
partner_country_id = fields.Many2one(
|
||||
'res.country',
|
||||
string='Partner Country',
|
||||
related='partner_id.country_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
@api.depends('move_line_ids', 'move_line_ids.qty_done', 'move_line_ids.product_id.weight')
|
||||
def _compute_total_weight(self):
|
||||
"""Compute total weight from move lines."""
|
||||
for picking in self:
|
||||
total = sum(
|
||||
line.qty_done * line.product_id.weight
|
||||
for line in picking.move_line_ids
|
||||
if line.product_id.weight
|
||||
)
|
||||
picking.total_weight = total
|
||||
|
||||
@api.depends('priority_level', 'scheduled_date')
|
||||
def _compute_is_urgent(self):
|
||||
"""Determine if picking is urgent."""
|
||||
from datetime import datetime, timedelta
|
||||
for picking in self:
|
||||
is_urgent = picking.priority_level == 'urgent'
|
||||
if picking.scheduled_date:
|
||||
due_soon = picking.scheduled_date <= datetime.now() + timedelta(hours=24)
|
||||
is_urgent = is_urgent or due_soon
|
||||
picking.is_urgent = is_urgent
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
"""Auto-fill fields when partner changes."""
|
||||
if self.partner_id and self.partner_id.user_id:
|
||||
self.responsible_id = self.partner_id.user_id
|
||||
|
||||
@api.constrains('serial_number')
|
||||
def _check_serial_number(self):
|
||||
"""Validate serial number format."""
|
||||
for picking in self:
|
||||
if picking.serial_number and len(picking.serial_number) < 5:
|
||||
raise ValidationError('Serial number must be at least 5 characters long!')
|
||||
```
|
||||
|
||||
Create view inheritance to display the new fields:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Extend stock.picking form view -->
|
||||
<record id="view_picking_form_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.form.inherit</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Add fields in header -->
|
||||
<xpath expr="//header" position="after">
|
||||
<div class="alert alert-danger" role="alert" attrs="{'invisible': [('is_urgent', '=', False)]}">
|
||||
<strong>URGENT:</strong> This picking requires immediate attention!
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Add field after existing field -->
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="responsible_id"/>
|
||||
<field name="serial_number"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Add field inside existing group -->
|
||||
<xpath expr="//group[@name='other_info']//field[@name='origin']" position="after">
|
||||
<field name="priority_level"/>
|
||||
<field name="total_weight"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Add new notebook page -->
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Tracking Info">
|
||||
<group>
|
||||
<field name="serial_number"/>
|
||||
<field name="is_urgent"/>
|
||||
<field name="partner_country_id"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend tree view -->
|
||||
<record id="view_picking_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.tree.inherit</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_internal_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree" position="attributes">
|
||||
<attribute name="decoration-danger">is_urgent</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="serial_number"/>
|
||||
<field name="priority_level"/>
|
||||
<field name="is_urgent" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend search view with filters -->
|
||||
<record id="view_picking_search_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.search.inherit</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_internal_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//search" position="inside">
|
||||
<field name="serial_number"/>
|
||||
<filter string="Urgent" name="urgent" domain="[('is_urgent', '=', True)]"/>
|
||||
<filter string="High Priority" name="high_priority" domain="[('priority_level', '=', 'high')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Priority Level" name="priority" context="{'group_by': 'priority_level'}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
### Pattern 2: Creating Wizards
|
||||
|
||||
Wizard model (transient):
|
||||
|
||||
```python
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class BulkInvoiceWizard(models.TransientModel):
|
||||
"""Wizard for bulk invoice generation."""
|
||||
|
||||
_name = 'bulk.invoice.wizard'
|
||||
_description = 'Bulk Invoice Generation Wizard'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Partner',
|
||||
help='Leave empty to process all partners'
|
||||
)
|
||||
date_from = fields.Date(
|
||||
string='Date From',
|
||||
required=True
|
||||
)
|
||||
date_to = fields.Date(
|
||||
string='Date To',
|
||||
required=True
|
||||
)
|
||||
invoice_date = fields.Date(
|
||||
string='Invoice Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today
|
||||
)
|
||||
group_by_partner = fields.Boolean(
|
||||
string='Group by Partner',
|
||||
default=True,
|
||||
help='Create one invoice per partner'
|
||||
)
|
||||
|
||||
@api.constrains('date_from', 'date_to')
|
||||
def _check_dates(self):
|
||||
"""Validate date range."""
|
||||
if self.date_from > self.date_to:
|
||||
raise UserError('Date From must be before Date To!')
|
||||
|
||||
def action_generate_invoices(self):
|
||||
"""Generate invoices based on wizard parameters."""
|
||||
self.ensure_one()
|
||||
|
||||
# Get records to invoice
|
||||
domain = [
|
||||
('date', '>=', self.date_from),
|
||||
('date', '<=', self.date_to),
|
||||
('invoice_status', '=', 'to invoice'),
|
||||
]
|
||||
if self.partner_id:
|
||||
domain.append(('partner_id', '=', self.partner_id.id))
|
||||
|
||||
orders = self.env['sale.order'].search(domain)
|
||||
if not orders:
|
||||
raise UserError('No orders found matching the criteria!')
|
||||
|
||||
# Group by partner if requested
|
||||
if self.group_by_partner:
|
||||
partners = orders.mapped('partner_id')
|
||||
invoices = self.env['account.move']
|
||||
for partner in partners:
|
||||
partner_orders = orders.filtered(lambda o: o.partner_id == partner)
|
||||
invoice = partner_orders._create_invoices()
|
||||
invoices |= invoice
|
||||
else:
|
||||
invoices = orders._create_invoices()
|
||||
|
||||
# Update invoice dates
|
||||
invoices.write({'invoice_date': self.invoice_date})
|
||||
|
||||
# Return action to view created invoices
|
||||
return {
|
||||
'name': 'Generated Invoices',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'tree,form',
|
||||
'domain': [('id', 'in', invoices.ids)],
|
||||
'context': {'create': False},
|
||||
}
|
||||
```
|
||||
|
||||
Wizard view:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_bulk_invoice_wizard_form" model="ir.ui.view">
|
||||
<field name="name">bulk.invoice.wizard.form</field>
|
||||
<field name="model">bulk.invoice.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate Bulk Invoices">
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="group_by_partner"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="invoice_date"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Generate Invoices" name="action_generate_invoices"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action to open wizard -->
|
||||
<record id="action_bulk_invoice_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Generate Bulk Invoices</field>
|
||||
<field name="res_model">bulk.invoice.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu item -->
|
||||
<menuitem id="menu_bulk_invoice_wizard"
|
||||
name="Generate Bulk Invoices"
|
||||
parent="account.menu_finance"
|
||||
action="action_bulk_invoice_wizard"
|
||||
sequence="100"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
For more implementation patterns including action buttons, reports (PDF/Excel), and scheduled actions, reference the `references/implementation_patterns.md` file.
|
||||
|
||||
## Update Instructions
|
||||
|
||||
After implementing enhancements:
|
||||
|
||||
1. **Update __manifest__.py** - Add new data files and dependencies
|
||||
2. **Update security** - Add access rights for new models
|
||||
3. **Update module** - Run with `-u module_name`
|
||||
4. **Test** - Verify all functionality works
|
||||
|
||||
```bash
|
||||
# Update module
|
||||
python3 /Users/jamshid/PycharmProjects/Siafa/src/odoo-bin \
|
||||
-c /Users/jamshid/PycharmProjects/Siafa/src/odoo.conf \
|
||||
-d DATABASE_NAME \
|
||||
-u module_name
|
||||
|
||||
# Run tests if available
|
||||
python3 /Users/jamshid/PycharmProjects/Siafa/src/odoo-bin \
|
||||
-c /Users/jamshid/PycharmProjects/Siafa/src/odoo.conf \
|
||||
-d DATABASE_NAME \
|
||||
--test-enable \
|
||||
--stop-after-init \
|
||||
-u module_name
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### references/xpath_patterns.md
|
||||
Comprehensive collection of XPath expressions for view inheritance - how to add fields before/after elements, replace content, add attributes, etc.
|
||||
|
||||
### references/field_types.md
|
||||
Complete reference of Odoo field types with examples and common attributes for each type.
|
||||
|
||||
### references/implementation_patterns.md
|
||||
Additional implementation patterns for action buttons, PDF reports, Excel reports, and scheduled actions (cron jobs).
|
||||
34
skills/odoo-feature-enhancer/references/api_reference.md
Normal file
34
skills/odoo-feature-enhancer/references/api_reference.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Reference Documentation for Odoo Feature Enhancer
|
||||
|
||||
This is a placeholder for detailed reference documentation.
|
||||
Replace with actual reference content or delete if not needed.
|
||||
|
||||
Example real reference docs from other skills:
|
||||
- product-management/references/communication.md - Comprehensive guide for status updates
|
||||
- product-management/references/context_building.md - Deep-dive on gathering context
|
||||
- bigquery/references/ - API references and query examples
|
||||
|
||||
## When Reference Docs Are Useful
|
||||
|
||||
Reference docs are ideal for:
|
||||
- Comprehensive API documentation
|
||||
- Detailed workflow guides
|
||||
- Complex multi-step processes
|
||||
- Information too lengthy for main SKILL.md
|
||||
- Content that's only needed for specific use cases
|
||||
|
||||
## Structure Suggestions
|
||||
|
||||
### API Reference Example
|
||||
- Overview
|
||||
- Authentication
|
||||
- Endpoints with examples
|
||||
- Error codes
|
||||
- Rate limits
|
||||
|
||||
### Workflow Guide Example
|
||||
- Prerequisites
|
||||
- Step-by-step instructions
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
81
skills/odoo-feature-enhancer/references/field_types.md
Normal file
81
skills/odoo-feature-enhancer/references/field_types.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Odoo Field Types Reference
|
||||
|
||||
## Basic Field Types
|
||||
|
||||
### Char - String field
|
||||
```python
|
||||
name = fields.Char(string='Name', required=True, size=128, index=True)
|
||||
```
|
||||
|
||||
### Text - Multi-line text
|
||||
```python
|
||||
description = fields.Text(string='Description')
|
||||
```
|
||||
|
||||
### Integer
|
||||
```python
|
||||
quantity = fields.Integer(string='Quantity', default=1)
|
||||
```
|
||||
|
||||
### Float
|
||||
```python
|
||||
price = fields.Float(string='Price', digits=(10, 2))
|
||||
```
|
||||
|
||||
### Boolean
|
||||
```python
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
```
|
||||
|
||||
### Selection
|
||||
```python
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('done', 'Done'),
|
||||
], string='Status', default='draft')
|
||||
```
|
||||
|
||||
### Date / Datetime
|
||||
```python
|
||||
date = fields.Date(string='Date', default=fields.Date.context_today)
|
||||
datetime = fields.Datetime(string='DateTime', default=fields.Datetime.now)
|
||||
```
|
||||
|
||||
### Monetary
|
||||
```python
|
||||
amount = fields.Monetary(string='Amount', currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency')
|
||||
```
|
||||
|
||||
## Relational Fields
|
||||
|
||||
### Many2one - Foreign key
|
||||
```python
|
||||
partner_id = fields.Many2one('res.partner', string='Partner', ondelete='cascade')
|
||||
```
|
||||
|
||||
### One2many - Reverse relationship
|
||||
```python
|
||||
line_ids = fields.One2many('model.line', 'parent_id', string='Lines')
|
||||
```
|
||||
|
||||
### Many2many
|
||||
```python
|
||||
tag_ids = fields.Many2many('model.tag', string='Tags')
|
||||
```
|
||||
|
||||
### Related Field
|
||||
```python
|
||||
partner_email = fields.Char(related='partner_id.email', string='Email', store=True, readonly=True)
|
||||
```
|
||||
|
||||
## Computed Fields
|
||||
|
||||
```python
|
||||
total = fields.Float(compute='_compute_total', store=True)
|
||||
|
||||
@api.depends('line_ids.amount')
|
||||
def _compute_total(self):
|
||||
for record in self:
|
||||
record.total = sum(record.line_ids.mapped('amount'))
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# Additional Implementation Patterns
|
||||
|
||||
## Action Buttons
|
||||
|
||||
```python
|
||||
def action_custom(self):
|
||||
self.ensure_one()
|
||||
# Logic here
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Success',
|
||||
'message': 'Action completed',
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PDF Reports (QWeb)
|
||||
|
||||
```python
|
||||
class CustomReport(models.AbstractModel):
|
||||
_name = 'report.module.report_name'
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
docs = self.env['model.name'].browse(docids)
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'docs': docs,
|
||||
'custom_data': self._prepare_data(docs),
|
||||
}
|
||||
```
|
||||
|
||||
## Excel Reports
|
||||
|
||||
```python
|
||||
from odoo import models
|
||||
|
||||
class ExcelReport(models.AbstractModel):
|
||||
_name = 'report.module.report_xlsx'
|
||||
_inherit = 'report.report_xlsx.abstract'
|
||||
|
||||
def generate_xlsx_report(self, workbook, data, objects):
|
||||
sheet = workbook.add_worksheet('Report')
|
||||
header_format = workbook.add_format({'bold': True})
|
||||
|
||||
sheet.write(0, 0, 'Header 1', header_format)
|
||||
# Add data rows
|
||||
```
|
||||
|
||||
## Scheduled Actions (Cron)
|
||||
|
||||
```xml
|
||||
<record id="ir_cron_task" model="ir.cron">
|
||||
<field name="name">Task Name</field>
|
||||
<field name="model_id" ref="model_model_name"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_method()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
</record>
|
||||
```
|
||||
58
skills/odoo-feature-enhancer/references/xpath_patterns.md
Normal file
58
skills/odoo-feature-enhancer/references/xpath_patterns.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# XPath Patterns for Odoo View Inheritance
|
||||
|
||||
## Common XPath Positions
|
||||
|
||||
- `before` - Insert before the target element
|
||||
- `after` - Insert after the target element
|
||||
- `inside` - Insert inside the target element (as last child)
|
||||
- `replace` - Replace the target element entirely
|
||||
- `attributes` - Add/modify attributes of the target element
|
||||
|
||||
## Examples
|
||||
|
||||
### Add Field After Another Field
|
||||
```xml
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="new_field"/>
|
||||
</xpath>
|
||||
```
|
||||
|
||||
### Add Field Inside Group
|
||||
```xml
|
||||
<xpath expr="//group[@name='group_name']" position="inside">
|
||||
<field name="new_field"/>
|
||||
</xpath>
|
||||
```
|
||||
|
||||
### Replace Field
|
||||
```xml
|
||||
<xpath expr="//field[@name='old_field']" position="replace">
|
||||
<field name="new_field"/>
|
||||
</xpath>
|
||||
```
|
||||
|
||||
### Add Attributes
|
||||
```xml
|
||||
<xpath expr="//field[@name='field_name']" position="attributes">
|
||||
<attribute name="readonly">1</attribute>
|
||||
<attribute name="required">1</attribute>
|
||||
</xpath>
|
||||
```
|
||||
|
||||
### Add to Header
|
||||
```xml
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_custom" string="Custom" type="object"/>
|
||||
</xpath>
|
||||
```
|
||||
|
||||
### Add Notebook Page
|
||||
```xml
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="New Page">
|
||||
<group>
|
||||
<field name="field1"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
```
|
||||
342
skills/odoo-migration-assistant/SKILL.md
Normal file
342
skills/odoo-migration-assistant/SKILL.md
Normal file
@@ -0,0 +1,342 @@
|
||||
---
|
||||
name: odoo-migration-assistant
|
||||
description: Helps migrate Odoo modules and customizations between versions, specifically focusing on upgrades to/from Odoo 16.0. This skill should be used when the user requests migration help, such as "Migrate this module to Odoo 16" or "Upgrade from version 15 to 16" or "What changed in Odoo 16 for stock valuation?" or "Migration guide for this module".
|
||||
---
|
||||
|
||||
# Odoo Migration Assistant
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides guidance for migrating Odoo modules between versions, with specialized knowledge of Odoo 16.0 changes, API differences, and upgrade procedures.
|
||||
|
||||
## Migration Scenarios
|
||||
|
||||
### 1. Upgrade TO Odoo 16.0
|
||||
Migrating modules from older versions (14.0, 15.0) to 16.0.
|
||||
|
||||
### 2. Upgrade FROM Odoo 16.0
|
||||
Preparing modules for future Odoo versions.
|
||||
|
||||
### 3. Version Compatibility Check
|
||||
Determining what changes are needed for version compatibility.
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Step 1: Identify Source and Target Versions
|
||||
|
||||
Ask for:
|
||||
- Current Odoo version
|
||||
- Target Odoo version
|
||||
- Module name and purpose
|
||||
- Dependencies
|
||||
|
||||
### Step 2: Assess Changes Required
|
||||
|
||||
Review:
|
||||
- API changes between versions
|
||||
- Deprecated features
|
||||
- New required fields or methods
|
||||
- View structure changes
|
||||
- Dependency updates
|
||||
|
||||
### Step 3: Create Migration Plan
|
||||
|
||||
Provide step-by-step migration guide.
|
||||
|
||||
## Odoo 16.0 Specific Changes
|
||||
|
||||
### Key Changes in Odoo 16.0
|
||||
|
||||
**1. Python Version**
|
||||
- Minimum: Python 3.8
|
||||
- Recommended: Python 3.10+
|
||||
|
||||
**2. Manifest Changes**
|
||||
```python
|
||||
# Odoo 15 and earlier
|
||||
{
|
||||
'version': '15.0.1.0.0',
|
||||
'depends': ['base', 'stock'],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
# Odoo 16.0
|
||||
{
|
||||
'version': '16.0.1.0.0', # Updated version
|
||||
'depends': ['base', 'stock'],
|
||||
'license': 'LGPL-3',
|
||||
# New optional keys
|
||||
'assets': { # Replaces some XML asset declarations
|
||||
'web.assets_backend': [
|
||||
'module/static/src/**/*',
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**3. Stock Valuation Changes**
|
||||
```python
|
||||
# Odoo 15
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
def _create_account_move_line(self):
|
||||
# Old method signature
|
||||
pass
|
||||
|
||||
# Odoo 16
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost):
|
||||
# Updated method signature with more parameters
|
||||
pass
|
||||
```
|
||||
|
||||
**4. Widget Changes**
|
||||
```xml
|
||||
<!-- Odoo 15 -->
|
||||
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
|
||||
|
||||
<!-- Odoo 16 - Same, but some widgets renamed or removed -->
|
||||
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
|
||||
```
|
||||
|
||||
**5. Removed/Deprecated Methods**
|
||||
- `_update_average_price()` - Replaced with new accounting methods
|
||||
- Some portal methods reorganized
|
||||
|
||||
## Migration Patterns
|
||||
|
||||
### Pattern 1: Update Manifest
|
||||
|
||||
```python
|
||||
# Step 1: Update version number
|
||||
'version': '16.0.1.0.0',
|
||||
|
||||
# Step 2: Check dependencies
|
||||
# Ensure all depends modules are compatible with Odoo 16
|
||||
|
||||
# Step 3: Update data files if needed
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/views.xml',
|
||||
# Remove any deprecated files
|
||||
],
|
||||
|
||||
# Step 4: Move assets if needed
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'module_name/static/src/js/*.js',
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
### Pattern 2: Update Model Fields
|
||||
|
||||
```python
|
||||
# Check for removed fields in base models
|
||||
# Example: If inheriting stock.move, check release notes
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
# Update field definitions if base definition changed
|
||||
custom_field = fields.Char(...)
|
||||
|
||||
# Update method signatures to match new base methods
|
||||
def _action_done(self, cancel_backorder=False):
|
||||
# Match new signature
|
||||
return super()._action_done(cancel_backorder=cancel_backorder)
|
||||
```
|
||||
|
||||
### Pattern 3: Update Views
|
||||
|
||||
```xml
|
||||
<!-- Check for removed/renamed view references -->
|
||||
<record id="view_form" model="ir.ui.view">
|
||||
<field name="inherit_id" ref="stock.view_move_form"/>
|
||||
<!-- Update XPath if base view structure changed -->
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="custom_field"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
### Pattern 4: Create Migration Script
|
||||
|
||||
```python
|
||||
# migrations/16.0.1.0.0/pre-migrate.py
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Pre-migration script for 16.0.1.0.0"""
|
||||
# Update data before module upgrade
|
||||
cr.execute("""
|
||||
UPDATE model_table
|
||||
SET new_field = old_field
|
||||
WHERE new_field IS NULL
|
||||
""")
|
||||
|
||||
# migrations/16.0.1.0.0/post-migrate.py
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Post-migration script for 16.0.1.0.0"""
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Recompute fields
|
||||
records = env['model.name'].search([])
|
||||
records._compute_field_name()
|
||||
|
||||
# Clean up old data
|
||||
old_records = env['old.model'].search([])
|
||||
old_records.unlink()
|
||||
```
|
||||
|
||||
### Pattern 5: Update Tests
|
||||
|
||||
```python
|
||||
# Update test imports if needed
|
||||
from odoo.tests import TransactionCase # Unchanged
|
||||
|
||||
class TestModule(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Update test data for new field requirements
|
||||
|
||||
def test_feature(self):
|
||||
# Update assertions for new behavior
|
||||
record = self.env['model.name'].create({
|
||||
'name': 'Test',
|
||||
# Add new required fields for Odoo 16
|
||||
})
|
||||
self.assertTrue(record)
|
||||
```
|
||||
|
||||
## Version-Specific Changes
|
||||
|
||||
### Migrating FROM 15.0 TO 16.0
|
||||
|
||||
**Major Changes:**
|
||||
1. Stock accounting methods updated
|
||||
2. Some JavaScript widgets updated
|
||||
3. Python 3.10 support added
|
||||
4. Minor ORM improvements
|
||||
|
||||
**Steps:**
|
||||
1. Update `__manifest__.py` version to `16.0.x.x.x`
|
||||
2. Test on Odoo 16 test database
|
||||
3. Check deprecation warnings
|
||||
4. Update any changed method signatures
|
||||
5. Test all functionality
|
||||
6. Create migration scripts if data changes needed
|
||||
|
||||
### Migrating FROM 14.0 TO 16.0
|
||||
|
||||
**Major Changes:**
|
||||
- All changes from 14→15 plus 15→16
|
||||
- Significant OWL (JavaScript framework) changes
|
||||
- Python 2 completely removed
|
||||
- Many deprecated features removed
|
||||
|
||||
**Steps:**
|
||||
1. Consider migrating 14→15→16 (two-step migration)
|
||||
2. Review all custom JavaScript (major changes)
|
||||
3. Update all deprecated API calls
|
||||
4. Extensive testing required
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Update manifest version
|
||||
- [ ] Check all dependencies compatible with target version
|
||||
- [ ] Review Odoo release notes for target version
|
||||
- [ ] Update deprecated method calls
|
||||
- [ ] Test views render correctly
|
||||
- [ ] Update method signatures if base methods changed
|
||||
- [ ] Create migration scripts (pre/post)
|
||||
- [ ] Update tests
|
||||
- [ ] Test on copy of production database
|
||||
- [ ] Check for deprecation warnings in logs
|
||||
- [ ] Update documentation
|
||||
- [ ] Test all user workflows
|
||||
- [ ] Performance test (especially for large datasets)
|
||||
- [ ] Backup production before upgrade
|
||||
|
||||
## Migration Commands
|
||||
|
||||
```bash
|
||||
# Create migration script directory
|
||||
mkdir -p module_name/migrations/16.0.1.0.0
|
||||
|
||||
# Test migration on copy of database
|
||||
pg_dump production_db > backup.sql
|
||||
createdb test_migration_db
|
||||
psql test_migration_db < backup.sql
|
||||
|
||||
# Run Odoo with migration
|
||||
python3 src/odoo-bin -c src/odoo.conf \
|
||||
-d test_migration_db \
|
||||
-u module_name \
|
||||
--stop-after-init
|
||||
|
||||
# Check logs for errors
|
||||
tail -f /var/log/odoo/odoo.log | grep ERROR
|
||||
```
|
||||
|
||||
## Common Migration Issues
|
||||
|
||||
### Issue 1: Missing Field Error
|
||||
```
|
||||
Error: Field 'xyz' does not exist
|
||||
```
|
||||
**Solution:** Add field to model or remove from views
|
||||
|
||||
### Issue 2: Method Signature Changed
|
||||
```
|
||||
TypeError: method() takes X positional arguments but Y were given
|
||||
```
|
||||
**Solution:** Update method call to match new signature
|
||||
|
||||
### Issue 3: View Inheritance Broken
|
||||
```
|
||||
Error: View inheritance may not use attribute: ...
|
||||
```
|
||||
**Solution:** Update XPath or view structure
|
||||
|
||||
### Issue 4: Dependencies Not Found
|
||||
```
|
||||
Error: Module 'xyz' not found
|
||||
```
|
||||
**Solution:** Update dependency version or find replacement
|
||||
|
||||
## Testing After Migration
|
||||
|
||||
```python
|
||||
# Run all tests
|
||||
python3 src/odoo-bin -c src/odoo.conf \
|
||||
-d DATABASE_NAME \
|
||||
--test-enable \
|
||||
--stop-after-init \
|
||||
-u module_name
|
||||
|
||||
# Check specific functionality
|
||||
python3 src/odoo-bin shell -c src/odoo.conf -d DATABASE_NAME
|
||||
|
||||
>>> env['model.name'].search([]).read()
|
||||
>>> # Test key functionality manually
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### references/odoo16_changes.md
|
||||
Comprehensive list of changes introduced in Odoo 16.0 affecting common modules and customizations.
|
||||
|
||||
### references/api_changes.md
|
||||
Detailed API changes by module (stock, account, sale, etc.) between Odoo versions.
|
||||
|
||||
### scripts/migration_template.py
|
||||
Template for creating migration scripts with common patterns and examples.
|
||||
@@ -0,0 +1 @@
|
||||
API changes reference
|
||||
@@ -0,0 +1 @@
|
||||
Odoo 16 changes reference
|
||||
19
skills/odoo-migration-assistant/scripts/example.py
Executable file
19
skills/odoo-migration-assistant/scripts/example.py
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example helper script for odoo-migration-assistant
|
||||
|
||||
This is a placeholder script that can be executed directly.
|
||||
Replace with actual implementation or delete if not needed.
|
||||
|
||||
Example real scripts from other skills:
|
||||
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
|
||||
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("This is an example script for odoo-migration-assistant")
|
||||
# TODO: Add actual script logic here
|
||||
# This could be data processing, file conversion, API calls, etc.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
Migration script template
|
||||
514
skills/odoo-module-creator/SKILL.md
Normal file
514
skills/odoo-module-creator/SKILL.md
Normal file
@@ -0,0 +1,514 @@
|
||||
---
|
||||
name: odoo-module-creator
|
||||
description: Creates complete Odoo 16.0 modules with proper structure, manifests, models, views, and security. This skill should be used when the user requests creation of a new Odoo module, such as "Create a new module for inventory tracking" or "I need a new POS customization module" or "Generate module structure for vendor management".
|
||||
---
|
||||
|
||||
# Odoo Module Creator
|
||||
|
||||
## Overview
|
||||
|
||||
This skill enables creation of complete, production-ready Odoo 16.0 Enterprise modules with proper directory structure, manifest files, models, views, security configurations, and documentation. It follows OCA guidelines and Siafa project standards.
|
||||
|
||||
## Module Creation Workflow
|
||||
|
||||
### Step 1: Gather Module Requirements
|
||||
|
||||
Ask clarifying questions to collect essential information:
|
||||
|
||||
1. **Module technical name** (snake_case format, e.g., `stock_batch_tracking`, `pos_custom_receipt`)
|
||||
2. **Module display name** (human-readable, e.g., "Stock Batch Tracking", "POS Custom Receipt")
|
||||
3. **Module purpose** (1-2 sentence description of functionality)
|
||||
4. **Module category** (select from: Sales, Inventory, Accounting, Point of Sale, Human Resources, Manufacturing, Purchases, Warehouse, Website, etc.)
|
||||
5. **Dependencies** (base modules required, e.g., `stock`, `account`, `point_of_sale`)
|
||||
6. **Module type** (see Module Types section below)
|
||||
7. **Target addon directory** (e.g., `addons-stock`, `addons-pos`, `addons-account`)
|
||||
|
||||
### Step 2: Determine Module Type
|
||||
|
||||
Identify which type of module to create based on the purpose:
|
||||
|
||||
**A. Simple Model Module** - CRUD operations for a new business entity
|
||||
- Creates new models with fields and views
|
||||
- Example: Customer feedback tracking, equipment registry
|
||||
|
||||
**B. Extension Module** - Extends existing Odoo models
|
||||
- Inherits and adds fields/methods to existing models
|
||||
- Example: Add serial number tracking to stock.picking
|
||||
|
||||
**C. POS Customization** - Point of Sale enhancements
|
||||
- Extends POS models, screens, or receipts
|
||||
- Example: Custom receipt format, loyalty points integration
|
||||
|
||||
**D. Stock/Inventory Enhancement** - Warehouse and inventory features
|
||||
- Stock valuation, warehouse operations, batch tracking
|
||||
- Example: Inter-warehouse transit, GRN-invoice linking
|
||||
|
||||
**E. Accounting Customization** - Financial module extensions
|
||||
- Account moves, vendor bills, analytic accounting
|
||||
- Example: Multi-dimensional analytics, custom invoicing
|
||||
|
||||
**F. Report Module** - Custom reports (PDF, Excel)
|
||||
- QWeb templates, data aggregation, export functionality
|
||||
- Example: Sales analysis, inventory valuation reports
|
||||
|
||||
**G. Integration Module** - External API/service connectors
|
||||
- REST API clients, webhooks, data synchronization
|
||||
- Example: Beatroute connector, payment gateway integration
|
||||
|
||||
**H. Widget/UI Customization** - Frontend enhancements
|
||||
- JavaScript widgets, custom views, web controllers
|
||||
- Example: Kanban view customizations, dashboard widgets
|
||||
|
||||
### Step 3: Generate Module Structure
|
||||
|
||||
Create the complete directory structure with all required files:
|
||||
|
||||
```
|
||||
module_name/
|
||||
├── __init__.py
|
||||
├── __manifest__.py
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ └── [model_files].py
|
||||
├── views/
|
||||
│ ├── [model]_views.xml
|
||||
│ └── menu_views.xml
|
||||
├── security/
|
||||
│ ├── security_groups.xml (if needed)
|
||||
│ └── ir.model.access.csv
|
||||
├── data/ (optional)
|
||||
│ └── data.xml
|
||||
├── wizards/ (if needed)
|
||||
│ ├── __init__.py
|
||||
│ └── [wizard_name].py
|
||||
├── report/ (if reports needed)
|
||||
│ ├── __init__.py
|
||||
│ ├── [report_name].py
|
||||
│ └── templates/
|
||||
│ └── [report_template].xml
|
||||
├── static/
|
||||
│ ├── description/
|
||||
│ │ ├── icon.png
|
||||
│ │ └── index.html
|
||||
│ └── src/ (for JS/CSS if needed)
|
||||
│ ├── js/
|
||||
│ └── css/
|
||||
└── tests/ (recommended)
|
||||
├── __init__.py
|
||||
└── test_[module].py
|
||||
```
|
||||
|
||||
### Step 4: Generate __manifest__.py
|
||||
|
||||
Create manifest with standard metadata:
|
||||
|
||||
```python
|
||||
{
|
||||
'name': '[Module Display Name]',
|
||||
'version': '16.0.1.0.0',
|
||||
'category': '[Category]',
|
||||
'summary': '[Brief one-line description]',
|
||||
'description': """
|
||||
[Detailed multi-line description of module functionality]
|
||||
|
||||
Key Features:
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
""",
|
||||
'author': 'Jamshid K',
|
||||
'website': 'https://siafadates.com',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'base',
|
||||
# Additional dependencies
|
||||
],
|
||||
'data': [
|
||||
'security/security_groups.xml', # Load first
|
||||
'security/ir.model.access.csv',
|
||||
'views/[model]_views.xml',
|
||||
'views/menu_views.xml',
|
||||
'data/data.xml', # If needed
|
||||
'report/templates/[report].xml', # If needed
|
||||
],
|
||||
'assets': { # If JS/CSS needed
|
||||
'web.assets_backend': [
|
||||
'module_name/static/src/js/*.js',
|
||||
'module_name/static/src/css/*.css',
|
||||
],
|
||||
},
|
||||
'demo': [], # Demo data if applicable
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False, # True for standalone apps
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Generate Model Files
|
||||
|
||||
Create model files following Odoo ORM best practices:
|
||||
|
||||
```python
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelName(models.Model):
|
||||
"""Description of the model."""
|
||||
|
||||
_name = 'module.model'
|
||||
_description = 'Model Description'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin'] # If needed
|
||||
_order = 'create_date desc'
|
||||
|
||||
# Fields
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
index=True,
|
||||
tracking=True,
|
||||
help='Primary identifier for this record'
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='If unchecked, this record will be hidden'
|
||||
)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('done', 'Done'),
|
||||
('cancel', 'Cancelled'),
|
||||
], string='Status', default='draft', required=True, tracking=True)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company
|
||||
)
|
||||
|
||||
# Relational fields
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
line_ids = fields.One2many('module.model.line', 'parent_id', string='Lines')
|
||||
|
||||
# Computed fields
|
||||
total_amount = fields.Float(
|
||||
string='Total Amount',
|
||||
compute='_compute_total_amount',
|
||||
store=True
|
||||
)
|
||||
|
||||
# Constraints
|
||||
_sql_constraints = [
|
||||
('name_unique', 'UNIQUE(name, company_id)', 'Name must be unique per company!'),
|
||||
]
|
||||
|
||||
@api.depends('line_ids', 'line_ids.amount')
|
||||
def _compute_total_amount(self):
|
||||
"""Compute total amount from lines."""
|
||||
for record in self:
|
||||
record.total_amount = sum(record.line_ids.mapped('amount'))
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
"""Update fields when partner changes."""
|
||||
if self.partner_id:
|
||||
# Logic here
|
||||
pass
|
||||
|
||||
@api.constrains('total_amount')
|
||||
def _check_total_amount(self):
|
||||
"""Validate total amount is positive."""
|
||||
for record in self:
|
||||
if record.total_amount < 0:
|
||||
raise ValidationError('Total amount must be positive!')
|
||||
|
||||
def action_confirm(self):
|
||||
"""Confirm the record."""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError('Only draft records can be confirmed!')
|
||||
self.write({'state': 'confirmed'})
|
||||
_logger.info('Record %s confirmed by user %s', self.name, self.env.user.name)
|
||||
```
|
||||
|
||||
### Step 6: Generate View Files
|
||||
|
||||
Create XML view definitions:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="view_model_tree" model="ir.ui.view">
|
||||
<field name="name">module.model.tree</field>
|
||||
<field name="model">module.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Model Name">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="state" decoration-info="state == 'draft'"
|
||||
decoration-success="state == 'done'"/>
|
||||
<field name="total_amount" sum="Total"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_model_form" model="ir.ui.view">
|
||||
<field name="name">module.model.form</field>
|
||||
<field name="model">module.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Name">
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm" type="object"
|
||||
class="oe_highlight" states="draft"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,confirmed,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Name..."/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="total_amount"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Lines">
|
||||
<field name="line_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="amount"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_model_search" model="ir.ui.view">
|
||||
<field name="name">module.model.search</field>
|
||||
<field name="model">module.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Done" name="done" domain="[('state', '=', 'done')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Partner" name="partner" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Status" name="state" context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_model" model="ir.actions.act_window">
|
||||
<field name="name">Model Name</field>
|
||||
<field name="res_model">module.model</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first record!
|
||||
</p>
|
||||
<p>
|
||||
Click the create button to add a new record.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
### Step 7: Generate Security Files
|
||||
|
||||
Create security groups (if needed):
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="module_category" model="ir.module.category">
|
||||
<field name="name">Module Category</field>
|
||||
<field name="sequence">100</field>
|
||||
</record>
|
||||
|
||||
<record id="group_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="category_id" ref="module_category"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="category_id" ref="module_category"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_user'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
Create access rights CSV:
|
||||
|
||||
```csv
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_model_user,module.model.user,model_module_model,group_user,1,1,1,0
|
||||
access_model_manager,module.model.manager,model_module_model,group_manager,1,1,1,1
|
||||
```
|
||||
|
||||
### Step 8: Generate Tests (Recommended)
|
||||
|
||||
**Use the `odoo-test-creator` skill** to create comprehensive test suites for the module. The odoo-test-creator skill provides:
|
||||
- Test templates for different module types (basic models, constraints, inheritance)
|
||||
- Best practices specific to Siafa project standards
|
||||
- Solutions to common testing pitfalls (database constraints, HTML fields, permissions)
|
||||
- Proper import patterns and setUp methods
|
||||
|
||||
To create tests with the odoo-test-creator skill, simply invoke:
|
||||
|
||||
```
|
||||
Use the odoo-test-creator skill to create tests for [module_name]
|
||||
```
|
||||
|
||||
The skill will:
|
||||
1. Analyze the module structure to determine what needs testing
|
||||
2. Select appropriate test templates based on module type
|
||||
3. Generate comprehensive test methods for CRUD operations, constraints, computed fields, and business logic
|
||||
4. Handle database constraints properly (using existing records vs. creating with .sudo())
|
||||
5. Apply Siafa-specific patterns and best practices
|
||||
|
||||
**Quick Test Example** (if not using the skill):
|
||||
|
||||
```python
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestModel(TransactionCase):
|
||||
"""Test cases for module.model"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['module.model']
|
||||
|
||||
# Use existing records when possible
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
def test_01_create_model(self):
|
||||
"""Test creating a model record"""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertTrue(record)
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
def test_02_constraint_validation(self):
|
||||
"""Test constraint validation"""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.write({'invalid_field': 'invalid_value'})
|
||||
|
||||
self.assertIn('expected error', str(context.exception))
|
||||
```
|
||||
|
||||
**Important:** For production modules, always use the `odoo-test-creator` skill to ensure comprehensive test coverage and proper handling of Siafa-specific constraints.
|
||||
|
||||
|
||||
|
||||
## Code Standards and Best Practices
|
||||
|
||||
Follow these standards when generating module code:
|
||||
|
||||
1. **Naming Conventions**
|
||||
- Module name: `snake_case` (e.g., `stock_batch_tracking`)
|
||||
- Model name: `module.model` (e.g., `stock.batch.tracking`)
|
||||
- Fields: `snake_case` (e.g., `batch_number`, `expiry_date`)
|
||||
- Methods: `snake_case` with verb prefix (e.g., `action_confirm`, `_compute_total`)
|
||||
- XML IDs: `view_model_type` (e.g., `view_batch_tracking_form`)
|
||||
|
||||
2. **Import Order**
|
||||
```python
|
||||
# Standard library
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# Odoo imports
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import float_compare, float_is_zero
|
||||
```
|
||||
|
||||
3. **Field Attributes**
|
||||
- Always provide `string` parameter
|
||||
- Add `help` text for complex fields
|
||||
- Use `tracking=True` for important fields
|
||||
- Set `index=True` for searchable fields
|
||||
- Include `company_id` for multi-company support
|
||||
|
||||
4. **Method Decorators**
|
||||
- Use `@api.depends()` for computed fields
|
||||
- Use `@api.onchange()` for onchange methods
|
||||
- Use `@api.constrains()` for validation
|
||||
- Use `@api.model` for class-level methods
|
||||
|
||||
5. **Error Handling**
|
||||
- Use `UserError` for user-facing errors
|
||||
- Use `ValidationError` for constraint violations
|
||||
- Always log important actions with `_logger`
|
||||
|
||||
6. **Security**
|
||||
- Always create access rights CSV
|
||||
- Use security groups for sensitive operations
|
||||
- Add record rules if row-level security needed
|
||||
- Test with different user permissions
|
||||
|
||||
## Module Type Templates
|
||||
|
||||
Reference the `assets/templates/` directory for complete templates by module type:
|
||||
- `simple_model/` - Basic CRUD module
|
||||
- `extension/` - Inheriting existing models
|
||||
- `pos_custom/` - POS customizations
|
||||
- `stock_enhancement/` - Inventory features
|
||||
- `report_module/` - Custom reports
|
||||
|
||||
## Resources
|
||||
|
||||
### assets/templates/
|
||||
Contains complete module templates for different module types. Use these as starting points and customize based on specific requirements.
|
||||
|
||||
### assets/icon.png
|
||||
Default module icon. Replace with custom icon if needed (PNG, 128x128px recommended).
|
||||
|
||||
### assets/index.html
|
||||
Module description HTML template for the Apps menu.
|
||||
82
skills/odoo-module-creator/assets/index.html
Normal file
82
skills/odoo-module-creator/assets/index.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Module Name</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #714B67;
|
||||
border-bottom: 3px solid #714B67;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #875A7B;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
ul li:before {
|
||||
content: "✓ ";
|
||||
color: #00A09D;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.author {
|
||||
background-color: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-left: 4px solid #714B67;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Module Name</h1>
|
||||
|
||||
<div class="section">
|
||||
<p>Brief description of what this module does and its purpose in the Odoo ecosystem.</p>
|
||||
</div>
|
||||
|
||||
<h2>Key Features</h2>
|
||||
<div class="section">
|
||||
<ul>
|
||||
<li>Feature 1: Description of first major feature</li>
|
||||
<li>Feature 2: Description of second major feature</li>
|
||||
<li>Feature 3: Description of third major feature</li>
|
||||
<li>Feature 4: Additional capabilities and enhancements</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Use Cases</h2>
|
||||
<div class="section">
|
||||
<ul>
|
||||
<li>Scenario 1: When to use this feature</li>
|
||||
<li>Scenario 2: How it solves business problems</li>
|
||||
<li>Scenario 3: Integration with existing workflows</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Technical Details</h2>
|
||||
<div class="section">
|
||||
<p><strong>Version:</strong> 16.0.1.0.0</p>
|
||||
<p><strong>Dependencies:</strong> base, [other modules]</p>
|
||||
<p><strong>Models:</strong> Brief description of main models introduced</p>
|
||||
</div>
|
||||
|
||||
<div class="author">
|
||||
<strong>Author:</strong> Jamshid K<br>
|
||||
<strong>Website:</strong> <a href="https://siafadates.com">https://siafadates.com</a><br>
|
||||
<strong>License:</strong> LGPL-3
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
471
skills/odoo-test-creator/SKILL.md
Normal file
471
skills/odoo-test-creator/SKILL.md
Normal file
@@ -0,0 +1,471 @@
|
||||
---
|
||||
name: odoo-test-creator
|
||||
description: Creates comprehensive test suites for Odoo 16.0 modules following Siafa project standards. This skill should be used when creating tests for Odoo modules, such as "Create tests for this module" or "Generate test cases for stock_location_usage_restriction" or "Add unit tests to validate this functionality". The skill provides test templates, patterns, and best practices specific to Odoo 16.0 Enterprise with knowledge of database constraints and common pitfalls in the Siafa codebase.
|
||||
---
|
||||
|
||||
# Odoo Test Creator
|
||||
|
||||
## Overview
|
||||
|
||||
Create production-ready test suites for Odoo 16.0 Enterprise modules that follow Siafa project standards, handle database constraints properly, and provide comprehensive test coverage.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Creating tests for new Odoo modules
|
||||
- Adding test coverage to existing modules
|
||||
- Validating model logic, constraints, and workflows
|
||||
- Testing inherited/extended Odoo models
|
||||
- Ensuring compliance with Siafa testing standards
|
||||
|
||||
## Test Creation Workflow
|
||||
|
||||
### Step 1: Analyze Module Structure
|
||||
|
||||
Examine the module to understand what needs testing:
|
||||
|
||||
1. **Identify Components to Test:**
|
||||
- Models (new models or inherited models)
|
||||
- Computed fields and @api.depends
|
||||
- Constraints (@api.constrains and _sql_constraints)
|
||||
- Onchange methods (@api.onchange)
|
||||
- Business logic methods
|
||||
- State transitions and workflows
|
||||
- Wizards and transient models
|
||||
- Reports (if applicable)
|
||||
|
||||
2. **Review Module Dependencies:**
|
||||
- Check `__manifest__.py` for dependencies
|
||||
- Identify which models from dependencies will be used
|
||||
- Plan to use existing records when possible
|
||||
|
||||
3. **Check for Special Requirements:**
|
||||
- Database constraints (NOT NULL, UNIQUE)
|
||||
- Multi-company considerations
|
||||
- Access rights and permissions
|
||||
- Integration points with other modules
|
||||
|
||||
### Step 2: Set Up Test File Structure
|
||||
|
||||
Create the test file following Siafa standards:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class TestModuleName(TransactionCase):
|
||||
"""Test cases for module_name functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
# Initialize model references
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Set up test data (Step 3)
|
||||
```
|
||||
|
||||
**Critical Import Pattern:**
|
||||
- ✅ Use `from odoo.tests.common import TransactionCase`
|
||||
- ❌ NOT `from odoo.tests import TransactionCase`
|
||||
|
||||
### Step 3: Set Up Test Data
|
||||
|
||||
Use the appropriate pattern based on database constraints:
|
||||
|
||||
#### Pattern A: Use Existing Records (Preferred)
|
||||
|
||||
Avoid database constraint issues by using existing records:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Use existing records from database
|
||||
self.warehouse = self.env['stock.warehouse'].search([], limit=1)
|
||||
if not self.warehouse:
|
||||
self.skipTest("No warehouse available for testing")
|
||||
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
if not self.product:
|
||||
self.skipTest("No storable product available for testing")
|
||||
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
```
|
||||
|
||||
**When to use:** For models with complex database constraints (products, partners, companies).
|
||||
|
||||
#### Pattern B: Create with .sudo() (When Necessary)
|
||||
|
||||
Create new records when specific test data is required:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Create test data with .sudo() to bypass permissions
|
||||
self.vendor = self.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
|
||||
self.product = self.env['product.product'].sudo().create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
'purchase_method': 'receive',
|
||||
'list_price': 100.0,
|
||||
'standard_price': 80.0,
|
||||
})
|
||||
```
|
||||
|
||||
**When to use:** When specific field values are required for tests or existing records may not have the right attributes.
|
||||
|
||||
#### Pattern C: Class-Level Setup (For Shared Data)
|
||||
|
||||
Use `setUpClass` for data shared across all test methods:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test data shared across all test methods."""
|
||||
super().setUpClass()
|
||||
|
||||
cls.vendor = cls.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
})
|
||||
```
|
||||
|
||||
**When to use:** For immutable test data that doesn't change between tests (saves database operations).
|
||||
|
||||
### Step 4: Write Test Methods
|
||||
|
||||
Create test methods following these guidelines:
|
||||
|
||||
#### Test Naming Convention
|
||||
|
||||
```python
|
||||
def test_01_descriptive_name(self):
|
||||
"""Test description in docstring."""
|
||||
pass
|
||||
|
||||
def test_02_another_scenario(self):
|
||||
"""Test another scenario."""
|
||||
pass
|
||||
```
|
||||
|
||||
**Numbering:** Use `01`, `02`, etc. to control execution order.
|
||||
|
||||
#### Test Coverage Areas
|
||||
|
||||
Create tests for each component identified in Step 1:
|
||||
|
||||
**A. CRUD Operations**
|
||||
|
||||
```python
|
||||
def test_01_create_record(self):
|
||||
"""Test creating a new record with valid data."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
self.assertTrue(record)
|
||||
self.assertEqual(record.name, 'Test Record')
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
def test_02_update_record(self):
|
||||
"""Test updating an existing record."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
record.write({'name': 'Updated Record'})
|
||||
|
||||
self.assertEqual(record.name, 'Updated Record')
|
||||
```
|
||||
|
||||
**B. Computed Fields**
|
||||
|
||||
```python
|
||||
def test_03_computed_field(self):
|
||||
"""Test computed field calculation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'quantity': 10,
|
||||
'unit_price': 5.0,
|
||||
})
|
||||
|
||||
self.assertEqual(record.total_amount, 50.0)
|
||||
|
||||
# Test recomputation on dependency change
|
||||
record.write({'quantity': 20})
|
||||
self.assertEqual(record.total_amount, 100.0)
|
||||
```
|
||||
|
||||
**C. Constraints**
|
||||
|
||||
```python
|
||||
def test_04_constraint_validation(self):
|
||||
"""Test constraint prevents invalid data."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'amount': -10.0})
|
||||
|
||||
self.assertIn('must be positive', str(context.exception).lower())
|
||||
```
|
||||
|
||||
**D. Onchange Methods**
|
||||
|
||||
```python
|
||||
def test_05_onchange_method(self):
|
||||
"""Test onchange method updates dependent fields."""
|
||||
record = self.Model.new({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
record.partner_id = self.partner
|
||||
record._onchange_partner_id()
|
||||
|
||||
# Verify onchange updated related fields
|
||||
# self.assertEqual(record.expected_field, expected_value)
|
||||
```
|
||||
|
||||
**E. State Transitions**
|
||||
|
||||
```python
|
||||
def test_06_state_transition(self):
|
||||
"""Test state transition workflow."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
record.action_confirm()
|
||||
self.assertEqual(record.state, 'confirmed')
|
||||
|
||||
# Test invalid transition
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.action_confirm() # Already confirmed
|
||||
|
||||
self.assertIn('Cannot confirm', str(context.exception))
|
||||
```
|
||||
|
||||
**F. Inheritance/Extension Tests**
|
||||
|
||||
For modules that inherit existing models:
|
||||
|
||||
```python
|
||||
def test_07_inherited_method_override(self):
|
||||
"""Test overridden method applies custom logic."""
|
||||
location = self.Location.create({
|
||||
'name': 'Test Location',
|
||||
'usage': 'internal',
|
||||
'location_id': self.parent_location.id,
|
||||
})
|
||||
|
||||
# Create stock move using this location
|
||||
self.StockMove.create({
|
||||
'name': 'Test Move',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'location_id': location.id,
|
||||
'location_dest_id': self.parent_location.id,
|
||||
})
|
||||
|
||||
# Test that custom validation prevents usage change
|
||||
with self.assertRaises(UserError) as context:
|
||||
location.write({'usage': 'inventory'})
|
||||
|
||||
self.assertIn('Cannot change the usage type', str(context.exception))
|
||||
```
|
||||
|
||||
### Step 5: Handle Common Pitfalls
|
||||
|
||||
Apply fixes for known issues in the Siafa codebase:
|
||||
|
||||
#### Pitfall 1: Database Constraints
|
||||
|
||||
**Problem:** Creating products fails with "null value in column 'sale_line_warn' violates not-null constraint"
|
||||
|
||||
**Solution:** Use existing products:
|
||||
```python
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
```
|
||||
|
||||
#### Pitfall 2: HTML Field Comparisons
|
||||
|
||||
**Problem:** HTML fields return `Markup` objects: `Markup('<p>Text</p>') != 'Text'`
|
||||
|
||||
**Solution:** Use non-HTML fields or convert to string:
|
||||
```python
|
||||
# Instead of comment field
|
||||
self.assertEqual(record.barcode, 'TEST001')
|
||||
|
||||
# Or convert to string
|
||||
self.assertIn('expected text', str(record.html_field))
|
||||
```
|
||||
|
||||
#### Pitfall 3: Permission Errors
|
||||
|
||||
**Problem:** Tests fail with access rights errors.
|
||||
|
||||
**Solution:** Use `.sudo()` when creating test data:
|
||||
```python
|
||||
self.partner = self.env['res.partner'].sudo().create({...})
|
||||
```
|
||||
|
||||
#### Pitfall 4: Incorrect Super() Call
|
||||
|
||||
**Problem:** Using old-style `super(ClassName, self).setUp()`
|
||||
|
||||
**Solution:** Use modern syntax:
|
||||
```python
|
||||
super().setUp() # ✅ Correct
|
||||
```
|
||||
|
||||
### Step 6: Run and Validate Tests
|
||||
|
||||
Execute tests and verify results:
|
||||
|
||||
```bash
|
||||
# Run tests during module update
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
-u MODULE_NAME
|
||||
|
||||
# Run with verbose output
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
--log-level=test \
|
||||
-u MODULE_NAME
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
INFO MODULE_NAME: 0 failed, 0 error(s) of N tests when loading database 'DATABASE_NAME'
|
||||
```
|
||||
|
||||
**If tests fail:**
|
||||
1. Read the full traceback carefully
|
||||
2. Check for database constraint violations
|
||||
3. Verify test data setup is correct
|
||||
4. Ensure imports are correct
|
||||
5. Review field types (especially HTML fields)
|
||||
|
||||
### Step 7: Document Tests
|
||||
|
||||
Add comprehensive docstrings to each test method:
|
||||
|
||||
```python
|
||||
def test_prevent_usage_change_with_moves(self):
|
||||
"""
|
||||
Test that location usage cannot be changed when moves exist.
|
||||
|
||||
This test verifies that the module prevents changing a location's
|
||||
usage type after it has been used in stock movements, protecting
|
||||
data integrity.
|
||||
"""
|
||||
# Test implementation
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### references/test_patterns.md
|
||||
|
||||
Comprehensive documentation of:
|
||||
- Test infrastructure patterns
|
||||
- Common setup patterns for different scenarios
|
||||
- Database constraint handling strategies
|
||||
- Test organization best practices
|
||||
- Assertion patterns
|
||||
- Complete list of common pitfalls and solutions
|
||||
- Running tests with various options
|
||||
|
||||
Load this reference when:
|
||||
- Creating complex test scenarios
|
||||
- Handling database constraints
|
||||
- Troubleshooting test failures
|
||||
- Learning Siafa-specific testing patterns
|
||||
|
||||
### assets/test_model_basic.py
|
||||
|
||||
Template for testing basic model operations:
|
||||
- CRUD operations (Create, Read, Update, Delete)
|
||||
- Computed field testing
|
||||
- Onchange method testing
|
||||
- Constraint validation
|
||||
- State transitions
|
||||
- Search operations
|
||||
|
||||
Use as starting point for new model tests.
|
||||
|
||||
### assets/test_model_constraints.py
|
||||
|
||||
Template for testing constraints:
|
||||
- Python constraints (@api.constrains)
|
||||
- SQL constraints (_sql_constraints)
|
||||
- Required field validation
|
||||
- Domain constraints
|
||||
- Dependent field constraints
|
||||
- Conditional constraints
|
||||
- Cascading constraints
|
||||
|
||||
Use when module has complex validation logic.
|
||||
|
||||
### assets/test_model_inheritance.py
|
||||
|
||||
Template for testing model inheritance and extensions:
|
||||
- New field validation
|
||||
- Overridden method testing
|
||||
- Super() call behavior
|
||||
- Added constraints
|
||||
- Computed field extensions
|
||||
- Onchange extensions
|
||||
- Backward compatibility
|
||||
|
||||
Use when module extends existing Odoo models.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use existing records when possible** to avoid database constraints
|
||||
2. **Test both success and failure cases** for comprehensive coverage
|
||||
3. **Verify error messages** when testing exceptions
|
||||
4. **Use .sudo() for test data creation** to bypass permission issues
|
||||
5. **Add descriptive docstrings** to every test method
|
||||
6. **Number test methods** for predictable execution order
|
||||
7. **Keep tests isolated** - each test should work independently
|
||||
8. **Test edge cases** - empty data, maximum values, invalid combinations
|
||||
9. **Follow naming conventions** - clear, descriptive test names
|
||||
10. **Run tests frequently** during development to catch issues early
|
||||
|
||||
## Example: Complete Test File
|
||||
|
||||
For reference, see `/Users/jamshid/PycharmProjects/Siafa/odoo16e_simc/addons-stock/stock_location_usage_restriction/tests/test_stock_location_usage_restriction.py`
|
||||
|
||||
This file demonstrates:
|
||||
- Proper imports (`from odoo.tests.common import TransactionCase`)
|
||||
- Using existing records (`self.product = self.Product.search(...)`)
|
||||
- Comprehensive test coverage (7 test methods)
|
||||
- Exception testing with message validation
|
||||
- Proper super() call (`super().setUp()`)
|
||||
- Avoiding HTML field comparison issues
|
||||
146
skills/odoo-test-creator/assets/test_model_basic.py
Normal file
146
skills/odoo-test-creator/assets/test_model_basic.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class TestModelName(TransactionCase):
|
||||
"""Test cases for model.name functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Use existing records when possible
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
# Or create with .sudo()
|
||||
self.test_partner = self.env['res.partner'].sudo().create({
|
||||
'name': 'Test Partner',
|
||||
'is_company': True,
|
||||
})
|
||||
|
||||
def test_01_create_record(self):
|
||||
"""Test creating a new record with valid data."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
# Add other required fields
|
||||
})
|
||||
|
||||
self.assertTrue(record)
|
||||
self.assertEqual(record.name, 'Test Record')
|
||||
self.assertEqual(record.state, 'draft') # Adjust as needed
|
||||
|
||||
def test_02_update_record(self):
|
||||
"""Test updating an existing record."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Update record
|
||||
record.write({
|
||||
'name': 'Updated Record',
|
||||
})
|
||||
|
||||
self.assertEqual(record.name, 'Updated Record')
|
||||
|
||||
def test_03_search_records(self):
|
||||
"""Test searching for records."""
|
||||
# Create test records
|
||||
self.Model.create({
|
||||
'name': 'Record A',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.Model.create({
|
||||
'name': 'Record B',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Search for records
|
||||
records = self.Model.search([('partner_id', '=', self.partner.id)])
|
||||
|
||||
self.assertGreaterEqual(len(records), 2)
|
||||
|
||||
def test_04_computed_field(self):
|
||||
"""Test computed field calculation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'quantity': 10,
|
||||
'unit_price': 5.0,
|
||||
})
|
||||
|
||||
# Test computed total
|
||||
self.assertEqual(record.total_amount, 50.0)
|
||||
|
||||
# Update dependency and verify recomputation
|
||||
record.write({'quantity': 20})
|
||||
self.assertEqual(record.total_amount, 100.0)
|
||||
|
||||
def test_05_onchange_method(self):
|
||||
"""Test onchange method behavior."""
|
||||
record = self.Model.new({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Trigger onchange
|
||||
record.partner_id = self.partner
|
||||
record._onchange_partner_id()
|
||||
|
||||
# Verify onchange updated fields
|
||||
# self.assertEqual(record.some_field, expected_value)
|
||||
|
||||
def test_06_constraint_validation(self):
|
||||
"""Test constraint validation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Test that invalid value raises ValidationError
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'invalid_field': 'invalid_value'})
|
||||
|
||||
# Verify error message
|
||||
self.assertIn('expected error message', str(context.exception))
|
||||
|
||||
def test_07_state_transition(self):
|
||||
"""Test state transition workflow."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Initially in draft state
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
# Confirm record
|
||||
record.action_confirm()
|
||||
self.assertEqual(record.state, 'confirmed')
|
||||
|
||||
# Test invalid transition
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.action_confirm() # Already confirmed
|
||||
|
||||
self.assertIn('Cannot confirm', str(context.exception))
|
||||
|
||||
def test_08_delete_record(self):
|
||||
"""Test deleting a record (if applicable)."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
record_id = record.id
|
||||
|
||||
# Delete record
|
||||
record.unlink()
|
||||
|
||||
# Verify record no longer exists
|
||||
self.assertFalse(self.Model.browse(record_id).exists())
|
||||
144
skills/odoo-test-creator/assets/test_model_constraints.py
Normal file
144
skills/odoo-test-creator/assets/test_model_constraints.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
|
||||
class TestModelConstraints(TransactionCase):
|
||||
"""Test cases for model constraints and validation."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Set up minimal test data
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
def test_01_python_constraint_positive_value(self):
|
||||
"""Test Python constraint for positive values."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'amount': 100.0,
|
||||
})
|
||||
|
||||
# Test valid positive value
|
||||
record.write({'amount': 50.0})
|
||||
self.assertEqual(record.amount, 50.0)
|
||||
|
||||
# Test that negative value raises ValidationError
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'amount': -10.0})
|
||||
|
||||
self.assertIn('must be positive', str(context.exception).lower())
|
||||
|
||||
def test_02_sql_constraint_unique(self):
|
||||
"""Test SQL constraint for unique values."""
|
||||
# Create first record
|
||||
self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'code': 'UNIQUE001',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Try to create duplicate
|
||||
with self.assertRaises(IntegrityError):
|
||||
with self.cr.savepoint():
|
||||
self.Model.create({
|
||||
'name': 'Test Record 2',
|
||||
'code': 'UNIQUE001', # Duplicate code
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def test_03_required_field_validation(self):
|
||||
"""Test that required fields are enforced."""
|
||||
# Test missing required field raises ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Model.create({
|
||||
'name': 'Test Record',
|
||||
# Missing required 'partner_id'
|
||||
})
|
||||
|
||||
def test_04_field_domain_constraint(self):
|
||||
"""Test field domain constraints."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Test valid state
|
||||
record.write({'state': 'confirmed'})
|
||||
self.assertEqual(record.state, 'confirmed')
|
||||
|
||||
# Test invalid state raises ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
record.write({'state': 'invalid_state'})
|
||||
|
||||
def test_05_dependent_field_constraint(self):
|
||||
"""Test constraints that depend on multiple fields."""
|
||||
# Test that start_date must be before end_date
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'start_date': '2024-12-31',
|
||||
'end_date': '2024-01-01', # End before start
|
||||
})
|
||||
|
||||
self.assertIn('end date', str(context.exception).lower())
|
||||
self.assertIn('start date', str(context.exception).lower())
|
||||
|
||||
def test_06_conditional_constraint(self):
|
||||
"""Test constraints that apply conditionally."""
|
||||
# Create record in state where constraint doesn't apply
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'state': 'draft',
|
||||
'approval_required': False,
|
||||
})
|
||||
|
||||
# Confirm - now constraint should apply
|
||||
record.write({'state': 'confirmed', 'approval_required': True})
|
||||
|
||||
# Test that missing approval raises error
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'approved_by': False}) # Clear approval
|
||||
|
||||
self.assertIn('approval', str(context.exception).lower())
|
||||
|
||||
def test_07_cascading_constraint(self):
|
||||
"""Test constraints that cascade to related records."""
|
||||
parent = self.Model.create({
|
||||
'name': 'Parent Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
child = self.Model.create({
|
||||
'name': 'Child Record',
|
||||
'parent_id': parent.id,
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Test that deleting parent with children raises error
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
parent.unlink()
|
||||
|
||||
self.assertIn('child', str(context.exception).lower())
|
||||
|
||||
def test_08_constraint_bypass_with_context(self):
|
||||
"""Test bypassing constraints with context (if applicable)."""
|
||||
# Some constraints can be bypassed with special context
|
||||
record = self.Model.with_context(skip_validation=True).create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'amount': -100.0, # Normally not allowed
|
||||
})
|
||||
|
||||
self.assertEqual(record.amount, -100.0)
|
||||
153
skills/odoo-test-creator/assets/test_model_inheritance.py
Normal file
153
skills/odoo-test-creator/assets/test_model_inheritance.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestModelInheritance(TransactionCase):
|
||||
"""Test cases for model inheritance and extensions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['base.model.name'] # The model being extended
|
||||
|
||||
# Set up test data
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
def test_01_new_fields_exist(self):
|
||||
"""Test that new fields added by inheritance exist."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
# Base fields
|
||||
})
|
||||
|
||||
# Test that new fields exist and have default values
|
||||
self.assertTrue(hasattr(record, 'new_field'))
|
||||
self.assertEqual(record.new_field, False) # Or expected default
|
||||
|
||||
def test_02_inherited_method_override(self):
|
||||
"""Test that overridden methods work correctly."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Call overridden method
|
||||
result = record.action_confirm()
|
||||
|
||||
# Verify custom behavior was applied
|
||||
# self.assertEqual(record.state, 'custom_state')
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_03_super_call_behavior(self):
|
||||
"""Test that super() calls preserve base functionality."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
initial_state = record.state
|
||||
|
||||
# Call overridden method that should call super()
|
||||
record.write({'name': 'Updated Record'})
|
||||
|
||||
# Verify both base and custom behavior applied
|
||||
self.assertEqual(record.name, 'Updated Record') # Base behavior
|
||||
# self.assertEqual(record.custom_field, 'value') # Custom behavior
|
||||
|
||||
def test_04_added_constraint(self):
|
||||
"""Test new constraints added by inheritance."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'new_field': 'valid_value',
|
||||
})
|
||||
|
||||
# Test new constraint
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.write({'new_field': 'invalid_value'})
|
||||
|
||||
self.assertIn('expected error', str(context.exception))
|
||||
|
||||
def test_05_computed_field_extension(self):
|
||||
"""Test computed fields added by inheritance."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'quantity': 10,
|
||||
'unit_price': 5.0,
|
||||
})
|
||||
|
||||
# Test new computed field
|
||||
self.assertEqual(record.total_with_tax, 52.5) # Example with 5% tax
|
||||
|
||||
def test_06_onchange_extension(self):
|
||||
"""Test onchange methods added by inheritance."""
|
||||
record = self.Model.new({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Trigger new onchange
|
||||
record.partner_id = self.partner
|
||||
record._onchange_partner_id_custom()
|
||||
|
||||
# Verify custom onchange behavior
|
||||
# self.assertEqual(record.custom_field, expected_value)
|
||||
|
||||
def test_07_api_depends_extension(self):
|
||||
"""Test that @api.depends works correctly on inherited fields."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'line_ids': [(0, 0, {
|
||||
'product_id': self.env['product.product'].search([], limit=1).id,
|
||||
'quantity': 5,
|
||||
'price_unit': 10.0,
|
||||
})],
|
||||
})
|
||||
|
||||
# Initial computed value
|
||||
initial_total = record.total_amount
|
||||
|
||||
# Add more lines
|
||||
record.write({
|
||||
'line_ids': [(0, 0, {
|
||||
'product_id': self.env['product.product'].search([], limit=1).id,
|
||||
'quantity': 3,
|
||||
'price_unit': 20.0,
|
||||
})],
|
||||
})
|
||||
|
||||
# Verify recomputation
|
||||
self.assertGreater(record.total_amount, initial_total)
|
||||
|
||||
def test_08_prevent_base_operation(self):
|
||||
"""Test blocking base operations with custom validation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Transition to state that prevents deletion
|
||||
record.write({'state': 'confirmed'})
|
||||
|
||||
# Test that deletion is now blocked
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.unlink()
|
||||
|
||||
self.assertIn('cannot delete', str(context.exception).lower())
|
||||
|
||||
def test_09_backward_compatibility(self):
|
||||
"""Test that base functionality still works after inheritance."""
|
||||
# Test base model functionality isn't broken
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Base operations should still work
|
||||
record.write({'name': 'Updated Name'})
|
||||
self.assertEqual(record.name, 'Updated Name')
|
||||
|
||||
# Base methods should still be callable
|
||||
if hasattr(record, 'base_method'):
|
||||
result = record.base_method()
|
||||
self.assertTrue(result)
|
||||
369
skills/odoo-test-creator/references/test_patterns.md
Normal file
369
skills/odoo-test-creator/references/test_patterns.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Odoo 16.0 Test Patterns and Best Practices
|
||||
|
||||
This document provides comprehensive patterns and best practices for writing tests in Odoo 16.0 Enterprise modules, based on the Siafa project standards.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Test Infrastructure](#test-infrastructure)
|
||||
2. [Common Setup Patterns](#common-setup-patterns)
|
||||
3. [Database Constraints](#database-constraints)
|
||||
4. [Test Organization](#test-organization)
|
||||
5. [Assertion Patterns](#assertion-patterns)
|
||||
6. [Common Pitfalls](#common-pitfalls)
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Import Statement
|
||||
|
||||
Always use the correct import for `TransactionCase`:
|
||||
|
||||
```python
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
```
|
||||
|
||||
**Note:** Use `from odoo.tests.common import TransactionCase`, NOT `from odoo.tests import TransactionCase`.
|
||||
|
||||
### Base Test Class
|
||||
|
||||
```python
|
||||
class TestModuleName(TransactionCase):
|
||||
"""Test cases for module functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp() # Use super().setUp() not super(ClassName, self).setUp()
|
||||
|
||||
# Initialize models
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Set up test data
|
||||
# ...
|
||||
```
|
||||
|
||||
## Common Setup Patterns
|
||||
|
||||
### Using Existing Records
|
||||
|
||||
**Preferred Pattern:** Use existing database records when possible to avoid database constraint issues:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Use existing warehouse
|
||||
self.warehouse = self.env['stock.warehouse'].search([], limit=1)
|
||||
if not self.warehouse:
|
||||
self.skipTest("No warehouse available for testing")
|
||||
|
||||
# Use existing product
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
if not self.product:
|
||||
self.skipTest("No storable product available for testing")
|
||||
```
|
||||
|
||||
### Creating New Records with .sudo()
|
||||
|
||||
When creating new records is necessary, use `.sudo()` to bypass access rights and permission issues:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create vendor
|
||||
self.vendor = self.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
|
||||
# Create product with minimal required fields
|
||||
self.product = self.env['product.product'].sudo().create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
'purchase_method': 'receive',
|
||||
'list_price': 100.0,
|
||||
'standard_price': 80.0,
|
||||
})
|
||||
```
|
||||
|
||||
### Class-Level Setup
|
||||
|
||||
For test data shared across all test methods, use `setUpClass`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test data shared across all test methods."""
|
||||
super().setUpClass()
|
||||
|
||||
# Create shared test data
|
||||
cls.vendor = cls.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
})
|
||||
```
|
||||
|
||||
## Database Constraints
|
||||
|
||||
### Handling NOT NULL Constraints
|
||||
|
||||
Many databases have custom NOT NULL constraints. Avoid creating records that trigger these constraints:
|
||||
|
||||
**Problem:** Creating a product might fail due to missing `sale_line_warn` field:
|
||||
```python
|
||||
# ❌ May fail with "null value in column 'sale_line_warn' violates not-null constraint"
|
||||
self.product = self.env['product.product'].create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
})
|
||||
```
|
||||
|
||||
**Solution 1:** Use existing records:
|
||||
```python
|
||||
# ✅ Use existing product from database
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
```
|
||||
|
||||
**Solution 2:** Provide all required fields (if known):
|
||||
```python
|
||||
# ✅ Provide the required field
|
||||
self.product = self.env['product.product'].sudo().create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
'sale_line_warn': 'no-message', # Required in some databases
|
||||
})
|
||||
```
|
||||
|
||||
### HTML/Markup Fields
|
||||
|
||||
HTML fields return `Markup` objects, not plain strings:
|
||||
|
||||
```python
|
||||
# ❌ Fails: Markup('<p>Text</p>') != 'Text'
|
||||
self.assertEqual(record.comment, 'Updated comment')
|
||||
|
||||
# ✅ Use a different field for testing
|
||||
self.assertEqual(record.barcode, 'TEST002')
|
||||
|
||||
# ✅ Or convert to string
|
||||
self.assertIn('Updated comment', str(record.comment))
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Test Method Naming
|
||||
|
||||
Use descriptive names with numbering for execution order:
|
||||
|
||||
```python
|
||||
def test_01_create_record(self):
|
||||
"""Test creating a new record."""
|
||||
pass
|
||||
|
||||
def test_02_update_record(self):
|
||||
"""Test updating an existing record."""
|
||||
pass
|
||||
|
||||
def test_03_validation_error(self):
|
||||
"""Test that validation error is raised for invalid data."""
|
||||
pass
|
||||
```
|
||||
|
||||
### Test Coverage Areas
|
||||
|
||||
Comprehensive tests should cover:
|
||||
|
||||
1. **CRUD Operations**
|
||||
- Create records with valid data
|
||||
- Update records
|
||||
- Read/search records
|
||||
- Delete records (if applicable)
|
||||
|
||||
2. **Validation Logic**
|
||||
- Test constraints
|
||||
- Test computed fields
|
||||
- Test onchange methods
|
||||
|
||||
3. **Business Logic**
|
||||
- Test state transitions
|
||||
- Test workflow methods
|
||||
- Test custom methods
|
||||
|
||||
4. **Edge Cases**
|
||||
- Test with empty data
|
||||
- Test with maximum values
|
||||
- Test with invalid combinations
|
||||
|
||||
5. **Error Handling**
|
||||
- Test that appropriate errors are raised
|
||||
- Test error messages are clear
|
||||
|
||||
## Assertion Patterns
|
||||
|
||||
### Basic Assertions
|
||||
|
||||
```python
|
||||
# Equality
|
||||
self.assertEqual(record.field, expected_value)
|
||||
self.assertNotEqual(record.field, unexpected_value)
|
||||
|
||||
# Boolean
|
||||
self.assertTrue(record.active)
|
||||
self.assertFalse(record.archived)
|
||||
|
||||
# Containment
|
||||
self.assertIn(item, collection)
|
||||
self.assertNotIn(item, collection)
|
||||
|
||||
# String matching
|
||||
self.assertIn('substring', record.message)
|
||||
```
|
||||
|
||||
### Exception Testing
|
||||
|
||||
```python
|
||||
# Test that exception is raised
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.forbidden_action()
|
||||
|
||||
# Check exception message
|
||||
self.assertIn('Cannot perform action', str(context.exception))
|
||||
```
|
||||
|
||||
### Recordset Assertions
|
||||
|
||||
```python
|
||||
# Check recordset size
|
||||
self.assertEqual(len(records), 3)
|
||||
|
||||
# Check recordset is empty
|
||||
self.assertFalse(records)
|
||||
|
||||
# Check record exists
|
||||
self.assertTrue(record)
|
||||
self.assertTrue(record.exists())
|
||||
```
|
||||
|
||||
### Computed Field Testing
|
||||
|
||||
```python
|
||||
# Test computed field
|
||||
self.assertEqual(record.total_amount, 100.0)
|
||||
|
||||
# Test compute dependency
|
||||
record.line_ids = [(5, 0, 0)] # Clear lines
|
||||
self.assertEqual(record.total_amount, 0.0)
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Product Creation Failures
|
||||
|
||||
**Issue:** Creating products fails due to database constraints.
|
||||
|
||||
**Solution:** Use existing products or create with `.sudo()` and minimal fields:
|
||||
```python
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
```
|
||||
|
||||
### 2. Incorrect Import Statement
|
||||
|
||||
**Issue:** `from odoo.tests import TransactionCase` causes import errors.
|
||||
|
||||
**Solution:** Use `from odoo.tests.common import TransactionCase`
|
||||
|
||||
### 3. Super() Call Format
|
||||
|
||||
**Issue:** Using old-style super() calls.
|
||||
|
||||
**Solution:** Use `super().setUp()` instead of `super(ClassName, self).setUp()`
|
||||
|
||||
### 4. Missing .sudo()
|
||||
|
||||
**Issue:** Tests fail due to access rights.
|
||||
|
||||
**Solution:** Use `.sudo()` when creating test data:
|
||||
```python
|
||||
self.partner = self.env['res.partner'].sudo().create({...})
|
||||
```
|
||||
|
||||
### 5. HTML Field Comparisons
|
||||
|
||||
**Issue:** Comparing HTML fields directly fails.
|
||||
|
||||
**Solution:** Use non-HTML fields or convert to string:
|
||||
```python
|
||||
# Instead of comment field, use barcode or another text field
|
||||
self.assertEqual(record.barcode, 'TEST001')
|
||||
```
|
||||
|
||||
### 6. Transaction Isolation
|
||||
|
||||
**Issue:** Tests affect each other due to shared data.
|
||||
|
||||
**Solution:** Each test method runs in its own transaction (automatic with `TransactionCase`)
|
||||
|
||||
### 7. Insufficient Error Checking
|
||||
|
||||
**Issue:** Not verifying error messages.
|
||||
|
||||
**Solution:** Always check exception messages:
|
||||
```python
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.action()
|
||||
self.assertIn('expected error message', str(context.exception))
|
||||
```
|
||||
|
||||
### 8. Missing Test Documentation
|
||||
|
||||
**Issue:** Tests without docstrings are hard to understand.
|
||||
|
||||
**Solution:** Always add descriptive docstrings:
|
||||
```python
|
||||
def test_prevent_usage_change_with_moves(self):
|
||||
"""Test that usage cannot be changed when location has stock moves."""
|
||||
# ...
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Command Patterns
|
||||
|
||||
```bash
|
||||
# Run tests during module installation
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
-i MODULE_NAME
|
||||
|
||||
# Run tests during module update
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
-u MODULE_NAME
|
||||
|
||||
# Run with verbose output
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
--log-level=test \
|
||||
-u MODULE_NAME
|
||||
```
|
||||
|
||||
### Test Tags
|
||||
|
||||
Tag tests for selective execution:
|
||||
|
||||
```python
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install', 'module_name')
|
||||
class TestModuleName(TransactionCase):
|
||||
"""Test cases with tags."""
|
||||
pass
|
||||
```
|
||||
|
||||
Run tagged tests:
|
||||
```bash
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
--test-tags module_name
|
||||
```
|
||||
Reference in New Issue
Block a user