Image wasn't persisting because transient model write was in the same transaction. Added cr.commit() after saving image to ensure it's available when WC downloads it. Added size/type logging to trace image data flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
14 KiB
Python
340 lines
14 KiB
Python
import base64
|
|
import json
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
from ..lib.image_processor import ImageProcessor
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WooVariantPushWizard(models.TransientModel):
|
|
_name = 'woo.variant.push.wizard'
|
|
_description = 'Push Variants to WooCommerce'
|
|
|
|
instance_id = fields.Many2one('woo.instance', required=True)
|
|
product_map_id = fields.Many2one('woo.product.map', required=True, string='Product Mapping')
|
|
product_template_id = fields.Many2one('product.template', string='Product Template', readonly=True)
|
|
product_name = fields.Char(readonly=True)
|
|
woo_product_id = fields.Integer(readonly=True)
|
|
|
|
line_ids = fields.One2many('woo.variant.push.line', 'wizard_id', string='Variants')
|
|
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
res = super().default_get(fields_list)
|
|
map_id = self.env.context.get('default_product_map_id')
|
|
if map_id:
|
|
pm = self.env['woo.product.map'].browse(map_id)
|
|
if pm.exists() and pm.product_id:
|
|
tmpl = pm.product_id.product_tmpl_id
|
|
res['instance_id'] = pm.instance_id.id
|
|
res['product_template_id'] = tmpl.id
|
|
res['product_name'] = tmpl.name
|
|
res['woo_product_id'] = pm.woo_product_id
|
|
|
|
# Populate variant lines — only active variants with attribute values
|
|
lines = []
|
|
active_variants = tmpl.product_variant_ids.filtered(
|
|
lambda v: v.active and v.product_template_attribute_value_ids
|
|
)
|
|
for variant in active_variants:
|
|
already_mapped = self.env['woo.product.map'].search([
|
|
('instance_id', '=', pm.instance_id.id),
|
|
('product_id', '=', variant.id),
|
|
('is_variation', '=', True),
|
|
], limit=1)
|
|
|
|
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': already_mapped.woo_sku if already_mapped else (variant.default_code or ''),
|
|
'regular_price': already_mapped.woo_regular_price if already_mapped else variant.list_price,
|
|
'sale_price': already_mapped.woo_sale_price if already_mapped else 0.0,
|
|
'cost_price': variant.standard_price,
|
|
'image': variant.image_variant_1920 or variant.image_1920 or False,
|
|
'include': True,
|
|
'already_synced': bool(already_mapped),
|
|
'wc_variation_id': already_mapped.woo_product_id if already_mapped else 0,
|
|
'map_id': already_mapped.id if already_mapped else 0,
|
|
}))
|
|
res['line_ids'] = lines
|
|
return res
|
|
|
|
def action_push(self):
|
|
"""Push selected variants to WooCommerce."""
|
|
self.ensure_one()
|
|
pm = self.product_map_id
|
|
inst = pm.instance_id
|
|
client = inst._get_client()
|
|
tmpl = self.product_template_id
|
|
|
|
_logger.info(
|
|
"Variant push wizard: %d lines total, fields: %s",
|
|
len(self.line_ids),
|
|
[(l.variant_name, l.already_synced, l.wc_variation_id, l.map_id) for l in self.line_ids],
|
|
)
|
|
lines_new = self.line_ids.filtered(lambda l: l.include and not l.already_synced)
|
|
lines_update = self.line_ids.filtered(lambda l: l.include and l.already_synced)
|
|
_logger.info("Variant push: %d new, %d update", len(lines_new), len(lines_update))
|
|
|
|
if not lines_new and not lines_update:
|
|
raise UserError("No variants selected.")
|
|
|
|
# Step 1: 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')
|
|
|
|
wc_attr = pm._find_or_create_wc_attribute(client, attr_name)
|
|
wc_terms = []
|
|
for val_name in attr_values:
|
|
term = pm._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,
|
|
})
|
|
|
|
# Build a lookup: Odoo attribute name → WC attribute ID
|
|
wc_attr_id_map = {}
|
|
for wc_attr in wc_attributes:
|
|
wc_attr_id_map[wc_attr['name'].upper()] = wc_attr['id']
|
|
|
|
# Step 2: Update product as variable with attributes + set default
|
|
# Default attribute = first included variant's attribute values
|
|
default_attrs = []
|
|
first_included = self.line_ids.filtered('include')[:1]
|
|
if first_included and first_included.product_id:
|
|
for ptav in first_included.product_id.product_template_attribute_value_ids:
|
|
default_attrs.append({
|
|
'name': ptav.attribute_id.name,
|
|
'option': ptav.name,
|
|
})
|
|
|
|
parent_update = {
|
|
'type': 'variable',
|
|
'attributes': wc_attributes,
|
|
}
|
|
if default_attrs:
|
|
parent_update['default_attributes'] = default_attrs
|
|
|
|
try:
|
|
client.update_product(pm.woo_product_id, parent_update)
|
|
pm.woo_product_type = 'variable'
|
|
except Exception as e:
|
|
raise UserError("Failed to update WC product: %s" % str(e))
|
|
|
|
# Step 3: Create NEW variations
|
|
created = 0
|
|
updated = 0
|
|
errors = []
|
|
for line in lines_new:
|
|
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.regular_price),
|
|
'sku': line.sku or '',
|
|
'attributes': var_attributes,
|
|
'manage_stock': True,
|
|
'stock_quantity': int(variant.qty_available),
|
|
}
|
|
|
|
# Sale price
|
|
if line.sale_price > 0:
|
|
var_data['sale_price'] = str(line.sale_price)
|
|
|
|
# Tax class
|
|
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(
|
|
inst, variant.taxes_id[:1].id if variant.taxes_id else False
|
|
)
|
|
if wc_tax_class:
|
|
var_data['tax_class'] = wc_tax_class
|
|
|
|
# Save wizard image to Odoo product, then pass URL to WC
|
|
if line.image and len(line.image) > 100:
|
|
variant.sudo().write({'image_1920': line.image})
|
|
self.env.cr.commit()
|
|
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
if odoo_base:
|
|
img_name = (line.sku or variant.default_code or 'variant') + '.png'
|
|
import time
|
|
cache_bust = int(time.time())
|
|
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{img_name}?t={cache_bust}"
|
|
var_data['image'] = {
|
|
'src': img_url,
|
|
'name': img_name,
|
|
'alt': line.variant_name or '',
|
|
}
|
|
|
|
try:
|
|
wc_variation = client.create_product_variation(pm.woo_product_id, var_data)
|
|
|
|
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.regular_price,
|
|
'woo_sale_price': line.sale_price,
|
|
'woo_permalink': pm.woo_permalink or '',
|
|
'woo_product_type': 'simple',
|
|
'woo_parent_id': pm.woo_product_id,
|
|
'is_variation': True,
|
|
'state': 'mapped',
|
|
'company_id': inst.company_id.id,
|
|
})
|
|
created += 1
|
|
except Exception as e:
|
|
errors.append('%s: %s' % (line.variant_name, str(e)))
|
|
_logger.error("Failed to create variation: %s", e)
|
|
|
|
# Step 4: UPDATE existing variations
|
|
for line in lines_update:
|
|
variant = line.product_id
|
|
wc_var_id = line.wc_variation_id
|
|
if not wc_var_id:
|
|
continue
|
|
|
|
# Build variation attributes with WC attribute IDs
|
|
var_attributes = []
|
|
for ptav in variant.product_template_attribute_value_ids:
|
|
attr_name = ptav.attribute_id.name
|
|
wc_attr_id = wc_attr_id_map.get(attr_name.upper(), 0)
|
|
attr_entry = {'option': ptav.name}
|
|
if wc_attr_id:
|
|
attr_entry['id'] = wc_attr_id
|
|
else:
|
|
attr_entry['name'] = attr_name
|
|
var_attributes.append(attr_entry)
|
|
|
|
var_data = {
|
|
'regular_price': str(line.regular_price),
|
|
'sku': line.sku or '',
|
|
'attributes': var_attributes,
|
|
'manage_stock': True,
|
|
'stock_quantity': int(variant.qty_available),
|
|
}
|
|
|
|
if line.sale_price > 0:
|
|
var_data['sale_price'] = str(line.sale_price)
|
|
else:
|
|
var_data['sale_price'] = ''
|
|
|
|
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(
|
|
inst, variant.taxes_id[:1].id if variant.taxes_id else False
|
|
)
|
|
if wc_tax_class:
|
|
var_data['tax_class'] = wc_tax_class
|
|
|
|
# Save wizard image to Odoo product, then pass URL to WC
|
|
if line.image:
|
|
img_data = line.image
|
|
img_size = len(img_data) if img_data else 0
|
|
_logger.info("Variant %d image data: type=%s size=%d", variant.id, type(img_data).__name__, img_size)
|
|
# Save the image from the wizard to the actual Odoo product
|
|
if img_size > 100: # Skip tiny placeholders
|
|
variant.sudo().write({'image_1920': img_data})
|
|
self.env.cr.commit() # Force commit so the image is available for download
|
|
_logger.info("Saved image to Odoo product %d (%d bytes)", variant.id, img_size)
|
|
else:
|
|
_logger.warning("Skipping tiny image for variant %d (%d bytes)", variant.id, img_size)
|
|
|
|
# Now build the public URL for WC to download
|
|
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
if odoo_base:
|
|
img_name = (line.sku or variant.default_code or 'variant') + '.png'
|
|
# Add timestamp to bust WC cache
|
|
import time
|
|
cache_bust = int(time.time())
|
|
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{img_name}?t={cache_bust}"
|
|
var_data['image'] = {
|
|
'src': img_url,
|
|
'name': img_name,
|
|
'alt': line.variant_name or '',
|
|
}
|
|
_logger.info("Variant image URL: %s", img_url)
|
|
|
|
try:
|
|
client.update_product_variation(pm.woo_product_id, wc_var_id, var_data)
|
|
|
|
# Update local map record
|
|
if line.map_id:
|
|
map_rec = self.env['woo.product.map'].browse(line.map_id)
|
|
if map_rec.exists():
|
|
map_rec.write({
|
|
'woo_sku': line.sku or '',
|
|
'woo_regular_price': line.regular_price,
|
|
'woo_sale_price': line.sale_price,
|
|
})
|
|
updated += 1
|
|
except Exception as e:
|
|
errors.append('Update %s: %s' % (line.variant_name, str(e)))
|
|
_logger.error("Failed to update variation: %s", e)
|
|
|
|
parts = []
|
|
if created:
|
|
parts.append('%d created' % created)
|
|
if updated:
|
|
parts.append('%d updated' % updated)
|
|
summary = ', '.join(parts) if parts else 'No changes'
|
|
|
|
inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success',
|
|
'Variants: %s for WC product #%s' % (summary, pm.woo_product_id))
|
|
|
|
msg = 'Variants: %s.' % summary
|
|
if errors:
|
|
msg += '\n\nErrors:\n' + '\n'.join(errors)
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': 'Variants Pushed',
|
|
'message': msg,
|
|
'type': 'success' if not errors else 'warning',
|
|
'sticky': bool(errors),
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
},
|
|
}
|
|
|
|
|
|
class WooVariantPushLine(models.TransientModel):
|
|
_name = 'woo.variant.push.line'
|
|
_description = 'Variant Push Line'
|
|
|
|
wizard_id = fields.Many2one('woo.variant.push.wizard', ondelete='cascade')
|
|
product_id = fields.Many2one('product.product', string='Variant')
|
|
variant_name = fields.Char(string='Variant')
|
|
attribute_values = fields.Char(string='Attributes')
|
|
sku = fields.Char(string='SKU')
|
|
regular_price = fields.Float(string='Standard Price', digits='Product Price')
|
|
sale_price = fields.Float(string='Sale 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)
|
|
already_synced = fields.Boolean(string='Already Synced')
|
|
wc_variation_id = fields.Integer(string='WC Variation ID')
|
|
map_id = fields.Integer(string='Map Record ID')
|