diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr.py index 89b883e6df4..cccb266981b 100644 --- a/addons/hr/models/hr.py +++ b/addons/hr/models/hr.py @@ -106,7 +106,7 @@ class Employee(models.Model): @api.model def _default_image(self): image_path = get_module_resource('hr', 'static/src/img', 'default_image.png') - return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read())) + return tools.image_process(base64.b64encode(open(image_path, 'rb').read()), size=tools.IMAGE_BIG_SIZE) # resource and user # required on the resource, make sure required="True" set in the view diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index 6a568bbb269..9ef4df884f7 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -21,7 +21,7 @@ class ImLivechatChannel(models.Model): def _default_image(self): image_path = modules.get_module_resource('im_livechat', 'static/src/img', 'default.png') - return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read())) + return tools.image_process(base64.b64encode(open(image_path, 'rb').read()), size=tools.IMAGE_BIG_SIZE) def _default_user_ids(self): return [(6, 0, [self._uid])] diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 1ae85a249ca..43a3dd54703 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -64,7 +64,7 @@ class Channel(models.Model): def _get_default_image(self): image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png') - return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read())) + return tools.image_process(base64.b64encode(open(image_path, 'rb').read()), size=tools.IMAGE_BIG_SIZE) @api.model def default_get(self, fields): diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 8fa4d67c36a..415be96bd27 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -7,11 +7,10 @@ from datetime import datetime from dateutil import relativedelta import pprint -from odoo import api, exceptions, fields, models, _ -from odoo.tools import consteq, float_round, image_resize_images, image_resize_image, ustr +from odoo import api, exceptions, fields, models, _, SUPERUSER_ID +from odoo.tools import consteq, float_round, image_resize_images, image_process, ustr from odoo.addons.base.models import ir_module from odoo.exceptions import ValidationError -from odoo import api, SUPERUSER_ID from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.misc import formatLang @@ -525,16 +524,16 @@ class PaymentIcon(models.Model): for vals in vals_list: if 'image' in vals: image = ustr(vals['image'] or '').encode('utf-8') - vals['image_payment_form'] = image_resize_image(image, size=(45,30)) - vals['image'] = image_resize_image(image, size=(64,64)) + vals['image_payment_form'] = image_process(image, size=(45,30)) + vals['image'] = image_process(image, size=(64,64)) return super(PaymentIcon, self).create(vals_list) @api.multi def write(self, vals): if 'image' in vals: image = ustr(vals['image'] or '').encode('utf-8') - vals['image_payment_form'] = image_resize_image(image, size=(45,30)) - vals['image'] = image_resize_image(image, size=(64,64)) + vals['image_payment_form'] = image_process(image, size=(45,30)) + vals['image'] = image_process(image, size=(64,64)) return super(PaymentIcon, self).write(vals) class PaymentTransaction(models.Model): diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 99a314662dd..4d566f2b100 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -168,7 +168,7 @@ class ProductProduct(models.Model): 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'] + large_name=False, medium_name=False, small_name=False)['image'] record.image_raw_large = images['image_large'] record.image_raw_medium = images['image_medium'] record.image_raw_small = images['image_small'] diff --git a/addons/purchase/controllers/portal.py b/addons/purchase/controllers/portal.py index 8e5cea717ac..171373bbec5 100644 --- a/addons/purchase/controllers/portal.py +++ b/addons/purchase/controllers/portal.py @@ -7,7 +7,7 @@ from collections import OrderedDict from odoo import http from odoo.exceptions import AccessError, MissingError from odoo.http import request -from odoo.tools import image_resize_image +from odoo.tools import image_process from odoo.tools.translate import _ from odoo.addons.portal.controllers.portal import pager as portal_pager, CustomerPortal from odoo.addons.web.controllers.main import Binary @@ -28,7 +28,7 @@ class CustomerPortal(CustomerPortal): def resize_to_48(b64source): if not b64source: b64source = base64.b64encode(Binary().placeholder()) - return image_resize_image(b64source, size=(48, 48)) + return image_process(b64source, size=(48, 48)) values = { 'order': order, diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index cf33f0232a4..a14a02ebb9b 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -36,7 +36,7 @@ import odoo import odoo.modules.registry from odoo.api import call_kw, Environment from odoo.modules import get_module_path, get_resource_path -from odoo.tools import limited_image_resize, topological_sort, html_escape, pycompat +from odoo.tools import image_process, topological_sort, html_escape, pycompat from odoo.tools.mimetypes import guess_mimetype from odoo.tools.translate import _ from odoo.tools.misc import str2bool, xlwt, file_open @@ -971,11 +971,6 @@ class Binary(http.Controller): addons_path = http.addons_manifest['web']['addons_path'] return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read() - def force_contenttype(self, headers, contenttype='image/png'): - dictheaders = dict(headers) - dictheaders['Content-Type'] = contenttype - return list(dictheaders.items()) - @http.route(['/web/content', '/web/content/', '/web/content//', @@ -1023,30 +1018,25 @@ class Binary(http.Controller): '/web/image/-/x/'], type='http', auth="public") def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename_field='datas_fname', unique=None, filename=None, mimetype=None, - download=None, width=0, height=0, crop=False, access_token=None, avoid_if_small=False, - upper_limit=False, placeholder='placeholder.png', **kw): - status, headers, content = request.env['ir.http'].binary_content( + download=None, width=0, height=0, crop=False, access_token=None, + placeholder='placeholder.png', **kwargs): + status, headers, image_base64 = request.env['ir.http'].binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype='image/png', access_token=access_token) if status == 301 or (status != 200 and download): - return request.env['ir.http']._response_by_status(status, headers, content) - if not content: - content = base64.b64encode(self.placeholder(image=placeholder)) - headers = self.force_contenttype(headers, contenttype='image/png') + return request.env['ir.http']._response_by_status(status, headers, image_base64) + if not image_base64: + image_base64 = base64.b64encode(self.placeholder(image=placeholder)) if not (width or height): - suffix = 'big' if field == 'image' else field.split('_')[-1] - if suffix in ('small', 'medium', 'large', 'big'): - content = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(content) + width, height = odoo.tools.image_guess_size_from_field_name(field) + image_base64 = image_process(image_base64, (width, height), crop=crop) - content = limited_image_resize( - content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) - - image_base64 = base64.b64decode(content) - headers.append(('Content-Length', len(image_base64))) - response = request.make_response(image_base64, headers) + content = base64.b64decode(image_base64) + headers = http.set_safe_image_headers(headers, content) + response = request.make_response(content, headers) response.status_code = status return response diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py index d1d792e8f54..e8a966647e3 100644 --- a/addons/web/models/ir_qweb.py +++ b/addons/web/models/ir_qweb.py @@ -45,7 +45,6 @@ class Image(models.AbstractModel): sha = hashlib.sha1(str(getattr(record, '__last_update')).encode('utf-8')).hexdigest()[0:7] max_size = '' if max_size is None else '/%s' % max_size - avoid_if_small = '&avoid_if_small=true' if options.get('avoid_if_small') else '' if options.get('filename-field') and getattr(record, options['filename-field'], None): filename = record[options['filename-field']] @@ -54,7 +53,7 @@ class Image(models.AbstractModel): else: filename = record.display_name - src = '/web/image/%s/%s/%s%s/%s?unique=%s%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha, avoid_if_small) + src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha) if options.get('alt-field') and getattr(record, options['alt-field'], None): alt = escape(record[options['alt-field']]) diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js index 2794d871e67..70afa09c066 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -177,21 +177,19 @@ var KanbanRecord = Widget.extend({ * @param {string} model the name of the model * @param {string} field the name of the field * @param {integer} id the id of the resource - * @param {integer} cache the cache duration, in seconds - * @param {Object} options + * @param {string} placeholder * @returns {string} the url of the image */ - _getImageURL: function (model, field, id, cache, options) { - options = options || {}; + _getImageURL: function (model, field, id, placeholder) { id = (_.isArray(id) ? id[0] : id) || false; - field = options.preview_image || field; + placeholder = placeholder || "/web/static/src/img/placeholder.png"; var isCurrentRecord = this.modelName === model && this.recordData.id === id; var url; if (isCurrentRecord && this.record[field] && this.record[field].raw_value && !utils.is_bin_size(this.record[field].raw_value)) { // Use magic-word technique for detecting image type url = 'data:image/' + this.file_type_magic_word[this.record[field].raw_value[0]] + ';base64,' + this.record[field].raw_value; } else if (!model || !field || !id || (isCurrentRecord && this.record[field] && !this.record[field].raw_value)) { - url = "/web/static/src/img/placeholder.png"; + url = placeholder; } else { var session = this.getSession(); var params = { @@ -202,10 +200,6 @@ var KanbanRecord = Widget.extend({ if (isCurrentRecord) { params.unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, ''); } - if (cache !== undefined) { - // Set the cache duration in seconds. - params.cache = parseInt(cache, 10); - } url = session.url('/web/image', params); } return url; diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss index e2c550f0a42..7ab56e2bbb3 100644 --- a/addons/web/static/src/scss/kanban_view.scss +++ b/addons/web/static/src/scss/kanban_view.scss @@ -146,7 +146,15 @@ margin-right: $o-kanban-inside-hgutter*2; flex: 1 0 $o-kanban-image-fill-width; min-height: 95px; - background: {size: cover; position: center }; + background: { + size: cover; + position: center; + repeat: no-repeat; + }; + + &.o_kanban_image_full { + background-size: contain; + } } // Adapt default 'o_kanban_image' element if present. @@ -158,6 +166,10 @@ flex: 0 0 $o-kanban-image-width; min-height: $o-kanban-image-width; align-self: baseline; + background: { + repeat: no-repeat; + position: center; + } // On medium screen size, align 'o_kanban_image' to 'o_kanban_image_fill_left' elements. @include media-breakpoint-up(md) { diff --git a/addons/web/tests/test_image.py b/addons/web/tests/test_image.py index e17073d0fb7..1e4b0c3c5b5 100644 --- a/addons/web/tests/test_image.py +++ b/addons/web/tests/test_image.py @@ -13,10 +13,10 @@ class TestImage(HttpCase): """The goal of this test is to make sure the placeholder image is resized appropriately depending on the given URL parameters.""" - # CASE: resize placeholder to given size + # CASE: resize placeholder, given size but original ratio is always kept response = self.url_open('/web/image/0/200x150') image = Image.open(io.BytesIO(response.content)) - self.assertEqual(image.size, (200, 150)) + self.assertEqual(image.size, (150, 150)) # CASE: resize placeholder to small response = self.url_open('/web/image/fake/0/image_small') diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 8360161fb3d..edc23e0da76 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -158,27 +158,17 @@ class Web_Editor(http.Controller): #------------------------------------------------------ @http.route('/web_editor/add_image_base64', type='json', auth='user', methods=['POST'], website=True) def add_image_base64(self, res_model, res_id, image_base64, filename, disable_optimization=None, **kwargs): - data = base64.b64decode(image_base64) - attachment = self._image_to_attachment(res_model, res_id, data, filename, filename, disable_optimization=disable_optimization) + attachment = self._image_to_attachment(res_model, res_id, image_base64, filename, filename, disable_optimization=disable_optimization) return attachment.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token'])[0] - def _image_to_attachment(self, res_model, res_id, data, name, datas_fname, disable_optimization=None): + def _image_to_attachment(self, res_model, res_id, image_base64, name, datas_fname, disable_optimization=None): Attachments = request.env['ir.attachment'] - try: - image = Image.open(io.BytesIO(data)) - w, h = image.size - if w*h > 42e6: # Nokia Lumia 1020 photo resolution - raise ValueError( - u"Image size excessive, uploaded images must be smaller " - u"than 42 million pixel") - if not disable_optimization and image.format in ('PNG', 'JPEG'): - data = tools.image_save_for_web(image) - except IOError: - pass + if not disable_optimization: + image_base64 = tools.image_process(image_base64, verify_resolution=True) attachment = Attachments.create({ 'name': name, 'datas_fname': datas_fname, - 'datas': base64.b64encode(data), + 'datas': image_base64, 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, @@ -224,12 +214,12 @@ class Web_Editor(http.Controller): try: attachments = request.env['ir.attachment'] for c_file in request.httprequest.files.getlist('upload'): - data = c_file.read() + image_base64 = base64.b64encode(c_file.read()) name = c_file.filename datas_fname = name if filters: datas_fname = filters + '_' + datas_fname - attachments += self._image_to_attachment(res_model, res_id, data, name, datas_fname, disable_optimization=disable_optimization) + attachments += self._image_to_attachment(res_model, res_id, image_base64, name, datas_fname, disable_optimization=disable_optimization) uploads += attachments.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token']) except Exception as e: logger.exception("Failed to upload image to attachment") diff --git a/addons/web_unsplash/controllers/main.py b/addons/web_unsplash/controllers/main.py index 219342d1c39..ddebf07e327 100644 --- a/addons/web_unsplash/controllers/main.py +++ b/addons/web_unsplash/controllers/main.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 -import io import logging +import mimetypes import requests import werkzeug.utils -from PIL import Image from odoo import http, tools, _ from odoo.http import request +from odoo.tools.mimetypes import guess_mimetype from werkzeug.urls import url_encode logger = logging.getLogger(__name__) @@ -89,8 +89,7 @@ class Web_Unsplash(http.Controller): continue # get mime-type of image url because unsplash url dosn't contains mime-types in url - mimetype = req.headers.get('Content-Type') - datas = req.content + image_base64 = base64.b64encode(req.content) except requests.exceptions.ConnectionError as e: logger.exception("Connection Error: " + str(e)) continue @@ -98,13 +97,10 @@ class Web_Unsplash(http.Controller): logger.exception("Timeout: " + str(e)) continue - # optimized image before save - if mimetype in ('image/jpeg', 'image/png'): - image = Image.open(io.BytesIO(datas)) - if image.format in ('PNG', 'JPEG'): - datas = tools.image_save_for_web(image) - # append image extension in name - query += '.' + str.lower(image.format) + image_base64 = tools.image_process(image_base64) + mimetype = guess_mimetype(base64.b64decode(image_base64)) + # append image extension in name + query += mimetypes.guess_extension(mimetype) or '' # /unsplash/5gR788gfd/lion url_frags = ['unsplash', key, query] @@ -114,7 +110,7 @@ class Web_Unsplash(http.Controller): 'url': '/' + '/'.join(url_frags), 'datas_fname': '_'.join(url_frags), 'mimetype': mimetype, - 'datas': base64.b64encode(datas), + 'datas': image_base64, 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 9701610dde3..737ff819de8 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -156,7 +156,6 @@ - diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 0261b1fc616..4f55ed27298 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 import inspect import logging import hashlib @@ -14,6 +15,7 @@ from odoo.addons.http_routing.models.ir_http import slugify, _guess_mimetype from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.addons.portal.controllers.portal import pager from odoo.http import request +from odoo.modules.module import get_resource_path from odoo.osv.expression import FALSE_DOMAIN from odoo.tools.translate import _ @@ -100,7 +102,13 @@ class Website(models.Model): partner_id = fields.Many2one(related='user_id.partner_id', relation='res.partner', string='Public Partner', readonly=False) menu_id = fields.Many2one('website.menu', compute='_compute_menu', string='Main Menu') homepage_id = fields.Many2one('website.page', string='Homepage') - favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.") + + def _default_favicon(self): + img_path = get_resource_path('web', 'static/src/img/favicon.ico') + with tools.file_open(img_path, 'rb') as f: + return base64.b64encode(f.read()) + + favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.", default=_default_favicon) theme_id = fields.Many2one('ir.module.module', help='Installed theme') specific_user_account = fields.Boolean('Specific User Account', help='If True, new accounts will be associated to the current website') @@ -122,6 +130,8 @@ class Website(models.Model): @api.model def create(self, vals): + self._handle_favicon(vals) + if 'user_id' not in vals: company = self.env['res.company'].browse(vals.get('company_id')) vals['user_id'] = company._get_public_user().id if company else self.env.ref('base.public_user').id @@ -138,6 +148,8 @@ class Website(models.Model): @api.multi def write(self, values): + self._handle_favicon(values) + self._get_languages.clear_cache(self) if 'company_id' in values and 'user_id' not in values: company = self.env['res.company'].browse(values['company_id']) @@ -149,6 +161,11 @@ class Website(models.Model): self.env['ir.qweb'].clear_caches() return result + @api.model + def _handle_favicon(self, vals): + if 'favicon' in vals: + vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO') + # ---------------------------------------------------------- # Page Management # ---------------------------------------------------------- diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py index 85ecad1cba8..f1f4a459cee 100644 --- a/addons/website/tests/__init__.py +++ b/addons/website/tests/__init__.py @@ -9,4 +9,5 @@ from . import test_ui from . import test_views from . import test_menu from . import test_page +from . import test_website_favicon from . import test_website_reset_password diff --git a/addons/website/tests/test_website_favicon.py b/addons/website/tests/test_website_favicon.py new file mode 100644 index 00000000000..3346ccd459f --- /dev/null +++ b/addons/website/tests/test_website_favicon.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from PIL import Image + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase +from odoo.tools import base64_to_image, image_to_base64 + + +@tagged('post_install', '-at_install') +class TestWebsiteResetPassword(TransactionCase): + + def test_01_website_favicon(self): + """The goal of this test is to make sure the favicon is correctly + handled on the website.""" + + # Test setting an Ico file directly, done through create + Website = self.env['website'] + + website = Website.create({ + 'name': 'Test Website', + 'favicon': Website._default_favicon(), + }) + + image = base64_to_image(website.favicon) + self.assertEqual(image.format, 'ICO') + + # Test setting a JPEG file that is too big, done through write + bg_color = (135, 90, 123) + image = Image.new('RGB', (1920, 1080), color=bg_color) + website.favicon = image_to_base64(image, 'JPEG') + image = base64_to_image(website.favicon) + self.assertEqual(image.format, 'ICO') + self.assertEqual(image.size, (256, 256)) + self.assertEqual(image.getpixel((0, 0)), bg_color) diff --git a/addons/website_partner/views/website_partner_templates.xml b/addons/website_partner/views/website_partner_templates.xml index c8682008619..2f5c58b1dee 100644 --- a/addons/website_partner/views/website_partner_templates.xml +++ b/addons/website_partner/views/website_partner_templates.xml @@ -17,7 +17,7 @@