diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 6a1abdb4459..9135d97e398 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -454,6 +454,145 @@ class WebsiteSale(http.Controller): # Compatibility pre-v14 return request.redirect(_build_url_w_params("/shop/%s" % slug(product), request.params), code=301) + @http.route(['/shop/product/extra-images'], type='json', auth='user', website=True) + def add_product_images(self, images, product_product_id, product_template_id, combination_ids=None): + """ + Turns a list of image ids refering to ir.attachments to product.images, + links all of them to product. + :raises NotFound : If the user is not allowed to access Attachment model + """ + + if not (request.env.user.has_group('website.group_website_designer') or\ + request.env.user.has_group('website.group_website_publisher')): + raise NotFound() + + image_ids = request.env["ir.attachment"].browse(i['id'] for i in images) + image_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] + + 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 = request.env['product.product'].browse( + product_template.create_product_variant(combination_ids)) + if product_template.has_configurable_attributes and product_product: + product_product.write({ + 'product_variant_image_ids': image_create_data + }) + else: + product_template.write({ + 'product_template_image_ids': image_create_data + }) + + @http.route(['/shop/product/clear-images'], type='json', auth='user', website=True) + def clear_product_images(self, product_product_id, product_template_id): + """ + Unlinks all images from the product. + """ + if not (request.env.user.has_group('website.group_website_designer') or\ + request.env.user.has_group('website.group_website_publisher')): + 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 product_product and product_product.product_variant_image_ids: + product_product.product_variant_image_ids.unlink() + else: + product_template.product_template_image_ids.unlink() + + @http.route(['/shop/product/remove-image'], type='json', auth='user', website=True) + def remove_product_image(self, image_res_model, image_res_id): + """ + Delete or clear the product's image. + """ + if not (request.env.user.has_group('website.group_website_designer') or\ + request.env.user.has_group('website.group_website_publisher')) or\ + image_res_model not in ['product.product', 'product.template', 'product.image']: + raise NotFound() + + image_res_id = int(image_res_id) + if image_res_model == 'product.product': + request.env['product.product'].browse(image_res_id).image_1920 = False + elif image_res_model == 'product.template': + request.env['product.template'].browse(image_res_id).image_1920 = False + else: + request.env['product.image'].browse(image_res_id).unlink() + + @http.route(['/shop/product/resequence-image'], type='json', auth='user', website=True) + def resequence_product_image(self, image_res_model, image_res_id, move): + if not (request.env.user.has_group('website.group_website_designer') or\ + request.env.user.has_group('website.group_website_publisher')) or\ + image_res_model not in ['product.product', 'product.template', 'product.image'] 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) + product = request.env['product.product'] + product_template = request.env['product.template'] + if image_res_model == 'product.product': + product = image_to_resequence + product_template = product.product_tmpl_id + elif image_res_model == 'product.template': + product_template = image_to_resequence + product = product_template.product_variant_id + else: + product = image_to_resequence.product_variant_id + product_template = product.product_tmpl_id or image_to_resequence.product_tmpl_id + + if not product and not product_template: + raise ValidationError(_("Product not found")) + + product_images = (product or product_template)._get_images() + if image_to_resequence not in product_images: + raise ValidationError(_("Invalid image")) + + image_idx = product_images.index(image_to_resequence) + new_image_idx = 0 + if move == 'left': + new_image_idx = max(0, image_idx - 1) + elif move == 'right': + new_image_idx = min(len(product_images) - 1, image_idx + 1) + elif move == 'last': + new_image_idx = len(product_images) - 1 + + # no-op resequences + if new_image_idx == image_idx: + return + # We can not move an embedded image to the first position (main product image) + if image_res_model == 'product.image' and image_to_resequence.video_url and product_images[new_image_idx]._name != 'product.image': + raise ValidationError(_("Can not resequence embedded image/video with a non compatible image.")) + + # Swap images + other_image = product_images[new_image_idx] + source_field = hasattr(image_to_resequence, 'video_url') and image_to_resequence.video_url and 'video_url' or 'image_1920' + target_field = hasattr(other_image, 'video_url') and other_image.video_url and 'video_url' or 'image_1920' + previous_data = other_image[target_field] + other_image[source_field] = image_to_resequence[source_field] + image_to_resequence[target_field] = previous_data + if source_field == 'video_url' and target_field != 'video_url': + image_to_resequence.video_url = False + if target_field == 'video_url' and source_field != 'video_url': + other_image.video_url = False + + if hasattr(other_image, 'video_url'): + other_image._onchange_video_url() + if hasattr(image_to_resequence, 'video_url'): + image_to_resequence._onchange_video_url() + def _prepare_product_values(self, product, category, search, **kwargs): ProductCategory = request.env['product.public.category'] diff --git a/addons/website_sale/models/product_image.py b/addons/website_sale/models/product_image.py index 4785553b6df..8ec8cb43dd2 100644 --- a/addons/website_sale/models/product_image.py +++ b/addons/website_sale/models/product_image.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 + from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError @@ -34,7 +36,8 @@ class ProductImage(models.Model): @api.onchange('video_url') def _onchange_video_url(self): if not self.image_1920: - self.image_1920 = get_video_thumbnail(self.video_url) or False + thumbnail = get_video_thumbnail(self.video_url) + self.image_1920 = thumbnail and base64.b64encode(thumbnail) or False @api.depends('video_url') def _compute_embed_code(self): diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index 82970911746..aa2e0bcc403 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -3,10 +3,15 @@ odoo.define('website_sale.editor', function (require) { var options = require('web_editor.snippets.options'); const Wysiwyg = require('website.wysiwyg'); +const { ComponentWrapper } = require('web.OwlCompatibility'); +const { MediaDialog, MediaDialogWrapper } = require('@web_editor/components/media_dialog/media_dialog'); +const { useWowlService } = require('@web/legacy/utils'); const {qweb, _t} = require('web.core'); const {Markup} = require('web.utils'); const Dialog = require('web.Dialog'); +const { onRendered } = owl; + Wysiwyg.include({ custom_events: Object.assign(Wysiwyg.prototype.custom_events, { get_ribbons: '_onGetRibbons', @@ -643,8 +648,48 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ } }); +// Small override of the MediaDialogWrapper to retrieve the attachment ids instead of img elements +class AttachmentMediaDialog extends MediaDialog { + /** + * @override + */ + async save() { + const selectedMedia = this.selectedMedia[this.state.activeTab]; + if (selectedMedia.length) { + this.props.save(selectedMedia); + } + this.props.close(); + } +} + +class AttachmentMediaDialogWrapper extends MediaDialogWrapper { + setup() { + this.dialogs = useWowlService('dialog'); + + onRendered(() => { + this.dialogs.add(AttachmentMediaDialog, this.props); + }); + } +} + options.registry.WebsiteSaleProductPage = options.Class.extend({ + /** + * @override + */ + async willStart() { + let productProduct = this.$target[0].querySelector('[data-oe-model="product.product"]'); + let productTemplate = this.$target[0].querySelector('[data-oe-model="product.template"]'); + this.productProductID = productProduct ? productProduct.dataset.oeId : null; + this.productTemplateID = productTemplate ? productTemplate.dataset.oeId : null; + this.mode = "product.template"; + if (this.productProductID) { + this.mode = "product.product" + } + + return this._super(...arguments); + }, + _updateWebsiteConfig(params) { this._rpc({ route: '/shop/config/website', @@ -670,6 +715,80 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ }); }, + /** + * Emulate click on the main image of the carousel. + */ + replaceMainImage: function () { + const image = this.$target[0].querySelector(`[data-oe-model="${this.mode}"][data-oe-field=image_1920] img`); + image.dispatchEvent(new Event('dblclick', {bubbles: true})); + }, + + _getSelectedVariantValues($container) { + const combination = $container.find('input.js_product_change:checked').data('combination'); + + if (combination) { + return combination; + } + const values = []; + + const variantsValuesSelectors = [ + 'input.js_variant_change:checked', + 'select.js_variant_change' + ]; + _.each($container.find(variantsValuesSelectors.join(', ')), function (el) { + values.push(+$(el).val()); + }); + + return values; + }, + + /** + * Prompts the user for images, then saves the new images. + */ + addImages: function () { + if(this.mode === 'product.template'){ + this.displayNotification({ + type: 'info', + message: 'Pictures will be added to the main image. Use "Instant" attributes to set pictures on each variants' + }); + } + const dialog = new ComponentWrapper(this, AttachmentMediaDialogWrapper, { + multiImages: true, + onlyImages: true, + save: attachments => { + this._rpc({ + route: `/shop/product/extra-images`, + params: { + images: attachments, + product_product_id: this.productProductID, + product_template_id: this.productTemplateID, + combination_ids: this._getSelectedVariantValues(this.$target.find('.js_add_cart_variants')), + } + }).then(() => { + this.trigger_up('request_save', {reload: true, optionSelector: this.data.selector}); + }); + } + }); + dialog.mount(document.body); + }, + + /** + * Removes all extra-images from the product. + */ + clearImages: function () { + this._rpc({ + route: `/shop/product/clear-images`, + params: { + model: this.mode, + product_product_id: this.productProductID, + product_template_id: this.productTemplateID, + combination_ids: this._getSelectedVariantValues(this.$target.find('.js_add_cart_variants')), + } + }).then(() => { + this.trigger_up('request_save', {reload: true, optionSelector: this.data.selector}); + }); + }, + /** * @override */ @@ -681,7 +800,7 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ return this.$target.data('image_layout'); } return this._super(...arguments); - } + }, }); options.registry.WebsiteSaleProductPageGrid = options.Class.extend({ @@ -792,5 +911,57 @@ options.registry.SnippetSave.include({ return await this._super(...arguments) && !this.$target.hasClass('o_wsale_alternative_products'); } -}) }); + +options.registry.ReplaceMedia.include({ + /** + * @override + */ + async willStart() { + const parent = this.$target.parent(); + this.isProductPageImage = this.$target.closest('#product_detail').length > 0; + // Product Page images may be the product's image or a record of `product.image` + this.recordModel = parent.data('oe-model'); + this.recordId = parent.data('oe-id'); + return this._super(...arguments); + }, + /** + * Removes the image in the back-end + */ + async removeMedia() { + this._rpc({ + route: '/shop/product/remove-image', + params: { + image_res_model: this.recordModel, + image_res_id: this.recordId, + }, + }).then(() => this.trigger_up('request_save', {reload: true, optionSelector: '#product_detail_main'})); + }, + /** + * Change sequence of product page images + * + */ + async setPosition(previewMode, widgetValue, params) { + this._rpc({ + route: '/shop/product/resequence-image', + params: { + image_res_model: this.recordModel, + image_res_id: this.recordId, + move: widgetValue, + }, + }).then(() => this.trigger_up('request_save', {reload: true, optionSelector: '#product_detail_main'})); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (['media_wsale_resequence', 'media_wsale_remove'].includes(widgetName)) { + // Only include these if we are inside of the product's page images + return this.isProductPageImage; + } + return this._super(...arguments); + } +}); + +}); + diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py index a5b1fb96c0f..6daadf34331 100644 --- a/addons/website_sale/tests/__init__.py +++ b/addons/website_sale/tests/__init__.py @@ -16,3 +16,4 @@ from . import test_website_sale_show_compare_list_price from . import test_website_sale_visitor from . import test_website_sale_product from . import test_website_sale_shop_variant_exclusion +from . import test_website_editor diff --git a/addons/website_sale/tests/test_website_editor.py b/addons/website_sale/tests/test_website_editor.py new file mode 100644 index 00000000000..2c4ebf7e012 --- /dev/null +++ b/addons/website_sale/tests/test_website_editor.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.website_sale.controllers.main import WebsiteSale +from odoo.addons.website.tools import MockRequest +from odoo.exceptions import ValidationError +from odoo.tests import HttpCase, tagged + +ATTACHMENT_DATA = [ + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAEElEQVR4nGKqf3geEAAA//8EGgIyYKYzzgAAAABJRU5ErkJggg==", + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAEElEQVR4nGKqvvQEEAAA//8EBQI0GMlQsAAAAABJRU5ErkJggg==", + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAEElEQVR4nGKKLakBBAAA//8ChwFQsvFlAwAAAABJRU5ErkJggg==", + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAEElEQVR4nGJqkdoACAAA//8CfAFRzSyOUAAAAABJRU5ErkJggg==", + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAEElEQVR4nGLSfxgICAAA//8CrAFkoLBhpQAAAABJRU5ErkJggg==", +] +ATTACHMENT_COUNT = 5 + + +@tagged('post_install', '-at_install') +class TestProductPictureController(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env['website'].browse(1) + cls.WebsiteSaleController = WebsiteSale() + cls.product = cls.env['product.product'].create({ + 'name': 'Storage Test Box', + 'standard_price': 70.0, + 'list_price': 79.0, + 'website_published': True, + }) + + cls.attachments = cls.env['ir.attachment'].create([ + { + 'datas': ATTACHMENT_DATA[i], + 'name': f'image0{i}.gif', + 'public': True + } + for i in range(ATTACHMENT_COUNT)]) + + def _create_product_images(self): + with MockRequest(self.product.env, website=self.website): + self.WebsiteSaleController.add_product_images( + [{'id': attachment.id} for attachment in self.attachments], + self.product.id, + self.product.product_tmpl_id.id, + ) + + def test_bulk_image_upload(self): + # Turns attachments to product_images + self._create_product_images() + + # Check if the media now exists on the product : + for i, image in enumerate(self.product.product_template_image_ids): + # Check if all names are now in the product + self.assertIn(image.name, self.attachments.mapped('name')) + # Check if image datas are the same + self.assertEqual(image.image_1920, ATTACHMENT_DATA[i]) + # Check if exactly ATTACHMENT_COUNT images were saved (no dupes/misses?) + self.assertEqual(ATTACHMENT_COUNT, len(self.product.product_template_image_ids)) + + def test_image_clear(self): + # First create some images + self._create_product_images() + self.assertEqual(ATTACHMENT_COUNT, len(self.product.product_template_image_ids)) + + # Remove all images + # (Exception raised if error) + with MockRequest(self.product.env, website=self.website): + self.WebsiteSaleController.clear_product_images( + self.product.id, + self.product.product_tmpl_id.id, + ) + # According to the product, there are no variants images. + self.assertEqual(0, len(self.product.product_template_image_ids)) + + def test_extra_images_with_new_variant(self): + # Test that adding images for a variant that is not yet created works + product_attribute = self.env['product.attribute'].create({ + "name": "Test attribute", + "create_variant": "dynamic", + }) + product_attribute_values = self.env['product.attribute.value'].create([ + { + "name" : "Test Dynamic 1", + "attribute_id": product_attribute.id, + "sequence": 1, + }, + { + "name" : "Test Dynamic 2", + "attribute_id": product_attribute.id, + "sequence": 2, + } + ]) + product_template = self.env['product.template'].create({ + "name": "test product", + "website_published": True, + }) + product_template_attribute_line = self.env['product.template.attribute.line'].create({ + "attribute_id": product_attribute.id, + "product_tmpl_id": product_template.id, + "value_ids": product_attribute_values, + }) + self.assertEqual(0, len(product_template.product_variant_ids)) + with MockRequest(product_template.env, website=self.website): + self.WebsiteSaleController.add_product_images( + [{'id': self.attachments[0].id}], + False, + product_template.id, + [product_template_attribute_line.product_template_value_ids[0].id], + ) + self.assertEqual(1, len(product_template.product_variant_ids)) + + def test_resequence_images(self): + self._create_product_images() + with MockRequest(self.product.env, website=self.website): + # Test moving to first position + images = self.product._get_images() + data_source = images[2].image_1920 + data_target = images[0].image_1920 + self.WebsiteSaleController.resequence_product_image( + images[2]._name, + images[2].id, + 'first', + ) + images = self.product._get_images() + self.assertEqual(images[2].image_1920, data_target) + self.assertEqual(images[0].image_1920, data_source) + # Test moving one to the left + data_source = images[2].image_1920 + data_target = images[1].image_1920 + self.WebsiteSaleController.resequence_product_image( + images[2]._name, + images[2].id, + 'left', + ) + images = self.product._get_images() + self.assertEqual(images[2].image_1920, data_target) + self.assertEqual(images[1].image_1920, data_source) + # Test moving one to the right + data_source = images[2].image_1920 + data_target = images[3].image_1920 + self.WebsiteSaleController.resequence_product_image( + images[2]._name, + images[2].id, + 'right', + ) + images = self.product._get_images() + self.assertEqual(images[2].image_1920, data_target) + self.assertEqual(images[3].image_1920, data_source) + # Test moving one to the last position + data_source = images[2].image_1920 + data_target = images[-1].image_1920 + self.WebsiteSaleController.resequence_product_image( + images[2]._name, + images[2].id, + 'last', + ) + images = self.product._get_images() + self.assertEqual(images[2].image_1920, data_target) + self.assertEqual(images[-1].image_1920, data_source) + # Test moving an image with a video_url instead of image_1920 + data_target = images[1].image_1920 + images[2].video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + self.WebsiteSaleController.resequence_product_image( + images[2]._name, + images[2].id, + 'left', + ) + images = self.product._get_images() + self.assertEqual(images[1].video_url, "https://www.youtube.com/watch?v=dQw4w9WgXcQ") + self.assertEqual(images[2].video_url, False) + self.assertEqual(images[2].image_1920, data_target) + # Test that it is not possible to move an "embedded" video to the first position + with self.assertRaises(ValidationError): + self.WebsiteSaleController.resequence_product_image( + images[1]._name, + images[1].id, + 'left', + ) + with self.assertRaises(ValidationError): + self.WebsiteSaleController.resequence_product_image( + images[1]._name, + images[1].id, + 'first', + ) diff --git a/addons/website_sale/views/snippets/snippets.xml b/addons/website_sale/views/snippets/snippets.xml index 483e0a9bb95..2048e1991a7 100644 --- a/addons/website_sale/views/snippets/snippets.xml +++ b/addons/website_sale/views/snippets/snippets.xml @@ -196,6 +196,13 @@ Carousel Grid + + Replace + + + Add + Remove all +
@@ -278,6 +285,20 @@ + +