diff options
| author | ThanhDodeurOdoo <tso@odoo.com> | 2018-08-14 11:26:55 +0200 |
|---|---|---|
| committer | ThanhDodeurOdoo <tso@odoo.com> | 2018-09-07 16:06:47 +0200 |
| commit | 16aeecc73d4cf95e55f447315c983fae4ca471f7 (patch) | |
| tree | 307ce38ff6d53bff5368e2488ce47b63da1ff1b8 | |
| parent | 469d7acb89822ec11179d7bf2355e2b0eb9053df (diff) | |
[REF] base, mail: Documents (enterprise) related changes
-removed force_ext from ir.http.
-added signature arg to web/image to differentiate cache entries by URL.
-added an embed youtube viewer for youtube URL's
-added a PDF splitter to the PDF viewer UI
-added tests for mail's Document Viewer
-added a "signature" param to web/image to go around the browser cache
-thumbnail field of ir.attachment is now stored in the filestore
as a distinct attachment.
task 1853490
Co-authored-by: Pierre Paridans <app@odoo.com>
| -rw-r--r-- | addons/mail/static/src/js/document_viewer.js | 58 | ||||
| -rw-r--r-- | addons/mail/static/src/scss/composer.scss | 34 | ||||
| -rw-r--r-- | addons/mail/static/src/xml/thread.xml | 9 | ||||
| -rw-r--r-- | addons/mail/static/tests/chatter_tests.js | 7 | ||||
| -rw-r--r-- | addons/mail/static/tests/document_viewer_tests.js | 242 | ||||
| -rw-r--r-- | addons/mail/views/mail_templates.xml | 1 | ||||
| -rw-r--r-- | addons/web/controllers/main.py | 10 | ||||
| -rw-r--r-- | addons/website/models/ir_http.py | 4 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_attachment.py | 124 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_http.py | 12 | ||||
| -rw-r--r-- | odoo/addons/base/tests/test_ir_http.py | 80 | ||||
| -rw-r--r-- | odoo/addons/base/views/ir_attachment_views.xml | 4 |
12 files changed, 440 insertions, 145 deletions
diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js index fc8f9940d81..e2c03e24d6a 100644 --- a/addons/mail/static/src/js/document_viewer.js +++ b/addons/mail/static/src/js/document_viewer.js @@ -13,6 +13,7 @@ var DocumentViewer = Widget.extend({ template: "DocumentViewer", events: { 'click .o_download_btn': '_onDownload', + 'click .o_split_btn': '_onSplitPDF', 'click .o_viewer_img': '_onImageClicked', 'click .o_viewer_video': '_onVideoClicked', 'click .move_next': '_onNext', @@ -43,10 +44,25 @@ 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|text)"); + var match = attachment.type == 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)"); if (match) { attachment.type = match[1]; + if (match[1].match("(.png|.jpg|.gif)")) { + attachment.type = 'image'; + } + if (match[1] === 'youtu') { + var youtube_array = attachment.url.split('/'); + var youtube_token = youtube_array[youtube_array.length-1]; + if (youtube_token.indexOf('watch') !== -1) { + youtube_token = youtube_token.split('v=')[1]; + var amp = youtube_token.indexOf('&') + if (amp !== -1){ + youtube_token = youtube_token.substring(0, amp); + } + } + attachment.youtube = youtube_token; + } return true; } }); @@ -64,6 +80,18 @@ var DocumentViewer = Widget.extend({ this.$('[data-toggle="tooltip"]').tooltip({delay: 0}); return this._super.apply(this, arguments); }, + /** + * @override + */ + destroy: function () { + if (this.isDestroyed()) { + return; + } + this.trigger_up('document_viewer_closed'); + this.$el.modal('hide'); + this.$el.remove(); + this._super.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private @@ -156,19 +184,15 @@ var DocumentViewer = Widget.extend({ */ _onClose: function (e) { e.preventDefault(); - this.$el.modal('hide'); - this.trigger_up('document_viewer_closed'); + this.destroy(); }, /** * When popup close complete destroyed modal even DOM footprint too + * * @private */ _onDestroy: function () { - if (this.isDestroyed()) { - return; - } - this.$el.modal('hide'); - this.$el.remove(); + this.destroy(); }, /** * @private @@ -305,6 +329,24 @@ var DocumentViewer = Widget.extend({ * @private * @param {MouseEvent} e */ + _onSplitPDF: function (e) { + e.stopPropagation(); + var self = this; + var indices = $("input[name=Indices]").val(); + var remainder = $("input[type=checkbox][name=remainder]").is(":checked"); + this._rpc({ + model: 'ir.attachment', + method: 'split_pdf', + args: [this.activeAttachment.id, indices, remainder], + }).always(function () { + self.trigger_up('document_viewer_attachment_changed'); + self.destroy(); + }); + }, + /** + * @private + * @param {MouseEvent} e + */ _onStartDrag: function (e) { e.preventDefault(); this.enableDrag = true; diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index 9925813bb73..5fd0ca0e8af 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -452,6 +452,32 @@ $o-mail-attachment-margin: 5px; font-size: $h1-font-size; font-weight: 300; } + + .o_image_caption { + bottom: 20%; + position: absolute; + + .o_split_pdf_area { + background-color: #3a3a3a; + margin: 5px; + padding: 6px; + margin-left: 50px; + + .o_split_btn { + vertical-align: baseline; + } + + .o_page_number_input { + width: 13%; + color: black; + display: inline; + } + + .o_remainder_input { + vertical-align: middle; + } + } + } } .o_loading_img { @@ -492,6 +518,14 @@ $o-mail-attachment-margin: 5px; box-shadow: 1px 1px 20px 1px #000; } + .o_viewer_text { + width: 80%; + height: 100%; + border: 0px; + box-shadow: 1px 1px 20px 1px #000; + background-color: white; + } + .o_viewer_video { height: 80%; } diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 242e97449f5..78295a037c7 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -32,6 +32,11 @@ <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.type == 'video'" role="img" aria-label="Video" title="Video"/> <t t-esc="widget.activeAttachment.name"/> <a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a> + <span t-if="widget.activeAttachment.type == 'application/pdf'" class="o_split_pdf_area"> + <label for="Indices">Extract pages:&nbsp;</label><input class="o_page_number_input" name="Indices" placeholder="e.g. 1-5, 7, 8-9"/> + <label for="remainder">All pages:&nbsp;</label><input class="o_remainder_input" type="checkbox" name="remainder" value="remainder"/> + <button class="btn btn-sm btn-primary o_split_btn" data-toggle="tooltip" title="Split">Split</button> + </span> </span> <a role="button" class="o_close_btn float-right" href="#" aria-label="Close" title="Close">×</a> </div> @@ -40,8 +45,10 @@ <div t-if="widget.activeAttachment.type == 'image'" class="o_loading_img text-center"> <i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/> </div> - <img t-if="widget.activeAttachment.type == 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1" alt="Viewer"/> + <img t-if="widget.activeAttachment.type == 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}" alt="Viewer"/> <iframe class="mt32 o_viewer_pdf" t-if="widget.activeAttachment.type == 'application/pdf'" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}" /> + <iframe class="mt32 o_viewer_text" t-if="(widget.activeAttachment.type || '').indexOf('text') !== -1" t-attf-src="/web/content/#{widget.activeAttachment.id}" /> + <iframe class="mt32 o_viewer_text" t-if="widget.activeAttachment.type == 'youtu'" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/> <video t-if="widget.activeAttachment.type == 'video'" class="o_viewer_video" controls="controls"> <source t-attf-src="/web/image/#{widget.activeAttachment.id}" t-att-data-type="widget.activeAttachment.mimetype"/> </video> diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 6bfa25de433..37fb75572c3 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -989,18 +989,21 @@ QUnit.test('chatter: Attachment viewer', function (assert) { attachment_ids: [{ filename: 'image1.jpg', id:1, + checksum: 999, mimetype: 'image/jpeg', name: 'Test Image 1', url: '/web/content/1?download=true' },{ filename: 'image2.jpg', id:2, + checksum: 999, mimetype: 'image/jpeg', name: 'Test Image 2', url: '/web/content/2?download=true' },{ filename: 'image3.jpg', id:3, + checksum: 999, mimetype: 'image/jpeg', name: 'Test Image 3', url: '/web/content/3?download=true' @@ -1051,11 +1054,11 @@ QUnit.test('chatter: Attachment viewer', function (assert) { "image caption should have correct download link"); // click on first image attachement form.$('.o_thread_message .o_attachment .o_image_box .o_image_overlay').first().click(); - assert.strictEqual($('.o_modal_fullscreen img.o_viewer_img[data-src="/web/image/1?unique=1"]').length, 1, + assert.strictEqual($('.o_modal_fullscreen img.o_viewer_img[data-src="/web/image/1?unique=1&signature=999"]').length, 1, "Modal popup should open with first image src"); // click on next button $('.modal .arrow.arrow-right.move_next span').click(); - assert.strictEqual($('.o_modal_fullscreen img.o_viewer_img[data-src="/web/image/2?unique=1"]').length, 1, + assert.strictEqual($('.o_modal_fullscreen img.o_viewer_img[data-src="/web/image/2?unique=1&signature=999"]').length, 1, "Modal popup should have now second image src"); assert.strictEqual($('.o_modal_fullscreen .o_viewer_toolbar .o_download_btn').length, 1, "Modal popup should have download button"); diff --git a/addons/mail/static/tests/document_viewer_tests.js b/addons/mail/static/tests/document_viewer_tests.js new file mode 100644 index 00000000000..bbd2d9a6fa1 --- /dev/null +++ b/addons/mail/static/tests/document_viewer_tests.js @@ -0,0 +1,242 @@ +odoo.define('mail.document_viewer_tests', function (require) { +"use strict"; + +var DocumentViewer = require('mail.DocumentViewer'); + +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +/** + * @param {Object} params + * @param {Object[]} params.attachments + * @param {int} params.attachmentID + * @param {function} [params.mockRPC] + * @param {boolean} [params.debug] + * @returns {DocumentViewer} + */ +var createViewer = function (params) { + var parent = new Widget(); + var viewer = new DocumentViewer(parent, params.attachments, params.attachmentID); + + var mockRPC = function (route) { + if (route === '/web/static/lib/pdfjs/web/viewer.html?file=/web/content/1') { + return $.when(); + } + if (route === 'https://www.youtube.com/embed/FYqW0Gdwbzk') { + return $.when(); + } + if (route === '/web/content/4') { + return $.when(); + } + if (route === '/web/image/6?unique=1&signature=999') { + return $.when(); + } + }; + testUtils.addMockEnvironment(parent, { + mockRPC: function () { + if (params.mockRPC) { + var _super = this._super; + this._super = mockRPC; + var def = params.mockRPC.apply(this, arguments); + this._super = _super; + return def; + } else { + return mockRPC.apply(this, arguments); + } + }, + intercepts: params.intercepts || {}, + }); + var $target = $("#qunit-fixture"); + if (params.debug) { + $target = $('body'); + $target.addClass('debug'); + } + viewer.appendTo($target); + + // actually destroy the parent when the viewer is destroyed + viewer.destroy = function () { + delete viewer.destroy; + parent.destroy(); + }; + return viewer; +}; + +QUnit.module('DocumentViewer', { + beforeEach: function () { + this.attachments = [ + {id: 1, datas_fname: 'filePdf.pdf', type: 'binary', mimetype: 'application/pdf', datas:'R0lGOP////ywAADs='}, + {id: 2, name: 'urlYoutubeName', type: 'url', mimetype: '', url: 'https://youtu.be/FYqW0Gdwbzk', datas_fname: 'urlYoutube'}, + {id: 3, name: 'urlGoogle', type: 'url', mimetype: '', url: 'https://www.google.com', datas_fname: 'urlRandom'}, + {id: 4, name: 'text.html', datas_fname: 'text.html', type: 'binary', mimetype: 'text/html', datas:'testee'}, + {id: 5, name: 'video.mp4', datas_fname: 'video.mp4', type: 'binary', mimetype: 'video/mp4', datas:'R0lDOP////ywAADs='}, + {id: 6, name: 'image.jpg', datas_fname: 'image.jpg', type: 'binary', mimetype: 'image/jpeg', checksum: 999, datas:'R0lVOP////ywAADs='}, + ]; + }, +}, function () { + + QUnit.test('basic rendering', function (assert) { + assert.expect(7); + + var viewer = createViewer({ + attachmentID: 1, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$('.o_viewer_content').length, 1, + "there should be a preview"); + assert.strictEqual(viewer.$('.o_close_btn').length, 1, + "there should be a close button"); + assert.strictEqual(viewer.$('.o_viewer-header').length, 1, + "there should be a header"); + assert.strictEqual(viewer.$('.o_image_caption').length, 1, + "there should be an image caption"); + assert.strictEqual(viewer.$('.o_viewer_zoomer').length, 1, + "there should be a zoomer"); + assert.strictEqual(viewer.$('.fa-chevron-right').length, 1, + "there should be a right nav icon"); + assert.strictEqual(viewer.$('.fa-chevron-left').length, 1, + "there should be a left nav icon"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer PDF', function (assert) { + assert.expect(6); + + var viewer = createViewer({ + attachmentID: 1, + attachments: this.attachments, + mockRPC: function (route, args) { + if (args.method === 'split_pdf') { + assert.deepEqual(args.args, [1, "", false], "should have the right arguments"); + return $.when(); + } + return this._super.apply(this, arguments); + }, + intercepts: { + document_viewer_attachment_changed: function () { + assert.ok(true, "should trigger document_viewer_attachment_changed event"); + } + }, + }); + + assert.strictEqual(viewer.$('.o_page_number_input').length, 1, + "pdf should have a page input"); + assert.strictEqual(viewer.$('.o_remainder_input').length, 1, + "pdf should have a remainder checkbox"); + assert.strictEqual(viewer.$('.o_split_btn').length, 1, + "pdf should have a split button"); + + viewer.$('.o_split_btn').click(); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); + + QUnit.test('Document Viewer Youtube', function (assert) { + assert.expect(3); + + var youtubeURL = 'https://www.youtube.com/embed/FYqW0Gdwbzk'; + var viewer = createViewer({ + attachmentID: 2, + attachments: this.attachments, + mockRPC: function (route) { + if (route === youtubeURL) { + assert.ok(true, "should have called youtube URL"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('urlYoutubeName')").length, 1, + "the viewer should be on the right attachment"); + assert.strictEqual(viewer.$('.o_viewer_text[data-src="' + youtubeURL + '"]').length, 1, + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer html/(txt)', function (assert) { + assert.expect(2); + + var viewer = createViewer({ + attachmentID: 4, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('text.html')").length, 1, + "the viewer be on the right attachment"); + assert.strictEqual(viewer.$('iframe[data-src="/web/content/4"]').length, 1, + "there should be an iframe with the right src"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer mp4', function (assert) { + assert.expect(2); + + var viewer = createViewer({ + attachmentID: 5, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('video.mp4')").length, 1, + "the viewer be on the right attachment"); + assert.strictEqual(viewer.$('.o_viewer_video').length, 1, + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer jpg', function (assert) { + assert.expect(2); + + var viewer = createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('image.jpg')").length, 1, + "the viewer be on the right attachment"); + assert.strictEqual(viewer.$('img[data-src="/web/image/6?unique=1&signature=999"]').length, 1, + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('is closable by button', function (assert) { + assert.expect(3); + + var viewer = createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$('.o_viewer_content').length, 1, + "should have a document viewer"); + assert.strictEqual(viewer.$('.o_close_btn').length, 1, + "should have a close button"); + + viewer.$('.o_close_btn').click(); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); + + QUnit.test('is closable by clicking on the wrapper', function (assert) { + assert.expect(3); + + var viewer = createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$('.o_viewer_content').length, 1, + "should have a document viewer"); + assert.strictEqual(viewer.$('.o_viewer_img_wrapper').length, 1, + "should have a wrapper"); + + viewer.$('.o_viewer_img_wrapper').click(); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); +}); +}); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 134b554c4b9..7e116200f43 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -107,6 +107,7 @@ <script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/mail_utils_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/discuss_tests.js"></script> + <script type="text/javascript" src="/mail/static/tests/document_viewer_tests.js"></script> <!-- systray --> <script type="text/javascript" src="/mail/static/tests/systray/systray_activity_menu_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/systray/systray_messaging_menu_tests.js"></script> diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 1719b70f6f8..4577a21ddff 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -425,12 +425,12 @@ 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', share_id=None, share_token=None, access_token=None, - force_ext=False, env=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, share_id=share_id, share_token=share_token, access_token=access_token, - force_ext=force_ext, env=env) + env=env) #---------------------------------------------------------- # Odoo Web web Controllers @@ -1008,11 +1008,11 @@ class Binary(http.Controller): 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, share_id=None, share_token=None, access_token=None, - force_ext=False, **kw): + **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, share_id=share_id, share_token=share_token, force_ext=force_ext) + access_token=access_token, share_id=share_id, share_token=share_token) if status == 304: response = werkzeug.wrappers.Response(status=status, headers=headers) elif status == 301: @@ -1047,7 +1047,7 @@ class Binary(http.Controller): 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, share_id=None, share_token=None, access_token=None, avoid_if_small=False, - upper_limit=False): + upper_limit=False, signature=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, diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index e3eb27a9d77..5057dc8be1e 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -236,7 +236,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, share_id=None, share_token=None, force_ext=False, env=None): + access_token=None, share_id=None, share_token=None, env=None): env = env or request.env obj = None if xmlid: @@ -250,7 +250,7 @@ class Http(models.AbstractModel): 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, share_id=share_id, share_token=share_token, - force_ext=force_ext, env=env) + env=env) @classmethod def _xmlid_to_obj(cls, env, xmlid): diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py index 62aa7fc82e6..d1019d800f5 100644 --- a/odoo/addons/base/models/ir_attachment.py +++ b/odoo/addons/base/models/ir_attachment.py @@ -16,6 +16,7 @@ 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 +from PyPDF2 import PdfFileWriter, PdfFileReader _logger = logging.getLogger(__name__) @@ -312,17 +313,7 @@ class IrAttachment(models.Model): 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') + thumbnail = fields.Binary(readonly=1, attachment=True) @api.model_cr_context def _auto_init(self): @@ -444,6 +435,18 @@ class IrAttachment(models.Model): self.check('read') return super(IrAttachment, self).read(fields, load=load) + def _make_thumbnail(self, vals): + if vals.get('datas') and not vals.get('res_field'): + vals['thumbnail'] = False + if vals.get('mimetype') and re.match('image.*(gif|jpeg|jpg|png)', vals['mimetype']): + try: + temp_image = crop_image(vals['datas'], type='center', size=(80, 80), ratio=(1, 1)) + vals['thumbnail'] = image_resize_image(base64_source=temp_image, size=(80, 80), + encoding='base64') + except Exception: + pass + return vals + @api.multi def write(self, vals): self.check('write', values=vals) @@ -452,6 +455,9 @@ class IrAttachment(models.Model): vals.pop(field, False) if 'mimetype' in vals or 'datas' in vals: vals = self._check_contents(vals) + vals = self._make_thumbnail(vals) + if all([not attachment.res_field for attachment in self]): + vals = self._make_thumbnail(vals) return super(IrAttachment, self).write(vals) @api.multi @@ -483,6 +489,7 @@ class IrAttachment(models.Model): for field in ('file_size', 'checksum'): values.pop(field, False) values = self._check_contents(values) + values = self._make_thumbnail(values) self.browse().check('write', values=values) return super(IrAttachment, self).create(vals_list) @@ -498,30 +505,79 @@ class IrAttachment(models.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): + def _make_pdf(self, output, name_ext): + """ + :param output: PdfFileWriter object. + :param name_ext: the additional name of the new attachment (page count). + :return: the id of the attachment. + """ + self.ensure_one() + try: + stream = io.BytesIO() + output.write(stream) + return self.copy({ + 'name': self.name+'-'+name_ext, + 'datas_fname': os.path.splitext(self.datas_fname or self.name)[0]+'-'+name_ext+".pdf", + 'datas': base64.b64encode(stream.getvalue()), + }) + except Exception: + raise Exception + + def _split_pdf_groups(self, pdf_groups, remainder=False): """ - directly uploads attachments. + calls _make_pdf to create the a new attachment for each page section. + :param pdf_groups: a list of lists representing the pages to split: pages = [[1,1], [4,5], [7,7]] + :returns the list of the ID's of the new PDF 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, - } + self.ensure_one() + with io.BytesIO(base64.b64decode(self.datas)) as stream: + try: + input_pdf = PdfFileReader(stream) + except Exception: + raise exceptions.ValidationError(_("ERROR: Invalid PDF file!")) + max_page = input_pdf.getNumPages() + remainder_set = set(range(0, max_page)) + new_pdf_ids = [] + for pages in pdf_groups: + pages[1] = min(max_page, pages[1]) + pages[0] = min(max_page, pages[0]) + if pages[0] == pages[1]: + name_ext = "%s" % (pages[0],) + else: + name_ext = "%s-%s" % (pages[0], pages[1]) + output = PdfFileWriter() + for i in range(pages[0]-1, pages[1]): + output.addPage(input_pdf.getPage(i)) + new_pdf_id = self._make_pdf(output, name_ext) + new_pdf_ids.append(new_pdf_id) + remainder_set = remainder_set.difference(set(range(pages[0] - 1, pages[1]))) + if remainder: + for i in remainder_set: + output_page = PdfFileWriter() + name_ext = "%s" % (i + 1,) + output_page.addPage(input_pdf.getPage(i)) + new_pdf_id = self._make_pdf(output_page, name_ext) + new_pdf_ids.append(new_pdf_id) + return new_pdf_ids + + def split_pdf(self, indices, remainder=False): + """ + called by the Document Viewer's Split PDF button. + evaluates the input string and turns it into a list of lists to be processed by _split_pdf_groups + + :param indices: the formatted string of pdf split (e.g. 1,5-10, 8-22, 29-34) o_page_number_input + :param remainder: bool, if true splits the non specified pages, one by one. form checkbox o_remainder_input + :returns the list of the ID's of the newly created pdf attachments. + """ + self.ensure_one() + if 'pdf' not in self.mimetype: + raise exceptions.ValidationError(_("ERROR: the file must be a PDF")) + try: + pages = [[int(x) for x in x.split('-')] for x in indices.split(',')] + except ValueError: + raise exceptions.ValidationError(_("ERROR: Invalid list of pages to split. Example: 1,5-9,10")) + return self._split_pdf_groups([[min(x), max(x)] for x in pages], remainder=remainder) + + - 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 71659aff1f5..292d2894a34 100644 --- a/odoo/addons/base/models/ir_http.py +++ b/odoo/addons/base/models/ir_http.py @@ -252,7 +252,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, share_id=None, share_token=None, force_ext=False, env=None): + access_token=None, share_id=None, share_token=None, env=None): """ Get file, attachment or downloadable content If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the @@ -273,8 +273,6 @@ class IrHttp(models.AbstractModel): :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) """ @@ -366,14 +364,6 @@ 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 00b32881cf6..313a86f308d 100644 --- a/odoo/addons/base/tests/test_ir_http.py +++ b/odoo/addons/base/tests/test_ir_http.py @@ -80,83 +80,3 @@ 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/addons/base/views/ir_attachment_views.xml b/odoo/addons/base/views/ir_attachment_views.xml index a954444169d..2cdfe812bd3 100644 --- a/odoo/addons/base/views/ir_attachment_views.xml +++ b/odoo/addons/base/views/ir_attachment_views.xml @@ -67,13 +67,13 @@ <field name="name" filter_domain="['|', ('name','ilike',self), ('datas_fname','ilike',self)]" string="Attachment"/> <field name="create_date"/> <filter name="url_filter" string="URL" domain="[('type','=','url')]"/> - <filter name="binary_filter" string="Binary" domain="[('type','=','binary')]"/> + <filter name="binary_filter" string="Stored" domain="[('type','=','binary')]"/> <separator/> <filter name="my_documents_filter" string="My Document(s)" domain="[('create_uid','=',uid)]" help="Filter on my documents"/> - <field name="create_uid"/> + <field name="create_uid" string="Created by"/> <field name="type"/> <group expand="0" string="Group By"> <filter name="owner" string="Owner" domain="[]" context="{'group_by':'create_uid'}"/> |
