538 lines
19 KiB
Python
538 lines
19 KiB
Python
#!/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()
|