[IMP] website_sale : llow bulk uploading of images from frontend

This commit adds more functionalities to the web editor when editing a
product's page in order to make it more user friendly to add change or
resequence the many images a product may have.

TaskId-2692189

closes odoo/odoo#85686

Signed-off-by: William Braeckman (wbr) <wbr@odoo.com>
This commit is contained in:
Bastien Dosimont
2022-07-28 15:03:48 +02:00
committed by William Braeckman
parent 8821fb0641
commit 3958d15086
6 changed files with 524 additions and 3 deletions
+139
View File
@@ -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']
+4 -1
View File
@@ -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):
@@ -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);
}
});
});
+1
View File
@@ -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
@@ -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',
)
@@ -196,6 +196,13 @@
<we-button data-set-image-layout="carousel">Carousel</we-button>
<we-button data-set-image-layout="grid">Grid</we-button>
</we-select>
<we-row string="Main image">
<we-button class="o_we_bg_brand_primary" data-replace-main-image="true" data-no-preview="true">Replace</we-button>
</we-row>
<we-row string="Extra Images">
<we-button class="o_we_bg_success" data-add-images="true" data-no-preview="true">Add</we-button>
<we-button class="o_we_bg_danger" data-clear-images="true" data-no-preview="true">Remove all</we-button>
</we-row>
</div>
<div data-selector="#product_detail #o-carousel-product" data-no-check="true">
<we-select string="Image Zoom" data-no-preview="true" data-reload="/">
@@ -278,6 +285,20 @@
</xpath>
</template>
<template id="snippets_options_web_editor" inherit_id="web_editor.snippet_options" name="e-commerce base snippet options">
<xpath expr="//div[@data-js='ReplaceMedia']" position="inside">
<we-row string="Re-order">
<we-button class="fa fa-fw fa-angle-double-left" data-no-preview="true" title="Move to first" data-set-position="first" data-name="media_wsale_resequence"/>
<we-button class="fa fa-fw fa-angle-left" data-no-preview="true" title="Move to previous" data-set-position="left" data-name="media_wsale_resequence"/>
<we-button class="fa fa-fw fa-angle-right" data-no-preview="true" title="Move to next" data-set-position="right" data-name="media_wsale_resequence"/>
<we-button class="fa fa-fw fa-angle-double-right" data-no-preview="true" title="Move to last" data-set-position="last" data-name="media_wsale_resequence"/>
</we-row>
</xpath>
<xpath expr="//div[@data-js='ReplaceMedia']/we-row" position="inside">
<we-button class="o_we_bg_danger" data-remove-media="true" data-no-preview="true" data-name="media_wsale_remove">Remove</we-button>
</xpath>
</template>
<template id="product_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="product search bar snippet options">
<xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside">
<we-button data-set-search-type="products" data-select-data-attribute="products" data-name="search_products_opt" data-form-action="/shop">Products</we-button>