Files
Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py
gsinghpal ed426912ce fix: add .png extension to image URLs — WC rejects extensionless URLs
WooCommerce requires a file extension in image src URLs to determine
file type. Added filename.png to all Odoo image URLs. Also fixed
variable name ordering bugs where img_name was used before defined.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:26:25 -04:00

707 lines
28 KiB
Python

import base64
import json
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
from ..lib.ai_service import AIService
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'
# Step tracking
step = fields.Selection([
('basic', 'Basic Info'),
('images', 'Images'),
('content', 'Content & SEO'),
('review', 'Review & Create'),
], default='basic')
instance_id = fields.Many2one('woo.instance', required=True, string='WooCommerce Instance')
odoo_product_id = fields.Many2one('product.product', string='Odoo Product')
# Step 1: Basic Info
product_name = fields.Char(string='Product Name (Odoo - CAPS)')
wc_product_name = fields.Char(string='Product Name (WC - Title Case)')
odoo_category_id = fields.Many2one('product.category', string='Odoo Category')
wc_category_id = fields.Integer(string='WC Category ID')
wc_category_name = fields.Char(string='WC Category', readonly=True)
sale_price = fields.Float(string='Sale Price', digits='Product Price')
cost_price = fields.Float(string='Cost Price', digits='Product Price')
sku = fields.Char(string='Internal Reference / SKU')
sales_tax_id = fields.Many2one('account.tax', string='Sales Tax',
domain="[('type_tax_use', '=', 'sale')]")
purchase_tax_id = fields.Many2one('account.tax', string='Purchase Tax',
domain="[('type_tax_use', '=', 'purchase')]")
wc_tax_class = fields.Char(string='WC Tax Class')
# Step 2: Images
image_1 = fields.Binary(string='Image 1 (Featured)')
image_1_filename = fields.Char()
image_2 = fields.Binary(string='Image 2')
image_2_filename = fields.Char()
image_3 = fields.Binary(string='Image 3')
image_3_filename = fields.Char()
image_4 = fields.Binary(string='Image 4')
image_4_filename = fields.Char()
image_5 = fields.Binary(string='Image 5')
image_5_filename = fields.Char()
# Image AI metadata (stored as JSON text)
image_metadata = fields.Text(string='Image Metadata', default='[]')
images_geotagged = fields.Boolean(string='Images Geo-tagged')
# Step 3: Content & SEO
raw_product_info = fields.Text(string='Product Information',
help='Type everything you know about this product. The AI will use this to generate descriptions.')
ai_keywords = fields.Char(string='Keywords')
wc_title = fields.Char(string='WC Product Title')
short_description = fields.Html(string='Short Description')
long_description = fields.Html(string='Long Description')
meta_title = fields.Char(string='Meta Title')
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')
def _onchange_odoo_product(self):
if self.odoo_product_id:
product = self.odoo_product_id
self.product_name = product.name.upper() if product.name else ''
self.wc_product_name = product.name.title() if product.name else ''
self.sale_price = product.list_price
self.cost_price = product.standard_price
self.sku = product.default_code or ''
self.odoo_category_id = product.categ_id.id if product.categ_id else False
# Auto-set sales tax
if product.taxes_id:
self.sales_tax_id = product.taxes_id[0].id
# Auto-set image
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."""
if self.odoo_category_id and self.instance_id:
mapping = self.env['woo.category.map'].search([
('instance_id', '=', self.instance_id.id),
('odoo_category_id', '=', self.odoo_category_id.id),
], limit=1)
if mapping:
self.wc_category_id = mapping.woo_category_id
self.wc_category_name = mapping.woo_category_name
@api.onchange('sales_tax_id')
def _onchange_sales_tax(self):
"""Auto-map sales tax to WC tax class and match purchase tax."""
if self.sales_tax_id and self.instance_id:
# Map to WC tax class
tax_map = self.env['woo.tax.map'].search([
('instance_id', '=', self.instance_id.id),
('tax_id', '=', self.sales_tax_id.id),
], limit=1)
if tax_map:
self.wc_tax_class = tax_map.woo_tax_class
# Match purchase tax by amount
purchase_tax = self.env['account.tax'].search([
('type_tax_use', '=', 'purchase'),
('amount', '=', self.sales_tax_id.amount),
('company_id', '=', self.instance_id.company_id.id),
], limit=1)
if purchase_tax:
self.purchase_tax_id = purchase_tax.id
# --- Navigation ---
def action_next(self):
self.ensure_one()
steps = ['basic', 'images', 'content', 'review']
idx = steps.index(self.step)
if idx < len(steps) - 1:
self.step = steps[idx + 1]
return self._reopen()
def action_back(self):
self.ensure_one()
steps = ['basic', 'images', 'content', 'review']
idx = steps.index(self.step)
if idx > 0:
self.step = steps[idx - 1]
return self._reopen()
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}
# --- AI Methods ---
def _get_ai_service(self):
"""Get AIService instance from the woo.instance settings."""
self.ensure_one()
inst = self.instance_id
if not inst.ai_provider or not inst.ai_api_key:
raise UserError(
"Please configure AI settings (Provider + API Key) on the WooCommerce instance first."
)
return AIService(inst.ai_provider, inst.ai_api_key, inst.ai_model or None)
def action_ai_generate_all(self):
"""Generate all content fields using AI."""
self.ensure_one()
ai = self._get_ai_service()
inst = self.instance_id
product_info = {
'name': self.product_name or '',
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'price': self.sale_price,
'sku': self.sku or '',
'raw_description': self.raw_product_info or '',
'keywords': self.ai_keywords or '',
}
prompts = {
'title': inst.prompt_product_title or '',
'short_desc': inst.prompt_short_description or '',
'long_desc': inst.prompt_long_description or '',
'meta_title': inst.prompt_meta_title or '',
'meta_desc': inst.prompt_meta_description or '',
'keywords': inst.prompt_keywords or '',
}
result = ai.generate_product_content(product_info, prompts)
self.wc_title = result.get('title', self.wc_product_name)
self.short_description = result.get('short_description', '')
self.long_description = result.get('long_description', '')
self.meta_title = result.get('meta_title', '')
self.meta_description = result.get('meta_description', '')
self.seo_keywords = result.get('keywords', self.ai_keywords or '')
return self._reopen()
def action_ai_generate_title(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
}
result = ai.generate_single_field(
product_info,
self.instance_id.prompt_product_title or 'Generate SEO product title in Title Case',
'title',
)
self.wc_title = result
return self._reopen()
def action_ai_generate_short_desc(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
}
result = ai.generate_single_field(
product_info,
self.instance_id.prompt_short_description or 'Write a short HTML product description',
'short_description',
)
self.short_description = result
return self._reopen()
def action_ai_generate_long_desc(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
'keywords': self.ai_keywords or '',
}
result = ai.generate_single_field(
product_info,
self.instance_id.prompt_long_description or 'Write a detailed HTML product description',
'long_description',
)
self.long_description = result
return self._reopen()
def action_ai_generate_meta(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.wc_title or self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'keywords': self.ai_keywords or '',
}
self.meta_title = ai.generate_single_field(
product_info,
self.instance_id.prompt_meta_title or 'Generate SEO meta title under 60 chars',
'meta_title',
)
self.meta_description = ai.generate_single_field(
product_info,
self.instance_id.prompt_meta_description or 'Generate SEO meta description under 160 chars',
'meta_description',
)
return self._reopen()
def action_ai_generate_keywords(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.wc_title or self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
}
self.seo_keywords = ai.generate_single_field(
product_info,
self.instance_id.prompt_keywords or 'Generate SEO focus keywords',
'keywords',
)
return self._reopen()
# --- Image AI ---
def action_ai_tag_images(self):
"""Generate AI metadata for all uploaded images."""
self.ensure_one()
ai = self._get_ai_service()
inst = self.instance_id
category_name = self.odoo_category_id.complete_name if self.odoo_category_id else ''
metadata = []
for i in range(1, 6):
img = getattr(self, f'image_{i}', None)
if img:
meta = ai.generate_image_metadata(
self.product_name or '',
category_name,
inst.prompt_image_alt or 'Descriptive alt text under 125 chars',
inst.prompt_image_caption or 'Short product image caption',
)
meta['index'] = i
metadata.append(meta)
self.image_metadata = json.dumps(metadata)
return self._reopen()
def action_geo_tag_images(self):
"""Write EXIF geo-tag data to all uploaded images."""
self.ensure_one()
inst = self.instance_id
for i in range(1, 6):
img = getattr(self, f'image_{i}', None)
if img:
tagged = ImageProcessor.geo_tag_image(
img.decode('utf-8') if isinstance(img, bytes) else img,
inst.geo_company_name,
inst.geo_company_address,
inst.geo_company_phone,
inst.geo_lat,
inst.geo_lng,
)
setattr(self, f'image_{i}', tagged)
self.images_geotagged = True
return self._reopen()
# --- Create Product ---
def action_create_product(self):
"""Create the product in WooCommerce and update Odoo."""
self.ensure_one()
inst = self.instance_id
client = inst._get_client()
# --- Update/Create Odoo product ---
odoo_product = self.odoo_product_id
odoo_vals = {
'name': self.product_name or (self.wc_title or '').upper(),
'list_price': self.sale_price,
'standard_price': self.cost_price,
'default_code': self.sku or '',
'type': 'consu',
}
if self.odoo_category_id:
odoo_vals['categ_id'] = self.odoo_category_id.id
if self.sales_tax_id:
odoo_vals['taxes_id'] = [(6, 0, [self.sales_tax_id.id])]
if self.purchase_tax_id:
odoo_vals['supplier_taxes_id'] = [(6, 0, [self.purchase_tax_id.id])]
if odoo_product:
odoo_product.write(odoo_vals)
else:
odoo_product = self.env['product.product'].create(odoo_vals)
# Set product image
if self.image_1:
odoo_product.image_1920 = self.image_1
# --- Prepare WC product data ---
wc_data = {
'name': self.wc_title or (self.product_name or '').title(),
'type': 'simple',
'regular_price': str(self.sale_price),
'sku': self.sku or '',
'short_description': self.short_description or '',
'description': self.long_description or '',
'manage_stock': False,
'status': 'publish',
}
# Category
if self.wc_category_id:
wc_data['categories'] = [{'id': self.wc_category_id}]
# Tax class
if self.wc_tax_class:
wc_data['tax_class'] = self.wc_tax_class
# SEO meta data — compatible with Rank Math, Yoast, AIOSEO, SEOPress
seo_meta = []
if self.meta_title:
seo_meta.extend([
{'key': 'rank_math_title', 'value': self.meta_title},
{'key': '_yoast_wpseo_title', 'value': self.meta_title},
{'key': '_aioseo_title', 'value': self.meta_title},
{'key': '_seopress_titles_title', 'value': self.meta_title},
])
if self.meta_description:
seo_meta.extend([
{'key': 'rank_math_description', 'value': self.meta_description},
{'key': '_yoast_wpseo_metadesc', 'value': self.meta_description},
{'key': '_aioseo_description', 'value': self.meta_description},
{'key': '_seopress_titles_desc', 'value': self.meta_description},
])
if self.seo_keywords:
seo_meta.extend([
{'key': 'rank_math_focus_keyword', 'value': self.seo_keywords},
{'key': '_yoast_wpseo_focuskw', 'value': self.seo_keywords},
{'key': '_aioseo_keywords', 'value': self.seo_keywords},
{'key': '_seopress_analysis_target_kw', 'value': self.seo_keywords},
])
if seo_meta:
wc_data['meta_data'] = seo_meta
# --- Upload images to WC ---
image_metadata = []
try:
image_metadata = json.loads(self.image_metadata or '[]')
except (json.JSONDecodeError, TypeError):
pass
# Set product images via Odoo's public URL — WC downloads them directly
# WC consumer key/secret cannot authenticate against /wp/v2/media (401)
wc_images = []
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if odoo_base and self.product_template_id:
tmpl_id = self.product_template_id.id
for i in range(1, 6):
img_data = getattr(self, f'image_{i}', None)
if not img_data:
continue
filename = getattr(self, f'image_{i}_filename', '') or f'product_image_{i}.jpg'
img_meta = next((m for m in image_metadata if m.get('index') == i), {})
img_url = f"{odoo_base}/web/image/product.template/{tmpl_id}/image_1920/{filename}"
wc_img = {
'src': img_url,
'name': img_meta.get('title', filename),
'alt': img_meta.get('alt_text', ''),
}
if i == 1:
wc_img['position'] = 0
wc_images.append(wc_img)
if wc_images:
wc_data['images'] = wc_images
# --- Handle variable vs simple product ---
if self.has_variants:
tmpl = self.product_template_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')
# 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,
})
# Build WC attribute ID lookup
wc_attr_id_map = {a['name'].upper(): a['id'] for a in wc_attributes}
# Create variations
variation_count = 0
for line in self.variant_line_ids:
if not line.include:
continue
variant = line.product_id
# Build variation attributes with WC IDs
var_attributes = []
for ptav in variant.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
var_attributes.append(entry)
var_data = {
'regular_price': str(line.sale_price),
'sku': line.sku or '',
'attributes': var_attributes,
'manage_stock': True,
'stock_quantity': int(variant.qty_available),
}
# Variant image — pass Odoo's public URL, WC downloads it directly
if line.image:
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if odoo_base and variant.id:
var_filename = f"variant_{line.sku or variant.id}.png"
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{var_filename}"
var_data['image'] = {
'src': img_url,
'name': var_filename,
'alt': line.variant_name or '',
}
# 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 {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Product Created',
'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,
})