Split 49 modules/suites into independent git repos; untrack from monorepo
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

Each top-level module/suite folder is now its own private repo on GitHub
(gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial
commit. The monorepo no longer tracks them (added to .gitignore + git rm
--cached); working-tree files are retained on disk and managed in their
own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/,
tools/, AGENTS.md, WIP/obsolete dirs) and full history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

@@ -1,5 +0,0 @@
from . import woo_setup_wizard
from . import woo_product_fetch
from . import woo_product_create
from . import woo_category_filter
from . import woo_variant_push

View File

@@ -1,42 +0,0 @@
from odoo import api, fields, models
class WooCategoryFilter(models.TransientModel):
_name = 'woo.category.filter'
_description = 'Manage Hidden Categories'
instance_id = fields.Many2one('woo.instance', required=True)
category_ids = fields.Many2many(
'product.category', string='Categories to Hide',
help='Select categories you want to hide from the unmatched products list.',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
instance_id = self.env.context.get('default_instance_id')
if instance_id:
instance = self.env['woo.instance'].browse(instance_id)
res['category_ids'] = [(6, 0, instance.excluded_category_ids.ids)]
return res
def action_save(self):
"""Save the hidden categories to the instance."""
self.ensure_one()
self.instance_id.excluded_category_ids = [(6, 0, self.category_ids.ids)]
return {'type': 'ir.actions.act_window_close'}
def action_clear_all(self):
"""Remove all hidden categories."""
self.ensure_one()
self.category_ids = [(5, 0, 0)]
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',
}

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_category_filter_form_view" model="ir.ui.view">
<field name="name">woo.category.filter.form</field>
<field name="model">woo.category.filter</field>
<field name="arch" type="xml">
<form string="Manage Hidden Categories">
<sheet>
<div class="alert alert-info" role="alert">
Select the product categories you want to hide from the unmatched products list.
These will persist across sessions. You can toggle them on/off from the product mapping screen.
</div>
<group>
<field name="instance_id" invisible="1"/>
<field name="category_ids" widget="many2many_tags"
options="{'no_create': True}"
placeholder="Search and select categories to hide..."/>
</group>
</sheet>
<footer>
<button name="action_save" type="object" string="Save" class="btn-primary"/>
<button name="action_clear_all" type="object" string="Clear All" class="btn-secondary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,706 +0,0 @@
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,
})

View File

@@ -1,193 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_product_create_wizard_form" model="ir.ui.view">
<field name="name">woo.product.create.wizard.form</field>
<field name="model">woo.product.create.wizard</field>
<field name="arch" type="xml">
<form string="Create Product in WooCommerce">
<sheet>
<!-- Step indicator using Odoo statusbar widget -->
<field name="step" widget="statusbar"
statusbar_visible="basic,images,content,review"/>
<field name="instance_id" invisible="1"/>
<field name="wc_category_id" invisible="1"/>
<!-- ========== STEP 1: Basic Info ========== -->
<div invisible="step != 'basic'">
<group>
<group string="Product">
<field name="odoo_product_id"/>
<field name="product_name" placeholder="PRODUCT NAME IN CAPS"/>
<field name="wc_product_name" placeholder="Product Name In Title Case"/>
<field name="sku" string="Internal Reference"/>
</group>
<group string="Pricing &amp; Tax">
<field name="sale_price"/>
<field name="cost_price"/>
<field name="sales_tax_id"/>
<field name="purchase_tax_id"/>
<field name="wc_tax_class" readonly="1"/>
</group>
</group>
<group>
<group string="Category">
<field name="odoo_category_id"/>
<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 ========== -->
<div invisible="step != 'images'">
<div class="mb-3">
<button name="action_ai_tag_images" type="object"
string="AI Tag Images" class="oe_highlight me-2"
icon="fa-magic"/>
<button name="action_geo_tag_images" type="object"
string="Geo-tag Images" class="btn-secondary"
icon="fa-map-marker"/>
<field name="images_geotagged" invisible="1"/>
</div>
<group>
<group>
<field name="image_1" widget="image" string="Featured Image"/>
<field name="image_1_filename" invisible="1"/>
<field name="image_2" widget="image" string="Image 2"/>
<field name="image_2_filename" invisible="1"/>
<field name="image_3" widget="image" string="Image 3"/>
<field name="image_3_filename" invisible="1"/>
</group>
<group>
<field name="image_4" widget="image" string="Image 4"/>
<field name="image_4_filename" invisible="1"/>
<field name="image_5" widget="image" string="Image 5"/>
<field name="image_5_filename" invisible="1"/>
</group>
</group>
<field name="image_metadata" invisible="1"/>
</div>
<!-- ========== STEP 3: Content & SEO ========== -->
<div invisible="step != 'content'">
<separator string="Product Information for AI"/>
<field name="raw_product_info" nolabel="1" widget="text"
placeholder="Type everything you know about this product — features, materials, use cases, target audience, dimensions, etc. The AI will use this to generate all descriptions."/>
<group>
<field name="ai_keywords" string="Keywords"
placeholder="mobility, wheelchair, medical equipment, ..."/>
</group>
<div class="mb-3">
<button name="action_ai_generate_all" type="object"
string="Generate All with AI" class="oe_highlight"
icon="fa-magic"/>
</div>
<separator string="Product Title"/>
<group>
<field name="wc_title" string="WC Product Title"/>
<button name="action_ai_generate_title" type="object"
string="AI Generate" class="oe_link"
icon="fa-magic"/>
</group>
<separator string="Short Description"/>
<button name="action_ai_generate_short_desc" type="object"
string="AI Generate" class="oe_link mb-1"
icon="fa-magic"/>
<field name="short_description" widget="html" nolabel="1"/>
<separator string="Long Description"/>
<button name="action_ai_generate_long_desc" type="object"
string="AI Generate" class="oe_link mb-1"
icon="fa-magic"/>
<field name="long_description" widget="html" nolabel="1"/>
<separator string="SEO Meta Data"/>
<div class="mb-2">
<button name="action_ai_generate_meta" type="object"
string="AI Generate Meta" class="oe_link"
icon="fa-magic"/>
<button name="action_ai_generate_keywords" type="object"
string="AI Generate Keywords" class="oe_link ms-3"
icon="fa-magic"/>
</div>
<group>
<field name="meta_title" placeholder="SEO title (under 60 characters)"/>
<field name="meta_description" widget="text"
placeholder="SEO description (under 160 characters)"/>
<field name="seo_keywords" placeholder="keyword1, keyword2, keyword3"/>
</group>
</div>
<!-- ========== STEP 4: Review & Create ========== -->
<div invisible="step != 'review'">
<group>
<group string="Product">
<field name="product_name" readonly="1"/>
<field name="wc_title" readonly="1" string="WC Title"/>
<field name="sku" readonly="1"/>
</group>
<group string="Pricing &amp; Tax">
<field name="sale_price" readonly="1"/>
<field name="cost_price" readonly="1"/>
<field name="sales_tax_id" readonly="1"/>
<field name="wc_tax_class" readonly="1"/>
</group>
</group>
<group>
<group string="Category">
<field name="odoo_category_id" readonly="1"/>
<field name="wc_category_name" readonly="1"/>
</group>
<group string="SEO">
<field name="meta_title" readonly="1"/>
<field name="meta_description" readonly="1"/>
<field name="seo_keywords" readonly="1"/>
</group>
</group>
</div>
</sheet>
<footer>
<button string="Back" type="object" name="action_back"
class="btn-secondary"
invisible="step == 'basic'"
icon="fa-arrow-left"/>
<button string="Next" type="object" name="action_next"
class="oe_highlight"
invisible="step == 'review'"
icon="fa-arrow-right"/>
<button string="Create in WooCommerce" type="object" name="action_create_product"
class="oe_highlight"
invisible="step != 'review'"
icon="fa-cloud-upload"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,177 +0,0 @@
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
wc_categories = wc_product.get('categories', [])
wc_cat_id = wc_categories[0].get('id', 0) if wc_categories else 0
wc_cat_name = wc_categories[0].get('name', '') if wc_categories else ''
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,
'woo_category_id': wc_cat_id,
'woo_category_name': wc_cat_name,
'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',
}

View File

@@ -1,61 +0,0 @@
<?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"
readonly="state != 'draft'"/>
<field name="state" readonly="1"/>
</group>
<group string="Results"
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"
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"
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"
invisible="state != 'draft'"/>
<button type="object" name="action_open_mapping"
string="Open Product Mapping"
class="btn-primary"
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>

View File

@@ -1,145 +0,0 @@
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',
}

View File

@@ -1,130 +0,0 @@
<?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"
invisible="step == 'connection'">
&#8592; Back
</button>
<span class="badge bg-primary me-1"
invisible="step != 'connection'">
Step 1: Connection
</span>
<span class="badge bg-secondary me-1"
invisible="step == 'connection'">
Step 1: Connection &#10003;
</span>
<span class="badge bg-primary me-1"
invisible="step != 'config'">
Step 2: Configuration
</span>
<span class="badge bg-secondary me-1"
invisible="step in ('connection', 'config')">
Step 2: Configuration &#10003;
</span>
<span class="badge bg-muted me-1"
invisible="step in ('config', 'done')">
Step 2: Configuration
</span>
<span class="badge bg-primary me-1"
invisible="step != 'done'">
Step 3: Done
</span>
<span class="badge bg-muted me-1"
invisible="step == 'done'">
Step 3: Done
</span>
</div>
<!-- Step 1: Connection -->
<group 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"
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"
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 invisible="step != 'config'">
<group string="Sync Configuration">
<field name="default_warehouse_id"/>
<field name="sync_interval"/>
</group>
</group>
<!-- Step 3: Done -->
<group 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"
invisible="step != 'connection'"/>
<button type="object" name="action_next_step"
string="Next"
class="btn-primary"
invisible="step != 'connection'"/>
<!-- Config step buttons -->
<button type="object" name="action_complete"
string="Complete Setup"
class="btn-primary"
invisible="step != 'config'"/>
<!-- Done step buttons -->
<button type="object" name="action_fetch_products"
string="Fetch Products"
class="btn-primary"
invisible="step != 'done'"/>
<button type="object" name="action_open_instance"
string="Open Instance"
class="btn-secondary"
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>

View File

@@ -1,344 +0,0 @@
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')

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_variant_push_wizard_form" model="ir.ui.view">
<field name="name">woo.variant.push.wizard.form</field>
<field name="model">woo.variant.push.wizard</field>
<field name="arch" type="xml">
<form string="Push Variants to WooCommerce">
<sheet>
<field name="instance_id" invisible="1"/>
<field name="product_map_id" invisible="1"/>
<group>
<group>
<field name="product_name" string="Product"/>
<field name="woo_product_id" string="WC Product ID"/>
</group>
<group>
<field name="product_template_id" string="Odoo Template" readonly="1"/>
</group>
</group>
<separator string="Variants to Push"/>
<div class="alert alert-info" role="alert">
Review and edit each variant's pricing, SKU, and image.
New variants will be created, already synced variants will be updated.
Uncheck "Include" to skip a variant.
</div>
<field name="line_ids">
<list editable="bottom">
<field name="include" widget="boolean_toggle"/>
<field name="is_default" string="Default" widget="boolean_toggle" force_save="1"/>
<field name="product_id" column_invisible="1"/>
<field name="variant_name" readonly="1" force_save="1"/>
<field name="attribute_values" readonly="1" force_save="1"/>
<field name="sku"/>
<field name="regular_price" string="Standard Price"/>
<field name="sale_price"/>
<field name="cost_price" readonly="1" force_save="1"/>
<field name="image" widget="image" options="{'size': [48, 48]}"/>
<field name="already_synced" column_invisible="1" force_save="1"/>
<field name="wc_variation_id" column_invisible="1" force_save="1"/>
<field name="map_id" column_invisible="1" force_save="1"/>
</list>
</field>
</sheet>
<footer>
<button name="action_push" type="object"
string="Save &amp; Sync to WooCommerce" class="oe_highlight"
icon="fa-cloud-upload"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>