Split 49 modules/suites into independent git repos; untrack from monorepo
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:
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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'">
|
||||
← 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 ✓
|
||||
</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 ✓
|
||||
</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>
|
||||
@@ -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')
|
||||
@@ -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 & Sync to WooCommerce" class="oe_highlight"
|
||||
icon="fa-cloud-upload"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user