From 4efd5066d09ed7be3b407ab865598bb40bb127d4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 1 Apr 2026 14:44:48 -0400 Subject: [PATCH] feat: AI-powered product creation wizard with SEO and image geo-tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4-step wizard: Basic Info → Images → Content & SEO → Review & Create. AI generates product titles, descriptions, meta data using Claude or OpenAI. Image geo-tagging with company EXIF data. SEO meta pushed to Rank Math, Yoast, AIOSEO, and SEOPress simultaneously. Products created with CAPS name in Odoo, Title Case in WooCommerce. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fusion_woocommerce/__manifest__.py | 1 + .../security/ir.model.access.csv | 1 + .../static/src/js/product_mapping.js | 15 +- .../fusion_woocommerce/wizard/__init__.py | 1 + .../wizard/woo_product_create.py | 509 ++++++++++++++++++ .../wizard/woo_product_create_views.xml | 206 +++++++ 6 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py create mode 100644 fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml diff --git a/fusion-woo-odoo/fusion_woocommerce/__manifest__.py b/fusion-woo-odoo/fusion_woocommerce/__manifest__.py index d015db9e..a426838a 100644 --- a/fusion-woo-odoo/fusion_woocommerce/__manifest__.py +++ b/fusion-woo-odoo/fusion_woocommerce/__manifest__.py @@ -32,6 +32,7 @@ 'views/woo_menus.xml', 'wizard/woo_setup_wizard_views.xml', 'wizard/woo_product_fetch_views.xml', + 'wizard/woo_product_create_views.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv b/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv index 8fe4bbde..891ca552 100644 --- a/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv +++ b/fusion-woo-odoo/fusion_woocommerce/security/ir.model.access.csv @@ -27,3 +27,4 @@ access_woo_category_map_user,woo.category.map.user,model_woo_category_map,fusion access_woo_category_map_manager,woo.category.map.manager,model_woo_category_map,fusion_woocommerce.group_woo_manager,1,1,1,1 access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1 access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1 +access_woo_product_create_wizard_manager,woo.product.create.wizard.manager,model_woo_product_create_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1 diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js index 1593117e..7d5a3e42 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js @@ -410,7 +410,20 @@ export class ProductMapping extends Component { } async createInWC(odooProductId) { - this.notification.add("Create in WooCommerce queued.", { type: "info" }); + if (!this.state.instanceId) { + this.notification.add("Please select an instance first.", { type: "warning" }); + return; + } + this.actionService.doAction({ + type: 'ir.actions.act_window', + res_model: 'woo.product.create.wizard', + views: [[false, 'form']], + target: 'new', + context: { + default_instance_id: this.state.instanceId, + default_odoo_product_id: odooProductId, + }, + }); } async createInOdoo(wooMapId) { diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py b/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py index 8c76722b..67b9f9cb 100644 --- a/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/__init__.py @@ -1,2 +1,3 @@ from . import woo_setup_wizard from . import woo_product_fetch +from . import woo_product_create diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py new file mode 100644 index 00000000..992d62b6 --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create.py @@ -0,0 +1,509 @@ +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 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') + + # Step 4 is review - uses fields above + + # --- 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 + + @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 + + wc_images = [] + 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' + + # Find AI metadata for this image + img_meta = next((m for m in image_metadata if m.get('index') == i), {}) + + # Upload image via WordPress REST API + try: + img_bytes = base64.b64decode( + img_data if isinstance(img_data, str) else img_data.decode('utf-8') + ) + + import requests + wp_url = inst.url.rstrip('/') + upload_url = f"{wp_url}/wp-json/wp/v2/media" + + headers = { + 'Content-Disposition': f'attachment; filename="{filename}"', + 'Content-Type': 'image/jpeg', + } + + resp = requests.post( + upload_url, + auth=(inst.consumer_key, inst.consumer_secret), + headers=headers, + data=img_bytes, + timeout=60, + ) + + if resp.status_code in (200, 201): + media = resp.json() + wc_img = { + 'id': media['id'], + 'alt': img_meta.get('alt_text', ''), + 'name': img_meta.get('title', filename), + 'caption': img_meta.get('caption', ''), + 'description': img_meta.get('description', ''), + } + # First image is featured + if i == 1: + wc_img['position'] = 0 + wc_images.append(wc_img) + else: + _logger.warning( + "Image upload failed (HTTP %s): %s", + resp.status_code, resp.text[:200], + ) + except Exception as e: + _logger.error("Image upload error: %s", str(e)) + + if wc_images: + wc_data['images'] = wc_images + + # --- Create WC 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 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, + ) + + # Close wizard and show success + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Product Created', + 'message': 'Product "%s" created in WooCommerce successfully!' % wc_data['name'], + 'type': 'success', + 'sticky': False, + 'next': {'type': 'ir.actions.act_window_close'}, + }, + } diff --git a/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml new file mode 100644 index 00000000..9a67ec39 --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/wizard/woo_product_create_views.xml @@ -0,0 +1,206 @@ + + + + + woo.product.create.wizard.form + woo.product.create.wizard + +
+ + +
+ + 1. Basic Info + + + 1. Basic Info ✓ + + + + 2. Images + + + 2. Images ✓ + + + 2. Images + + + + 3. Content & SEO + + + + + 3. Content & SEO + + + + 4. Review & Create + + + 4. Review & Create + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ +