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') ) is_first = len(lines) == 0 # For already-synced variants, don't pre-fill image # (to avoid re-uploading the same image on every sync) # User can upload a new image if they want to change it pre_image = False if not already_mapped: pre_image = variant.image_variant_1920 or variant.image_1920 or False 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': pre_image, 'include': True, 'is_default': is_first, '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 # Use the variant marked as default, or first included default_line = self.line_ids.filtered(lambda l: l.include and l.is_default)[:1] if not default_line: default_line = self.line_ids.filtered('include')[:1] default_attrs = [] if default_line and default_line.product_id: for ptav in default_line.product_id.product_template_attribute_value_ids: attr_name = ptav.attribute_id.name wc_aid = wc_attr_id_map.get(attr_name.upper(), 0) entry = {'option': ptav.name} if wc_aid: entry['id'] = wc_aid else: entry['name'] = attr_name default_attrs.append(entry) 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 # Serve image directly from wizard line via custom endpoint if line.image: # Commit the line data so the image endpoint can read it 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' img_url = f"{odoo_base}/woo/image/{line.id}/{img_name}" var_data['image'] = { 'src': img_url, 'name': img_name, 'alt': line.variant_name or '', } _logger.info("Variant image URL: %s", img_url) # Also save to Odoo product for future reference variant.sudo().write({'image_variant_1920': line.image}) 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', attachment=False) include = fields.Boolean(string='Include', default=True) is_default = fields.Boolean(string='Default') already_synced = fields.Boolean(string='Already Synced') wc_variation_id = fields.Integer(string='WC Variation ID') map_id = fields.Integer(string='Map Record ID')