[REF] base, *: refactor binary_content

*: tools, web, website, website_forum, mail, im_livechat

This commit refactors ir_http to make it more readable
and flexible.

Move the resize function of web/image to odoo.tools

Co-authored-by: XavierDo <xdo@odoo.com>
Co-authored-by: Antony Lesuisse <al@openerp.com>

closes: #28563
task: #1908896
This commit is contained in:
ThanhDodeurOdoo
2018-12-06 19:09:33 +00:00
co-authored by XavierDo Antony Lesuisse
parent 89f20d2c29
commit 7d85ab1eac
8 changed files with 188 additions and 216 deletions
+1 -2
View File
@@ -6,7 +6,6 @@ import base64
from odoo import http, _
from odoo.http import request
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.addons.web.controllers.main import binary_content
class LivechatController(http.Controller):
@@ -22,7 +21,7 @@ class LivechatController(http.Controller):
if isinstance(mock_attachment, list): # suppose that CSS asset will not required to be split in pages
mock_attachment = mock_attachment[0]
# can't use /web/content directly because we don't have attachment ids (attachments must be created)
status, headers, content = binary_content(id=mock_attachment.id, unique=asset.checksum)
status, headers, content = request.env['ir.http'].binary_content(id=mock_attachment.id, unique=asset.checksum)
content_base64 = base64.b64decode(content) if content else ''
headers.append(('Content-Length', len(content_base64)))
return request.make_response(content_base64, headers)
+2 -2
View File
@@ -9,7 +9,6 @@ import werkzeug
from werkzeug import url_encode
from odoo import api, http, registry, SUPERUSER_ID, _
from odoo.addons.web.controllers.main import binary_content
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.tools import consteq
@@ -216,7 +215,8 @@ class MailController(http.Controller):
# if the current user has access to the document, get the partner avatar as sudo()
request.env[res_model].browse(res_id).check_access_rule('read')
if partner_id in request.env[res_model].browse(res_id).sudo().exists().message_ids.mapped('author_id').ids:
status, headers, _content = binary_content(model='res.partner', id=partner_id, field='image_medium', default_mimetype='image/png', env=request.env(user=SUPERUSER_ID))
status, headers, _content = request.env['ir.http'].sudo().binary_content(
model='res.partner', id=partner_id, field='image_medium', default_mimetype='image/png')
# binary content return an empty string and not a placeholder if obj[field] is False
if _content != '':
content = _content
+16 -51
View File
@@ -36,7 +36,7 @@ import odoo
import odoo.modules.registry
from odoo.api import call_kw, Environment
from odoo.modules import get_resource_path
from odoo.tools import crop_image, topological_sort, html_escape, pycompat
from odoo.tools import limited_image_resize, 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
@@ -424,16 +424,6 @@ def xml2json_from_elementtree(el, preserve_whitespaces=False):
res["children"] = kids
return res
def binary_content(xmlid=None, model='ir.attachment', id=None, field='datas', unique=False,
filename=None, filename_field='datas_fname', download=False, mimetype=None,
default_mimetype='application/octet-stream', related_id=None, access_mode=None, access_token=None,
env=None):
return request.registry['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=default_mimetype, related_id=related_id, access_mode=access_mode, access_token=access_token,
env=env)
#----------------------------------------------------------
# Odoo Web web Controllers
#----------------------------------------------------------
@@ -1029,18 +1019,14 @@ class Binary(http.Controller):
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
filename=None, filename_field='datas_fname', unique=None, mimetype=None,
download=None, data=None, token=None, access_token=None, related_id=None, access_mode=None,
**kw):
status, headers, content = binary_content(
download=None, data=None, token=None, access_token=None, **kw):
status, headers, content = 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,
access_token=access_token, related_id=related_id, access_mode=access_mode)
if status == 304:
response = werkzeug.wrappers.Response(status=status, headers=headers)
elif status == 301:
return werkzeug.utils.redirect(content, code=301)
elif status != 200:
response = request.not_found()
filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token)
if status != 200:
return request.env['ir.http']._response_by_status(status, headers, content)
else:
content_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(content_base64)))
@@ -1068,39 +1054,18 @@ class Binary(http.Controller):
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], 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, related_id=None, access_mode=None,
access_token=None, avoid_if_small=False, upper_limit=False, signature=False):
status, headers, content = binary_content(
download=None, width=0, height=0, crop=False, access_token=None, avoid_if_small=False,
upper_limit=False, **kw):
status, headers, content = 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', related_id=related_id, access_mode=access_mode, access_token=access_token)
if status == 304:
return werkzeug.wrappers.Response(status=304, headers=headers)
elif status == 301:
return werkzeug.utils.redirect(content, code=301)
elif status != 200 and download:
return request.not_found()
default_mimetype='image/png', access_token=access_token)
if headers and dict(headers).get('Content-Type', '') == 'image/svg+xml': # we shan't resize svg images
height = 0
width = 0
else:
height = int(height or 0)
width = int(width or 0)
if status != 200 and download:
return request.env['ir.http']._response_by_status(status, headers, content)
if crop and (width or height):
content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
elif content and (width or height):
if not upper_limit:
# resize maximum 500*500
if width > 500:
width = 500
if height > 500:
height = 500
content = odoo.tools.image_resize_image(base64_source=content, size=(width or None, height or None),
encoding='base64', upper_limit=upper_limit,
avoid_if_small=avoid_if_small)
content = limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
if content:
image_base64 = base64.b64decode(content)
+9 -12
View File
@@ -237,25 +237,22 @@ class Http(models.AbstractModel):
html = request.env['ir.ui.view'].render_template('website.http_error', values)
return werkzeug.wrappers.Response(html, status=code, content_type='text/html;charset=utf-8')
@classmethod
def binary_content(cls, xmlid=None, model='ir.attachment', id=None, field='datas',
def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
unique=False, filename=None, filename_field='datas_fname', download=False,
mimetype=None, default_mimetype='application/octet-stream',
access_token=None, related_id=None, access_mode=None, env=None):
env = env or request.env
access_token=None):
obj = None
if xmlid:
obj = cls._xmlid_to_obj(env, xmlid)
elif id and model in env:
obj = env[model].browse(int(id))
obj = self._xmlid_to_obj(self.env, xmlid)
elif id and model in self.env:
obj = self.env[model].browse(int(id))
if obj and 'website_published' in obj._fields:
if env[obj._name].sudo().search([('id', '=', obj.id), ('website_published', '=', True)]):
env = env(user=SUPERUSER_ID)
return super(Http, cls).binary_content(
if self.env[obj._name].sudo().search([('id', '=', obj.id), ('website_published', '=', True)]):
self.sudo()
return super(Http, self).binary_content(
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype,
default_mimetype=default_mimetype, access_token=access_token, related_id=related_id,
access_mode=access_mode, env=env)
default_mimetype=default_mimetype, access_token=access_token)
@classmethod
def _xmlid_to_obj(cls, env, xmlid):
+2 -2
View File
@@ -12,7 +12,6 @@ from datetime import datetime
from odoo import http, modules, SUPERUSER_ID, tools, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.addons.web.controllers.main import binary_content
from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.http import request
@@ -591,7 +590,8 @@ class WebsiteForum(http.Controller):
@http.route(['/forum/user/<int:user_id>/avatar'], type='http', auth="public", website=True, sitemap=False)
def user_avatar(self, user_id=0, **post):
status, headers, content = binary_content(model='res.users', id=user_id, field='image_medium', default_mimetype='image/png', env=request.env(user=SUPERUSER_ID))
status, headers, content = request.env['ir.http'].sudo().binary_content(
model='res.users', id=user_id, field='image_medium', default_mimetype='image/png')
if not content:
img_path = modules.get_module_resource('web', 'static/src/img', 'placeholder.png')
+131 -136
View File
@@ -74,6 +74,10 @@ class IrHttp(models.AbstractModel):
_name = 'ir.http'
_description = "HTTP Routing"
#------------------------------------------------------
# Routing map
#------------------------------------------------------
@classmethod
def _get_converters(cls):
return {'model': ModelConverter, 'models': ModelsConverter, 'int': SignedIntConverter}
@@ -236,41 +240,124 @@ class IrHttp(models.AbstractModel):
if hasattr(cls, '_routing_map'):
del cls._routing_map
@classmethod
def content_disposition(cls, filename):
return content_disposition(filename)
#------------------------------------------------------
# Binary server
#------------------------------------------------------
@classmethod
def _xmlid_to_obj(cls, env, xmlid):
return env.ref(xmlid, False)
@classmethod
def _check_access_mode(cls, env, id, access_mode, model, access_token=None, related_id=None):
"""
Implemented by each module to define an additional way to check access.
def _get_record_and_check(self, xmlid=None, model=None, id=None, field='datas', access_token=None):
# get object and content
record = None
if xmlid:
record = self._xmlid_to_obj(self.env, xmlid)
elif id and model in self.env:
record = self.env[model].browse(int(id))
:param env: the env of binary_content
:param id: id of the record from which to fetch the binary
:param access_mode: typically a string that describes the behaviour of the custom check
:param model: the model of the object for which binary_content was called
:param related_id: optional id to check security.
:return: True if the test passes, else False.
"""
return False
# obj exists
if not record or not record.exists() or field not in record:
return None, 404
# access token grant access
if model == 'ir.attachment' and access_token:
record = record.sudo()
if not consteq(record.access_token or '', access_token):
return None, 403
# check read access
try:
last_update = record['__last_update']
except AccessError:
return None, 403
return record, 200
@classmethod
def _get_special_models(cls):
"""
:return: the set of models that have to pass through several specific fields checks:
used for the 'url' and the 'access_token' fields.
"""
return {'ir.attachment'}
def _binary_ir_attachment_redirect_content(cls, record, default_mimetype='application/octet-stream'):
# mainly used for theme images attachemnts
status = content = filename = mimetype = filehash = None
if record.type == 'url' and record.url:
# if url in in the form /somehint server locally
url_match = re.match("^/(\w+)/(.+)$", record.url)
if url_match:
module = url_match.group(1)
module_path = get_module_path(module)
module_resource_path = get_resource_path(module, url_match.group(2))
@classmethod
def binary_content(cls, xmlid=None, model='ir.attachment', id=None, field='datas',
if module_path and module_resource_path:
module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
module_resource_path = os.path.normpath(module_resource_path)
if module_resource_path.startswith(module_path):
with open(module_resource_path, 'rb') as f:
content = base64.b64encode(f.read())
status = 200
filename = os.path.basename(module_resource_path)
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
else:
status = 301
content = record.url
return status, content, filename, mimetype, filehash
def _binary_record_content(
self, record, field='datas', filename=None,
filename_field='datas_fname', default_mimetype='application/octet-stream'):
model = record._name
mimetype = 'mimetype' in record and record.mimetype or False
content = None
filehash = 'checksum' in record and record['checksum'] or False
field_def = record._fields[field]
if field_def.type == 'binary' and field_def.attachment:
field_attachment = self.env['ir.attachment'].search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['datas', 'mimetype', 'checksum'], limit=1)
if field_attachment:
mimetype = field_attachment[0]['mimetype']
content = field_attachment[0]['datas']
filehash = field_attachment[0]['checksum']
if not content:
content = record[field] or ''
# filename
if not filename:
if filename_field in record:
filename = record[filename_field]
else:
filename = "%s-%s-%s" % (record._name, record.id, field)
if not mimetype:
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
if not filehash:
filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
status = 200 if content else 404
return status, content, filename, mimetype, filehash
def _binary_set_headers(self, status, content, filename, mimetype, unique, filehash=None, download=False):
headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff')]
# cache
etag = bool(request) and request.httprequest.headers.get('If-None-Match')
status = status or (304 if filehash and etag == filehash else 200)
if filehash:
headers.append(('ETag', filehash))
headers.append(('Cache-Control', 'max-age=%s' % (STATIC_CACHE if unique else 0)))
# content-disposition default name
if download:
headers.append(('Content-Disposition', content_disposition(filename)))
return (status, headers, content)
def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
unique=False, filename=None, filename_field='datas_fname', download=False,
mimetype=None, default_mimetype='application/octet-stream',
access_token=None, related_id=None, access_mode=None, env=None):
access_token=None):
""" Get file, attachment or downloadable content
If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the
@@ -286,126 +373,34 @@ class IrHttp(models.AbstractModel):
:param str filename_field: if not create an filename with model-id-field
:param bool download: apply headers to download the file
:param str mimetype: mintype of the field (for headers)
:param related_id: the id of another record used for custom_check
:param access_mode: if truthy, will call custom_check to fetch the object that contains the binary.
:param str default_mimetype: default mintype if no mintype found
:param str access_token: optional token for unauthenticated access
only available for ir.attachment
:param Environment env: by default use request.env
:returns: (status, headers, content)
"""
env = env or request.env
attachment_models = cls._get_special_models()
# get object and content
obj = None
if xmlid:
obj = cls._xmlid_to_obj(env, xmlid)
elif id and model in env.registry:
obj = env[model].browse(int(id))
# obj exists
if not obj or not obj.exists() or field not in obj:
return (404, [], None)
record, status = self._get_record_and_check(xmlid=xmlid, model=model, id=id, field=field, access_token=access_token)
# access token grant access
if model in attachment_models and access_token:
obj = obj.sudo()
if access_mode:
if not cls._check_access_mode(env, id, access_mode, model, access_token=access_token,
related_id=related_id):
return (403, [], None)
elif not consteq(obj.access_token or u'', access_token):
return (403, [], None)
if not record:
return (status or 404, [], None)
# check read access
try:
last_update = obj['__last_update']
except AccessError:
return (403, [], None)
content, headers, status = None, [], None
status, headers, content = None, [], None
if record._name == 'ir.attachment':
status, content, filename, mimetype, filehash = self._binary_ir_attachment_redirect_content(record, default_mimetype=default_mimetype)
if not content:
status, content, filename, mimetype, filehash = self._binary_record_content(
record, field=field, filename=None, filename_field=filename_field,
default_mimetype='application/octet-stream')
# attachment by url check
module_resource_path = None
if model in attachment_models and obj.type == 'url' and obj.url:
url_match = re.match("^/(\w+)/(.+)$", obj.url)
if url_match:
module = url_match.group(1)
module_path = get_module_path(module)
module_resource_path = get_resource_path(module, url_match.group(2))
if module_path and module_resource_path:
module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
module_resource_path = os.path.normpath(module_resource_path)
if module_resource_path.startswith(module_path):
with open(module_resource_path, 'rb') as f:
content = base64.b64encode(f.read())
last_update = str(os.path.getmtime(module_resource_path))
status, headers, content = self._binary_set_headers(
status, content, filename, mimetype, unique, filehash=False, download=download)
if not module_resource_path:
module_resource_path = obj.url
return status, headers, content
if not content:
status = 301
content = module_resource_path
else:
content = obj[field] or ''
# filename
if not filename:
if filename_field in obj:
filename = obj[filename_field]
elif module_resource_path:
filename = os.path.basename(module_resource_path)
else:
filename = "%s-%s-%s" % (obj._name, obj.id, field)
# mimetype
mimetype = 'mimetype' in obj and obj.mimetype or False
if not mimetype:
if filename:
mimetype = mimetypes.guess_type(filename)[0]
if not mimetype and getattr(env[model]._fields[field], 'attachment', False):
# for binary fields, fetch the ir_attachement for mimetype check
attach_mimetype = env['ir.attachment'].search_read(domain=[('res_model', '=', model), ('res_id', '=', id), ('res_field', '=', field)], fields=['mimetype'], limit=1)
mimetype = attach_mimetype and attach_mimetype[0]['mimetype']
if not mimetype:
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
headers += [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff')]
# cache
etag = bool(request) and request.httprequest.headers.get('If-None-Match')
retag = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
status = status or (304 if etag == retag else 200)
headers.append(('ETag', retag))
headers.append(('Cache-Control', 'max-age=%s' % (STATIC_CACHE if unique else 0)))
# content-disposition default name
if download:
headers.append(('Content-Disposition', cls.content_disposition(filename)))
return (status, headers, content)
def convert_exception_to(to_type, with_message=False):
""" Should only be called from an exception handler. Fetches the current
exception data from sys.exc_info() and creates a new exception of type
``to_type`` with the original traceback.
If ``with_message`` is ``True``, sets the new exception's message to be
the stringification of the original exception. If ``False``, does not
set the new exception's message. Otherwise, uses ``with_message`` as the
new exception's message.
:type with_message: str|bool
"""
etype, original, tb = sys.exc_info()
try:
if with_message is False:
message = None
elif with_message is True:
message = str(original)
else:
message = str(with_message)
raise pycompat.reraise(to_type, to_type(message), tb)
except to_type as e:
return e
def _response_by_status(self, status, headers, content):
if status == 304:
return werkzeug.wrappers.Response(status=status, headers=headers)
elif status == 301:
return werkzeug.utils.redirect(content, code=301)
elif status != 200:
return request.not_found()
+1 -6
View File
@@ -19,7 +19,6 @@ class test_ir_http_mimetype(common.TransactionCase):
id=attachment.id,
mimetype=None,
default_mimetype='application/octet-stream',
env=self.env
)
mimetype = dict(headers).get('Content-Type')
self.assertEqual(mimetype, 'image/gif')
@@ -35,7 +34,6 @@ class test_ir_http_mimetype(common.TransactionCase):
id=attachment.id,
mimetype=None,
default_mimetype='application/octet-stream',
env=self.env
)
mimetype = dict(headers).get('Content-Type')
# TODO: fix and change it in master, should be image/gif
@@ -53,7 +51,6 @@ class test_ir_http_mimetype(common.TransactionCase):
id=partner.id,
field='image',
default_mimetype='application/octet-stream',
env=self.env
)
mimetype = dict(headers).get('Content-Type')
self.assertEqual(mimetype, 'image/gif')
@@ -75,7 +72,6 @@ class test_ir_http_mimetype(common.TransactionCase):
id=prop.id,
field='value_binary',
default_mimetype='application/octet-stream',
env=self.env
)
mimetype = dict(headers).get('Content-Type')
self.assertEqual(mimetype, 'image/gif')
@@ -92,11 +88,10 @@ class test_ir_http_mimetype(common.TransactionCase):
defaults = {
'id': attachment.id,
'default_mimetype': 'image/gif',
'env': public_user.sudo(public_user.id).env,
}
def test_access(**kwargs):
status, _, _ = self.env['ir.http'].binary_content(
status, _, _ = self.env['ir.http'].sudo(public_user.id).binary_content(
**dict(defaults, **kwargs)
)
return status
+26 -5
View File
@@ -75,11 +75,9 @@ def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', file
asked_width, asked_height = size
if upper_limit:
if asked_width:
if asked_width >= image.size[0]:
asked_width = image.size[0]
asked_width = min(asked_width, image.size[0])
if asked_height:
if asked_height >= image.size[1]:
asked_height = image.size[1]
asked_height = min(asked_height, image.size[1])
if image.size[0] >= image.size[1]:
asked_height = None
@@ -187,7 +185,7 @@ def image_resize_image_small(base64_source, size=(64, 64), encoding='base64', fi
# ----------------------------------------
# Crop Image
# ----------------------------------------
def crop_image(data, type='top', ratio=False, size=None, image_format=None):
def crop_image(data, type='top', ratio=None, size=None, image_format=None):
""" Used for cropping image and create thumbnail
:param data: base64 data of image.
:param type: Used for cropping position possible
@@ -319,6 +317,29 @@ def image_resize_images(vals, big_name='image', medium_name='image_medium', smal
elif big_name in vals or medium_name in vals or small_name in vals:
vals[big_name] = vals[medium_name] = vals[small_name] = False
def limited_image_resize(content, width=None, height=None, crop=False, upper_limit=False, avoid_if_small=False):
"""
:param content: bytes (should be an image)
"""
if content:
signatures = [b'\xFF\xD8\xFF', b'\x89PNG\r\n\x1A\n']
decoded_content = base64.b64decode(content)
is_image = any(decoded_content.startswith(signature) for signature in signatures)
if (width or height) and is_image:
height = int(height or 0)
width = int(width or 0)
if crop:
return crop_image(content, type='center', size=(width, height), ratio=(1, 1))
else:
if not upper_limit:
# resize maximum 500*500
width = min(width, 500)
height = min(height, 500)
return image_resize_image(
base64_source=content, size=(width or None, height or None), encoding='base64', upper_limit=upper_limit,
avoid_if_small=avoid_if_small)
return content
def image_data_uri(base64_source):
"""This returns data URL scheme according RFC 2397
(https://tools.ietf.org/html/rfc2397) for all kind of supported images