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, })