+
diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml
index fd5867e0648..36899afcdb2 100644
--- a/addons/mail/views/mail_templates.xml
+++ b/addons/mail/views/mail_templates.xml
@@ -76,6 +76,7 @@
+
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index 9ce8b1761ef..a94ef8b9b76 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -424,11 +424,13 @@ def xml2json_from_elementtree(el, preserve_whitespaces=False):
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', access_token=None, env=None):
+ default_mimetype='application/octet-stream', share_id=None, share_token=None, access_token=None,
+ force_ext=False, 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, access_token=access_token, env=env)
+ default_mimetype=default_mimetype, share_id=share_id, share_token=share_token, access_token=access_token,
+ force_ext=force_ext, env=env)
#----------------------------------------------------------
# Odoo Web web Controllers
@@ -1004,11 +1006,12 @@ class Binary(http.Controller):
'/web/content/
///'], 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, **kw):
+ download=None, data=None, token=None, share_id=None, share_token=None, access_token=None,
+ force_ext=False, **kw):
status, headers, content = 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)
+ access_token=access_token, share_id=share_id, share_token=share_token, force_ext=force_ext)
if status == 304:
response = werkzeug.wrappers.Response(status=status, headers=headers)
elif status == 301:
@@ -1042,11 +1045,12 @@ 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):
+ download=None, width=0, height=0, crop=False, share_id=None, share_token=None, access_token=None, avoid_if_small=False,
+ upper_limit=False):
status, headers, content = 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)
+ default_mimetype='image/png', share_id=share_id, share_token=share_token, access_token=access_token)
if status == 304:
return werkzeug.wrappers.Response(status=304, headers=headers)
elif status == 301:
@@ -1065,12 +1069,15 @@ class Binary(http.Controller):
content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
elif content and (width or height):
- # 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', filetype='PNG', avoid_if_small=avoid_if_small)
+ 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', filetype='PNG', upper_limit=upper_limit,
+ avoid_if_small=avoid_if_small)
# resize force png as filetype
headers = self.force_contenttype(headers, contenttype='image/png')
diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss
index c00e5ab0d26..9096bcefdd5 100644
--- a/addons/web/static/src/scss/kanban_view.scss
+++ b/addons/web/static/src/scss/kanban_view.scss
@@ -225,8 +225,20 @@
}
.o_attachment_image > img {
- width: 100%;
- height: auto;
+ overflow: hidden;
+ padding-top: 75%;
+
+ .o_attachment_image {
+ @include o-position-absolute(0, 0, 0, 0);
+ background-size: cover;
+ background-position: center;
+ transform: scale(1.05);
+ transition: all 0.3s;
+ }
+
+ &:hover .o_attachment_image {
+ transform: scale(1);
+ }
}
.o_progressbar {
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index aa703071d9b..10250e2a756 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -235,7 +235,7 @@ class Http(models.AbstractModel):
def binary_content(cls, 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, env=None):
+ access_token=None, share_id=None, share_token=None, force_ext=False, env=None):
env = env or request.env
obj = None
if xmlid:
@@ -248,7 +248,8 @@ class Http(models.AbstractModel):
return super(Http, cls).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, env=env)
+ default_mimetype=default_mimetype, access_token=access_token, share_id=share_id, share_token=share_token,
+ force_ext=force_ext, env=env)
class ModelConverter(ModelConverter):
diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py
index 99f107bab96..62aa7fc82e6 100644
--- a/odoo/addons/base/models/ir_attachment.py
+++ b/odoo/addons/base/models/ir_attachment.py
@@ -6,14 +6,16 @@ import itertools
import logging
import mimetypes
import os
+import io
import re
from collections import defaultdict
import uuid
-from odoo import api, fields, models, tools, SUPERUSER_ID, _
+from odoo import api, fields, models, tools, SUPERUSER_ID, exceptions, _
from odoo.exceptions import AccessError, ValidationError
from odoo.tools import config, human_size, ustr, html_escape
from odoo.tools.mimetypes import guess_mimetype
+from odoo.tools import crop_image, image_resize_image
_logger = logging.getLogger(__name__)
@@ -42,6 +44,14 @@ class IrAttachment(models.Model):
record = self.env[attachment.res_model].browse(attachment.res_id)
attachment.res_name = record.display_name
+ @api.depends('res_model')
+ def _compute_res_model_name(self):
+ for record in self:
+ if record.res_model:
+ model = self.env['ir.model'].search([('model', '=', record.res_model)], limit=1)
+ if model:
+ record.res_model_name = model[0].name
+
@api.model
def _storage(self):
return self.env['ir.config_parameter'].sudo().get_param('ir_attachment.location', 'file')
@@ -272,15 +282,16 @@ class IrAttachment(models.Model):
"""
return ['base.group_system']
- name = fields.Char('Attachment Name', required=True)
- datas_fname = fields.Char('File Name')
+ name = fields.Char('Name', required=True)
+ datas_fname = fields.Char('Filename')
description = fields.Text('Description')
res_name = fields.Char('Resource Name', compute='_compute_res_name', store=True)
res_model = fields.Char('Resource Model', readonly=True, help="The database object this attachment will be attached to.")
+ res_model_name = fields.Char(compute='_compute_res_model_name', store=True, index=True)
res_field = fields.Char('Resource Field', readonly=True)
res_id = fields.Integer('Resource ID', readonly=True, help="The record id this is attached to.")
create_date = fields.Datetime('Date Created', readonly=True)
- create_uid = fields.Many2one('res.users', string='Owner', readonly=True)
+ create_uid = fields.Many2one('res.users', readonly=True)
company_id = fields.Many2one('res.company', string='Company', change_default=True,
default=lambda self: self.env['res.company']._company_default_get('ir.attachment'))
type = fields.Selection([('url', 'URL'), ('binary', 'File')],
@@ -300,6 +311,18 @@ class IrAttachment(models.Model):
checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True)
mimetype = fields.Char('Mime Type', readonly=True)
index_content = fields.Text('Indexed Content', readonly=True, prefetch=False)
+ active = fields.Boolean(default=True, string="Active", oldname='archived')
+ thumbnail = fields.Binary(compute='_get_thumbnail', store=True, string="")
+
+ @api.depends('mimetype', 'file_size', 'checksum')
+ def _get_thumbnail(self):
+ for record in self:
+ if record.mimetype:
+ if re.match('image.*(gif|jpeg|jpg|png)', record.mimetype):
+ if not record.thumbnail:
+ temp_image = crop_image(record.datas, type='center', size=(100, 100), ratio=(1, 1))
+ record.thumbnail = image_resize_image(base64_source=temp_image, size=(100, 100),
+ encoding='base64', filetype='PNG')
@api.model_cr_context
def _auto_init(self):
@@ -438,6 +461,8 @@ class IrAttachment(models.Model):
@api.multi
def unlink(self):
+ if not self:
+ return True
self.check('unlink')
# First delete in the database, *then* in the filesystem if the
@@ -472,3 +497,31 @@ class IrAttachment(models.Model):
@api.model
def action_get(self):
return self.env['ir.actions.act_window'].for_xml_id('base', 'action_attachment')
+
+ @api.model
+ def upload_attachment(self, attachments, custom_values=None):
+ """
+ directly uploads attachments.
+
+ :param attachments: array of dictionaries representing attachment data:
+ eg: [{'name': 'doc.zip', 'data': 'data:application/zip;base64,R0lGODdhAQBADs='}]
+ :param custom_values: additional values for the attachment create dictionary.
+ :return: the ids of the new attachments.
+ """
+ if not custom_values:
+ custom_values = {}
+ ids = []
+ for attachment in attachments:
+ data = attachment['data']
+ raw_name = attachment['name']
+ values = {
+ 'mimetype': data[data.find(':') + 1:data.find(';')],
+ 'name': raw_name[:raw_name.rfind('.')],
+ 'datas': data[data.find(',') + 1:],
+ 'datas_fname': raw_name,
+ }
+
+ values.update(custom_values)
+ created_attachment = self.env['ir.attachment'].create(values)
+ ids.append(created_attachment.id)
+ return ids
diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py
index eea8c78927a..cfc4967e9b4 100644
--- a/odoo/addons/base/models/ir_http.py
+++ b/odoo/addons/base/models/ir_http.py
@@ -23,6 +23,7 @@ from odoo.exceptions import AccessDenied, AccessError
from odoo.http import request, STATIC_CACHE, content_disposition
from odoo.tools import pycompat, consteq
from odoo.tools.mimetypes import guess_mimetype
+from ast import literal_eval
from odoo.modules.module import get_resource_path, get_module_path
_logger = logging.getLogger(__name__)
@@ -246,7 +247,7 @@ class IrHttp(models.AbstractModel):
def binary_content(cls, 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, env=None):
+ access_token=None, share_id=None, share_token=None, force_ext=False, env=None):
""" Get file, attachment or downloadable content
If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the
@@ -262,9 +263,13 @@ 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 share_id: the id of the documents.share that contains the attachment
+ :param share_token: the token of the documents.share that contains the attachment
: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 bool force_ext: if true, adds the extension to the filename
+ that corresponds to the mimetype
:param Environment env: by default use request.env
:returns: (status, headers, content)
"""
@@ -277,6 +282,24 @@ class IrHttp(models.AbstractModel):
obj = env[model].sudo().browse(int(id))
if not consteq(obj.access_token, access_token):
return (403, [], None)
+ elif id and share_id and share_token:
+ share = env['documents.share'].sudo().browse(int(share_id))
+ if share:
+ if share.state == 'expired':
+ return (403, [], None)
+ if not consteq(share.access_token, share_token):
+ return (403, [], None)
+ elif share.type == 'ids' and (id in share.attachment_ids.ids):
+ obj = env[model].sudo().browse(int(id))
+ elif share.type == 'domain':
+ obj = env[model].sudo().browse(int(id))
+ share_domain = []
+ if share.domain:
+ share_domain = literal_eval(share.domain)
+ domain = [['folder_id', '=', share.folder_id.id]] + share_domain
+ attachments_check = http.request.env['ir.attachment'].sudo().search(domain)
+ if obj not in attachments_check:
+ return (403, [], None)
elif id and model in env.registry:
obj = env[model].browse(int(id))
@@ -338,6 +361,14 @@ class IrHttp(models.AbstractModel):
if not mimetype:
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
+ if force_ext and (mimetype != default_mimetype):
+ dot_index = filename.rfind('.')
+ if dot_index > -1:
+ if mimetypes.guess_extension(mimetype) != filename[dot_index:]:
+ filename = filename[:dot_index] + mimetypes.guess_extension(mimetype)
+ else:
+ filename = filename + mimetypes.guess_extension(mimetype)
+
headers += [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff')]
# cache
diff --git a/odoo/addons/base/tests/test_ir_http.py b/odoo/addons/base/tests/test_ir_http.py
index 420a16004ce..00b32881cf6 100644
--- a/odoo/addons/base/tests/test_ir_http.py
+++ b/odoo/addons/base/tests/test_ir_http.py
@@ -79,3 +79,84 @@ class test_ir_http_mimetype(common.TransactionCase):
)
mimetype = dict(headers).get('Content-Type')
self.assertEqual(mimetype, 'image/gif')
+
+ def test_ir_http_binary_content_force_ext_for_wrong_ext(self):
+ """ Test force mimetype for wrong extension when renamed """
+ attachment = self.env['ir.attachment'].create({
+ 'datas': GIF,
+ 'name': 'Test mimetype gif',
+ 'datas_fname': 'file.gif'})
+
+ attachment.datas_fname = 'file.png'
+
+ status, headers, content = self.env['ir.http'].binary_content(
+ id=attachment.id,
+ mimetype=None,
+ default_mimetype='application/octet-stream',
+ env=self.env,
+ download=True,
+ force_ext=True,
+ )
+ disposition = dict(headers).get('Content-Disposition')
+ self.assertTrue(disposition.endswith('file.gif'))
+
+ def test_ir_http_binary_content_force_ext_for_right_ext(self):
+ """ Test force mimetype for right extension when renamed """
+ attachment = self.env['ir.attachment'].create({
+ 'datas': GIF,
+ 'name': 'Test mimetype gif',
+ 'datas_fname': 'file.gif'})
+
+ attachment.datas_fname = 'file.gif'
+
+ status, headers, content = self.env['ir.http'].binary_content(
+ id=attachment.id,
+ mimetype=None,
+ default_mimetype='application/octet-stream',
+ env=self.env,
+ download=True,
+ force_ext=True,
+ )
+ disposition = dict(headers).get('Content-Disposition')
+ self.assertTrue(disposition.endswith('file.gif'))
+
+ def test_ir_http_binary_content_force_ext_without_ext(self):
+ """ Test force mimetype for right extension when renamed whith filename without extension """
+ attachment = self.env['ir.attachment'].create({
+ 'datas': GIF,
+ 'name': 'Test mimetype gif',
+ 'datas_fname': 'file.gif'})
+
+ attachment.datas_fname = 'file'
+
+ status, headers, content = self.env['ir.http'].binary_content(
+ id=attachment.id,
+ mimetype=None,
+ default_mimetype='application/octet-stream',
+ env=self.env,
+ download=True,
+ force_ext=True,
+ )
+ disposition = dict(headers).get('Content-Disposition')
+ self.assertTrue(disposition.endswith('file.gif'))
+
+ def test_ir_http_binary_content_force_ext_for_binary_mimetype(self):
+ """ Test force mimetype for wrong extension when renamed and mimetyp is 'application/octet-stream' """
+ attachment = self.env['ir.attachment'].create({
+ 'datas': GIF,
+ 'name': 'Test mimetype exe',
+ 'datas_fname': 'file.exe',
+ })
+ attachment.mimetype = 'application/octet-stream'
+ attachment.datas_fname = 'file.txt'
+
+ status, headers, content = self.env['ir.http'].binary_content(
+ id=attachment.id,
+ mimetype=None,
+ default_mimetype='application/octet-stream',
+ env=self.env,
+ download=True,
+ force_ext=True,
+ )
+ disposition = dict(headers).get('Content-Disposition')
+ self.assertTrue(disposition.endswith('file.txt'))
diff --git a/odoo/tools/image.py b/odoo/tools/image.py
index 7bfdbb98e3b..5a7294bf07e 100644
--- a/odoo/tools/image.py
+++ b/odoo/tools/image.py
@@ -18,7 +18,7 @@ Image._initialized = 2
# Image resizing
# ----------------------------------------
-def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', filetype=None, avoid_if_small=False):
+def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', filetype=None, avoid_if_small=False, upper_limit=False):
""" Function to resize an image. The image will be resized to the given
size, while keeping the aspect ratios, and holes in the image will be
filled with transparent background. The image will not be stretched if
@@ -63,18 +63,32 @@ def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', file
}.get(filetype, filetype)
asked_width, asked_height = size
+ if upper_limit:
+ if asked_width:
+ if asked_width >= image.size[0]:
+ asked_width = image.size[0]
+ if asked_height:
+ if asked_height >= image.size[1]:
+ asked_height = image.size[1]
+
+ if image.size[0] >= image.size[1]:
+ asked_height = None
+ else:
+ asked_width = None
+ if asked_width is None and asked_height is None:
+ return base64_source
+
if asked_width is None:
asked_width = int(image.size[0] * (float(asked_height) / image.size[1]))
if asked_height is None:
asked_height = int(image.size[1] * (float(asked_width) / image.size[0]))
size = asked_width, asked_height
-
# check image size: do not create a thumbnail if avoiding smaller images
if avoid_if_small and image.size[0] <= size[0] and image.size[1] <= size[1]:
return base64_source
if image.size != size:
- image = image_resize_and_sharpen(image, size)
+ image = image_resize_and_sharpen(image, size, upper_limit=upper_limit)
if image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (filetype == 'JPEG' and image.mode == 'RGBA'):
image = image.convert("RGB")
@@ -82,7 +96,7 @@ def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', file
image.save(background_stream, filetype)
return codecs.encode(background_stream.getvalue(), encoding)
-def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0):
+def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0, upper_limit=False):
"""
Create a thumbnail by resizing while keeping ratio.
A sharpen filter is applied for a better looking result.
@@ -101,8 +115,12 @@ def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.
sharpener = ImageEnhance.Sharpness(image)
resized_image = sharpener.enhance(factor)
# create a transparent image for background and paste the image on it
- image = Image.new('RGBA', size, (255, 255, 255, 0))
+ if upper_limit:
+ image = Image.new('RGBA', (size[0], size[1]-3), (255, 255, 255, 0)) # FIXME temporary fix for trimming the ghost border.
+ else:
+ image = Image.new('RGBA', size, (255, 255, 255, 0))
image.paste(resized_image, ((size[0] - resized_image.size[0]) // 2, (size[1] - resized_image.size[1]) // 2))
+
if image.mode != origin_mode:
image = image.convert(origin_mode)
return image