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 @@