[IMP] portal: allow attachment upload from portal chatter

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) <odo@openerp.com>


Co-authored-by: Pratima Gupta <pgu@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
This commit is contained in:
Pratima Gupta
2019-07-24 12:30:31 +00:00
committed by Sébastien Theys
co-authored by Sébastien Theys
parent 997bd55016
commit 61de1c263a
8 changed files with 457 additions and 22 deletions
+62 -7
View File
@@ -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)
+82 -3
View File
@@ -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
+109 -1
View File
@@ -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 <strong>%s</strong> 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,
}));
},
});
+14
View File
@@ -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 {
@@ -22,9 +22,10 @@
-->
<t t-name="portal.Composer">
<div class="o_portal_chatter_composer" t-if="widget.options['allow_composer']">
<t t-set="discussion_url" t-value="window.encodeURI(window.location.href.split('#')[0] + '#discussion')"/>
<t t-if="!widget.options['display_composer']">
<h4>Leave a comment</h4>
<p>You must be <a t-attf-href="/web/login?redirect=#{window.encodeURI(window.location.href + '#discussion')}">logged in</a> to post a comment.</p>
<p>You must be <a t-attf-href="/web/login?redirect=#{discussion_url}">logged in</a> to post a comment.</p>
</t>
<t t-if="widget.options['display_composer']">
<div class="media">
@@ -41,18 +42,46 @@
<input type='hidden' name="hash" t-att-value="widget.options['hash']" t-if="widget.options['hash']"/>
<input type="hidden" name="sha_in" t-att-value="widget.options['sha_in']" t-if="widget.options['sha_in']"/>
<input type="hidden" name="sha_time" t-att-value="widget.options['sha_time']" t-if="widget.options['sha_time']"/>
<input type="hidden" name="redirect" t-att-value="discussion_url"/>
<input type="hidden" name="attachment_ids" class="o_portal_chatter_attachment_ids"/>
<input type="hidden" name="attachment_tokens" class="o_portal_chatter_attachment_tokens"/>
<div class="alert alert-danger mt8 mb0 o_portal_chatter_composer_error" style="display:none;" role="alert">
Oops! Something went wrong. Try to reload the page and log in.
</div>
<button t-attf-class="o_portal_chatter_composer_btn btn btn-primary mt8 o_website_message_post_helper" type="submit">Send</button>
<div class="o_portal_chatter_attachments mt-3"/>
<div class="mt8">
<button t-attf-class="o_portal_chatter_composer_btn btn btn-primary" type="submit">Send</button>
<button class="o_portal_chatter_attachment_btn btn btn-secondary" type="button" title="Add attachment">
<i class="fa fa-paperclip"/>
</button>
</div>
</div>
</form>
<form class="d-none">
<input type="file" class="o_portal_chatter_file_input" multiple="multiple"/>
</form>
</div>
</div>
</t>
</div>
</t>
<t t-name="portal.Chatter.Attachments">
<div t-if="attachments.length" class="row">
<div t-foreach="attachments" t-as="attachment" class="col-lg-2 col-md-3 col-sm-6">
<div class="o_portal_chatter_attachment mb-2 position-relative text-center" t-att-data-id="attachment.id">
<button t-if="showDelete" class="o_portal_chatter_attachment_delete btn btn-sm btn-outline-danger" title="Delete">
<i class="fa fa-times"/>
</button>
<a t-attf-href="/web/content/#{attachment.id}?download=true" target="_blank">
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype"/>
<div class='o_portal_chatter_attachment_name'><t t-esc='attachment.name'/></div>
</a>
</div>
</div>
</div>
</t>
<!--
Widget PortalChatter, and subtemplates
-->
@@ -71,12 +100,9 @@
<t t-raw="message.body"/>
<div class="o_portal_chatter_attachments">
<div class="col-lg-2 col-md-3 text-center" t-foreach='message.attachment_ids' t-as='attachment'>
<a t-attf-href="/web/content/#{attachment.id}?download=true" target="_blank">
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-data-src="/web/image/#{attachment.id}/100x80"/>
<div class='oe_attachment_name'><t t-raw='attachment.name'/></div>
</a>
</div>
<t t-call="portal.Chatter.Attachments">
<t t-set="attachments" t-value="message.attachment_ids"/>
</t>
</div>
</div>
</div>
+1
View File
@@ -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
+152
View File
@@ -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'<svg></svg>', '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'<svg></svg>')
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'<svg></svg>')
# 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)
+3 -3
View File
@@ -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):