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):