Initial commit

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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