Files
gh-jamshu-jamshi-marketplac…/skills/odoo-connector-module-creator/scripts/add_binding.py
2025-11-29 18:50:04 +08:00

538 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()