From 22d44c48d969875fdff0dd7c9df02623b92ee5ce Mon Sep 17 00:00:00 2001 From: Shrey Mehta Date: Thu, 8 Jan 2026 11:08:25 +0530 Subject: [PATCH 1/6] [IMP] website_sale: variant images management Prior this commit: - Variant images had to be uploaded and managed individually on each product variant, even when multiple variants shared the same characteristics (e.g., the same image for all sizes of a blue T-shirt). Post this commit: - Images can now be uploaded directly on the product template and linked to specific attribute values. These images are automatically applied to all matching variants, simplifying variant image management and avoiding unnecessary duplication. task-5405584 --- addons/website_sale/__manifest__.py | 1 + addons/website_sale/models/product_image.py | 3 + .../website_sale/models/product_template.py | 18 ++++++ .../src/js/product_image/product_image.js | 64 +++++++++++++++++++ .../src/js/product_image/product_image.xml | 47 ++++++++++++++ .../views/product_image_views.xml | 2 + 6 files changed, 135 insertions(+) create mode 100644 addons/website_sale/static/src/js/product_image/product_image.js create mode 100644 addons/website_sale/static/src/js/product_image/product_image.xml diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 6d8164bf694bb..06e057d903cb5 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -172,6 +172,7 @@ 'website_sale/static/src/js/website_sale_video_field_preview.js', 'website_sale/static/src/scss/website_sale_backend.scss', 'website_sale/static/src/js/tours/website_sale_shop.js', + 'website_sale/static/src/js/product_image/*', 'website_sale/static/src/xml/website_sale.xml', 'website_sale/static/src/scss/kanban_record.scss', 'website_sale/static/src/js/dashboard/dashboard.js', diff --git a/addons/website_sale/models/product_image.py b/addons/website_sale/models/product_image.py index 8ded116f23e2e..d6b3f463802dd 100644 --- a/addons/website_sale/models/product_image.py +++ b/addons/website_sale/models/product_image.py @@ -37,6 +37,9 @@ class ProductImage(models.Model): compute='_compute_can_image_1024_be_zoomed', store=True, ) + attribute_value_ids = fields.Many2many( + 'product.attribute.value', + ) #=== COMPUTE METHODS ===# diff --git a/addons/website_sale/models/product_template.py b/addons/website_sale/models/product_template.py index ed55b5ee964dd..cee92dcacefe9 100644 --- a/addons/website_sale/models/product_template.py +++ b/addons/website_sale/models/product_template.py @@ -755,6 +755,24 @@ def _get_suitable_image_size(self, columns, x_size, y_size): return 'image_512' return 'image_1024' + def get_template_attribute_values_for_image_assignment(self): + self.ensure_one() + return [ + { + 'id': line.attribute_id.id, + 'name': line.attribute_id.name, + 'values': [ + { + 'id': ptav.product_attribute_value_id.id, + 'name': ptav.name, + } + for ptav in line.product_template_value_ids + if ptav.ptav_active + ], + } + for line in self.attribute_line_ids + ] + def _init_column(self, column_name): # to avoid generating a single default website_sequence when installing the module, # we need to set the default row by row for this column diff --git a/addons/website_sale/static/src/js/product_image/product_image.js b/addons/website_sale/static/src/js/product_image/product_image.js new file mode 100644 index 0000000000000..11e19c138f5d1 --- /dev/null +++ b/addons/website_sale/static/src/js/product_image/product_image.js @@ -0,0 +1,64 @@ +import { Component, useState } from '@odoo/owl'; +import { Dropdown } from '@web/core/dropdown/dropdown'; +import { useDropdownState } from '@web/core/dropdown/dropdown_hooks'; +import { DropdownItem } from '@web/core/dropdown/dropdown_item'; +import { x2ManyCommands } from "@web/core/orm_service"; +import { registry } from '@web/core/registry'; +import { useService } from '@web/core/utils/hooks'; + +export class ProductImage extends Component { + static template = 'variant_image_assignment'; + static components = { Dropdown, DropdownItem }; + static props = { + id: String, + name: String, + readonly: Boolean, + record: Object, + }; + + setup() { + this.orm = useService('orm'); + this.record = this.props.record; + + this.state = useState({ + attributes: [], + checkedIds: new Set(), + }); + + this.dropdownState = useDropdownState(); + } + + async beforeOpen() { + const tmplId = this.record.data.product_tmpl_id.id; + if (!tmplId) { + return; + } + this.state.attributes = await this.orm.call( + 'product.template', + 'get_template_attribute_values_for_image_assignment', + [tmplId] + ); + this.state.checkedIds = new Set( + this.record.data[this.props.name].resIds || [] + ); + } + + toggleValue(valueId) { + const checkedIds = this.state.checkedIds; + const isChecked = checkedIds.has(valueId); + + isChecked ? checkedIds.delete(valueId) : checkedIds.add(valueId); + + this.record.update({ + [this.props.name]: [ + isChecked + ? x2ManyCommands.unlink(valueId) + : x2ManyCommands.link(valueId), + ], + }); + } +} + +const productImage = { component: ProductImage }; + +registry.category('fields').add('variant_image_assignment', productImage); diff --git a/addons/website_sale/static/src/js/product_image/product_image.xml b/addons/website_sale/static/src/js/product_image/product_image.xml new file mode 100644 index 0000000000000..a674540839cc2 --- /dev/null +++ b/addons/website_sale/static/src/js/product_image/product_image.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + +
+ + + + +
+ + + + +
+
+
+
+
+
+ +
diff --git a/addons/website_sale/views/product_image_views.xml b/addons/website_sale/views/product_image_views.xml index 5b4a33e7676fa..56a8cc0529505 100644 --- a/addons/website_sale/views/product_image_views.xml +++ b/addons/website_sale/views/product_image_views.xml @@ -41,6 +41,7 @@ +
@@ -67,6 +68,7 @@
+
From ab83a4acae7472ccc375d69c841cc1aecd1e1772 Mon Sep 17 00:00:00 2001 From: Shrey Mehta Date: Fri, 23 Jan 2026 15:48:50 +0530 Subject: [PATCH 2/6] [IMP] website_sale: WIP2 --- .../tests/test_performance.py | 6 +- addons/website_sale/controllers/main.py | 2 +- addons/website_sale/data/demo.xml | 38 ++++---- addons/website_sale/models/product_image.py | 89 +++++++++++++++++-- addons/website_sale/models/product_product.py | 34 +++++-- .../website_sale/models/product_template.py | 18 ---- .../src/js/product_image/product_image.js | 22 +++-- .../src/js/product_image/product_image.xml | 12 ++- .../website_sale/tests/test_product_image.py | 14 +-- .../views/product_image_views.xml | 3 +- addons/website_sale/views/product_views.xml | 5 +- 11 files changed, 168 insertions(+), 75 deletions(-) diff --git a/addons/test_website_modules/tests/test_performance.py b/addons/test_website_modules/tests/test_performance.py index 8345e69d2ae48..169382feea2b8 100644 --- a/addons/test_website_modules/tests/test_performance.py +++ b/addons/test_website_modules/tests/test_performance.py @@ -117,7 +117,7 @@ def setUpClass(cls): }, { 'name': 'Variant image', 'image_1920': red_image, - 'product_variant_id': cls.productC.id, + 'product_variant_ids': [Command.link(cls.productC.id)], }]) for i in range(20): @@ -148,7 +148,7 @@ def setUpClass(cls): images.append({ 'name': 'Variant image', 'image_1920': red_image, - 'product_variant_id': variant.id, + 'product_variant_ids': [Command.link(variant.id)], }) cls.env['product.image'].create(images) @@ -293,7 +293,7 @@ def _get_queries_shop(self): html = self.url_open('/shop').text self.assertIn(f' Office Chair - Context View 10 - + Office Chair - Context View 10 - + Drawer Black - Context View 10 - + Office Lamp - Context View 10 - + Customizable Desk Steel - Context View 10 - + @@ -1034,91 +1034,91 @@ Four Person Desk - Context View 10 - + Desk Organizer - Context View 10 - + Individual Workplace - Context View 10 - + Office Chair Black - Context View 10 - + Drawer - Context View 10 - + Large Meeting Table - Context View 10 - + Cabinet Width Doors - Context View 10 - + Storage Box - Context View 10 - + Pedal Bin - Context View 10 - + Large Cabinet - Context View 10 - + Corner Desk Right Sit - Context View 10 - + Two-Seat Sofa - Context View 10 - + Desk Combination - Context View 10 - + @@ -1126,7 +1126,7 @@ Customizable Desk Steel - Detail View 11 - + diff --git a/addons/website_sale/models/product_image.py b/addons/website_sale/models/product_image.py index d6b3f463802dd..9969603e513bf 100644 --- a/addons/website_sale/models/product_image.py +++ b/addons/website_sale/models/product_image.py @@ -2,7 +2,9 @@ import base64 -from odoo import _, api, fields, models +from collections import defaultdict + +from odoo import _, api, fields, models, Command from odoo.exceptions import ValidationError from odoo.tools.image import is_image_size_above @@ -23,8 +25,12 @@ class ProductImage(models.Model): product_tmpl_id = fields.Many2one( string="Product Template", comodel_name='product.template', ondelete='cascade', index=True, ) - product_variant_id = fields.Many2one( - string="Product Variant", comodel_name='product.product', ondelete='cascade', index=True, + product_variant_ids = fields.Many2many( + 'product.product', + string="Product Variants", + relation='product_image_product_variant_rel', + column1='product_image_id', + column2='product_variant_id', ) video_url = fields.Char( string="Video URL", @@ -40,6 +46,11 @@ class ProductImage(models.Model): attribute_value_ids = fields.Many2many( 'product.attribute.value', ) + is_template_image = fields.Boolean( + compute='_compute_is_template_image', + store=True, + index=True, + ) #=== COMPUTE METHODS ===# @@ -53,6 +64,11 @@ def _compute_embed_code(self): for image in self: image.embed_code = image.video_url and get_video_embed_code(image.video_url) or False + @api.depends('product_tmpl_id', 'product_variant_ids') + def _compute_is_template_image(self): + for image in self: + image.is_template_image = bool(image.product_tmpl_id and not image.product_variant_ids) + #=== ONCHANGE METHODS ===# @api.onchange('video_url') @@ -75,18 +91,79 @@ def _check_valid_video_url(self): def create(self, vals_list): """ We don't want the default_product_tmpl_id from the context - to be applied if we have a product_variant_id set to avoid + to be applied if we have a product_variant_ids set to avoid having the variant images to show also as template images. - But we want it if we don't have a product_variant_id set. + But we want it if we don't have a product_variant_ids set. """ context_without_template = self.with_context({k: v for k, v in self.env.context.items() if k != 'default_product_tmpl_id'}) normal_vals = [] variant_vals_list = [] for vals in vals_list: - if vals.get('product_variant_id') and 'default_product_tmpl_id' in self.env.context: + if vals.get('product_variant_ids') and 'default_product_tmpl_id' in self.env.context: + variant = self.env['product.product'].browse(vals['product_variant_ids'][0][1]) + vals['attribute_value_ids'] = [ + (4, ptav.product_attribute_value_id.id) for ptav in variant.product_template_attribute_value_ids + ] variant_vals_list.append(vals) else: normal_vals.append(vals) return super().create(normal_vals) + super(ProductImage, context_without_template).create(variant_vals_list) + + def write(self, vals): + res = super().write(vals) + if 'attribute_value_ids' in vals: + self._sync_variant_images() + return res + + # === BUSINESS METHODS === # + + def get_attribute_values_for_image_assignment(self): + product_template = self.product_variant_ids[:1].product_tmpl_id or self.product_tmpl_id + if not product_template: + return [] + + return [ + { + 'id': line.attribute_id.id, + 'name': line.attribute_id.name, + 'values': [ + { + 'id': ptav.product_attribute_value_id.id, + 'name': ptav.product_attribute_value_id.name, + } + for ptav in line.product_template_value_ids + if ptav.ptav_active + ], + } + for line in product_template.attribute_line_ids + ] + + def _sync_variant_images(self): + for image in self: + product_template = image.product_variant_ids[:1].product_tmpl_id or image.product_tmpl_id + + if not product_template or not image.attribute_value_ids: + image.product_variant_ids = [Command.clear()] + continue + + image_vals_by_attr = defaultdict(set) + for val in image.attribute_value_ids: + image_vals_by_attr[val.attribute_id.id].add(val.id) + + compatible_variant_ids = [] + + for variant in product_template.product_variant_ids: + variant_vals = { + ptav.attribute_id.id: ptav.product_attribute_value_id.id + for ptav in variant.product_template_attribute_value_ids + } + + if all( + variant_vals.get(attr_id) in allowed_vals + for attr_id, allowed_vals in image_vals_by_attr.items() + ): + compatible_variant_ids.append(variant.id) + + image.product_variant_ids = [Command.set(compatible_variant_ids)] diff --git a/addons/website_sale/models/product_product.py b/addons/website_sale/models/product_product.py index a351326f7ba54..a23021c057ab7 100644 --- a/addons/website_sale/models/product_product.py +++ b/addons/website_sale/models/product_product.py @@ -14,10 +14,20 @@ class ProductProduct(models.Model): variant_ribbon_id = fields.Many2one(string="Variant Ribbon", comodel_name='product.ribbon') website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False) - product_variant_image_ids = fields.One2many( + image_variant_1920 = fields.Image( + string="Variant Image", + max_width=1920, + max_height=1920, + compute='_compute_image_variant_1920', + store=True, + readonly=True, + ) + product_variant_image_ids = fields.Many2many( + 'product.image', string="Extra Variant Images", - comodel_name='product.image', - inverse_name='product_variant_id', + relation='product_image_product_variant_rel', + column1='product_variant_id', + column2='product_image_id', ) base_unit_count = fields.Float( @@ -50,6 +60,20 @@ class ProductProduct(models.Model): #=== COMPUTE METHODS ===# + @api.depends( + 'product_variant_image_ids', + 'product_variant_image_ids.image_1920', + 'product_variant_image_ids.sequence', + ) + def _compute_image_variant_1920(self): + for product in self: + if product.product_variant_image_ids: + product.image_variant_1920 = ( + product.product_variant_image_ids.sorted('sequence')[0].image_1920 + ) + else: + product.image_variant_1920 = False + def _get_base_unit_price(self, price): self.ensure_one() return self.base_unit_count and price / self.base_unit_count @@ -119,8 +143,8 @@ def _get_images(self): image of the template, if unset), the Variant Extra Images, and the Template Extra Images. """ self.ensure_one() - variant_images = list(self.product_variant_image_ids) - template_images = list(self.product_tmpl_id.product_template_image_ids) + variant_images = list(self.product_variant_image_ids[1:]) + template_images = list(self.product_tmpl_id.product_template_image_ids.filtered('is_template_image')) return [self] + variant_images + template_images def _get_combination_info_variant(self, **kwargs): diff --git a/addons/website_sale/models/product_template.py b/addons/website_sale/models/product_template.py index cee92dcacefe9..ed55b5ee964dd 100644 --- a/addons/website_sale/models/product_template.py +++ b/addons/website_sale/models/product_template.py @@ -755,24 +755,6 @@ def _get_suitable_image_size(self, columns, x_size, y_size): return 'image_512' return 'image_1024' - def get_template_attribute_values_for_image_assignment(self): - self.ensure_one() - return [ - { - 'id': line.attribute_id.id, - 'name': line.attribute_id.name, - 'values': [ - { - 'id': ptav.product_attribute_value_id.id, - 'name': ptav.name, - } - for ptav in line.product_template_value_ids - if ptav.ptav_active - ], - } - for line in self.attribute_line_ids - ] - def _init_column(self, column_name): # to avoid generating a single default website_sequence when installing the module, # we need to set the default row by row for this column diff --git a/addons/website_sale/static/src/js/product_image/product_image.js b/addons/website_sale/static/src/js/product_image/product_image.js index 11e19c138f5d1..ba0677eccbd49 100644 --- a/addons/website_sale/static/src/js/product_image/product_image.js +++ b/addons/website_sale/static/src/js/product_image/product_image.js @@ -28,16 +28,24 @@ export class ProductImage extends Component { this.dropdownState = useDropdownState(); } - async beforeOpen() { - const tmplId = this.record.data.product_tmpl_id.id; - if (!tmplId) { - return; + get showDropdown() { + if (!this.record.resId) { + return false; + } + + if (this.record.data.product_tmpl_id && this.record._parentRecord?.resModel === 'product.template') { + return this.record._parentRecord.data.attribute_line_ids.count > 0; } + return true; + } + + async beforeOpen() { this.state.attributes = await this.orm.call( - 'product.template', - 'get_template_attribute_values_for_image_assignment', - [tmplId] + 'product.image', + 'get_attribute_values_for_image_assignment', + [this.record.resId], ); + this.state.checkedIds = new Set( this.record.data[this.props.name].resIds || [] ); diff --git a/addons/website_sale/static/src/js/product_image/product_image.xml b/addons/website_sale/static/src/js/product_image/product_image.xml index a674540839cc2..a4eea5f22a170 100644 --- a/addons/website_sale/static/src/js/product_image/product_image.xml +++ b/addons/website_sale/static/src/js/product_image/product_image.xml @@ -3,20 +3,18 @@ - + + - - + +
diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index b890ced71e903..bdb5f9997a171 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -261,9 +261,12 @@ - + + + 1 + From e3f8e9a4ee40064c19d4aee96bd2d89c0ed216b0 Mon Sep 17 00:00:00 2001 From: Shrey Mehta Date: Sun, 1 Feb 2026 23:27:53 +0530 Subject: [PATCH 3/6] [IMP] website_sale: WIP3 --- addons/product/models/product_template.py | 2 +- addons/website_sale/__init__.py | 14 +++ addons/website_sale/controllers/main.py | 82 ++++++++------ addons/website_sale/models/product_image.py | 100 +++++++++--------- addons/website_sale/models/product_product.py | 64 ++++++----- .../website_sale/models/product_template.py | 28 ++++- .../src/js/product_image/product_image.js | 37 +++++-- .../src/js/product_image/product_image.xml | 62 ++++++----- .../product_image_option_plugin.js | 1 + .../website_builder/product_page_option.xml | 5 - .../product_page_option_plugin.js | 47 +------- .../templates/product_page_templates.xml | 1 + .../templates/product_tile_templates.xml | 13 +-- .../views/product_image_views.xml | 10 +- addons/website_sale/views/product_views.xml | 14 ++- 15 files changed, 271 insertions(+), 209 deletions(-) diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index be630bc01715d..ac33427a7a550 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -670,7 +670,7 @@ def write(self, vals): if 'active' in vals and not vals.get('active'): self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')}) if 'image_1920' in vals: - self.env['product.product'].invalidate_model([ + self.env['product.product'].invalidate_recordset([ 'image_1920', 'image_1024', 'image_512', diff --git a/addons/website_sale/__init__.py b/addons/website_sale/__init__.py index 009ce3a49001f..0b9392dc2c442 100644 --- a/addons/website_sale/__init__.py +++ b/addons/website_sale/__init__.py @@ -17,6 +17,7 @@ def _post_init_hook(env): existing_websites = env['website'].search([]) for website in existing_websites: website._create_checkout_steps() + _create_extra_variant_images(env) def uninstall_hook(env): ''' Need to reenable the `product` pricelist multi-company rule that were @@ -27,3 +28,16 @@ def uninstall_hook(env): multi_company_rules = pl_rule or env['ir.rule'] multi_company_rules += pl_item_rule or env['ir.rule'] multi_company_rules.write({'active': True}) + +def _create_extra_variant_images(env): + products = env['product.product'].search([('product_tmpl_id.image_1920', '!=', False)]) + image_vals = [] + for product in products: + image_vals.append({ + 'name': product.display_name, + 'product_variant_ids': [(4, product.id)], + 'attribute_value_ids': [(6, 0, product.product_template_attribute_value_ids.ids)], + 'image_1920': product.image_variant_1920 or product.product_tmpl_id.image_1920, + 'sequence': 0, + }) + env['product.image'].create(image_vals) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index e9cb8768a507a..c3ff38b55a746 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -655,14 +655,47 @@ def add_product_media(self, media, type, product_product_id, product_template_id if not request.env.user.has_group('website.group_website_restricted_editor'): raise NotFound() + product_product = ( + request.env['product.product'].browse(int(product_product_id)) + if product_product_id else False + ) + product_template = ( + request.env['product.template'].browse(int(product_template_id)) + if product_template_id else False + ) + + if product_product and not product_template: + product_template = product_product.product_tmpl_id + + if not product_product and product_template and product_template.has_dynamic_attributes(): + combination = request.env['product.template.attribute.value'].browse(combination_ids) + product_product = product_template._get_variant_for_combination(combination) + if not product_product: + product_product = product_template._create_product_variant(combination) + + is_variant_media = ( + product_template.has_configurable_attributes + and product_product + and not all( + pa.create_variant == 'no_variant' + for pa in product_template.attribute_line_ids.attribute_id + ) + ) if type == 'image': # Image case image_ids = request.env["ir.attachment"].browse(i['id'] for i in media) - media_create_data = [Command.create({ - 'name': image.name, # Images uploaded from url do not have any datas. This recovers them manually - 'image_1920': image.datas - if image.datas - else request.env['ir.qweb.field.image'].load_remote_url(image.url), - }) for image in image_ids] + media_create_data = [] + for image in image_ids: + media_create_values = { + 'name': image.name, # Images uploaded from url do not have any datas. This recovers them manually + 'image_1920': image.datas + if image.datas + else request.env['ir.qweb.field.image'].load_remote_url(image.url), + } + if is_variant_media: + media_create_values['attribute_value_ids'] = [ + Command.set(product_product.product_template_attribute_value_ids.ids) + ] + media_create_data.append(Command.create(media_create_values)) elif type == 'video': # Video case video_data = media[0] thumbnail = None @@ -679,18 +712,7 @@ def add_product_media(self, media, type, product_product_id, product_template_id 'image_1920': thumbnail, })] - product_product = request.env['product.product'].browse(int(product_product_id)) if product_product_id else False - product_template = request.env['product.template'].browse(int(product_template_id)) if product_template_id else False - - if product_product and not product_template: - product_template = product_product.product_tmpl_id - - if not product_product and product_template and product_template.has_dynamic_attributes(): - combination = request.env['product.template.attribute.value'].browse(combination_ids) - product_product = product_template._get_variant_for_combination(combination) - if not product_product: - product_product = product_template._create_product_variant(combination) - if product_template.has_configurable_attributes and product_product and not all(pa.create_variant == 'no_variant' for pa in product_template.attribute_line_ids.attribute_id): + if is_variant_media: product_product.write({ 'product_variant_image_ids': media_create_data }) @@ -719,14 +741,16 @@ def clear_product_images(self, product_product_id, product_template_id): product_template.product_template_image_ids.unlink() @route(['/shop/product/resequence-image'], type='jsonrpc', auth='user', website=True) - def resequence_product_image(self, image_res_model, image_res_id, move): + def resequence_product_image(self, image_res_model, image_res_id, move, product_variant_id): """ Move the product image in the given direction and update all images' sequence. :param str image_res_model: The model of the image. It can be 'product.template', - 'product.product', or 'product.image'. + or 'product.image'. :param str image_res_id: The record ID of the image to move. :param str move: The direction of the move. It can be 'first', 'left', 'right', or 'last'. + :param str product_variant_id: The ID of the product variant in whose context the image + resequencing is performed :raises NotFound: If the user does not have the required permissions, if the model of the image is not allowed, or if the move direction is not allowed. :raise ValidationError: If the product is not found. @@ -740,17 +764,15 @@ def resequence_product_image(self, image_res_model, image_res_id, move): or move not in ['first', 'left', 'right', 'last'] ): raise NotFound() - image_res_id = int(image_res_id) image_to_resequence = request.env[image_res_model].browse(image_res_id) - if image_res_model == 'product.product': - product = image_to_resequence - product_template = product.product_tmpl_id - elif image_res_model == 'product.template': + if image_res_model == 'product.template': product_template = image_to_resequence product = product_template.product_variant_id else: - product = image_to_resequence.product_variant_ids[:1] + product = image_to_resequence.product_variant_ids.filtered( + lambda p: p.id == int(product_variant_id) + ) product_template = product.product_tmpl_id or image_to_resequence.product_tmpl_id if not product and not product_template: @@ -778,12 +800,12 @@ def resequence_product_image(self, image_res_model, image_res_id, move): # If the main image has been reordered (i.e. it's no longer in first position), use the # image that's now in first position as main image instead. - # Additional images are product.image records. The main image is a product.product or - # product.template record. + # Additional images are product.image records. The main image is a product.template record. main_image_idx = next( - idx for idx, image in enumerate(product_images) if image._name != 'product.image' + (idx for idx, image in enumerate(product_images) if image._name != 'product.image'), + None ) - if main_image_idx != 0: + if main_image_idx is not None and main_image_idx != 0: main_image = product_images[main_image_idx] additional_image = product_images[0] if additional_image.video_url: diff --git a/addons/website_sale/models/product_image.py b/addons/website_sale/models/product_image.py index 9969603e513bf..d6688207788ac 100644 --- a/addons/website_sale/models/product_image.py +++ b/addons/website_sale/models/product_image.py @@ -43,14 +43,8 @@ class ProductImage(models.Model): compute='_compute_can_image_1024_be_zoomed', store=True, ) - attribute_value_ids = fields.Many2many( - 'product.attribute.value', - ) - is_template_image = fields.Boolean( - compute='_compute_is_template_image', - store=True, - index=True, - ) + attribute_value_ids = fields.Many2many('product.template.attribute.value') + is_template_image = fields.Boolean(compute='_compute_is_template_image', store=True) #=== COMPUTE METHODS ===# @@ -101,15 +95,18 @@ def create(self, vals_list): for vals in vals_list: if vals.get('product_variant_ids') and 'default_product_tmpl_id' in self.env.context: - variant = self.env['product.product'].browse(vals['product_variant_ids'][0][1]) - vals['attribute_value_ids'] = [ - (4, ptav.product_attribute_value_id.id) for ptav in variant.product_template_attribute_value_ids - ] + if not vals.get('attribute_value_ids'): + variant = self.env['product.product'].browse(vals['product_variant_ids'][0][1]) + vals['attribute_value_ids'] = [ + Command.set(variant.product_template_attribute_value_ids.ids) + ] variant_vals_list.append(vals) else: normal_vals.append(vals) - return super().create(normal_vals) + super(ProductImage, context_without_template).create(variant_vals_list) + images = super().create(normal_vals) + super(ProductImage, context_without_template).create(variant_vals_list) + images.filtered(lambda img: img.attribute_value_ids)._sync_variant_images() + return images def write(self, vals): res = super().write(vals) @@ -119,51 +116,54 @@ def write(self, vals): # === BUSINESS METHODS === # - def get_attribute_values_for_image_assignment(self): - product_template = self.product_variant_ids[:1].product_tmpl_id or self.product_tmpl_id - if not product_template: - return [] - - return [ - { - 'id': line.attribute_id.id, - 'name': line.attribute_id.name, - 'values': [ - { - 'id': ptav.product_attribute_value_id.id, - 'name': ptav.product_attribute_value_id.name, - } - for ptav in line.product_template_value_ids - if ptav.ptav_active - ], - } - for line in product_template.attribute_line_ids - ] - def _sync_variant_images(self): + """Update the product variants to which each image applies. + + For each image, this method computes the set of product variants that match the image's + attribute values and updates the image's linked variants accordingly. Images without + attribute value are not applied to any variant. + + :return: None + :rtype: None + """ for image in self: - product_template = image.product_variant_ids[:1].product_tmpl_id or image.product_tmpl_id + product_template = ( + image.product_variant_ids[:1].product_tmpl_id or image.product_tmpl_id + ) if not product_template or not image.attribute_value_ids: image.product_variant_ids = [Command.clear()] continue - image_vals_by_attr = defaultdict(set) - for val in image.attribute_value_ids: - image_vals_by_attr[val.attribute_id.id].add(val.id) + compatible_variants = product_template.product_variant_ids.filtered( + image._is_applicable_to_variant + ) - compatible_variant_ids = [] + image.product_variant_ids = [Command.set(compatible_variants.ids)] - for variant in product_template.product_variant_ids: - variant_vals = { - ptav.attribute_id.id: ptav.product_attribute_value_id.id - for ptav in variant.product_template_attribute_value_ids - } + def _is_applicable_to_variant(self, variant): + """Check whether this image applies to the given product variant. - if all( - variant_vals.get(attr_id) in allowed_vals - for attr_id, allowed_vals in image_vals_by_attr.items() - ): - compatible_variant_ids.append(variant.id) + The image applies if the variant matches all attribute values set on the image. + Attributes that are not set do not affect the result. - image.product_variant_ids = [Command.set(compatible_variant_ids)] + :param variant: product.product recordset + :return: Whether the image applies to the variant or not. + :rtype: bool + """ + self.ensure_one() + variant.ensure_one() + + variant_vals = { + ptav.attribute_id.id: ptav.id + for ptav in variant.product_template_attribute_value_ids + } + + image_vals_by_attr = defaultdict(set) + for val in self.attribute_value_ids: + image_vals_by_attr[val.attribute_id.id].add(val.id) + + return all( + variant_vals.get(attr_id) in allowed_vals + for attr_id, allowed_vals in image_vals_by_attr.items() + ) diff --git a/addons/website_sale/models/product_product.py b/addons/website_sale/models/product_product.py index a23021c057ab7..39ccd57f39773 100644 --- a/addons/website_sale/models/product_product.py +++ b/addons/website_sale/models/product_product.py @@ -14,14 +14,6 @@ class ProductProduct(models.Model): variant_ribbon_id = fields.Many2one(string="Variant Ribbon", comodel_name='product.ribbon') website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False) - image_variant_1920 = fields.Image( - string="Variant Image", - max_width=1920, - max_height=1920, - compute='_compute_image_variant_1920', - store=True, - readonly=True, - ) product_variant_image_ids = fields.Many2many( 'product.image', string="Extra Variant Images", @@ -60,19 +52,38 @@ class ProductProduct(models.Model): #=== COMPUTE METHODS ===# - @api.depends( - 'product_variant_image_ids', - 'product_variant_image_ids.image_1920', - 'product_variant_image_ids.sequence', - ) - def _compute_image_variant_1920(self): + def _compute_image_1920(self): for product in self: - if product.product_variant_image_ids: - product.image_variant_1920 = ( - product.product_variant_image_ids.sorted('sequence')[0].image_1920 - ) + extra_image = product.product_variant_image_ids.sorted('sequence')[:1] + if ( + extra_image + and extra_image.attribute_value_ids + and extra_image._is_applicable_to_variant(product) + ): + product.image_1920 = extra_image.image_1920 + else: + product.image_1920 = product.product_tmpl_id.image_1920 + + product._set_image_fields('image_1920', 'image_variant_1920') + + def _set_image_fields(self, template_field, variant_field): + for record in self: + # There is only one variant, always write on the template. + if ( + self.search_count([ + ('product_tmpl_id', '=', record.product_tmpl_id.id), + ('active', '=', True), + ]) <= 1 + ): + record[variant_field] = False + record.product_tmpl_id[template_field] = record[template_field] + # We are trying to add a field to the variant, but the template field is + # not set, write on the template instead and then set variant field same as template. + elif (record[template_field] and not record.product_tmpl_id[template_field]): + record.product_tmpl_id[template_field] = record[template_field] + record[variant_field] = record.product_tmpl_id[template_field] else: - product.image_variant_1920 = False + record[variant_field] = record[template_field] def _get_base_unit_price(self, price): self.ensure_one() @@ -136,16 +147,13 @@ def _get_images(self): """Return a list of records implementing `image.mixin` to display on the carousel on the website for this variant. - This returns a list and not a recordset because the records might be - from different models (template, variant and image). - - It contains in this order: the main image of the variant (which will fall back on the main - image of the template, if unset), the Variant Extra Images, and the Template Extra Images. + It contains in this order: the main image of the variant the Variant Extra Images, + and the Template Extra Images. """ self.ensure_one() - variant_images = list(self.product_variant_image_ids[1:]) + variant_images = list(self.product_variant_image_ids) template_images = list(self.product_tmpl_id.product_template_image_ids.filtered('is_template_image')) - return [self] + variant_images + template_images + return variant_images + template_images if (variant_images + template_images) else [self] def _get_combination_info_variant(self, **kwargs): """Return the variant info based on its combination. @@ -247,7 +255,8 @@ def _get_extra_image_1920_urls(self): self.ensure_one() return [ self.env['website'].image_url(extra_image, 'image_1920') - for extra_image in self.product_variant_image_ids + self.product_template_image_ids + for extra_image in self.product_variant_image_ids + + self.product_template_image_ids.filtered('is_template_image') if extra_image.image_128 # only images, no video urls ] @@ -259,6 +268,7 @@ def write(self, vals): ('product_id', 'in', self.ids), ('order_id', 'any', [('website_id', '!=', False)]), ]).unlink() + return super().write(vals) def _is_in_wishlist(self): diff --git a/addons/website_sale/models/product_template.py b/addons/website_sale/models/product_template.py index ed55b5ee964dd..f52eda98eb37a 100644 --- a/addons/website_sale/models/product_template.py +++ b/addons/website_sale/models/product_template.py @@ -270,6 +270,32 @@ def write(self, vals): #=== BUSINESS METHODS ===# + def get_attribute_values_for_image_assignment(self, product_variant_id=False): + current_value_ids = ( + self.env['product.product'] + .browse(product_variant_id) + .product_template_attribute_value_ids.ids + if product_variant_id else [] + ) + + attributes = [ + { + 'id': line.attribute_id.id, + 'values': [ + { + 'id': ptav.id, + 'name': ptav.name, + } + for ptav in line.product_template_value_ids + if ptav.ptav_active + ], + } + for line in self.attribute_line_ids + if line.attribute_id.create_variant != 'no_variant' + ] + + return {'attributes': attributes, 'current_value_ids': current_value_ids} + def _prepare_variant_values(self, combination): variant_dict = super()._prepare_variant_values(combination) variant_dict['base_unit_count'] = self.base_unit_count @@ -849,7 +875,7 @@ def _get_images(self): Template Extra Images. """ self.ensure_one() - return [self] + list(self.product_template_image_ids) + return [self] + list(self.product_template_image_ids.filtered('is_template_image')) def _get_attribute_value_domain(self, attribute_value_dict): return [ diff --git a/addons/website_sale/static/src/js/product_image/product_image.js b/addons/website_sale/static/src/js/product_image/product_image.js index ba0677eccbd49..04506414a87a0 100644 --- a/addons/website_sale/static/src/js/product_image/product_image.js +++ b/addons/website_sale/static/src/js/product_image/product_image.js @@ -29,26 +29,43 @@ export class ProductImage extends Component { } get showDropdown() { - if (!this.record.resId) { + const parent = this.record._parentRecord; + if (!parent.resId) { return false; } - - if (this.record.data.product_tmpl_id && this.record._parentRecord?.resModel === 'product.template') { - return this.record._parentRecord.data.attribute_line_ids.count > 0; + if (this.record.data.product_tmpl_id) { + return parent.data.product_variant_count > 1 } return true; } + get selectedCount() { + return this.record.data[this.props.name].count || 0; + } + async beforeOpen() { - this.state.attributes = await this.orm.call( - 'product.image', + const isNewRecord = !this.record.resId; + const productTmplId = this.record.data.product_tmpl_id.id || this.record.context.active_id; + const productVariantId = isNewRecord + ? this.record.context.default_product_variant_ids?.[0] + : false; + + const { attributes, current_value_ids } = await this.orm.call( + 'product.template', 'get_attribute_values_for_image_assignment', - [this.record.resId], + [productTmplId, productVariantId], ); - this.state.checkedIds = new Set( - this.record.data[this.props.name].resIds || [] - ); + this.state.attributes = attributes; + + const ids = current_value_ids.length + ? current_value_ids + : this.record.data[this.props.name]._currentIds; + + if (current_value_ids.length) { + this.record.update({ [this.props.name]: [x2ManyCommands.set(current_value_ids)] }); + } + this.state.checkedIds = new Set(ids); } toggleValue(valueId) { diff --git a/addons/website_sale/static/src/js/product_image/product_image.xml b/addons/website_sale/static/src/js/product_image/product_image.xml index a4eea5f22a170..242eca5928299 100644 --- a/addons/website_sale/static/src/js/product_image/product_image.xml +++ b/addons/website_sale/static/src/js/product_image/product_image.xml @@ -7,36 +7,50 @@ state="dropdownState" beforeOpen.bind="beforeOpen" > - - - - -
- - - - -
- - - - -
-
+ + + +
+ + + + +
+
+
+
+ And +
+
diff --git a/addons/website_sale/static/src/website_builder/product_image_option_plugin.js b/addons/website_sale/static/src/website_builder/product_image_option_plugin.js index 6cfee4ae1a130..804db4a91413f 100644 --- a/addons/website_sale/static/src/website_builder/product_image_option_plugin.js +++ b/addons/website_sale/static/src/website_builder/product_image_option_plugin.js @@ -56,6 +56,7 @@ export class SetPositionAction extends BuilderAction { image_res_model: el.parentElement.dataset.oeModel, image_res_id: el.parentElement.dataset.oeId, move: value, + product_variant_id: this.document.querySelector('[data-product-variant-id]').dataset.productVariantId, }; await rpc("/shop/product/resequence-image", params); diff --git a/addons/website_sale/static/src/website_builder/product_page_option.xml b/addons/website_sale/static/src/website_builder/product_page_option.xml index 56de75bfd5ee8..8efd559655e94 100644 --- a/addons/website_sale/static/src/website_builder/product_page_option.xml +++ b/addons/website_sale/static/src/website_builder/product_page_option.xml @@ -112,11 +112,6 @@ - - Replace - Add More Remove All diff --git a/addons/website_sale/static/src/website_builder/product_page_option_plugin.js b/addons/website_sale/static/src/website_builder/product_page_option_plugin.js index dab98fbabb7bb..0a3c3fc110599 100644 --- a/addons/website_sale/static/src/website_builder/product_page_option_plugin.js +++ b/addons/website_sale/static/src/website_builder/product_page_option_plugin.js @@ -24,7 +24,6 @@ class ProductPageOptionPlugin extends Plugin { ProductPageImageRoundnessAction, ProductPageImageGridSpacingAction, ProductPageImageGridColumnsAction, - ProductReplaceMainImageAction, ProductAddExtraImageAction, ProductRemoveAllExtraImagesAction, }, @@ -186,9 +185,9 @@ export class BaseProductPageAction extends BuilderAction { this.reload = {}; const mainEl = this.document.querySelector(ProductPageOption.selector); if (mainEl) { - const productProduct = mainEl.querySelector('[data-oe-model="product.product"]'); + const productProduct = mainEl.querySelector('[data-product-variant-id]'); const productTemplate = mainEl.querySelector('[data-oe-model="product.template"]'); - this.productProductID = productProduct ? productProduct.dataset.oeId : null; + this.productProductID = productProduct ? productProduct.dataset.productVariantId : null; this.productTemplateID = productTemplate ? productTemplate.dataset.oeId : null; this.model = "product.template"; if (this.productProductID) { @@ -315,48 +314,6 @@ export class ProductPageImageGridColumnsAction extends BaseProductPageAction { }); } } -export class ProductReplaceMainImageAction extends BaseProductPageAction { - static id = "productReplaceMainImage"; - static dependencies = [...super.dependencies, "media"]; - setup() { - super.setup(); - this.reload = false; - this.canTimeout = false; - } - apply({ editingElement: productDetailMainEl }) { - // Emulate click on the main image of the carousel. - const image = productDetailMainEl.querySelector( - `[data-oe-model="${this.model}"][data-oe-field=image_1920] img` - ); - this.dependencies.media.openMediaDialog({ - multiImages: false, - visibleTabs: ["IMAGES"], - node: productDetailMainEl, - save: (imgEl, selectedMedia) => { - const attachment = selectedMedia[0]; - if (["image/gif", "image/svg+xml"].includes(attachment.mimetype)) { - image.src = attachment.image_src; - return; - } - const originalSize = Math.max(imgEl.width, imgEl.height); - const ratio = Math.min(originalSize, 1920) / originalSize; - const canvas = document.createElement("canvas"); - canvas.width = parseInt(imgEl.width * ratio); - canvas.height = parseInt(imgEl.height * ratio); - const ctx = canvas.getContext("2d") - ctx.fillStyle = "transparent"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(imgEl, 0, 0); - image.src = canvas.toDataURL("image/webp"); - const { model, productProductID: productID, productTemplateID: templateID } = this; - const resID = parseInt(model === "product.product" ? productID : templateID); - this.services.orm.write(model, [resID], { - image_1920: image.src.split(",")[1], - }); - }, - }); - } -} export class ProductAddExtraImageAction extends BaseProductPageAction { static id = "productAddExtraImage"; diff --git a/addons/website_sale/templates/product_page_templates.xml b/addons/website_sale/templates/product_page_templates.xml index ca17dfd75bb89..1473ee8bee9d2 100644 --- a/addons/website_sale/templates/product_page_templates.xml +++ b/addons/website_sale/templates/product_page_templates.xml @@ -100,6 +100,7 @@
diff --git a/addons/website_sale/templates/product_tile_templates.xml b/addons/website_sale/templates/product_tile_templates.xml index 9733554b0442e..bdc3534071829 100644 --- a/addons/website_sale/templates/product_tile_templates.xml +++ b/addons/website_sale/templates/product_tile_templates.xml @@ -46,23 +46,14 @@ >
- - + +
-
- -
+ diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index bdb5f9997a171..c509c2397a70c 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -260,8 +260,18 @@ - - + + From 07d7eab15f826df290d67520070150264205a2a1 Mon Sep 17 00:00:00 2001 From: Shrey Mehta Date: Tue, 17 Feb 2026 17:43:12 +0530 Subject: [PATCH 4/6] [FIX] website_sale: primary product image on shop page not editable Issue: - When trying to replace or edit a product image on the shop page, the secondary image was replaced instead of the primary image. Cause: - The secondary image wrapper was capturing click events in edit mode, causing the editor to select the wrong image. Fix: - Added `pointer-events: none;` to the secondary image wrapper so that click events pass through to the primary image. This ensures the Replace Media action targets the correct image. task-5405584 --- addons/website_sale/static/src/scss/product_tile.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/website_sale/static/src/scss/product_tile.scss b/addons/website_sale/static/src/scss/product_tile.scss index 7d3b713990f29..9cebbbc02608a 100644 --- a/addons/website_sale/static/src/scss/product_tile.scss +++ b/addons/website_sale/static/src/scss/product_tile.scss @@ -109,6 +109,7 @@ .oe_product_image_img_wrapper { &.oe_product_image_img_wrapper_secondary { display: var(--o-wsale-card-img-wrapper-secondary-display, none); + pointer-events: none; } } .oe_product_image_img, .oe_product_image_img_secondary { From c8b8c48411db3ead428c4d40f1d03b2c1ebda0bb Mon Sep 17 00:00:00 2001 From: Shrey Mehta Date: Wed, 18 Feb 2026 18:59:25 +0530 Subject: [PATCH 5/6] [IMP] website_sale: WIP4 --- addons/product/models/product_template.py | 2 +- .../tests/test_performance.py | 18 +-- addons/website_sale/__init__.py | 1 + addons/website_sale/controllers/main.py | 4 + addons/website_sale/models/product_image.py | 24 ++- addons/website_sale/models/product_product.py | 65 ++++---- .../tests/builder/product_page_option.test.js | 145 ------------------ .../website_sale/tests/test_product_image.py | 8 +- .../website_sale/tests/test_website_editor.py | 62 ++++---- addons/website_sale/views/product_views.xml | 3 + 10 files changed, 92 insertions(+), 240 deletions(-) delete mode 100644 addons/website_sale/static/tests/builder/product_page_option.test.js diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index ac33427a7a550..be630bc01715d 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -670,7 +670,7 @@ def write(self, vals): if 'active' in vals and not vals.get('active'): self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')}) if 'image_1920' in vals: - self.env['product.product'].invalidate_recordset([ + self.env['product.product'].invalidate_model([ 'image_1920', 'image_1024', 'image_512', diff --git a/addons/test_website_modules/tests/test_performance.py b/addons/test_website_modules/tests/test_performance.py index 169382feea2b8..455ef169f6948 100644 --- a/addons/test_website_modules/tests/test_performance.py +++ b/addons/test_website_modules/tests/test_performance.py @@ -113,11 +113,7 @@ def setUpClass(cls): }) cls.product_images = cls.env['product.image'].with_context(default_product_tmpl_id=cls.productC.product_tmpl_id.id).create([{ 'name': 'Template image', - 'image_1920': blue_image, - }, { - 'name': 'Variant image', 'image_1920': red_image, - 'product_variant_ids': [Command.link(cls.productC.id)], }]) for i in range(20): @@ -291,11 +287,11 @@ def _allow_to_use_cache(request): def _get_queries_shop(self): html = self.url_open('/shop').text - self.assertIn(f' { - const { waitSidebarUpdated } = await setupWebsiteBuilder(` -
-
-
-
-
- -
-
-
-
-
`); - - onRpc("/website/theme_customize_data", () => expect.step("theme_customize_data")); - onRpc("/website/theme_customize_data_get", () => expect.step("theme_customize_data_get")); - onRpc("/shop/config/website", () => expect.step("config")); - onRpc("ir.ui.view", "save", () => { - expect.step("save"); - return []; - }); - onRpc("product.product", "write", () => { - expect.step("product_write"); - return true; - }); - - const base64Image = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5" + - "AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYIIA"; - onRpc("ir.attachment", "search_read", () => [ - { - mimetype: "image/png", - image_src: "/web/image/hoot.png", - access_token: false, - public: true, - }, - ]); - onRpc("/html_editor/get_image_info", () => { - expect.step("get_image_info"); - return { - attachment: { id: 1 }, - original: { id: 1, image_src: "/web/image/hoot.png", mimetype: "image/png" }, - }; - }); - onRpc("/web/image/hoot.png", () => { - // converted image won't be used if original is not larger - return dataURItoBlob(base64Image + "A".repeat(1000)); - }); - - await contains(":iframe .o_wsale_product_page").click(); - await contains("[data-action-id=productReplaceMainImage]").click(); - await contains(".o_select_media_dialog .o_existing_attachment_cell button").click(); - await expect.waitForSteps(["theme_customize_data_get", "get_image_info", "product_write"]); - await waitForNone(".o_select_media_dialog"); - - expect(":iframe #product_detail_main img[src^='data:image/webp;base64,']").toHaveCount(1); - expect(":iframe img").toHaveCount(2); - await contains("button#o_wsale_image_width").click(); - // Avoid selecting the first option to prevent the image layout option from disappearing - await contains("[data-action-id=productPageImageWidth][data-action-value='50_pc']").click(); - await expect.waitForSteps(["config"]); - await waitSidebarUpdated(); - - await contains("button#o_wsale_image_layout").click(); - await contains("[data-action-id=productPageImageLayout]").click(); - await waitSidebarUpdated(); - await expect.waitForSteps([ - // Activate the carousel view and change the shop config - "config", - // Save the pending image width class changes - "save", - // Save the image changes - "save", - // Reload the view - "theme_customize_data_get", - ]); - - // Make sure that clicking quickly on a builder button after an clicking on - // an action that reloads the editor does not produce a crash. - await contains("[data-action-id=websiteConfig].o_we_buy_now_btn").click(); - await contains("button#o_wsale_image_layout").click(); - await expect.waitForSteps(["theme_customize_data", "theme_customize_data_get"]); -}); diff --git a/addons/website_sale/tests/test_product_image.py b/addons/website_sale/tests/test_product_image.py index bbf47335c8d1e..7d0252ab21db9 100644 --- a/addons/website_sale/tests/test_product_image.py +++ b/addons/website_sale/tests/test_product_image.py @@ -222,17 +222,13 @@ def test_01_admin_shop_zoom_tour(self): }).unlink() self.assertEqual(template.image_1920, red_image) - # CASE: display variant image first if set - self.assertEqual(product_green._get_images()[0].image_1920, green_image) - # CASE: display variant fallback after variant o2m, correct fallback # write on the variant field, otherwise it will write on the fallback product_green.image_variant_1920 = False images = product_green._get_images() # images on fields are resized to max 1920 - # image_png = Image.open(io.BytesIO(base64.b64decode(images[1].image_1920))) - self.assertEqual(images[0].image_1920, red_image) - # self.assertEqual(image_png.size, (1268, 1920)) + image_png = Image.open(io.BytesIO(base64.b64decode(images[0].image_1920))) + self.assertEqual(image_png.size, (1268, 1920)) self.assertEqual(images[1].image_1920, image_gif) self.assertEqual(images[2].image_1920, image_svg) diff --git a/addons/website_sale/tests/test_website_editor.py b/addons/website_sale/tests/test_website_editor.py index e82081870e541..834c0ca0e38ca 100644 --- a/addons/website_sale/tests/test_website_editor.py +++ b/addons/website_sale/tests/test_website_editor.py @@ -129,100 +129,94 @@ def test_resequence_image_first(self): self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() + i1, i2, i3, i4, i5 = self._get_product_image_data() self.WebsiteSaleController.resequence_product_image( - images[2]._name, images[2].id, 'first', + images[2]._name, images[2].id, 'first', self.product.id, ) # Trigger the reordering of product.image records based on their sequence. self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i3, i1, i2, i4, i5, i6]) - self.assertEqual(self.product.image_1920, i3) + self.assertListEqual(self._get_product_image_data(), [i3, i1, i2, i4, i5]) def test_resequence_image_left(self): self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() + i1, i2, i3, i4, i5 = self._get_product_image_data() self.WebsiteSaleController.resequence_product_image( - images[2]._name, images[2].id, 'left', + images[2]._name, images[2].id, 'left', self.product.id, ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i1, i3, i2, i4, i5, i6]) + self.assertListEqual(self._get_product_image_data(), [i1, i3, i2, i4, i5]) def test_resequence_image_right(self): self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() + i1, i2, i3, i4, i5 = self._get_product_image_data() self.WebsiteSaleController.resequence_product_image( - images[2]._name, images[2].id, 'right', + images[2]._name, images[2].id, 'right', self.product.id, ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i1, i2, i4, i3, i5, i6]) + self.assertListEqual(self._get_product_image_data(), [i1, i2, i4, i3, i5]) def test_resequence_image_last(self): self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() + i1, i2, i3, i4, i5 = self._get_product_image_data() self.WebsiteSaleController.resequence_product_image( - images[2]._name, images[2].id, 'last', + images[2]._name, images[2].id, 'last', self.product.id, ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i1, i2, i4, i5, i6, i3]) + self.assertListEqual(self._get_product_image_data(), [i1, i2, i4, i5, i3]) def test_resequence_image_first_to_last(self): """Moving an image from first to last position is an edge case in the code.""" self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() + i1, i2, i3, i4, i5 = self._get_product_image_data() self.WebsiteSaleController.resequence_product_image( - images[0]._name, images[0].id, 'last', + images[0]._name, images[0].id, 'last', self.product.id, ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i2, i3, i4, i5, i6, i1]) - self.assertEqual(self.product.image_1920, i2) + self.assertListEqual(self._get_product_image_data(), [i2, i3, i4, i5, i1]) def test_resequence_video_left(self): self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() images[2].video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() + i1, i2, i3, i4, i5 = self._get_product_image_data() self.WebsiteSaleController.resequence_product_image( - images[2]._name, images[2].id, 'left', + images[2]._name, images[2].id, 'left', self.product.id, ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i1, i3, i2, i4, i5, i6]) + self.assertListEqual(self._get_product_image_data(), [i1, i3, i2, i4, i5]) def test_resequence_video_first(self): - """A video can't be resequenced to first position.""" self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() images[2].video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() - with self.assertRaises(ValidationError): - self.WebsiteSaleController.resequence_product_image( - images[2]._name, images[2].id, 'first', - ) + i1, i2, i3, i4, i5 = self._get_product_image_data() + self.WebsiteSaleController.resequence_product_image( + images[2]._name, images[2].id, 'first', self.product.id, + ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i1, i2, i3, i4, i5, i6]) + self.assertListEqual(self._get_product_image_data(), [i3, i1, i2, i4, i5]) def test_resequence_video_replace_first(self): - """A video can't replace an image that was resequenced away from first position.""" self._create_product_images() with MockRequest(self.product.env, website=self.website): images = self.product._get_images() images[1].video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" - i1, i2, i3, i4, i5, i6 = self._get_product_image_data() - with self.assertRaises(ValidationError): - self.WebsiteSaleController.resequence_product_image( - images[0]._name, images[0].id, 'right', - ) + i1, i2, i3, i4, i5 = self._get_product_image_data() + self.WebsiteSaleController.resequence_product_image( + images[0]._name, images[0].id, 'right', self.product.id, + ) self.env['product.image'].invalidate_model() - self.assertListEqual(self._get_product_image_data(), [i1, i2, i3, i4, i5, i6]) + self.assertListEqual(self._get_product_image_data(), [i2, i1, i3, i4, i5]) @tagged('post_install', '-at_install') diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index c509c2397a70c..8ce0019070cbc 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -151,6 +151,9 @@
+ + 1 + From 2a92baa7df2f99730ed63eee63f0ded272311c94 Mon Sep 17 00:00:00 2001 From: "Antoine (anso)" Date: Fri, 13 Feb 2026 17:28:28 +0100 Subject: [PATCH 6/6] [IMP] website_sale: design support Design support for product extra images task-5405584 --- .../src/js/product_image/product_image.xml | 88 ++++++++++--------- .../static/src/scss/kanban_record.scss | 20 ++--- .../static/src/scss/website_sale_backend.scss | 14 --- .../views/product_image_views.xml | 8 +- addons/website_sale/views/product_views.xml | 32 ++++--- 5 files changed, 70 insertions(+), 92 deletions(-) diff --git a/addons/website_sale/static/src/js/product_image/product_image.xml b/addons/website_sale/static/src/js/product_image/product_image.xml index 242eca5928299..d43bc6e4fe00d 100644 --- a/addons/website_sale/static/src/js/product_image/product_image.xml +++ b/addons/website_sale/static/src/js/product_image/product_image.xml @@ -7,52 +7,54 @@ state="dropdownState" beforeOpen.bind="beforeOpen" > -
- - - - - -
- - - - -
-
+ + + + + +
+ + + + +
+
+
+
+ And +
+
-
- And -
-
- +
diff --git a/addons/website_sale/static/src/scss/kanban_record.scss b/addons/website_sale/static/src/scss/kanban_record.scss index 355421f45f849..9454bd05b7503 100644 --- a/addons/website_sale/static/src/scss/kanban_record.scss +++ b/addons/website_sale/static/src/scss/kanban_record.scss @@ -1,17 +1,9 @@ -.o_form_renderer { - .o_field_x2_many_media_viewer .o_kanban_renderer { - --KanbanRecord-width: 100px; +.o_form_renderer .o_field_x2_many_media_viewer .o_website_sale_image_list_card_image { + width: 12em; - article.o_kanban_record { - display: flex; - justify-content: center; - margin-bottom: unset !important; - - & img { - height: 128px; - width: 168.86px; - object-fit: contain; - } - } + img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; } } diff --git a/addons/website_sale/static/src/scss/website_sale_backend.scss b/addons/website_sale/static/src/scss/website_sale_backend.scss index f020e2112150f..2378ae4dfbdbb 100644 --- a/addons/website_sale/static/src/scss/website_sale_backend.scss +++ b/addons/website_sale/static/src/scss/website_sale_backend.scss @@ -16,20 +16,6 @@ @include media-breakpoint-up(xl) { flex: 0 0 percentage(1/6); } - // make the image square and in the center - .o_squared_image { - position: relative; - overflow: hidden; - padding-bottom: 100%; - > img { - position: absolute; - margin: auto; - top: 0; - left: 0; - bottom: 0; - right: 0; - } - } .o_product_image_size { position: absolute; diff --git a/addons/website_sale/views/product_image_views.xml b/addons/website_sale/views/product_image_views.xml index 1c8838201c791..51ae3807689c5 100644 --- a/addons/website_sale/views/product_image_views.xml +++ b/addons/website_sale/views/product_image_views.xml @@ -46,13 +46,13 @@ -
-
- +
+
+
diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index 8ce0019070cbc..370cf2d2ac029 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -214,8 +214,6 @@ - - - - - + + + @@ -263,7 +261,7 @@ - +
- +
1