diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 07ec041050b..e4b005e1469 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -964,6 +964,8 @@ class Message(models.Model): def unlink(self): # cascade-delete attachments that are directly attached to the message (should only happen # for mail.messages that act as parent for a standalone mail.mail record). + if not self: + return True self.check_access_rule('unlink') self.mapped('attachment_ids').filtered( lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0) diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index cec45f30bec..de36e854077 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -320,6 +320,8 @@ class MailThread(models.AbstractModel): def unlink(self): """ Override unlink to delete messages and followers. This cannot be cascaded, because link is done through (res_model, res_id). """ + if not self: + return True self.env['mail.message'].search([('model', '=', self._name), ('res_id', 'in', self.ids)]).unlink() res = super(MailThread, self).unlink() self.env['mail.followers'].sudo().search( diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js index 3aa7e25c399..fc8f9940d81 100644 --- a/addons/mail/static/src/js/document_viewer.js +++ b/addons/mail/static/src/js/document_viewer.js @@ -43,7 +43,7 @@ var DocumentViewer = Widget.extend({ init: function (parent, attachments, activeAttachmentID) { this._super.apply(this, arguments); this.attachment = _.filter(attachments, function (attachment) { - var match = attachment.mimetype.match("(image|video|application/pdf)"); + var match = attachment.mimetype.match("(image|video|application/pdf|text)"); if (match) { attachment.type = match[1]; @@ -116,7 +116,7 @@ var DocumentViewer = Widget.extend({ * @param {float} angle */ _getTransform: function(scale, angle) { - return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)' + return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)'; }, /** * Rotate image clockwise by provided angle diff --git a/addons/mail/static/src/scss/kanban_view.scss b/addons/mail/static/src/scss/kanban_view.scss new file mode 100644 index 00000000000..bd9f92ffec5 --- /dev/null +++ b/addons/mail/static/src/scss/kanban_view.scss @@ -0,0 +1,65 @@ +$o-kanban-attachement-image-size: 80px; + +.o_kanban_view { + align-items: flex-start; + + .o_kanban_record.o_kanban_attachment { + padding: 0; + + .o_kanban_image { + width: $o-kanban-attachement-image-size; + + + div { + padding-left: $o-kanban-attachement-image-size + $o-kanban-inside-hgutter; + @include media-breakpoint-down(sm) { + padding-left: $o-kanban-attachement-image-size + $o-kanban-inside-hgutter-mobile; + } + } + + .o_kanban_image_wrapper { + min-height: $o-kanban-attachement-image-size; + display: flex; + align-items: center; + justify-content: center; + } + + .o_attachment_image { + @include size($o-kanban-attachement-image-size); + } + + .o_image { + @include size($o-kanban-attachement-image-size*0.7); + } + } + + .o_kanban_details { + .o_kanban_details_wrapper { + display: flex; + flex-direction: column; + min-height: $o-kanban-attachement-image-size; + padding: $o-kanban-inside-vgutter $o-kanban-inside-hgutter; + + .o_kanban_record_title { + margin-bottom: $o-kanban-inside-vgutter*0.5; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 95%; + } + + .o_kanban_record_body { + flex: 1 1 auto; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font-size: smaller; + } + + .oe_kanban_avatar { + border-radius: 4px; + border: 1px solid $component-active-color; + } + } + } + } +} diff --git a/addons/mail/views/mail_message_views.xml b/addons/mail/views/mail_message_views.xml index 6993ca81e0a..56f7b6871c1 100644 --- a/addons/mail/views/mail_message_views.xml +++ b/addons/mail/views/mail_message_views.xml @@ -127,7 +127,7 @@ tree,form - + @@ -140,23 +140,54 @@ + + + + + + + -
-
-
-
- +
+
+
+ + +
+
+
+ + + + + +
-
- - -
-
- Download -
-
+
+ + + + : + + + + + + +
+
+ +
+ +
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