Initial commit
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user