feat: add setup wizard and product fetch wizard with SKU auto-match
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@
|
||||
'views/res_config_settings.xml',
|
||||
'views/woo_dashboard.xml',
|
||||
'views/woo_menus.xml',
|
||||
'wizard/woo_setup_wizard_views.xml',
|
||||
'wizard/woo_product_fetch_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -23,3 +23,5 @@ access_woo_return_user,woo.return.user,model_woo_return,fusion_woocommerce.group
|
||||
access_woo_return_manager,woo.return.manager,model_woo_return,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_return_line_user,woo.return.line.user,model_woo_return_line,fusion_woocommerce.group_woo_user,1,0,0,0
|
||||
access_woo_return_line_manager,woo.return.line.manager,model_woo_return_line,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
|
||||
|
@@ -1 +1,2 @@
|
||||
# Wizards will be imported here
|
||||
from . import woo_setup_wizard
|
||||
from . import woo_product_fetch
|
||||
|
||||
171
fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_fetch.py
Normal file
171
fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_fetch.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import difflib
|
||||
import logging
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooProductFetch(models.TransientModel):
|
||||
_name = 'woo.product.fetch'
|
||||
_description = 'Fetch WooCommerce Products'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, string='WooCommerce Instance')
|
||||
state = fields.Selection([
|
||||
('draft', 'Ready'),
|
||||
('fetching', 'Fetching...'),
|
||||
('matching', 'Matching...'),
|
||||
('done', 'Complete'),
|
||||
], default='draft')
|
||||
total_fetched = fields.Integer(readonly=True)
|
||||
auto_matched = fields.Integer(string='Auto-matched (SKU)', readonly=True)
|
||||
suggested = fields.Integer(string='Name Suggestions', readonly=True)
|
||||
unmatched = fields.Integer(readonly=True)
|
||||
|
||||
def action_fetch(self):
|
||||
self.ensure_one()
|
||||
instance = self.instance_id
|
||||
if not instance:
|
||||
raise UserError("Please select a WooCommerce instance.")
|
||||
|
||||
client = instance._get_client()
|
||||
|
||||
# --- Fetch all products (paginated) ---
|
||||
self.state = 'fetching'
|
||||
all_products = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = client.get_products(page=page, per_page=100)
|
||||
if not batch:
|
||||
break
|
||||
all_products.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
# Expand variable products with their variations
|
||||
expanded = []
|
||||
for product in all_products:
|
||||
expanded.append(product)
|
||||
if product.get('type') == 'variable':
|
||||
var_page = 1
|
||||
while True:
|
||||
variations = client.get_product_variations(
|
||||
product['id'], page=var_page, per_page=100
|
||||
)
|
||||
if not variations:
|
||||
break
|
||||
for var in variations:
|
||||
var['_parent_id'] = product['id']
|
||||
var['_is_variation'] = True
|
||||
expanded.extend(variations)
|
||||
if len(variations) < 100:
|
||||
break
|
||||
var_page += 1
|
||||
|
||||
total_fetched = len(expanded)
|
||||
|
||||
# --- Match products ---
|
||||
self.state = 'matching'
|
||||
|
||||
ProductMap = self.env['woo.product.map']
|
||||
ProductProduct = self.env['product.product']
|
||||
|
||||
# Pre-load existing maps for this instance to avoid repeated queries
|
||||
existing_woo_ids = set(
|
||||
ProductMap.search([('instance_id', '=', instance.id)]).mapped('woo_product_id')
|
||||
)
|
||||
|
||||
# Pre-load all odoo products with a SKU for fast lookup
|
||||
odoo_products_with_sku = ProductProduct.search([('default_code', '!=', False)])
|
||||
sku_index = {p.default_code: p for p in odoo_products_with_sku}
|
||||
|
||||
# For name matching, load all product names
|
||||
all_odoo_products = ProductProduct.search([])
|
||||
odoo_name_list = [(p.name or '', p) for p in all_odoo_products]
|
||||
|
||||
auto_matched = 0
|
||||
suggested_count = 0
|
||||
unmatched = 0
|
||||
|
||||
for wc_product in expanded:
|
||||
woo_id = wc_product.get('id')
|
||||
if not woo_id:
|
||||
continue
|
||||
|
||||
# Skip already-mapped
|
||||
if woo_id in existing_woo_ids:
|
||||
continue
|
||||
|
||||
woo_sku = wc_product.get('sku') or ''
|
||||
woo_name = wc_product.get('name') or ''
|
||||
woo_type = wc_product.get('type', 'simple')
|
||||
is_variation = wc_product.get('_is_variation', False)
|
||||
parent_id = wc_product.get('_parent_id') or wc_product.get('parent_id') or 0
|
||||
|
||||
map_vals = {
|
||||
'instance_id': instance.id,
|
||||
'woo_product_id': woo_id,
|
||||
'woo_product_name': woo_name,
|
||||
'woo_sku': woo_sku,
|
||||
'woo_product_type': woo_type if not is_variation else 'variable',
|
||||
'is_variation': is_variation,
|
||||
'woo_parent_id': parent_id,
|
||||
'company_id': instance.company_id.id,
|
||||
}
|
||||
|
||||
# a) SKU match
|
||||
matched_product = None
|
||||
if woo_sku and woo_sku in sku_index:
|
||||
matched_product = sku_index[woo_sku]
|
||||
map_vals['product_id'] = matched_product.id
|
||||
map_vals['state'] = 'mapped'
|
||||
auto_matched += 1
|
||||
else:
|
||||
# b) Name similarity
|
||||
if woo_name and odoo_name_list:
|
||||
ratios = [
|
||||
(difflib.SequenceMatcher(None, woo_name.lower(), name.lower()).ratio(), p)
|
||||
for name, p in odoo_name_list
|
||||
]
|
||||
best_ratio, best_product = max(ratios, key=lambda x: x[0])
|
||||
if best_ratio > 0.8:
|
||||
map_vals['product_id'] = best_product.id
|
||||
map_vals['state'] = 'unmapped'
|
||||
suggested_count += 1
|
||||
else:
|
||||
map_vals['state'] = 'unmapped'
|
||||
unmatched += 1
|
||||
else:
|
||||
map_vals['state'] = 'unmapped'
|
||||
unmatched += 1
|
||||
|
||||
ProductMap.create(map_vals)
|
||||
|
||||
self.write({
|
||||
'total_fetched': total_fetched,
|
||||
'auto_matched': auto_matched,
|
||||
'suggested': suggested_count,
|
||||
'unmatched': unmatched,
|
||||
'state': 'done',
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_open_mapping(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Product Mapping',
|
||||
'res_model': 'woo.product.map',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('instance_id', '=', self.instance_id.id)],
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="woo_product_fetch_form_view" model="ir.ui.view">
|
||||
<field name="name">woo.product.fetch.form</field>
|
||||
<field name="model">woo.product.fetch</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Fetch WooCommerce Products">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="instance_id"
|
||||
attrs="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="state" readonly="1"/>
|
||||
</group>
|
||||
|
||||
<group string="Results"
|
||||
attrs="{'invisible': [('state', '=', 'draft')]}">
|
||||
<group>
|
||||
<field name="total_fetched"/>
|
||||
<field name="auto_matched"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="suggested"/>
|
||||
<field name="unmatched"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<div class="alert alert-info" role="alert"
|
||||
attrs="{'invisible': [('state', '!=', 'draft')]}">
|
||||
Select a WooCommerce instance and click Fetch Products. All products and variations will be imported and matched to Odoo products by SKU or name similarity.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" role="alert"
|
||||
attrs="{'invisible': [('state', '!=', 'done')]}">
|
||||
Fetch complete! Review the product mapping to confirm or adjust suggestions.
|
||||
</div>
|
||||
</sheet>
|
||||
|
||||
<footer>
|
||||
<button type="object" name="action_fetch"
|
||||
string="Fetch Products"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('state', '!=', 'draft')]}"/>
|
||||
<button type="object" name="action_open_mapping"
|
||||
string="Open Product Mapping"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('state', '!=', 'done')]}"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_woo_product_fetch" model="ir.actions.act_window">
|
||||
<field name="name">Fetch WooCommerce Products</field>
|
||||
<field name="res_model">woo.product.fetch</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
145
fusion-woo-odoo/fusion_woocommerce/wizard/woo_setup_wizard.py
Normal file
145
fusion-woo-odoo/fusion_woocommerce/wizard/woo_setup_wizard.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ..lib.woo_api_client import WooApiClient
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooSetupWizard(models.TransientModel):
|
||||
_name = 'woo.setup.wizard'
|
||||
_description = 'WooCommerce Setup Wizard'
|
||||
|
||||
# Step 1: Connection
|
||||
name = fields.Char(string='Instance Name', required=True)
|
||||
url = fields.Char(string='WooCommerce URL', required=True)
|
||||
consumer_key = fields.Char(string='Consumer Key', required=True)
|
||||
consumer_secret = fields.Char(string='Consumer Secret', required=True)
|
||||
|
||||
# Step 2: Configuration
|
||||
default_warehouse_id = fields.Many2one('stock.warehouse', string='Default Warehouse')
|
||||
sync_interval = fields.Selection([
|
||||
('5', '5 Minutes'),
|
||||
('15', '15 Minutes'),
|
||||
('30', '30 Minutes'),
|
||||
('60', '1 Hour'),
|
||||
], string='Sync Interval', default='15')
|
||||
|
||||
# State tracking
|
||||
step = fields.Selection([
|
||||
('connection', 'Connection'),
|
||||
('config', 'Configuration'),
|
||||
('done', 'Done'),
|
||||
], default='connection')
|
||||
connection_tested = fields.Boolean()
|
||||
instance_id = fields.Many2one('woo.instance')
|
||||
api_key = fields.Char(string='Generated API Key', readonly=True)
|
||||
|
||||
def action_test_connection(self):
|
||||
self.ensure_one()
|
||||
try:
|
||||
client = WooApiClient(
|
||||
url=self.url,
|
||||
consumer_key=self.consumer_key,
|
||||
consumer_secret=self.consumer_secret,
|
||||
)
|
||||
success, info = client.test_connection()
|
||||
if success:
|
||||
self.connection_tested = True
|
||||
else:
|
||||
raise UserError(f"Connection failed: {info}")
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UserError(f"Connection error: {exc}") from exc
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_next_step(self):
|
||||
self.ensure_one()
|
||||
if self.step == 'connection':
|
||||
if not self.connection_tested:
|
||||
raise UserError("Please test the connection before proceeding.")
|
||||
self.step = 'config'
|
||||
elif self.step == 'config':
|
||||
self.step = 'done'
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_back(self):
|
||||
self.ensure_one()
|
||||
if self.step == 'config':
|
||||
self.step = 'connection'
|
||||
elif self.step == 'done':
|
||||
self.step = 'config'
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_complete(self):
|
||||
self.ensure_one()
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
instance = self.env['woo.instance'].create({
|
||||
'name': self.name,
|
||||
'url': self.url,
|
||||
'consumer_key': self.consumer_key,
|
||||
'consumer_secret': self.consumer_secret,
|
||||
'default_warehouse_id': self.default_warehouse_id.id,
|
||||
'sync_interval': self.sync_interval,
|
||||
'odoo_api_key': api_key,
|
||||
'state': 'connected',
|
||||
})
|
||||
self.instance_id = instance
|
||||
self.api_key = api_key
|
||||
self.step = 'done'
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_open_instance(self):
|
||||
self.ensure_one()
|
||||
if not self.instance_id:
|
||||
raise UserError("No instance created yet.")
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'woo.instance',
|
||||
'res_id': self.instance_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_fetch_products(self):
|
||||
self.ensure_one()
|
||||
if not self.instance_id:
|
||||
raise UserError("No instance created yet.")
|
||||
wizard = self.env['woo.product.fetch'].create({
|
||||
'instance_id': self.instance_id.id,
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'woo.product.fetch',
|
||||
'res_id': wizard.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="woo_setup_wizard_form_view" model="ir.ui.view">
|
||||
<field name="name">woo.setup.wizard.form</field>
|
||||
<field name="model">woo.setup.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="WooCommerce Setup Wizard">
|
||||
<sheet>
|
||||
<!-- Step indicator -->
|
||||
<div class="o_statusbar_status mb-3">
|
||||
<button type="object" name="action_back"
|
||||
class="btn btn-secondary me-1"
|
||||
attrs="{'invisible': [('step', '=', 'connection')]}">
|
||||
← Back
|
||||
</button>
|
||||
<span class="badge bg-primary me-1"
|
||||
attrs="{'invisible': [('step', '!=', 'connection')]}">
|
||||
Step 1: Connection
|
||||
</span>
|
||||
<span class="badge bg-secondary me-1"
|
||||
attrs="{'invisible': [('step', '=', 'connection')]}">
|
||||
Step 1: Connection ✓
|
||||
</span>
|
||||
<span class="badge bg-primary me-1"
|
||||
attrs="{'invisible': [('step', '!=', 'config')]}">
|
||||
Step 2: Configuration
|
||||
</span>
|
||||
<span class="badge bg-secondary me-1"
|
||||
attrs="{'invisible': [('step', 'in', ('connection', 'config'))]}">
|
||||
Step 2: Configuration ✓
|
||||
</span>
|
||||
<span class="badge bg-muted me-1"
|
||||
attrs="{'invisible': [('step', 'in', ('config', 'done'))]}">
|
||||
Step 2: Configuration
|
||||
</span>
|
||||
<span class="badge bg-primary me-1"
|
||||
attrs="{'invisible': [('step', '!=', 'done')]}">
|
||||
Step 3: Done
|
||||
</span>
|
||||
<span class="badge bg-muted me-1"
|
||||
attrs="{'invisible': [('step', '=', 'done')]}">
|
||||
Step 3: Done
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Connection -->
|
||||
<group attrs="{'invisible': [('step', '!=', 'connection')]}">
|
||||
<group string="WooCommerce Connection">
|
||||
<field name="name" placeholder="e.g. My WooCommerce Store"/>
|
||||
<field name="url" placeholder="https://mystore.com"/>
|
||||
<field name="consumer_key" password="True"/>
|
||||
<field name="consumer_secret" password="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<div class="alert alert-info" role="alert"
|
||||
attrs="{'invisible': [('connection_tested', '=', True)]}">
|
||||
Enter your WooCommerce store URL and REST API credentials, then test the connection.
|
||||
</div>
|
||||
<div class="alert alert-success" role="alert"
|
||||
attrs="{'invisible': [('connection_tested', '=', False)]}">
|
||||
Connection successful! Click Next to continue.
|
||||
</div>
|
||||
<field name="connection_tested" invisible="1"/>
|
||||
<field name="step" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Step 2: Configuration -->
|
||||
<group attrs="{'invisible': [('step', '!=', 'config')]}">
|
||||
<group string="Sync Configuration">
|
||||
<field name="default_warehouse_id"/>
|
||||
<field name="sync_interval"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Step 3: Done -->
|
||||
<group attrs="{'invisible': [('step', '!=', 'done')]}">
|
||||
<group string="Setup Complete">
|
||||
<div class="alert alert-success" role="alert">
|
||||
<strong>Your WooCommerce instance has been created successfully!</strong>
|
||||
<br/>Save the API key below — it is used to authenticate incoming webhooks from WooCommerce.
|
||||
</div>
|
||||
<field name="api_key" readonly="1"/>
|
||||
<field name="instance_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
<footer>
|
||||
<!-- Connection step buttons -->
|
||||
<button type="object" name="action_test_connection"
|
||||
string="Test Connection"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('step', '!=', 'connection')]}"/>
|
||||
<button type="object" name="action_next_step"
|
||||
string="Next"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('step', '!=', 'connection')]}"/>
|
||||
|
||||
<!-- Config step buttons -->
|
||||
<button type="object" name="action_complete"
|
||||
string="Complete Setup"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('step', '!=', 'config')]}"/>
|
||||
|
||||
<!-- Done step buttons -->
|
||||
<button type="object" name="action_fetch_products"
|
||||
string="Fetch Products"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('step', '!=', 'done')]}"/>
|
||||
<button type="object" name="action_open_instance"
|
||||
string="Open Instance"
|
||||
class="btn-secondary"
|
||||
attrs="{'invisible': [('step', '!=', 'done')]}"/>
|
||||
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_woo_setup_wizard" model="ir.actions.act_window">
|
||||
<field name="name">WooCommerce Setup Wizard</field>
|
||||
<field name="res_model">woo.setup.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user