[IMP] base,mail,web: attachments adaptation for documents
This commit is the counter-part of an enteprise commit introducing
the Documents app.
Here is a summary of what has been done:
- tweak unlink of ir_attachment to prevent unlink recursivity
(when attachments are attached to ir_attachments)
- improve ir_attachment kanban view
- add several arguments to binary_content controller:
- 'force_ext': to force the extension in the filename, base on
mimetype
- 'share_token' and 'share_id': to autorize download from a
share link
- add 'thumbnail' field on ir_attachment to optimize Kanban view
- add 'upper_limit' argument to image to allow to bypass the
500*500 size limit
- DocumentViewer now handles text files
More information available on task 1853490
Co-authored-by: Pierre Paridans <app@odoo.com>
Co-authored-by: sri-odoo <sri@odoo.com>
Co-authored-by: ThanhDodeurOdoo <tso@odoo.com>
This commit is contained in:
committed by
Aaron Bohy
co-authored by
Pierre Paridans
sri-odoo
parent
ad2e2cc8b5
commit
60aa9ffd01
@@ -964,6 +964,8 @@ class Message(models.Model):
|
|||||||
def unlink(self):
|
def unlink(self):
|
||||||
# cascade-delete attachments that are directly attached to the message (should only happen
|
# 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).
|
# for mail.messages that act as parent for a standalone mail.mail record).
|
||||||
|
if not self:
|
||||||
|
return True
|
||||||
self.check_access_rule('unlink')
|
self.check_access_rule('unlink')
|
||||||
self.mapped('attachment_ids').filtered(
|
self.mapped('attachment_ids').filtered(
|
||||||
lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0)
|
lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0)
|
||||||
|
|||||||
@@ -320,6 +320,8 @@ class MailThread(models.AbstractModel):
|
|||||||
def unlink(self):
|
def unlink(self):
|
||||||
""" Override unlink to delete messages and followers. This cannot be
|
""" Override unlink to delete messages and followers. This cannot be
|
||||||
cascaded, because link is done through (res_model, res_id). """
|
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()
|
self.env['mail.message'].search([('model', '=', self._name), ('res_id', 'in', self.ids)]).unlink()
|
||||||
res = super(MailThread, self).unlink()
|
res = super(MailThread, self).unlink()
|
||||||
self.env['mail.followers'].sudo().search(
|
self.env['mail.followers'].sudo().search(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ var DocumentViewer = Widget.extend({
|
|||||||
init: function (parent, attachments, activeAttachmentID) {
|
init: function (parent, attachments, activeAttachmentID) {
|
||||||
this._super.apply(this, arguments);
|
this._super.apply(this, arguments);
|
||||||
this.attachment = _.filter(attachments, function (attachment) {
|
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) {
|
if (match) {
|
||||||
attachment.type = match[1];
|
attachment.type = match[1];
|
||||||
@@ -116,7 +116,7 @@ var DocumentViewer = Widget.extend({
|
|||||||
* @param {float} angle
|
* @param {float} angle
|
||||||
*/
|
*/
|
||||||
_getTransform: function(scale, 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
|
* Rotate image clockwise by provided angle
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
<field name="view_mode">tree,form</field>
|
<field name="view_mode">tree,form</field>
|
||||||
<field name="search_view_id" ref="view_message_search"/>
|
<field name="search_view_id" ref="view_message_search"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Add menu entry in Settings/Email -->
|
<!-- Add menu entry in Settings/Email -->
|
||||||
<menuitem name="Messages" id="menu_mail_message" parent="base.menu_email" action="action_view_mail_message"/>
|
<menuitem name="Messages" id="menu_mail_message" parent="base.menu_email" action="action_view_mail_message"/>
|
||||||
|
|
||||||
@@ -140,23 +140,54 @@
|
|||||||
<field name="mimetype"/>
|
<field name="mimetype"/>
|
||||||
<field name="type"/>
|
<field name="type"/>
|
||||||
<field name="datas_fname"/>
|
<field name="datas_fname"/>
|
||||||
|
<field name="create_uid"/>
|
||||||
|
<field name="url"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
<field name="active"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
|
<field name="res_model_name"/>
|
||||||
|
<field name="res_name"/>
|
||||||
<templates>
|
<templates>
|
||||||
<t t-name="kanban-box">
|
<t t-name="kanban-box">
|
||||||
<div class="oe_kanban_global_click">
|
<div class="oe_kanban_global_area o_kanban_attachment">
|
||||||
<div class="oe_kanban_vignette">
|
<div class="o_kanban_image">
|
||||||
<div class="oe_attachment">
|
<div class="o_kanban_image_wrapper">
|
||||||
<div class="oe_name">
|
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png)').test(record.mimetype.value)"/>
|
||||||
<field name="name"/>
|
<div t-if="record.type.raw_value == 'url'" class="o_url_image fa fa-link fa-3x text-muted"/>
|
||||||
|
<img t-elif="webimage" t-attf-src="/web/image/#{record.id.raw_value}?field=thumbnail" width="100" height="100" alt="Document" class="o_attachment_image"/>
|
||||||
|
<div t-else="!webimage" class="o_image o_image_thumbnail" t-att-data-mimetype="record.mimetype.value"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_details">
|
||||||
|
<div class="o_kanban_details_wrapper">
|
||||||
|
<div t-att-title="record.datas_fname.raw_value" class="o_kanban_record_title">
|
||||||
|
<t t-if="record.type.raw_value == 'url'">
|
||||||
|
<field name="name" class="o_text_overflow"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<field name="datas_fname" class="o_text_overflow"/>
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div t-if="record.type.raw_value == 'binary'">
|
<div class="o_kanban_record_body">
|
||||||
<a t-attf-href="/web/content/#{record.id.raw_value}?download=1">
|
<t t-if="record.res_model_name.raw_value">
|
||||||
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png)').test(record.mimetype.value)"/>
|
<strong><field name="res_model_name"/></strong>
|
||||||
<div t-if="!webimage" class="o_image" t-att-data-mimetype="record.mimetype.value"/>
|
<t t-if="record.res_name.raw_value">
|
||||||
<div t-if="webimage" class="o_attachment_image">
|
<span t-att-title="record.res_name.raw_value">: <field name="res_name"/></span>
|
||||||
<img t-attf-src="/web/image/#{record.id.raw_value}?download=1" alt="Download"/>
|
</t>
|
||||||
</div>
|
</t>
|
||||||
</a>
|
<t t-elif="record.type.raw_value == 'url'">
|
||||||
|
<span class="o_document_url"><i class="fa fa-globe"/> <field name="url" widget="url"/></span>
|
||||||
|
</t>
|
||||||
|
<samp t-else="" class="text-muted"> </samp>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom">
|
||||||
|
<time class="oe_kanban_bottom_left">
|
||||||
|
<field name="create_date" widget="date"/>
|
||||||
|
</time>
|
||||||
|
<div class="oe_kanban_bottom_right">
|
||||||
|
<img t-att-src="kanban_image('res.users', 'image_small', record.create_uid.raw_value)" t-att-data-member_id="record.create_uid.raw_value" t-att-alt="record.create_uid.raw_value" width="32" height="32" class="oe_kanban_avatar"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/systray.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/systray.scss"/>
|
||||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/mail_activity.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/mail_activity.scss"/>
|
||||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/activity_view.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/activity_view.scss"/>
|
||||||
|
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/kanban_view.scss"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
def binary_content(xmlid=None, model='ir.attachment', id=None, field='datas', unique=False,
|
||||||
filename=None, filename_field='datas_fname', download=False, mimetype=None,
|
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(
|
return request.registry['ir.http'].binary_content(
|
||||||
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
||||||
filename_field=filename_field, download=download, mimetype=mimetype,
|
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
|
# Odoo Web web Controllers
|
||||||
@@ -1004,11 +1006,12 @@ class Binary(http.Controller):
|
|||||||
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
|
'/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',
|
def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
|
||||||
filename=None, filename_field='datas_fname', unique=None, mimetype=None,
|
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(
|
status, headers, content = binary_content(
|
||||||
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
||||||
filename_field=filename_field, download=download, mimetype=mimetype,
|
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:
|
if status == 304:
|
||||||
response = werkzeug.wrappers.Response(status=status, headers=headers)
|
response = werkzeug.wrappers.Response(status=status, headers=headers)
|
||||||
elif status == 301:
|
elif status == 301:
|
||||||
@@ -1042,11 +1045,12 @@ class Binary(http.Controller):
|
|||||||
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], type='http', auth="public")
|
'/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',
|
def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
|
||||||
filename_field='datas_fname', unique=None, filename=None, mimetype=None,
|
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(
|
status, headers, content = binary_content(
|
||||||
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
||||||
filename_field=filename_field, download=download, mimetype=mimetype,
|
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:
|
if status == 304:
|
||||||
return werkzeug.wrappers.Response(status=304, headers=headers)
|
return werkzeug.wrappers.Response(status=304, headers=headers)
|
||||||
elif status == 301:
|
elif status == 301:
|
||||||
@@ -1065,12 +1069,15 @@ class Binary(http.Controller):
|
|||||||
content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
|
content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
|
||||||
|
|
||||||
elif content and (width or height):
|
elif content and (width or height):
|
||||||
# resize maximum 500*500
|
if not upper_limit:
|
||||||
if width > 500:
|
# resize maximum 500*500
|
||||||
width = 500
|
if width > 500:
|
||||||
if height > 500:
|
width = 500
|
||||||
height = 500
|
if 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)
|
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
|
# resize force png as filetype
|
||||||
headers = self.force_contenttype(headers, contenttype='image/png')
|
headers = self.force_contenttype(headers, contenttype='image/png')
|
||||||
|
|
||||||
|
|||||||
@@ -225,8 +225,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_attachment_image > img {
|
.o_attachment_image > img {
|
||||||
width: 100%;
|
overflow: hidden;
|
||||||
height: auto;
|
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 {
|
.o_progressbar {
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ class Http(models.AbstractModel):
|
|||||||
def binary_content(cls, xmlid=None, model='ir.attachment', id=None, field='datas',
|
def binary_content(cls, xmlid=None, model='ir.attachment', id=None, field='datas',
|
||||||
unique=False, filename=None, filename_field='datas_fname', download=False,
|
unique=False, filename=None, filename_field='datas_fname', download=False,
|
||||||
mimetype=None, default_mimetype='application/octet-stream',
|
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
|
env = env or request.env
|
||||||
obj = None
|
obj = None
|
||||||
if xmlid:
|
if xmlid:
|
||||||
@@ -248,7 +248,8 @@ class Http(models.AbstractModel):
|
|||||||
return super(Http, cls).binary_content(
|
return super(Http, cls).binary_content(
|
||||||
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
||||||
filename_field=filename_field, download=download, mimetype=mimetype,
|
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):
|
class ModelConverter(ModelConverter):
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import itertools
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import re
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import uuid
|
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.exceptions import AccessError, ValidationError
|
||||||
from odoo.tools import config, human_size, ustr, html_escape
|
from odoo.tools import config, human_size, ustr, html_escape
|
||||||
from odoo.tools.mimetypes import guess_mimetype
|
from odoo.tools.mimetypes import guess_mimetype
|
||||||
|
from odoo.tools import crop_image, image_resize_image
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,6 +44,14 @@ class IrAttachment(models.Model):
|
|||||||
record = self.env[attachment.res_model].browse(attachment.res_id)
|
record = self.env[attachment.res_model].browse(attachment.res_id)
|
||||||
attachment.res_name = record.display_name
|
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
|
@api.model
|
||||||
def _storage(self):
|
def _storage(self):
|
||||||
return self.env['ir.config_parameter'].sudo().get_param('ir_attachment.location', 'file')
|
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']
|
return ['base.group_system']
|
||||||
|
|
||||||
name = fields.Char('Attachment Name', required=True)
|
name = fields.Char('Name', required=True)
|
||||||
datas_fname = fields.Char('File Name')
|
datas_fname = fields.Char('Filename')
|
||||||
description = fields.Text('Description')
|
description = fields.Text('Description')
|
||||||
res_name = fields.Char('Resource Name', compute='_compute_res_name', store=True)
|
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 = 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_field = fields.Char('Resource Field', readonly=True)
|
||||||
res_id = fields.Integer('Resource ID', readonly=True, help="The record id this is attached to.")
|
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_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,
|
company_id = fields.Many2one('res.company', string='Company', change_default=True,
|
||||||
default=lambda self: self.env['res.company']._company_default_get('ir.attachment'))
|
default=lambda self: self.env['res.company']._company_default_get('ir.attachment'))
|
||||||
type = fields.Selection([('url', 'URL'), ('binary', 'File')],
|
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)
|
checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True)
|
||||||
mimetype = fields.Char('Mime Type', readonly=True)
|
mimetype = fields.Char('Mime Type', readonly=True)
|
||||||
index_content = fields.Text('Indexed Content', readonly=True, prefetch=False)
|
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
|
@api.model_cr_context
|
||||||
def _auto_init(self):
|
def _auto_init(self):
|
||||||
@@ -438,6 +461,8 @@ class IrAttachment(models.Model):
|
|||||||
|
|
||||||
@api.multi
|
@api.multi
|
||||||
def unlink(self):
|
def unlink(self):
|
||||||
|
if not self:
|
||||||
|
return True
|
||||||
self.check('unlink')
|
self.check('unlink')
|
||||||
|
|
||||||
# First delete in the database, *then* in the filesystem if the
|
# First delete in the database, *then* in the filesystem if the
|
||||||
@@ -472,3 +497,31 @@ class IrAttachment(models.Model):
|
|||||||
@api.model
|
@api.model
|
||||||
def action_get(self):
|
def action_get(self):
|
||||||
return self.env['ir.actions.act_window'].for_xml_id('base', 'action_attachment')
|
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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from odoo.exceptions import AccessDenied, AccessError
|
|||||||
from odoo.http import request, STATIC_CACHE, content_disposition
|
from odoo.http import request, STATIC_CACHE, content_disposition
|
||||||
from odoo.tools import pycompat, consteq
|
from odoo.tools import pycompat, consteq
|
||||||
from odoo.tools.mimetypes import guess_mimetype
|
from odoo.tools.mimetypes import guess_mimetype
|
||||||
|
from ast import literal_eval
|
||||||
from odoo.modules.module import get_resource_path, get_module_path
|
from odoo.modules.module import get_resource_path, get_module_path
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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',
|
def binary_content(cls, xmlid=None, model='ir.attachment', id=None, field='datas',
|
||||||
unique=False, filename=None, filename_field='datas_fname', download=False,
|
unique=False, filename=None, filename_field='datas_fname', download=False,
|
||||||
mimetype=None, default_mimetype='application/octet-stream',
|
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
|
""" Get file, attachment or downloadable content
|
||||||
|
|
||||||
If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the
|
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 str filename_field: if not create an filename with model-id-field
|
||||||
:param bool download: apply headers to download the file
|
:param bool download: apply headers to download the file
|
||||||
:param str mimetype: mintype of the field (for headers)
|
: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 default_mimetype: default mintype if no mintype found
|
||||||
:param str access_token: optional token for unauthenticated access
|
:param str access_token: optional token for unauthenticated access
|
||||||
only available for ir.attachment
|
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
|
:param Environment env: by default use request.env
|
||||||
:returns: (status, headers, content)
|
:returns: (status, headers, content)
|
||||||
"""
|
"""
|
||||||
@@ -277,6 +282,24 @@ class IrHttp(models.AbstractModel):
|
|||||||
obj = env[model].sudo().browse(int(id))
|
obj = env[model].sudo().browse(int(id))
|
||||||
if not consteq(obj.access_token, access_token):
|
if not consteq(obj.access_token, access_token):
|
||||||
return (403, [], None)
|
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:
|
elif id and model in env.registry:
|
||||||
obj = env[model].browse(int(id))
|
obj = env[model].browse(int(id))
|
||||||
|
|
||||||
@@ -338,6 +361,14 @@ class IrHttp(models.AbstractModel):
|
|||||||
if not mimetype:
|
if not mimetype:
|
||||||
mimetype = guess_mimetype(base64.b64decode(content), default=default_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')]
|
headers += [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff')]
|
||||||
|
|
||||||
# cache
|
# cache
|
||||||
|
|||||||
@@ -79,3 +79,84 @@ class test_ir_http_mimetype(common.TransactionCase):
|
|||||||
)
|
)
|
||||||
mimetype = dict(headers).get('Content-Type')
|
mimetype = dict(headers).get('Content-Type')
|
||||||
self.assertEqual(mimetype, 'image/gif')
|
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'))
|
||||||
|
|||||||
+23
-5
@@ -18,7 +18,7 @@ Image._initialized = 2
|
|||||||
# Image resizing
|
# 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
|
""" 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
|
size, while keeping the aspect ratios, and holes in the image will be
|
||||||
filled with transparent background. The image will not be stretched if
|
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)
|
}.get(filetype, filetype)
|
||||||
|
|
||||||
asked_width, asked_height = size
|
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:
|
if asked_width is None:
|
||||||
asked_width = int(image.size[0] * (float(asked_height) / image.size[1]))
|
asked_width = int(image.size[0] * (float(asked_height) / image.size[1]))
|
||||||
if asked_height is None:
|
if asked_height is None:
|
||||||
asked_height = int(image.size[1] * (float(asked_width) / image.size[0]))
|
asked_height = int(image.size[1] * (float(asked_width) / image.size[0]))
|
||||||
size = asked_width, asked_height
|
size = asked_width, asked_height
|
||||||
|
|
||||||
# check image size: do not create a thumbnail if avoiding smaller images
|
# 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]:
|
if avoid_if_small and image.size[0] <= size[0] and image.size[1] <= size[1]:
|
||||||
return base64_source
|
return base64_source
|
||||||
|
|
||||||
if image.size != size:
|
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'):
|
if image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (filetype == 'JPEG' and image.mode == 'RGBA'):
|
||||||
image = image.convert("RGB")
|
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)
|
image.save(background_stream, filetype)
|
||||||
return codecs.encode(background_stream.getvalue(), encoding)
|
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.
|
Create a thumbnail by resizing while keeping ratio.
|
||||||
A sharpen filter is applied for a better looking result.
|
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)
|
sharpener = ImageEnhance.Sharpness(image)
|
||||||
resized_image = sharpener.enhance(factor)
|
resized_image = sharpener.enhance(factor)
|
||||||
# create a transparent image for background and paste the image on it
|
# 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))
|
image.paste(resized_image, ((size[0] - resized_image.size[0]) // 2, (size[1] - resized_image.size[1]) // 2))
|
||||||
|
|
||||||
if image.mode != origin_mode:
|
if image.mode != origin_mode:
|
||||||
image = image.convert(origin_mode)
|
image = image.convert(origin_mode)
|
||||||
return image
|
return image
|
||||||
|
|||||||
Reference in New Issue
Block a user