From 61de1c263a7a40a36f2b58b8a107c350de6b6c7f Mon Sep 17 00:00:00 2001 From: Pratima Gupta Date: Wed, 24 Jul 2019 12:30:31 +0000 Subject: [PATCH] [IMP] portal: allow attachment upload from portal chatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, it was only possible to add attachments on documents from the backend or by sending them by email. It is now possible to add them also from the portal chatter, including for portal/public users who have a valid access_token. Part of task-37264 closes odoo/odoo#34526 Signed-off-by: Olivier Dony (odo) Co-authored-by: Pratima Gupta Co-authored-by: Sébastien Theys --- addons/portal/controllers/mail.py | 69 +++++++- addons/portal/controllers/portal.py | 85 +++++++++- addons/portal/static/src/js/portal_chatter.js | 110 ++++++++++++- addons/portal/static/src/scss/portal.scss | 14 ++ .../portal/static/src/xml/portal_chatter.xml | 42 ++++- addons/portal/tests/__init__.py | 1 + addons/portal/tests/test_attachment.py | 152 ++++++++++++++++++ odoo/tests/common.py | 6 +- 8 files changed, 457 insertions(+), 22 deletions(-) create mode 100644 addons/portal/tests/test_attachment.py diff --git a/addons/portal/controllers/mail.py b/addons/portal/controllers/mail.py index 977dccdc663..c694caec6df 100644 --- a/addons/portal/controllers/mail.py +++ b/addons/portal/controllers/mail.py @@ -10,7 +10,8 @@ from odoo.http import request from odoo.osv import expression from odoo.tools import consteq, plaintext2html from odoo.addons.mail.controllers.main import MailController -from odoo.exceptions import AccessError +from odoo.addons.portal.controllers.portal import CustomerPortal +from odoo.exceptions import AccessError, MissingError def _check_special_access(res_model, res_id, token='', _hash='', pid=False): @@ -78,7 +79,9 @@ def _message_post_helper(res_model, res_id, message, token='', nosubscribe=True, author_id = kw.get('pid') kw.pop('csrf_token', None) + kw.pop('redirect', None) kw.pop('attachment_ids', None) + kw.pop('attachment_tokens', None) kw.pop('hash', None) kw.pop('pid', None) return record.with_context(mail_create_nosubscribe=nosubscribe).message_post( @@ -92,14 +95,66 @@ def _message_post_helper(res_model, res_id, message, token='', nosubscribe=True, class PortalChatter(http.Controller): + def _post_process_portal_attachments(self, message, res_model, res_id, attachment_ids, attachment_tokens): + """Associate "pending" attachments to a message and its main record. + + The user must have the rights to all attachments or he must provide a + valid access_token for each of them. The attachments have to be on a + "pending" state as well: res_id=0 and res_model='mail.compose.message'. + + :param message: the related message on which to link the attachments + :type message: recordset of one `mail.message` + + :param res_model: the related model that will be saved on the attachment + :type res_model: string + + :param res_id: the id of the record that will be saved on the attachment + :type res_id: int + + :param attachment_ids: id of the attachments to associate + :type attachment_ids: iterable of int + + :param attachment_tokens: access_token of the attachments to associate. + Must always be the same length as `attachment_ids`, but only has to + contain a valid access_token if the user does not have the rights to + access the attachment without token. + :type attachment_tokens: iterable of string + """ + message.ensure_one() + attachments = request.env['ir.attachment'].sudo() + for (attachment_id, access_token) in zip(attachment_ids, attachment_tokens): + try: + attachment = CustomerPortal._document_check_access(self, 'ir.attachment', attachment_id, access_token) + if attachment.res_model == 'mail.compose.message' and attachment.res_id == 0: + attachments += attachment + except (AccessError, MissingError): + pass + attachments.write({'res_model': res_model, 'res_id': res_id}) + message.attachment_ids |= attachments + @http.route(['/mail/chatter_post'], type='http', methods=['POST'], auth='public', website=True) - def portal_chatter_post(self, res_model, res_id, message, **kw): - url = request.httprequest.referrer - if message: + def portal_chatter_post(self, res_model, res_id, message, redirect=None, attachment_ids=None, attachment_tokens=None, **kw): + """Create a new `mail.message` with the given `message` and/or + `attachment_ids` and redirect the user to the newly created message. + + The message will be associated to the record `res_id` of the model + `res_model`. The user must have access rights on this target document or + must provide valid identifiers through `kw`. See `_message_post_helper`. + """ + url = redirect or (request.httprequest.referrer and request.httprequest.referrer + "#discussion") or '/my' + if message or (attachment_ids and attachment_tokens): + res_id = int(res_id) # message is received in plaintext and saved in html - message = plaintext2html(message) - _message_post_helper(res_model, int(res_id), message, **kw) - url = url + "#discussion" + if message: + message = plaintext2html(message) + message = _message_post_helper(res_model=res_model, res_id=res_id, message=message, **kw) + + if attachment_ids and attachment_tokens and message: + attachment_ids = [int(res_id) for res_id in attachment_ids.split(',')] + attachment_tokens = attachment_tokens.split(',') + self._post_process_portal_attachments(message=message, res_model=res_model, res_id=res_id, + attachment_ids=attachment_ids, attachment_tokens=attachment_tokens) + return request.redirect(url) @http.route('/mail/chatter_init', type='json', auth='public', website=True) diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 5991b6bdc01..286cd970a0c 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -2,13 +2,13 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import json import math -import os import re from werkzeug import urls -from odoo import fields as odoo_fields, tools, _, SUPERUSER_ID +from odoo import fields as odoo_fields, http, tools, _, SUPERUSER_ID from odoo.exceptions import ValidationError, AccessError, MissingError, UserError from odoo.http import content_disposition, Controller, request, route from odoo.tools import consteq @@ -196,6 +196,85 @@ class CustomerPortal(Controller): response.headers['X-Frame-Options'] = 'DENY' return response + @http.route('/portal/attachment/add', type='http', auth='public', methods=['POST'], website=True) + def attachment_add(self, name, file, res_model, res_id, access_token=None, **kwargs): + """Process a file uploaded from the portal chatter and create the + corresponding `ir.attachment`. + + The attachment will be created "pending" until the associated message + is actually created, and it will be garbage collected otherwise. + + :param name: name of the file to save. + :type name: string + + :param file: the file to save + :type file: werkzeug.FileStorage + + :param res_model: name of the model of the original document. + To check access rights only, it will not be saved here. + :type res_model: string + + :param res_id: id of the original document. + To check access rights only, it will not be saved here. + :type res_id: int + + :param access_token: access_token of the original document. + To check access rights only, it will not be saved here. + :type access_token: string + + :return: attachment data {id, name, mimetype, file_size, access_token} + :rtype: dict + """ + try: + self._document_check_access(res_model, int(res_id), access_token=access_token) + except (AccessError, MissingError) as e: + raise UserError(_("The document does not exist or you do not have the rights to access it.")) + + IrAttachment = request.env['ir.attachment'] + access_token = False + + # Avoid using sudo or creating access_token when not necessary: internal + # users can create attachments, as opposed to public and portal users. + if not request.env.user.has_group('base.group_user'): + IrAttachment = IrAttachment.sudo().with_context(binary_field_real_user=IrAttachment.env.user) + access_token = IrAttachment._generate_access_token() + + # At this point the related message does not exist yet, so we assign + # those specific res_model and res_is. They will be correctly set + # when the message is created: see `_post_process_portal_attachments`, + # or garbage collected otherwise: see `_garbage_collect_attachments`. + attachment = IrAttachment.create({ + 'name': name, + 'datas': base64.b64encode(file.read()), + 'res_model': 'mail.compose.message', + 'res_id': 0, + 'access_token': access_token, + }) + return request.make_response( + data=json.dumps(attachment.read(['id', 'name', 'mimetype', 'file_size', 'access_token'])[0]), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/portal/attachment/remove', type='json', auth='public') + def attachment_remove(self, attachment_id, access_token=None): + """Remove the given `attachment_id`, only if it is in a "pending" state. + + The user must have access right on the attachment or provide a valid + `access_token`. + """ + try: + attachment_sudo = self._document_check_access('ir.attachment', int(attachment_id), access_token=access_token) + except (AccessError, MissingError) as e: + raise UserError(_("The attachment does not exist or you do not have the rights to access it.")) + + if attachment_sudo.res_model != 'mail.compose.message' or attachment_sudo.res_id != 0: + raise UserError(_("The attachment %s cannot be removed because it is not in a pending state.") % attachment_sudo.name) + + if attachment_sudo.env['mail.message'].search([('attachment_ids', 'in', attachment_sudo.ids)]): + raise UserError(_("The attachment %s cannot be removed because it is linked to a message.") % attachment_sudo.name) + + return attachment_sudo.unlink() + def details_form_validate(self, data): error = dict() error_message = [] @@ -249,7 +328,7 @@ class CustomerPortal(Controller): document.check_access_rights('read') document.check_access_rule('read') except AccessError: - if not access_token or not consteq(document_sudo.access_token, access_token): + if not access_token or not document_sudo.access_token or not consteq(document_sudo.access_token, access_token): raise return document_sudo diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js index 9ef2e6e6c2a..532dd756c18 100644 --- a/addons/portal/static/src/js/portal_chatter.js +++ b/addons/portal/static/src/js/portal_chatter.js @@ -1,6 +1,7 @@ odoo.define('portal.chatter', function (require) { 'use strict'; +var ajax = require('web.ajax'); var core = require('web.core'); var publicWidget = require('web.public.widget'); var time = require('web.time'); @@ -18,6 +19,9 @@ var PortalComposer = publicWidget.Widget.extend({ template: 'portal.Composer', xmlDependencies: ['/portal/static/src/xml/portal_chatter.xml'], events: { + 'change .o_portal_chatter_file_input': '_onFileInputChange', + 'click .o_portal_chatter_attachment_btn': '_onAttachmentButtonClick', + 'click .o_portal_chatter_attachment_delete': 'async _onAttachmentDeleteClick', 'click .o_portal_chatter_composer_btn': 'async _onSubmitButtonClick', }, @@ -34,6 +38,20 @@ var PortalComposer = publicWidget.Widget.extend({ 'res_model': false, 'res_id': false, }); + this.attachments = []; + }, + /** + * @override + */ + start: function () { + this.$attachmentButton = this.$('.o_portal_chatter_attachment_btn'); + this.$fileInput = this.$('.o_portal_chatter_file_input'); + this.$sendButton = this.$('.o_portal_chatter_composer_btn'); + this.$attachments = this.$('.o_portal_chatter_composer_form .o_portal_chatter_attachments'); + this.$attachmentIds = this.$('.o_portal_chatter_attachment_ids'); + this.$attachmentTokens = this.$('.o_portal_chatter_attachment_tokens'); + + return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- @@ -43,8 +61,98 @@ var PortalComposer = publicWidget.Widget.extend({ /** * @private */ + _onAttachmentButtonClick: function () { + this.$fileInput.click(); + }, + /** + * @private + * @param {Event} ev + * @returns {Promise} + */ + _onAttachmentDeleteClick: function (ev) { + var self = this; + var attachmentId = $(ev.currentTarget).closest('.o_portal_chatter_attachment').data('id'); + var accessToken = _.find(this.attachments, {'id': attachmentId}).access_token; + ev.preventDefault(); + ev.stopPropagation(); + + this.$sendButton.prop('disabled', true); + + return this._rpc({ + route: '/portal/attachment/remove', + params: { + 'attachment_id': attachmentId, + 'access_token': accessToken, + }, + }).then(function () { + self.attachments = _.reject(self.attachments, {'id': attachmentId}); + self._updateAttachments(); + self.$sendButton.prop('disabled', false); + }); + }, + /** + * @private + * @returns {Promise} + */ + _onFileInputChange: function () { + var self = this; + + this.$sendButton.prop('disabled', true); + + return Promise.all(_.map(this.$fileInput[0].files, function (file) { + return new Promise(function (resolve, reject) { + var data = { + 'name': file.name, + 'file': file, + 'res_id': self.options.res_id, + 'res_model': self.options.res_model, + 'access_token': self.options.token, + }; + ajax.post('/portal/attachment/add', data).then(function (attachment) { + self.attachments.push(attachment); + self._updateAttachments(); + resolve(); + }).guardedCatch(function (error) { + self.displayNotification({ + title: _t("Oops! Something went wrong."), + message: _.str.sprintf(_t("The file %s could not be saved."), + _.escape(file.name)), + type: 'warning', + sticky: true, + }); + resolve(); + }); + }); + })).then(function () { + self.$sendButton.prop('disabled', false); + }); + }, + /** + * Returns a Promise that is never resolved to prevent sending the form + * twice when clicking twice on the button, in combination with the `async` + * in the event definition. + * + * @private + * @returns {Promise} + */ _onSubmitButtonClick: function () { - return new Promise(function() {}); + return new Promise(function (resolve, reject) {}); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _updateAttachments: function () { + this.$attachmentIds.val(_.pluck(this.attachments, 'id')); + this.$attachmentTokens.val(_.pluck(this.attachments, 'access_token')); + this.$attachments.html(qweb.render('portal.Chatter.Attachments', { + attachments: this.attachments, + showDelete: true, + })); }, }); diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss index 055fa1405c3..87c64e73576 100644 --- a/addons/portal/static/src/scss/portal.scss +++ b/addons/portal/static/src/scss/portal.scss @@ -450,6 +450,20 @@ img.o_we_custom_image { .o_portal_chatter_pager { text-align: center; } + + .o_portal_chatter_attachment { + .o_portal_chatter_attachment_name { + word-wrap: break-word; + } + + .o_portal_chatter_attachment_delete { + @include o-position-absolute($top: 0, $right: 0); + opacity: 0; + } + &:hover .o_portal_chatter_attachment_delete { + opacity: 1; + } + } } form label { diff --git a/addons/portal/static/src/xml/portal_chatter.xml b/addons/portal/static/src/xml/portal_chatter.xml index f090965bde2..339477b3715 100644 --- a/addons/portal/static/src/xml/portal_chatter.xml +++ b/addons/portal/static/src/xml/portal_chatter.xml @@ -22,9 +22,10 @@ -->
+

Leave a comment

-

You must be logged in to post a comment.

+

You must be logged in to post a comment.

@@ -41,18 +42,46 @@ + + + - +
+
+ + +
+
+ +
+ +
+
+
+ + + +
+
+ + @@ -71,12 +100,9 @@
-
- - + + +
diff --git a/addons/portal/tests/__init__.py b/addons/portal/tests/__init__.py index 06a8d4cd529..752ffa6f96c 100644 --- a/addons/portal/tests/__init__.py +++ b/addons/portal/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_load_process +from . import test_attachment diff --git a/addons/portal/tests/test_attachment.py b/addons/portal/tests/test_attachment.py new file mode 100644 index 00000000000..a3f8c3b11db --- /dev/null +++ b/addons/portal/tests/test_attachment.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo import http, tests +from odoo.tools import mute_logger + + +@tests.tagged('post_install', '-at_install') +class TestUi(tests.HttpCase): + + @mute_logger('odoo.addons.website.models.ir_http', 'odoo.http') + def test_01_portal_attachment(self): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + # For this test we need a parent document with an access_token field: + # attachment itself has an access_token so we can use it as parent too. + record = self.env['ir.attachment'].create({ + 'name': 'a record with an access_token field', + }) + + # Test public user can't create attachment without token of document + create_data = { + 'name': "new attachment", + 'res_model': record._name, + 'res_id': record.id, + 'csrf_token': http.WebRequest.csrf_token(self), + } + create_url = base_url + '/portal/attachment/add' + files = [('file', ('test.txt', b'test', 'plain/text'))] + res = self.url_open(url=create_url, data=create_data, files=files) + self.assertEqual(res.status_code, 400) + self.assertTrue("you do not have the rights" in res.text) + + # Test public user can create attachment with token + create_data['access_token'] = record.generate_access_token()[0] + res = self.url_open(url=create_url, data=create_data, files=files) + self.assertEqual(res.status_code, 200) + create_res = json.loads(res.content.decode('utf-8')) + self.assertEqual(create_res['name'], "new attachment") + + # Test created attachment is private + res_binary = self.url_open('/web/content/%d' % create_res['id']) + self.assertEqual(res_binary.status_code, 404) + + # Test created access_token is working + res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token'])) + self.assertEqual(res_binary.status_code, 200) + + # Test mimetype is neutered as non-admin + files = [('file', ('test.svg', b'', 'image/svg+xml'))] + res = self.url_open(url=create_url, data=create_data, files=files) + self.assertEqual(res.status_code, 200) + create_res = json.loads(res.content.decode('utf-8')) + self.assertEqual(create_res['mimetype'], 'text/plain') + + res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token'])) + self.assertEqual(res_binary.headers['Content-Type'], 'text/plain') + self.assertEqual(res_binary.content, b'') + + res_image = self.url_open('/web/image/%d?access_token=%s' % (create_res['id'], create_res['access_token'])) + self.assertEqual(res_image.headers['Content-Type'], 'text/plain') + self.assertEqual(res_image.content, b'') + + # Test attachment can't be removed without valid token + remove_data = { + 'attachment_id': create_res['id'], + 'access_token': 'wrong', + } + remove_url = base_url + '/portal/attachment/remove' + res = self.opener.post(url=remove_url, json={'params': remove_data}) + self.assertEqual(res.status_code, 200) + self.assertTrue(self.env['ir.attachment'].search([('id', '=', create_res['id'])])) + self.assertTrue("you do not have the rights" in res.text) + + # Test attachment can be removed with token if "pending" state + remove_data['access_token'] = create_res['access_token'] + res = self.opener.post(url=remove_url, json={'params': remove_data}) + self.assertEqual(res.status_code, 200) + remove_res = json.loads(res.content.decode('utf-8'))['result'] + self.assertFalse(self.env['ir.attachment'].search([('id', '=', create_res['id'])])) + self.assertTrue(remove_res is True) + + # Test attachment can't be removed if not "pending" state + remove_data = { + 'attachment_id': record.id, + 'access_token': record.access_token, + } + res = self.opener.post(url=remove_url, json={'params': remove_data}) + self.assertEqual(res.status_code, 200) + self.assertTrue(self.env['ir.attachment'].search([('id', '=', record.id)])) + self.assertTrue("not in a pending state" in res.text) + + # Test attachment can't be removed if attached to a message + attachment = self.env['ir.attachment'].create({ + 'name': 'a record with an access_token field', + 'res_model': 'mail.compose.message', + 'res_id': 0, + 'access_token': self.env['ir.attachment']._generate_access_token(), + }) + message = self.env['mail.message'].create({ + 'attachment_ids': [(6, 0, attachment.ids)], + }) + remove_data = { + 'attachment_id': attachment.id, + 'access_token': attachment.access_token, + } + res = self.opener.post(url=remove_url, json={'params': remove_data}) + self.assertEqual(res.status_code, 200) + self.assertTrue(attachment.exists()) + self.assertTrue("it is linked to a message" in res.text) + message.unlink() + + # Need a `mail.thread` record for the following test + thread_record = self.env['mail.channel'].create({ + 'name': 'channel', + 'public': 'public', + }) + # Authenticate because public user can't create `mail.message` without + # token, and `mail.channel` does not have an access_token. + self.authenticate('portal', 'portal') + + # Test attachment can't be associated if no token. + post_url = base_url + '/mail/chatter_post' + post_data = { + 'res_model': thread_record._name, + 'res_id': thread_record.id, + 'message': "test message", + 'attachment_ids': attachment.id, + 'attachment_tokens': 'false', + 'csrf_token': http.WebRequest.csrf_token(self), + } + self.assertFalse(thread_record.message_ids) + res = self.url_open(url=post_url, data=post_data) + thread_record.invalidate_cache(fnames=['message_ids'], ids=thread_record.ids) + message = thread_record.message_ids[0] + self.assertFalse(message.attachment_ids) + + # Test attachment can't be associated if not "pending" state + post_data['attachment_tokens'] = attachment.access_token + attachment.write({'res_model': 'model'}) + res = self.url_open(url=post_url, data=post_data) + thread_record.invalidate_cache(fnames=['message_ids'], ids=thread_record.ids) + message = thread_record.message_ids[0] + self.assertFalse(message.attachment_ids) + + # Test attachment can be associated if all good + attachment.write({'res_model': 'mail.compose.message'}) + res = self.url_open(url=post_url, data=post_data) + thread_record.invalidate_cache(fnames=['message_ids'], ids=thread_record.ids) + message = thread_record.message_ids[0] + self.assertEqual(len(message.attachment_ids), 1) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 11d54f323a6..a8195359fe0 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -883,11 +883,11 @@ class HttpCase(TransactionCase): self.opener = requests.Session() self.opener.cookies['session_id'] = self.session_id - def url_open(self, url, data=None, timeout=10, headers=None): + def url_open(self, url, data=None, files=None, timeout=10, headers=None): if url.startswith('/'): url = "http://%s:%s%s" % (HOST, PORT, url) - if data: - return self.opener.post(url, data=data, timeout=timeout, headers=headers) + if data or files: + return self.opener.post(url, data=data, files=files, timeout=timeout, headers=headers) return self.opener.get(url, timeout=timeout, headers=headers) def _wait_remaining_requests(self):