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 @@
-->