[IMP] base,product,website_sale,*: improve product images

* = account, purchase, website_sale_comparison, website_sale_wishlist

Raw Image & Mixin
=================

Previously, the main image from a product was stored resized. This caused
inconsistencies with the size of the images on ecommerce: the main image was
resized, but the extra images were not.

Now, we always store all raw images, thanks to the new mixin. This way, product
images that were created before the installation of ecommerce will be ready to
be used in ecommerce without any other change.

We also store the resized versions instead of computing them on the fly: this
will take more disk space, but significantly increase the speed of the
pages when displaying images, as well as reduce server CPU load.

Images on variants
==================

Previously, it was only possible to set the extra product images on a template,
and not on a variant.

Now, we allow extra images also on the variants, this gives more flexibility to
the user.

Both extra images on template and variant have been given a sequence field to be
able to sort them easily.

On /shop we always display the image of the template for performance reasons.
On /product we never display the image of the template, unless the variant has
no image, then the template image is the fallback.

Carousel
========

The carousel view has been cleaned and improved:

- remove the duplicate XML that was created with the product configurator fix
- use a single loop from a single source to get all the images, instead of
	merging the different sources in the view with complex conditions
- improve the HTML structure, reduce the CSS with appropriate classes

Preview Image
=============

Both in website and backend: make better use of `preview_image` to display
a small image whenever possible, to reduce download size for the end user.

task-34045
PR: #30656

Co-authored-by: Parth Gajjar <pga@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
This commit is contained in:
Dharmraj Jhala
2019-02-14 16:37:25 +00:00
committed by Sébastien Theys
co-authored by Parth Gajjar Sébastien Theys
parent 413c63556f
commit 7daa85dd12
21 changed files with 374 additions and 305 deletions
-1
View File
@@ -1693,7 +1693,6 @@ class AccountInvoiceLine(models.Model):
ondelete='set null', index=True, oldname='uos_id')
product_id = fields.Many2one('product.product', string='Product',
ondelete='restrict', index=True)
product_image = fields.Binary('Product Image', related="product_id.image", store=False, readonly=True)
account_id = fields.Many2one('account.account', string='Account', domain=[('deprecated', '=', False)],
default=_default_account,
help="The income or expense account related to the selected product.")
+21 -56
View File
@@ -83,7 +83,7 @@ class ProductProduct(models.Model):
_name = "product.product"
_description = "Product"
_inherits = {'product.template': 'product_tmpl_id'}
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin']
_order = 'default_code, name, id'
# price: total price, context dependent (partner, pricelist, quantity)
@@ -118,20 +118,6 @@ class ProductProduct(models.Model):
'product.attribute.value', string='Attribute Values', ondelete='restrict')
product_template_attribute_value_ids = fields.Many2many(
'product.template.attribute.value', string='Template Attribute Values', compute="_compute_product_template_attribute_value_ids")
# image: all image fields are base64 encoded and PIL-supported
image_variant = fields.Binary(
"Variant Image",
help="This field holds the image used as image for the product variant, limited to 1024x1024px.")
image = fields.Binary(
"Big-sized image", compute='_compute_images', inverse='_set_image',
help="Image of the product variant (Big-sized image of product template if false). It is automatically "
"resized as a 1024x1024px image, with aspect ratio preserved.")
image_small = fields.Binary(
"Small-sized image", compute='_compute_images', inverse='_set_image_small',
help="Image of the product variant (Small-sized image of product template if false).")
image_medium = fields.Binary(
"Medium-sized image", compute='_compute_images', inverse='_set_image_medium',
help="Image of the product variant (Medium-sized image of product template if false).")
is_product_variant = fields.Boolean(compute='_compute_is_product_variant')
standard_price = fields.Float(
@@ -241,47 +227,6 @@ class ProductProduct(models.Model):
else:
self.partner_ref = self.display_name
@api.one
@api.depends('image_variant', 'product_tmpl_id.image')
def _compute_images(self):
if self._context.get('bin_size'):
self.image_medium = self.image_variant
self.image_small = self.image_variant
self.image = self.image_variant
else:
resized_images = tools.image_get_resized_images(self.image_variant, return_big=True, avoid_resize_medium=True)
self.image_medium = resized_images['image_medium']
self.image_small = resized_images['image_small']
self.image = resized_images['image']
if not self.image_medium:
self.image_medium = self.product_tmpl_id.image_medium
if not self.image_small:
self.image_small = self.product_tmpl_id.image_small
if not self.image:
self.image = self.product_tmpl_id.image
@api.one
def _set_image(self):
self._set_image_value(self.image)
@api.one
def _set_image_medium(self):
self._set_image_value(self.image_medium)
@api.one
def _set_image_small(self):
self._set_image_value(self.image_small)
@api.one
def _set_image_value(self, value):
if isinstance(value, str):
value = value.encode('ascii')
image = tools.image_resize_image_big(value)
if self.product_tmpl_id.image:
self.image_variant = image
else:
self.product_tmpl_id.image = image
@api.depends('product_tmpl_id', 'attribute_value_ids')
def _compute_product_template_attribute_value_ids(self):
# Fetch and pre-map the values first for performance. It assumes there
@@ -306,6 +251,26 @@ class ProductProduct(models.Model):
else:
product.product_template_attribute_value_ids += values_per_template[product.product_tmpl_id.id][pav.id]
@api.multi
def _get_image_fallback_record(self):
"""Override to get the image from the template if no image is set on the
variant."""
return self.product_tmpl_id
@api.multi
def _force_write_image_on_fallback(self):
"""Override to always write on the template if there is only one
variant.
This is needed because when there is only one variant, the user doesn't
know there is a difference between template and variant, he expects both
images to be the same.
"""
return self.env['product.product'].search_count([
('product_tmpl_id', '=', self.product_tmpl_id.id),
('active', '=', True),
]) <= 1
@api.one
def _get_pricelist_items(self):
self.pricelist_item_ids = self.env['product.pricelist.item'].search([
+1 -19
View File
@@ -12,7 +12,7 @@ from odoo.osv import expression
class ProductTemplate(models.Model):
_name = "product.template"
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin']
_description = "Product Template"
_order = "name"
@@ -134,20 +134,6 @@ class ProductTemplate(models.Model):
item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items')
# image: all image fields are base64 encoded and PIL-supported
image = fields.Binary(
"Image", help="This field holds the image used as image for the product, limited to 1024x1024px.")
image_medium = fields.Binary(
"Medium-sized image",
help="Medium-sized image of the product. It is automatically "
"resized as a 128x128px image, with aspect ratio preserved, "
"only when the image exceeds one of those sizes. Use this field in form views or some kanban views.")
image_small = fields.Binary(
"Small-sized image",
help="Small-sized image of the product. It is automatically "
"resized as a 64x64px image, with aspect ratio preserved. "
"Use this field anywhere a small image is required.")
@api.depends('product_variant_ids')
def _compute_product_variant_id(self):
for p in self:
@@ -328,9 +314,6 @@ class ProductTemplate(models.Model):
@api.model_create_multi
def create(self, vals_list):
''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date'''
# TDE FIXME: context brol
for vals in vals_list:
tools.image_resize_images(vals)
templates = super(ProductTemplate, self).create(vals_list)
if "create_product_product" not in self._context:
templates.with_context(create_from_tmpl=True).create_variant_ids()
@@ -355,7 +338,6 @@ class ProductTemplate(models.Model):
@api.multi
def write(self, vals):
tools.image_resize_images(vals)
res = super(ProductTemplate, self).write(vals)
if 'attribute_line_ids' in vals or vals.get('active'):
self.create_variant_ids()
+2 -2
View File
@@ -26,7 +26,7 @@
options='{"terminology": "archive"}'/>
</button>
</div>
<field name="image_medium" widget="image" class="oe_avatar"/>
<field name="image" widget="image" class="oe_avatar" options="{'preview_image': 'image_medium'}"/>
<div class="oe_title">
<label class="oe_edit_only" for="name" string="Product Name"/>
<h1><field name="name" placeholder="Product Name"/></h1>
@@ -275,7 +275,7 @@
</button>
</div>
<field name="id" invisible="1"/>
<field name="image_medium" widget="image" class="oe_avatar"/>
<field name="image" widget="image" class="oe_avatar" options="{'preview_image': 'image_medium'}"/>
<div class="oe_title">
<label class="oe_edit_only" for="name" string="Product Name"/>
<h1><field name="name" readonly="1" placeholder="e.g. Odoo Enterprise Subscription"/></h1>
-3
View File
@@ -434,9 +434,6 @@ class PurchaseOrderLine(models.Model):
taxes_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)])
product_uom = fields.Many2one('uom.uom', string='Product Unit of Measure', required=True)
product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True, required=True)
product_image = fields.Binary(
'Product Image', related="product_id.image", readonly=False,
help="Non-stored related field to allow portal user to see the image of the product he has ordered")
product_type = fields.Selection(related='product_id.type', readonly=True)
price_unit = fields.Float(string='Unit Price', required=True, digits=dp.get_precision('Product Price'))
-3
View File
@@ -1114,9 +1114,6 @@ class SaleOrderLine(models.Model):
# because the result might be different then.
product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants')
# Non-stored related field to allow portal user to see the image of the product he has ordered
product_image = fields.Binary('Product Image', related="product_id.image", store=False, readonly=False)
qty_delivered_method = fields.Selection([
('manual', 'Manual'),
('analytic', 'Analytic From Expenses')
+4 -4
View File
@@ -1220,10 +1220,10 @@ class WebsiteSale(ProductConfiguratorController):
"""
kw.pop('pricelist_id')
res = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw)
if request.env.ref('website_sale.shop_product_carousel', raise_if_not_found=False): # IF for compatibility 12.0
res.update(carousel=request.env['ir.ui.view'].render_template('website_sale.shop_product_carousel', values={
carousel_view = request.env['ir.ui.view'].render_template('website_sale.shop_product_carousel',
values={
'product': request.env['product.template'].browse(res['product_template_id']),
'product_variant': request.env['product.product'].browse(res['product_id']),
}))
})
res['carousel'] = carousel_view
return res
@@ -43,7 +43,7 @@
<table width="100%">
<tr>
<td style="padding: 10px; width:150px;">
<img src="/web/image/product.product/${line.product_id.id}/image" height="100px" width="100px" alt="Product image"></img>
<img src="/web/image/product.product/${line.product_id.id}/image_medium" height="100px" width="100px" alt="Product image"></img>
</td>
<td>
<strong>${line.product_id.display_name}</strong><br/>${line.name}
+1
View File
@@ -5,6 +5,7 @@ from . import account_invoice
from . import crm_team
from . import ir_http
from . import mail_compose_message
from . import product_image
from . import product
from . import res_country
from . import res_partner
+41 -7
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
from odoo.addons import decimal_precision as dp
from odoo.tools.translate import html_translate
@@ -124,7 +125,8 @@ class ProductTemplate(models.Model):
public_categ_ids = fields.Many2many('product.public.category', string='Website Product Category',
help="The product will be available in each mentioned e-commerce category. Go to"
"Shop > Customize and enable 'E-commerce categories' to view all e-commerce categories.")
product_image_ids = fields.One2many('product.image', 'product_tmpl_id', string='Images')
product_template_image_ids = fields.One2many('product.image', 'product_tmpl_id', string="Extra Product Images", copy=True)
# website_price deprecated, directly use _get_combination_info instead
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
@@ -346,12 +348,28 @@ class ProductTemplate(models.Model):
domain = super(ProductTemplate, self)._rating_domain()
return expression.AND([domain, [('website_published', '=', True)]])
@api.multi
def _get_images(self):
"""Return a list of records implementing `image.mixin` to
display on the carousel on the website for this template.
This returns a list and not a recordset because the records might be
from different models (template and image).
It contains in this order: the main image of the template and the
Template Extra Images.
"""
self.ensure_one()
return [self] + list(self.product_template_image_ids)
class Product(models.Model):
_inherit = "product.product"
website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False)
product_variant_image_ids = fields.One2many('product.image', 'product_variant_id', string="Extra Variant Images")
# website_price deprecated, directly use _get_combination_info instead
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
# website_public_price deprecated, directly use _get_combination_info instead
@@ -371,11 +389,27 @@ class Product(models.Model):
self.ensure_one()
return self.product_tmpl_id.website_publish_button()
@api.multi
def _get_images(self):
"""Return a list of records implementing `image.mixin` to
display on the carousel on the website for this variant.
class ProductImage(models.Model):
_name = 'product.image'
_description = 'Product Image'
This returns a list and not a recordset because the records might be
from different models (template, variant and image).
name = fields.Char('Name')
image = fields.Binary('Image', attachment=True)
product_tmpl_id = fields.Many2one('product.template', 'Related Product', copy=True)
It contains in this order: the main image of the variant (if set), the
Variant Extra Images, and the Template Extra Images.
"""
self.ensure_one()
variant_images = list(self.product_variant_image_ids)
if self.image_raw_original:
# if the main variant image is set, display it first
variant_images = [self] + variant_images
else:
# If the main variant image is empty, it will fallback to template
# image, in this case insert it after the other variant images, so
# that all variant images are first and all template images last.
variant_images = variant_images + [self]
# [1:] to remove the main image from the template, we only display
# the template extra images here
return variant_images + self.product_tmpl_id._get_images()[1:]
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class ProductImage(models.Model):
_name = 'product.image'
_description = "Product Image"
_inherit = ['image.mixin']
_order = 'sequence, id'
name = fields.Char("Name", required=True)
sequence = fields.Integer(default=10, index=True)
image_original = fields.Binary(required=True)
product_tmpl_id = fields.Many2one('product.template', "Product Template", index=True, ondelete='cascade')
product_variant_id = fields.Many2one('product.product', "Product Variant", index=True, ondelete='cascade')
@@ -209,6 +209,24 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
}
return ProductConfiguratorMixin.getSelectedVariantValues.apply(this, arguments);
},
/**
* Write the properties of the form elements in the DOM to prevent the
* current selection from being lost when activating the web editor.
*
* @override
*/
onChangeVariant: function (ev) {
var $component = $(ev.currentTarget).closest('.js_product');
$component.find('input').each(function () {
var $el = $(this);
$el.attr('checked', $el.is(':checked'));
});
$component.find('select option').each(function () {
var $el = $(this);
$el.attr('selected', $el.is(':selected'));
});
return ProductConfiguratorMixin.onChangeVariant.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
@@ -345,21 +363,12 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
// Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page
if (!config.device.isMobile) {
var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false,
factorZoom = parseFloat($('.ecom-zoomable').data('ecom-zoom-factor')) || 1.5,
attach = '#o-carousel-product';
_.each($('.ecom-zoomable img[data-zoom]'), function (el) {
onImageLoaded(el, function () {
var $img = $(el);
if (!_.str.endsWith(el.src, el.dataset.zoomImage) || // if zoom-img != img
el.naturalWidth >= $(attach).width() * factorZoom || el.naturalHeight >= $(attach).height() * factorZoom) {
$img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach});
$img.attr('data-zoom', 1); // add cursor (if previously removed)
} else {
$img.removeAttr('data-zoom'); // remove cursor
// remove zooming but keep the attribute because
// it can potentially be set back
$img.attr('data-zoom-image', '');
}
$img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach});
$img.attr('data-zoom', 1);
});
});
}
@@ -5,12 +5,12 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
var base = require("web_editor.base");
tour.register("shop_custom_attribute_value", {
url: "/shop",
url: "/shop?search=Customizable Desk",
test: true,
wait_for: base.ready()
}, [{
trigger: 'img[src*="/product.product/10"]',
run: 'click'
content: "click on Customizable Desk",
trigger: '.oe_product_cart a:contains("Customizable Desk")',
}, {
trigger: 'li.js_attribute_value span:contains(Custom)',
extra_trigger: 'li.js_attribute_value',
@@ -433,14 +433,6 @@ div#payment_method {
.oe_website_sale .row .row .col-lg-12 {
float: none;
}
/* product detail */
.product_detail_img {
margin-left: auto;
margin-right: auto;
display: block;
max-height: 500px;
}
.oe_website_sale input.js_quantity {
min-width: 48px;
@@ -615,46 +607,36 @@ a.no-decoration {
opacity: 0.2;
}
.carousel-inner div.item, .carousel-control-prev, .carousel-control-next {
.carousel-outer {
height: 400px;
}
.carousel-inner div.item img {
max-height: 100%;
max-height: 90vh;
}
.carousel-control-prev, carousel-control-next {
background-image: none !important;
.carousel-control-prev, .carousel-control-next {
opacity: 0.5;
cursor: pointer;
&:focus {
opacity: 0.65;
}
&:hover {
opacity: 0.8;
}
> span {
background: rgba(0, 0, 0, 0.8);
}
}
.carousel-indicators {
background-color: transparent;
margin: 10px 0 0;
overflow-y: hidden;
position: static;
text-align: left;
white-space: nowrap;
width: 100%;
height: 100px !important;
text-align: center;
list-style-position: inside;
li {
background-color: transparent !important;
border-radius: 0;
display: inline-block;
margin: 0 !important;
width: 54px !important;
height: 54px !important;
border: 0 !important;
box-shadow: none !important;
width: 64px;
height: 64px;
text-indent: unset;
border: 1px solid gray('600');
opacity: 0.5;
img {
display: block;
opacity: 0.5;
}
&.active img {
&.active {
opacity: 1;
border: 1px solid theme-color('primary');
}
}
}
@@ -1,12 +1,9 @@
.o_website_sale_image_list .o_kanban_view.o_kanban_ungrouped.o_attachments_previews {
.o_website_sale_image_list .o_kanban_view.o_kanban_ungrouped {
width: auto;
.o_kanban_record.o_attachment.o_website_sale_image_card {
border: none;
background: none;
margin: 0 0 $grid-gutter-width*0.2;
padding: $o-mail-attachment-margin;
.o_kanban_record{
flex: 0 1 50%;
position: relative;
@include media-breakpoint-up(md) {
flex: 0 0 percentage(1/3);
@@ -19,45 +16,26 @@
@include media-breakpoint-up(xl) {
flex: 0 0 percentage(1/6);
}
.o_image_box {
padding-top: 50%;
@include media-breakpoint-up(md) {
padding-top: 75%;
}
}
.o_image_overlay {
bottom: 16%;
background: rgba(gray('900'), 0.2);
}
.o_website_sale_image_card_title {
@include o-text-overflow(block);
padding:6px;
// make the image square and in the center
.o_squared_image {
position: relative;
background: gray('200');
color:$headings-color;
}
.o_website_sale_image_card_remove, .o_website_sale_image_card_size {
@include o-position-absolute(0, 0);
border-radius: 0 2px;
}
.o_website_sale_image_card_size {
left: 0;
right: auto;
border-radius: 0 0 2px;
font-weight: normal;
font-size: $font-size-sm - 1;
&.label-normal {
background-color: gray('200');
color: $body-color;
overflow: hidden;
padding-bottom: 100%;
> img {
position: absolute;
margin: auto;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
}
.o_product_image_size {
position: absolute;
top: 0;
left: 0;
}
}
}
+66 -43
View File
@@ -102,47 +102,27 @@
<field name="website_style_ids" widget="many2many_tags" groups="base.group_no_one"/>
</group>
</group>
<group name="extra_images" string="Extra Images">
<field name="product_image_ids" class="o_website_sale_image_list" context="{'default_name': name, 'default_product_tmpl_id': active_id}" nolabel="1">
<kanban string="Product Images" class="o_attachments_previews">
<field name="id"/>
<field name="name"/>
<field name="image"/>
<templates>
<t t-name="kanban-box">
<div class="o_attachment oe_kanban_global_click o_website_sale_image_card">
<div class="o_attachment_wrap">
<div class="o_image_box">
<div class="o_attachment_image" t-attf-style="background-image:url({{kanban_image('product.image', 'image', record.id.raw_value)}});"/>
<div t-attf-class="o_image_overlay" t-att-title="record.name.value"/>
<div class="o_website_sale_image_card_title" t-att-title="record.name.value">
<field name="name"/>
</div>
</div>
<t t-set="size" t-value="[record.image.value][0]"/>
<t t-set="size_status" t-value="'normal'" t-if="size.indexOf('Bytes') !== -1 or ( size.indexOf('Kb') !== -1 and size[2]=='.')"/>
<t t-set="size_status" t-value="'warning'" t-elif="( size.indexOf('Kb') !== -1 and size[3]=='.')"/>
<t t-set="size_status" t-value="'danger'" t-else=""/>
<t t-set="message" t-value="'Acceptable file size'" t-if="size_status == 'normal'"></t>
<t t-set="message" t-value="'Huge file size. The image should be optimized/reduced.'" t-elif="size_status == 'warning'"></t>
<t t-set="message" t-value="'Optimization required! Reduce the image size or increase your compression settings.'" t-else=""></t>
<span t-attf-class="o_website_sale_image_card_size badge badge-#{size_status}" t-att-title="message">
<t t-esc="record.image.value"/>
</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
<group name="product_template_images" string="Extra Product Images">
<field name="product_template_image_ids" class="o_website_sale_image_list" context="{'default_name': name}" mode="kanban" options="{'create_text':'Add an Image'}" nolabel="1"/>
</group>
</page>
</xpath>
</field>
</record>
<record id="product_product_view_form_easy_inherit_website_sale" model="ir.ui.view">
<field name="name">product.product.view.form.easy.inherit.website_sale</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_variant_easy_edit_view"/>
<field name="arch" type="xml">
<sheet position="inside">
<group name="product_variant_images" string="Extra Variant Images">
<field name="product_variant_image_ids" class="o_website_sale_image_list" context="{'default_name': name}" mode="kanban" options="{'create_text':'Add an Image'}" nolabel="1"/>
</group>
</sheet>
</field>
</record>
<!-- Product Public Categories -->
<record id="product_public_category_form_view" model="ir.ui.view">
<field name="name">product.public.category.form</field>
@@ -216,25 +196,68 @@
</field>
</record>
<record model="ir.ui.view" id="view_product_image_form">
<field name="name">product.image.form</field>
<!-- This view should only be used from the product o2m because the required field product_tmpl_id has to be automatically set. -->
<record id="view_product_image_form" model="ir.ui.view">
<field name="name">product.image.view.form</field>
<field name="model">product.image</field>
<field name="arch" type="xml">
<form string="Product Images">
<div class="row o_website_sale_image_modal">
<div class="col-md-6 col-xl-5">
<div class="oe_title">
<label class="small text-muted" for="name" string="Image Name"/>
<h2><field name="name" placeholder="Image Name" required="True"/></h2>
<field name="product_tmpl_id" invisible="1"/>
</div>
<label for="name" string="Image Name"/>
<h2><field name="name" placeholder="Image Name"/></h2>
<!-- Unfortunately for now we can't drag and drop kanban o2m, so we have to let the user input the sequence manually. -->
<label for="sequence" string="Sequence"/><br/>
<field name="sequence"/>
</div>
<div class="col-md-6 col-xl-7 text-center o_website_sale_image_modal_container">
<field name="image" widget="image"/>
<field name="image_original" widget="image"/>
</div>
</div>
</form>
</field>
</record>
<record id="product_image_view_kanban" model="ir.ui.view">
<field name="name">product.image.view.kanban</field>
<field name="model">product.image</field>
<field name="arch" type="xml">
<kanban string="Product Images" default_order="sequence">
<field name="id"/>
<field name="name"/>
<field name="image_original"/>
<field name="sequence"/>
<templates>
<t t-name="kanban-box">
<div class="card oe_kanban_global_click p-0">
<div class="o_squared_image">
<img class="card-img-top" t-att-src="kanban_image('product.image', 'image_original', record.id.value)" t-att-alt="record.name.value"/>
</div>
<div class="card-body p-0">
<h4 class="card-title p-2 m-0 bg-200">
<small><field name="name"/></small>
</h4>
</div>
<!-- below 100 Kb: good -->
<t t-if="record.image_original.raw_value.length &lt; 100*1000">
<t t-set="size_status" t-value="'badge-success'"/>
<t t-set="message">Acceptable file size</t>
</t>
<!-- below 1000 Kb: decent -->
<t t-elif="record.image_original.raw_value.length &lt; 1000*1000">
<t t-set="size_status" t-value="'badge-warning'" />
<t t-set="message">Huge file size. The image should be optimized/reduced.</t>
</t>
<!-- above 1000 Kb: bad -->
<t t-else="1">
<t t-set="size_status" t-value="'badge-danger'"/>
<t t-set="message">Optimization required! Reduce the image size or increase your compression settings.</t>
</t>
<span t-attf-class="badge #{size_status} o_product_image_size" t-esc="record.image_original.value" t-att-title="message"/>
</div>
</t>
</templates>
</kanban>
</field>
</record>
</odoo>
+23 -69
View File
@@ -99,11 +99,10 @@
<!-- Product item used by /shop and /shop/cart -->
<template id="products_item" name="Products item">
<t t-set="product_href" t-value="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']&gt;1 else None))" />
<t t-set="product_href" t-value="request.httprequest.url_root + keep('shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']&gt;1 else None))" />
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<form action="/shop/cart/update" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
@@ -113,8 +112,7 @@
</div>
<div class="oe_product_image">
<a itemprop="url" t-att-href="product_href">
<span t-if="product_variant" itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.product/{{product_variant.id}}/image" t-field="product_variant.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" />
<span t-else="" itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.template/{{product.id}}/image" t-field="product.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" />
<span t-field="product.image" t-options="{'widget': 'image', 'preview_image': 'image' if product_image_big else 'image_large'}"/>
</a>
</div>
<t t-if="show_publish">
@@ -457,45 +455,10 @@
</div>
</div>
<div class="row">
<div class="col-md-6">
<t t-set="variant_img" t-value="product_variant and product_variant.image_variant"/>
<t t-set="image_ids" t-value="product.product_image_ids"/>
<div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0">
<div class="carousel-outer">
<div class="carousel-inner">
<div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product_variant.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product_variant['__last_update']) or '')}"/>
<div t-attf-class="carousel-item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<div class="carousel-item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/>
</t>
</div>
<t t-if="len(image_ids) or variant_img">
<a class="carousel-control-prev" href="#o-carousel-product" role="button" data-slide="prev" >
<span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
</a>
<a class="carousel-control-next" href="#o-carousel-product" role="button" data-slide="next">
<span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
</a>
</t>
</div>
<ol class="carousel-indicators" t-if="len(image_ids) or variant_img">
<li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active">
<img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product_variant.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else '0'" t-att-class="'' if variant_img else 'active'">
<img class="img img-fluid" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<li data-target="#o-carousel-product" t-att-data-slide-to="pimg_index + (variant_img and 2 or 1)">
<img class="img img-fluid" t-attf-src="/website/image/product.image/{{pimg.id}}/image/90x90" t-att-alt="pimg.name"/>
</li>
</t>
</ol>
</div>
<div class="col-md-6 col-xl-8">
<t t-call="website_sale.shop_product_carousel"/>
</div>
<div class="col-md-6 col-xl-4 offset-xl-2" id="product_details">
<div class="col-md-6 col-xl-4" id="product_details">
<h1 itemprop="name" t-field="product.name">Product Name</h1>
<span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/>
<form t-if="product._is_add_to_cart_possible()" action="/shop/cart/update" method="POST">
@@ -1707,41 +1670,32 @@
</template>
<template id="website_sale.shop_product_carousel" name="Shop Product Carousel">
<t t-set="variant_img" t-value="product_variant and product_variant.image_variant"/>
<t t-set="image_ids" t-value="product.product_image_ids"/>
<t t-set="product_images" t-value="product_variant._get_images() if product_variant else product._get_images()"/>
<div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0">
<div class="carousel-outer">
<div class="carousel-inner">
<div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product_variant.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product_variant['__last_update']) or '')}"/>
<div t-attf-class="carousel-item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<div class="carousel-item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/>
<div class="carousel-outer position-relative">
<div class="carousel-inner h-100">
<t t-foreach="product_images" t-as="product_image">
<div t-attf-class="carousel-item h-100#{' active' if product_image_first else ''}">
<div t-field="product_image.image" class="d-flex align-items-center justify-content-center h-100" t-options='{"widget": "image", "preview_image": "image", "class": "product_detail_img mh-100", "alt-field": "name", "zoom": product_image.can_image_be_zoomed and "image_original"}'/>
</div>
</t>
</div>
<t t-if="len(image_ids) or variant_img">
<a class="carousel-control-prev" href="#o-carousel-product" role="button" data-slide="prev" >
<span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
<t t-if="len(product_images) > 1">
<a class="carousel-control-prev" href="#o-carousel-product" role="button" data-slide="prev">
<span class="fa fa-chevron-left p-2" role="img" aria-label="Previous" title="Previous"/>
</a>
<a class="carousel-control-next" href="#o-carousel-product" role="button" data-slide="next">
<span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
<span class="fa fa-chevron-right p-2" role="img" aria-label="Next" title="Next"/>
</a>
</t>
</div>
<ol t-if="len(image_ids) or variant_img" class="carousel-indicators">
<li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active">
<img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product_variant.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else '0'" t-att-class="'' if variant_img else 'active'">
<img class="img img-fluid" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<li data-target="#o-carousel-product" t-att-data-slide-to="pimg_index + (variant_img and 2 or 1)">
<img class="img img-fluid" t-attf-src="/website/image/product.image/{{pimg.id}}/image/90x90" t-att-alt="pimg.name"/>
</li>
</t>
</ol>
<div t-ignore="True" class="d-none d-md-block text-center">
<ol t-if="len(product_images) > 1" class="carousel-indicators d-inline-block position-static mx-auto my-0 p-1 text-left">
<t t-foreach="product_images" t-as="product_image"><li t-attf-class="d-inline-block m-1 align-top {{'active' if product_image_first else ''}}" data-target="#o-carousel-product" t-att-data-slide-to="str(product_image_index)">
<div t-field="product_image.image_small" class="d-flex align-items-center justify-content-center h-100" t-options='{"widget": "image", "alt-field": "name"}'/>
</li></t>
</ol>
</div>
</div>
</template>
@@ -91,7 +91,7 @@
<strong class='float-right'>x</strong>
</a>
<a t-att-href="'/shop/product/%s' % slug(product.product_tmpl_id)">
<img t-attf-src="/web/image/product.product/#{product.id}/image/200x150" class="img img-fluid" style="margin:auto;" alt="Product image"/>
<img t-attf-src="/web/image/product.product/#{product.id}/image_large" class="img img-fluid" style="margin:auto;" alt="Product image"/>
</a>
<div class='product_summary'>
<a class="o_product_comparison_table" t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }">
@@ -58,7 +58,7 @@
<tr t-att-data-wish-id='wish.id' t-att-data-product-id='wish.product_id.id'>
<td class='td-img'>
<a t-att-href="'/shop/product/%s' % slug(wish.product_id.product_tmpl_id)">
<img t-attf-src="/web/image/product.product/#{wish.product_id.id}/image/100x100" class="img img-fluid" style="margin:auto;" alt="Product image"/>
<img t-attf-src="/web/image/product.product/#{wish.product_id.id}/image_medium" class="img img-fluid" style="margin:auto;" alt="Product image"/>
</a>
</td>
<td class='text-left'>
+3
View File
@@ -30,6 +30,9 @@ from . import ir_module
from . import ir_demo
from . import ir_demo_failure
from . import report_paperformat
from . import image_mixin
from . import res_country
from . import res_lang
from . import res_partner
+126
View File
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, tools
class ImageMixin(models.AbstractModel):
_name = 'image.mixin'
_description = "Image Mixin"
# all image fields are base64 encoded and PIL-supported
# all image_raw fields are technical and should not be displayed to the user
image_raw_original = fields.Binary("Raw Original Image")
# resized fields stored (as attachment) for performance
image_raw_big = fields.Binary("Raw Big-sized Image", compute='_compute_image_raw', store=True)
image_raw_large = fields.Binary("Raw Large-sized Image", compute='_compute_image_raw', store=True)
image_raw_medium = fields.Binary("Raw Medium-sized Image", compute='_compute_image_raw', store=True)
image_raw_small = fields.Binary("Raw Small-sized Image", compute='_compute_image_raw', store=True)
can_image_raw_be_zoomed = fields.Boolean("Can image raw be zoomed", compute='_compute_image_raw', store=True)
# Computed fields that are used to create a fallback if necessary, it's
# recommended to display those fields to the user.
image_original = fields.Binary("Original Image", compute='_compute_image_original', inverse='_set_image_original', help="Image in its original size, as it was uploaded.")
image = fields.Binary("Big-sized Image", help="1024px * 1024px", compute='_compute_image', inverse='_set_image')
image_large = fields.Binary("Large-sized Image", help="256px * 256px", compute='_compute_image_large')
image_medium = fields.Binary("Medium-sized Image", help="128px * 128px", compute='_compute_image_medium')
image_small = fields.Binary("Small-sized Image", help="64px * 64px", compute='_compute_image_small')
can_image_be_zoomed = fields.Boolean("Can image be zoomed", compute='_compute_can_image_be_zoomed')
@api.multi
@api.depends('image_raw_original')
def _compute_image_raw(self):
for record in self:
images = tools.image_get_resized_images(record.image_raw_original, big_name=False)
record.image_raw_big = tools.image_get_resized_images(record.image_raw_original,
large_name=False, medium_name=False, small_name=False, preserve_aspect_ratio=True)['image']
record.image_raw_large = images['image_large']
record.image_raw_medium = images['image_medium']
record.image_raw_small = images['image_small']
record.can_image_raw_be_zoomed = tools.is_image_size_above(record.image_raw_original)
@api.multi
def _compute_image_original(self):
for record in self:
record.image_original = record.image_raw_original or record._get_image_fallback_record().image_raw_original
@api.multi
def _set_image_original(self):
for record in self:
fallback = record._get_image_fallback_record()
if (
# We are trying to remove an image even though it is already
# not set, remove it from the fallback record instead.
not record.image_original and not record.image_raw_original or
# We are trying to add an image, but the fallback image is
# not set, write on the fallback instead.
record.image_original and not fallback.image_raw_original or
# The record using the mixin asks to always write on fallback.
record._force_write_image_on_fallback()
):
fallback.image_raw_original = record.image_original
else:
record.image_raw_original = record.image_original
@api.multi
def _compute_image(self):
for record in self:
record.image = record.image_raw_big or record._get_image_fallback_record().image_raw_big
@api.multi
def _set_image(self):
for record in self:
record.image_original = record.image
@api.multi
def _compute_image_large(self):
for record in self:
record.image_large = record.image_raw_large or record._get_image_fallback_record().image_raw_large
@api.multi
def _compute_image_medium(self):
for record in self:
record.image_medium = record.image_raw_medium or record._get_image_fallback_record().image_raw_medium
@api.multi
def _compute_image_small(self):
for record in self:
record.image_small = record.image_raw_small or record._get_image_fallback_record().image_raw_small
@api.multi
def _compute_can_image_be_zoomed(self):
for record in self:
record.can_image_be_zoomed = record.can_image_raw_be_zoomed if record.image_raw_original else record._get_image_fallback_record().can_image_raw_be_zoomed
@api.multi
def _get_image_fallback_record(self):
"""Return a record to fallback on when getting an image if the image is
not set for self.
The returned record must implement the current mixin, or at least have
the different ``image_raw`` fields defined on it."""
self.ensure_one()
return self
@api.multi
def _force_write_image_on_fallback(self):
"""Return whether we should always write the image on the fallback.
:return: True to always write on the fallback no matter the situation,
False to write on the current record or on the fallback depending
on the situation. See `_set_image_original`.
"""
self.ensure_one()
return False
@api.multi
def unlink(self):
"""If the fallback image is not set, move the current image to the
fallback."""
for record in self:
fallback = record._get_image_fallback_record()
if fallback != record and record.image_raw_original and not fallback.image_raw_original:
fallback.image_raw_original = record.image_raw_original
return super(ImageMixin, self).unlink()