diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js index 962699bc003..c80a5f0de97 100644 --- a/addons/portal/static/src/js/portal_chatter.js +++ b/addons/portal/static/src/js/portal_chatter.js @@ -144,6 +144,9 @@ var PortalChatter = publicWidget.Widget.extend({ this.messageFetch(); this._reloadComposer(); }, + _createComposerWidget: function () { + return new portalComposer.PortalComposer(this, this.options); + }, /** * Destroy current composer widget and initialize and insert new widget * @@ -154,7 +157,7 @@ var PortalChatter = publicWidget.Widget.extend({ this._composer.destroy(); } if (this.options.display_composer) { - this._composer = new portalComposer.PortalComposer(this, this.options); + this._composer = this._createComposerWidget(); await this._composer.appendTo(this.$('.o_portal_chatter_composer')); } }, diff --git a/addons/portal/static/src/js/portal_composer.js b/addons/portal/static/src/js/portal_composer.js index 8d8cabecb28..645210c0116 100644 --- a/addons/portal/static/src/js/portal_composer.js +++ b/addons/portal/static/src/js/portal_composer.js @@ -98,6 +98,15 @@ var PortalComposer = publicWidget.Widget.extend({ self.$sendButton.prop('disabled', false); }); }, + _prepareAttachmentData: function (file) { + return { + 'name': file.name, + 'file': file, + 'res_id': this.options.res_id, + 'res_model': this.options.res_model, + 'access_token': this.options.token, + }; + }, /** * @private * @returns {Promise} @@ -109,13 +118,7 @@ var PortalComposer = publicWidget.Widget.extend({ 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, - }; + var data = self._prepareAttachmentData(file); ajax.post('/portal/attachment/add', data).then(function (attachment) { attachment.state = 'pending'; self.attachments.push(attachment); diff --git a/addons/project/controllers/__init__.py b/addons/project/controllers/__init__.py index 903b755e71e..22decb94323 100644 --- a/addons/project/controllers/__init__.py +++ b/addons/project/controllers/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import portal +from . import project_sharing_chatter diff --git a/addons/project/controllers/project_sharing_chatter.py b/addons/project/controllers/project_sharing_chatter.py new file mode 100644 index 00000000000..77f3145e52f --- /dev/null +++ b/addons/project/controllers/project_sharing_chatter.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import Forbidden + +from odoo.http import request, route +from odoo.tools import consteq + +from odoo.addons.portal.controllers.mail import PortalChatter + + +class ProjectSharingChatter(PortalChatter): + def _check_project_access_and_get_token(self, project_id, res_model, res_id, token): + """ Check if the chatter in project sharing can be accessed + + If the portal user is in the project sharing, then we do not have the access token of the task + but we can have the one of the project (if the user accessed to the project sharing views via the shared link). + So, we need to check if the chatter is for a task and if the res_id is a task + in the project shared. Then, if we had the project token and this one is the one in the project + then we return the token of the task to continue the portal chatter process. + If we do not have any token, then we need to check if the portal user is a follower of the project shared. + If it is the case, then we give the access token of the task. + """ + project_sudo = request.env['project.project'].browse(project_id).sudo() + can_access = project_sudo and res_model == 'project.task' and res_id in project_sudo.tasks.ids + if token: + can_access &= consteq(project_sudo.access_token, token) + else: + can_access &= request.env.user.partner_id.commercial_partner_id in project_sudo.message_partner_ids + task = None + if can_access: + task = request.env['project.task'].sudo().search([('id', '=', res_id), ('project_id', '=', project_sudo.id)]) + if not can_access or not task: + raise Forbidden() + return task[task._mail_post_token_field] + + # ============================================================ # + # Note concerning the methods portal_chatter_(init/post/fetch) + # ============================================================ # + # + # When the project is shared to a portal user with the edit rights, + # he has the read/write access to the related tasks. So it could be + # possible to call directly the message_post method on a task. + # + # This change is considered as safe, as we only willingly expose + # records, for some assumed fields only, and this feature is + # optional and opt-in. (like the public employee model for example). + # It doesn't allow portal users to access other models, like + # a timesheet or an invoice. + # + # It could seem odd to use those routes, and converting the project + # access token into the task access token, as the user has actually + # access to the records. + # + # However, it has been decided that it was the less hacky way to + # achieve this, as: + # + # - We're reusing the existing routes, that convert all the data + # into valid arguments for the methods we use (message_post, ...). + # That way, we don't have to reinvent the wheel, duplicating code + # from mail/portal that surely will lead too desynchronization + # and inconsistencies over the time. + # + # - We don't define new routes, to do the exact same things than portal, + # considering that the portal user can use message_post for example + # because he has access to the record. + # Let's suppose that we remove this in a future development, those + # new routes won't be valid anymore. + # + # - We could have reused the mail widgets, as we already reuse the + # form/list/kanban views, etc. However, we only want to display + # the messages and allow to post. We don't need the next activities + # the followers system, etc. This required to override most of the + # mail.thread basic methods, without being sure that this would + # work with other installed applications or customizations + + @route() + def portal_chatter_init(self, res_model, res_id, domain=False, limit=False, **kwargs): + project_sharing_id = kwargs.get('project_sharing_id') + if project_sharing_id: + # if there is a token in `kwargs` then it should be the access_token of the project shared + token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kwargs.get('token')) + if token: + del kwargs['project_sharing_id'] + kwargs['token'] = token + return super().portal_chatter_init(res_model, res_id, domain=domain, limit=limit, **kwargs) + + @route() + def portal_chatter_post(self, res_model, res_id, message, attachment_ids=None, attachment_tokens=None, **kw): + project_sharing_id = kw.get('project_sharing_id') + if project_sharing_id: + token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kw.get('token')) + if token: + del kw['project_sharing_id'] + kw['token'] = token + return super().portal_chatter_post(res_model, res_id, message, attachment_ids=attachment_ids, attachment_tokens=attachment_tokens, **kw) + + @route() + def portal_message_fetch(self, res_model, res_id, domain=False, limit=10, offset=0, **kw): + project_sharing_id = kw.get('project_sharing_id') + if project_sharing_id: + token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kw.get('token')) + if token: + kw['token'] = token + return super().portal_message_fetch(res_model, res_id, domain=domain, limit=limit, offset=offset, **kw) diff --git a/addons/project/static/src/project_sharing/components/chatter.js b/addons/project/static/src/project_sharing/components/chatter.js new file mode 100644 index 00000000000..3f85c37f935 --- /dev/null +++ b/addons/project/static/src/project_sharing/components/chatter.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +import { PortalChatter } from 'portal.chatter'; +import Composer from './composer'; + +export default PortalChatter.extend({ + _messageFetchPrepareParams() { + const data = this._super.apply(this, arguments); + if (this.options.project_sharing_id) { + data.project_sharing_id = this.options.project_sharing_id; + } + return data; + }, + _createComposerWidget() { + return new Composer(this, this.options); + }, +}); diff --git a/addons/project/static/src/project_sharing/components/composer.js b/addons/project/static/src/project_sharing/components/composer.js new file mode 100644 index 00000000000..a7e75aad119 --- /dev/null +++ b/addons/project/static/src/project_sharing/components/composer.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +import { PortalComposer } from 'portal.composer'; + +export default PortalComposer.extend({ + _prepareAttachmentData() { + const data = this._super.apply(this, arguments); + const newData = {}; + if (this.options.display_composer && typeof this.options.display_composer == 'string') { + // then we should have the access_token of the task + newData.access_token = this.options.display_composer; + } else { + newData.project_sharing_id = this.options.project_sharing_id; + } + return Object.assign(data, newData); + }, +}); diff --git a/addons/project/static/src/project_sharing/views/form/renderer.js b/addons/project/static/src/project_sharing/views/form/renderer.js index 5ef0ddc3470..41105160548 100644 --- a/addons/project/static/src/project_sharing/views/form/renderer.js +++ b/addons/project/static/src/project_sharing/views/form/renderer.js @@ -1,16 +1,20 @@ /** @odoo-module **/ import FormRenderer from 'web.FormRenderer'; -import { PortalChatter } from 'portal.chatter'; +import Chatter from '@project/project_sharing/components/chatter'; +import { session } from '@web/session'; export default FormRenderer.extend({ _makeChatterContainerComponent() { const props = this._makeChatterContainerProps(); - this._chatterContainerComponent = new PortalChatter(this, props); + this._chatterContainerComponent = new Chatter(this, props); }, _makeChatterContainerProps() { + // FIXME: perhaps check if we have a parent in the window ? + // Call the parent window to get the url displayed in the browser since we are in an iframe + const query = new URLSearchParams(window.parent.location.search); return { - token: '', + token: query.get('access_token') || '', res_model: 'project.task', pid: '', hash: '', @@ -18,6 +22,7 @@ export default FormRenderer.extend({ pager_step: 10, allow_composer: true, two_columns: false, + project_sharing_id: session.project_id, }; }, _makeChatterContainerTarget() {