feat: full variable product support — create WC products with variants from Odoo

Detects Odoo product variants (product.template.attribute_line) and creates
WC variable products with attributes and variations. Each variation gets
its own price, SKU, image, stock, and tax class. Variant lines shown in
wizard with include/exclude toggle. WC attributes and terms auto-created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-01 17:22:40 -04:00
parent 05c84d077d
commit 9d483fb474
4 changed files with 314 additions and 26 deletions

View File

@@ -85,6 +85,35 @@ class WooApiClient:
def create_product(self, data):
return self.post('products', data)
# Attribute endpoints
def get_product_attributes(self):
return self.get('products/attributes', params={'per_page': 100})
def create_product_attribute(self, data):
return self.post('products/attributes', data)
def get_attribute_terms(self, attribute_id, page=1, per_page=100):
return self.get(f'products/attributes/{attribute_id}/terms', params={'page': page, 'per_page': per_page})
def create_attribute_term(self, attribute_id, data):
return self.post(f'products/attributes/{attribute_id}/terms', data)
# Variation endpoints
def create_product_variation(self, product_id, data):
return self.post(f'products/{product_id}/variations', data)
def update_product_variation(self, product_id, variation_id, data):
return self.put(f'products/{product_id}/variations/{variation_id}', data)
def delete_product_variation(self, product_id, variation_id):
return self.delete(f'products/{product_id}/variations/{variation_id}')
def batch_create_variations(self, product_id, variations_data):
"""Create multiple variations at once using WC batch endpoint."""
return self.post(f'products/{product_id}/variations/batch', {'create': variations_data})
# Order endpoints
def get_orders(self, page=1, per_page=100, **kwargs):

View File

@@ -29,3 +29,4 @@ access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard,
access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_create_wizard_manager,woo.product.create.wizard.manager,model_woo_product_create_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,group_woo_manager,1,1,1,1
access_woo_product_create_variant_line_manager,woo.product.create.variant.line.manager,model_woo_product_create_variant_line,fusion_woocommerce.group_woo_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
29 access_woo_product_fetch_manager woo.product.fetch.manager model_woo_product_fetch fusion_woocommerce.group_woo_manager 1 1 1 1
30 access_woo_product_create_wizard_manager woo.product.create.wizard.manager model_woo_product_create_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
31 access_woo_category_filter_manager woo.category.filter.manager model_woo_category_filter group_woo_manager 1 1 1 1
32 access_woo_product_create_variant_line_manager woo.product.create.variant.line.manager model_woo_product_create_variant_line fusion_woocommerce.group_woo_manager 1 1 1 1

View File

@@ -11,6 +11,21 @@ from ..lib.image_processor import ImageProcessor
_logger = logging.getLogger(__name__)
class WooProductCreateVariantLine(models.TransientModel):
_name = 'woo.product.create.variant.line'
_description = 'Product Creation Variant Line'
wizard_id = fields.Many2one('woo.product.create.wizard', ondelete='cascade')
product_id = fields.Many2one('product.product', string='Variant', readonly=True)
variant_name = fields.Char(string='Variant', readonly=True)
attribute_values = fields.Char(string='Attributes', readonly=True)
sku = fields.Char(string='SKU')
sale_price = fields.Float(string='Price', digits='Product Price')
cost_price = fields.Float(string='Cost', digits='Product Price')
image = fields.Binary(string='Image')
include = fields.Boolean(string='Include', default=True)
class WooProductCreateWizard(models.TransientModel):
_name = 'woo.product.create.wizard'
_description = 'Create Product in WooCommerce'
@@ -68,8 +83,32 @@ class WooProductCreateWizard(models.TransientModel):
meta_description = fields.Text(string='Meta Description')
seo_keywords = fields.Char(string='SEO Focus Keywords')
# Variant detection
has_variants = fields.Boolean(compute='_compute_has_variants')
variant_count = fields.Integer(compute='_compute_has_variants')
product_template_id = fields.Many2one('product.template', compute='_compute_has_variants')
# Variant line display
variant_line_ids = fields.One2many('woo.product.create.variant.line', 'wizard_id')
# Step 4 is review - uses fields above
# --- Variant Detection ---
@api.depends('odoo_product_id')
def _compute_has_variants(self):
for rec in self:
if rec.odoo_product_id:
tmpl = rec.odoo_product_id.product_tmpl_id
variants = tmpl.product_variant_ids
rec.product_template_id = tmpl.id
rec.has_variants = len(variants) > 1
rec.variant_count = len(variants)
else:
rec.product_template_id = False
rec.has_variants = False
rec.variant_count = 0
# --- Onchange / Defaults ---
@api.onchange('odoo_product_id')
@@ -89,6 +128,29 @@ class WooProductCreateWizard(models.TransientModel):
if product.image_1920:
self.image_1 = product.image_1920
# Populate variant lines
tmpl = product.product_tmpl_id
variants = tmpl.product_variant_ids
if len(variants) > 1:
lines = []
for variant in variants:
attr_values = ', '.join(
variant.product_template_attribute_value_ids.mapped('name')
)
lines.append((0, 0, {
'product_id': variant.id,
'variant_name': variant.display_name,
'attribute_values': attr_values,
'sku': variant.default_code or '',
'sale_price': variant.list_price,
'cost_price': variant.standard_price,
'image': variant.image_variant_1920 or False,
'include': True,
}))
self.variant_line_ids = lines
else:
self.variant_line_ids = [(5, 0, 0)]
@api.onchange('odoo_category_id')
def _onchange_category(self):
"""Auto-map Odoo category to WC category using woo.category.map."""
@@ -467,33 +529,175 @@ class WooProductCreateWizard(models.TransientModel):
if wc_images:
wc_data['images'] = wc_images
# --- Create WC product ---
try:
wc_product = client.create_product(wc_data)
wc_product_id = wc_product['id']
wc_permalink = wc_product.get('permalink', '')
except Exception as e:
raise UserError("Failed to create WooCommerce product: %s" % str(e))
# --- Handle variable vs simple product ---
if self.has_variants:
tmpl = self.product_template_id
# --- Create product mapping ---
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': odoo_product.id,
'woo_product_id': wc_product_id,
'woo_product_name': wc_data['name'],
'woo_sku': self.sku or '',
'woo_regular_price': self.sale_price,
'woo_sale_price': 0.0,
'woo_permalink': wc_permalink,
'woo_product_type': 'simple',
'state': 'mapped',
'company_id': inst.company_id.id,
})
# Build WC attributes from Odoo attribute lines
wc_attributes = []
for attr_line in tmpl.attribute_line_ids:
attr_name = attr_line.attribute_id.name
attr_values = attr_line.value_ids.mapped('name')
inst._log_sync(
'product', 'odoo_to_woo', odoo_product.name, 'success',
'Created WC product #%s' % wc_product_id,
)
# Find or create WC attribute
wc_attr = self._find_or_create_wc_attribute(client, attr_name)
# Create terms for each value
wc_terms = []
for val_name in attr_values:
term = self._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name)
wc_terms.append(term['name'])
wc_attributes.append({
'id': wc_attr['id'],
'name': attr_name,
'position': 0,
'visible': True,
'variation': True,
'options': wc_terms,
})
wc_data['type'] = 'variable'
wc_data['attributes'] = wc_attributes
# Remove regular_price for variable products (set on variations)
wc_data.pop('regular_price', None)
# Create the parent product
try:
wc_product = client.create_product(wc_data)
wc_product_id = wc_product['id']
wc_permalink = wc_product.get('permalink', '')
except Exception as e:
raise UserError("Failed to create WooCommerce variable product: %s" % str(e))
# Create parent product map
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': odoo_product.id,
'woo_product_id': wc_product_id,
'woo_product_name': wc_data['name'],
'woo_sku': self.sku or '',
'woo_regular_price': 0,
'woo_sale_price': 0,
'woo_permalink': wc_permalink,
'woo_product_type': 'variable',
'state': 'mapped',
'company_id': inst.company_id.id,
})
# Create variations
variation_count = 0
for line in self.variant_line_ids:
if not line.include:
continue
variant = line.product_id
# Build variation attributes
var_attributes = []
for ptav in variant.product_template_attribute_value_ids:
var_attributes.append({
'name': ptav.attribute_id.name,
'option': ptav.name,
})
var_data = {
'regular_price': str(line.sale_price),
'sku': line.sku or '',
'attributes': var_attributes,
'manage_stock': True,
'stock_quantity': int(variant.qty_available),
}
# Upload variant image if present
if line.image:
try:
img_bytes = base64.b64decode(
line.image if isinstance(line.image, str) else line.image.decode('utf-8')
)
import requests as req
wp_url = inst.url.rstrip('/')
upload_url = f"{wp_url}/wp-json/wp/v2/media"
var_filename = f"variant_{line.sku or variant.id}.jpg"
headers = {
'Content-Disposition': f'attachment; filename="{var_filename}"',
'Content-Type': 'image/jpeg',
}
resp = req.post(
upload_url,
auth=(inst.consumer_key, inst.consumer_secret),
headers=headers,
data=img_bytes,
timeout=60,
)
if resp.status_code in (200, 201):
media = resp.json()
var_data['image'] = {'id': media['id']}
except Exception as img_e:
_logger.warning("Variant image upload failed for %s: %s", line.variant_name, img_e)
# Tax class
if self.wc_tax_class:
var_data['tax_class'] = self.wc_tax_class
try:
wc_variation = client.create_product_variation(wc_product_id, var_data)
# Create variation product map
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': variant.id,
'woo_product_id': wc_variation['id'],
'woo_product_name': line.variant_name,
'woo_sku': line.sku or '',
'woo_regular_price': line.sale_price,
'woo_sale_price': 0,
'woo_permalink': wc_permalink,
'woo_product_type': 'simple',
'woo_parent_id': wc_product_id,
'is_variation': True,
'state': 'mapped',
'company_id': inst.company_id.id,
})
variation_count += 1
except Exception as e:
_logger.error("Failed to create variation for %s: %s", line.variant_name, e)
inst._log_sync(
'product', 'odoo_to_woo', tmpl.name, 'success',
'Created variable product with %d variations' % variation_count,
)
success_msg = 'Variable product "%s" created with %d variations!' % (wc_data['name'], variation_count)
else:
# --- Simple product creation ---
try:
wc_product = client.create_product(wc_data)
wc_product_id = wc_product['id']
wc_permalink = wc_product.get('permalink', '')
except Exception as e:
raise UserError("Failed to create WooCommerce product: %s" % str(e))
# Create product mapping
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': odoo_product.id,
'woo_product_id': wc_product_id,
'woo_product_name': wc_data['name'],
'woo_sku': self.sku or '',
'woo_regular_price': self.sale_price,
'woo_sale_price': 0.0,
'woo_permalink': wc_permalink,
'woo_product_type': 'simple',
'state': 'mapped',
'company_id': inst.company_id.id,
})
inst._log_sync(
'product', 'odoo_to_woo', odoo_product.name, 'success',
'Created WC product #%s' % wc_product_id,
)
success_msg = 'Product "%s" created in WooCommerce successfully!' % wc_data['name']
# Close wizard and show success
return {
@@ -501,9 +705,42 @@ class WooProductCreateWizard(models.TransientModel):
'tag': 'display_notification',
'params': {
'title': 'Product Created',
'message': 'Product "%s" created in WooCommerce successfully!' % wc_data['name'],
'message': success_msg,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
# --- Variant Helper Methods ---
def _find_or_create_wc_attribute(self, client, attr_name):
"""Find or create a WC product attribute by name."""
try:
attrs = client.get_product_attributes()
for a in attrs:
if a.get('name', '').lower() == attr_name.lower():
return a
except Exception:
pass
# Create new attribute
return client.create_product_attribute({
'name': attr_name,
'slug': attr_name.lower().replace(' ', '-'),
'type': 'select',
'order_by': 'menu_order',
})
def _find_or_create_wc_attribute_term(self, client, attr_id, term_name):
"""Find or create a WC attribute term."""
try:
terms = client.get_attribute_terms(attr_id)
for t in terms:
if t.get('name', '').lower() == term_name.lower():
return t
except Exception:
pass
# Create new term
return client.create_attribute_term(attr_id, {
'name': term_name,
})

View File

@@ -37,6 +37,27 @@
<field name="wc_category_name" readonly="1"/>
</group>
</group>
<!-- Variant info (visible when product has variants) -->
<div invisible="not has_variants" class="mt-3">
<separator string="Product Variants"/>
<div class="alert alert-info" role="alert">
This product has <field name="variant_count" readonly="1" class="d-inline"/> variants.
They will be created as WooCommerce variations.
</div>
<field name="variant_line_ids">
<list editable="bottom">
<field name="include" widget="boolean_toggle"/>
<field name="variant_name" readonly="1"/>
<field name="attribute_values" readonly="1"/>
<field name="sku"/>
<field name="sale_price"/>
<field name="cost_price"/>
<field name="image" widget="image" options="{'size': [64, 64]}"/>
</list>
</field>
</div>
<field name="has_variants" invisible="1"/>
<field name="product_template_id" invisible="1"/>
</div>
<!-- ========== STEP 2: Images ========== -->