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()