[MERGE] website_{rating, slides}: Manage attachments in review update

PURPOSE

Allow to manage attachments when updating a frontend review.

SPECIFICATIONS

Currently when posting a review in eLearning or eCommerce one can attach
files to its review thanks for portal composer. Rating popup composer allow
to update its comment and rating. However this edit mode has two limitations.
It does not display attachments previously added and does not allow to add
attachments.

This merge fixes that by

* giving attachment values to the composer, allowing its display by the
composer display at startup;
* allowing to give new attachments and properly handle them in slides
route;

Uploaded and validated attachments cannot be removed by users. Indeed
message with attachments has probably already be sent to people and removing
attachments would lead to information loss.

LINKS

Task 2066600 (manage attachments)
Task 2058595 (eLearning v13 testing)

closes odoo/odoo#36409

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2019-09-05 18:25:43 +02:00
committed by GitHub
14 changed files with 313 additions and 263 deletions
+10 -7
View File
@@ -88,6 +88,15 @@ class PortalChatter(http.Controller):
def _portal_post_filter_params(self):
return ['token', 'hash', 'pid']
def _portal_post_check_attachments(self, attachment_ids, attachment_tokens):
if len(attachment_tokens) != len(attachment_ids):
raise UserError(_("An access token must be provided for each attachment."))
for (attachment_id, access_token) in zip(attachment_ids, attachment_tokens):
try:
CustomerPortal._document_check_access(self, 'ir.attachment', attachment_id, access_token)
except (AccessError, MissingError):
raise UserError(_("The attachment %s does not exist or you do not have the rights to access it.") % attachment_id)
@http.route(['/mail/chatter_post'], type='http', methods=['POST'], auth='public', website=True)
def portal_chatter_post(self, res_model, res_id, message, redirect=None, attachment_ids='', attachment_tokens='', **kw):
"""Create a new `mail.message` with the given `message` and/or
@@ -103,13 +112,7 @@ class PortalChatter(http.Controller):
attachment_ids = [int(attachment_id) for attachment_id in attachment_ids.split(',') if attachment_id]
attachment_tokens = [attachment_token for attachment_token in attachment_tokens.split(',') if attachment_token]
if len(attachment_tokens) != len(attachment_ids):
raise UserError(_("An access token must be provided for each attachment."))
for (attachment_id, access_token) in zip(attachment_ids, attachment_tokens):
try:
CustomerPortal._document_check_access(self, 'ir.attachment', attachment_id, access_token)
except (AccessError, MissingError):
raise UserError(_("The attachment %s does not exist or you do not have the rights to access it.") % attachment_id)
self._portal_post_check_attachments(attachment_ids, attachment_tokens)
if message or attachment_ids:
# message is received in plaintext and saved in html
+2 -152
View File
@@ -1,163 +1,14 @@
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');
var portalComposer = require('portal.composer');
var qweb = core.qweb;
var _t = core._t;
/**
* Widget PortalComposer
*
* Display the composer (according to access right)
*
*/
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',
},
/**
* @constructor
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.options = _.defaults(options || {}, {
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
'token': false,
'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);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @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("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 (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,
}));
},
});
/**
* Widget PortalChatter
*
@@ -237,7 +88,7 @@ var PortalChatter = publicWidget.Widget.extend({
// instanciate and insert composer widget
if (this.options['display_composer']) {
this._composer = new PortalComposer(this, this.options);
this._composer = new portalComposer.PortalComposer(this, this.options);
defs.push(this._composer.replace(this.$('.o_portal_chatter_composer')));
}
@@ -426,7 +277,6 @@ publicWidget.registry.portalChatter = publicWidget.Widget.extend({
});
return {
PortalComposer: PortalComposer,
PortalChatter: PortalChatter,
};
});
@@ -0,0 +1,172 @@
odoo.define('portal.composer', function (require) {
'use strict';
var ajax = require('web.ajax');
var core = require('web.core');
var publicWidget = require('web.public.widget');
var qweb = core.qweb;
var _t = core._t;
/**
* Widget PortalComposer
*
* Display the composer (according to access right)
*
*/
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',
},
/**
* @constructor
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.options = _.defaults(options || {}, {
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
'token': false,
'res_model': false,
'res_id': false,
});
this.attachments = [];
},
/**
* @override
*/
start: function () {
var self = this;
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).then(function () {
if (self.options.default_attachment_ids) {
self.attachments = self.options.default_attachment_ids || [];
_.each(self.attachments, function(attachment) {
attachment.state = 'done';
});
self._updateAttachments();
}
return Promise.resolve();
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @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) {
attachment.state = 'pending';
self.attachments.push(attachment);
self._updateAttachments();
resolve();
}).guardedCatch(function (error) {
self.displayNotification({
title: _t("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 (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,
}));
},
});
return {
PortalComposer: PortalComposer,
};
});
+60 -48
View File
@@ -418,54 +418,6 @@ img.o_we_custom_image {
width: 50px;
}
.o_portal_chatter {
padding: 10px;
.o_portal_chatter_avatar{
width: 45px;
height: 45px;
margin-right: 1rem;
}
.o_portal_chatter_header {
margin-bottom: 15px;
}
.o_portal_chatter_composer {
margin-bottom: 15px;
}
.o_portal_chatter_messages {
margin-bottom: 15px;
.o_portal_chatter_message_title {
p {
font-size:85%;
color:$o-main-color-muted;
margin: 0px;
}
}
}
.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 {
font-weight: $font-weight-bold;
&.label-optional {
@@ -512,3 +464,63 @@ form label {
}
}
}
// ------------------------------------------------------------
// Frontend Discuss widget
// ------------------------------------------------------------
// Readonly display
.o_portal_chatter {
padding: 10px;
.o_portal_chatter_avatar {
width: 45px;
height: 45px;
margin-right: 1rem;
}
.o_portal_chatter_header {
margin-bottom: 15px;
}
.o_portal_chatter_composer {
margin-bottom: 15px;
}
.o_portal_chatter_messages {
margin-bottom: 15px;
.o_portal_chatter_message_title {
p {
font-size:85%;
color:$o-main-color-muted;
margin: 0px;
}
}
}
.o_portal_chatter_pager {
text-align: center;
}
}
// Readonly / Composer mix display
.o_portal_chatter,
.o_portal_chatter_composer {
.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;
}
}
}
@@ -70,7 +70,7 @@
<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">
<button t-if="showDelete and attachment.state == 'pending'" 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&amp;access_token=#{attachment.access_token}" target="_blank">
+1
View File
@@ -19,6 +19,7 @@
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/portal/static/src/js/portal.js"></script>
<script type="text/javascript" src="/portal/static/src/js/portal_chatter.js"></script>
<script type="text/javascript" src="/portal/static/src/js/portal_composer.js"></script>
<script type="text/javascript" src="/portal/static/src/js/portal_signature.js"></script>
<script type="text/javascript" src="/portal/static/src/js/portal_sidebar.js"></script>
</xpath>
@@ -2,11 +2,11 @@ odoo.define('rating.portal.composer', function (require) {
'use strict';
var core = require('web.core');
var portalChatter = require('portal.chatter');
var portalComposer = require('portal.composer');
var _t = core._t;
var PortalComposer = portalChatter.PortalComposer;
var PortalComposer = portalComposer.PortalComposer;
var STAR_RATING_RATIO = 2; // conversion factor from the star (1-5) to the db rating range (1-10)
@@ -3,9 +3,9 @@ odoo.define('portal.rating.composer', function (require) {
var publicWidget = require('web.public.widget');
var session = require('web.session');
var portalChatter = require('portal.chatter');
var portalComposer = require('portal.composer');
var PortalComposer = portalChatter.PortalComposer;
var PortalComposer = portalComposer.PortalComposer;
var STAR_RATING_RATIO = 2; // conversion factor from the star (1-5) to the db rating range (1-10)
@@ -61,7 +61,8 @@ publicWidget.registry.RatingPopupComposer = publicWidget.Widget.extend({
* @override
*/
start: function () {
var ratingPopup = new RatingPopupComposer(this, this.$el.data());
var ratingPopupData = this.$el.data();
var ratingPopup = new RatingPopupComposer(this, ratingPopupData);
return Promise.all([
this._super.apply(this, arguments),
ratingPopup.appendTo(this.$el)
@@ -23,6 +23,7 @@
t-att-data-default_message="default_message"
t-att-data-default_message_id="default_message_id"
t-att-data-default_rating_value="default_rating_value"
t-att-data-default_attachment_ids="default_attachment_ids"
t-att-data-force_submit_url="force_submit_url"
t-att-data-disable_composer="disable_composer">
</div>
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mail
from . import main
from . import portal
from . import mail
+36 -15
View File
@@ -1,33 +1,51 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo.http import request
import werkzeug
from werkzeug.exceptions import NotFound, Forbidden
from odoo import http
from odoo.addons.portal.controllers.mail import _check_special_access
from odoo.http import request
from odoo.addons.portal.controllers.mail import _check_special_access, PortalChatter
from odoo.tools import plaintext2html
_logger = logging.getLogger(__name__)
class SlidesPortalChatter(PortalChatter):
class SlidesMail(http.Controller):
@http.route(['/mail/chatter_post'], type='http', methods=['POST'], auth='public', website=True)
def portal_chatter_post(self, res_model, res_id, message, **kw):
result = super(SlidesPortalChatter, self).portal_chatter_post(res_model, res_id, message, **kw)
if res_model == 'slide.channel':
rating_value = kw.get('rating_value', False)
slide_channel = request.env[res_model].sudo().browse(int(res_id))
if rating_value and slide_channel and request.env.user.partner_id.id == int(kw.get('pid')):
# apply karma gain rule only once
request.env.user.add_karma(slide_channel.karma_gen_channel_rank)
return result
@http.route('/slides/mail/update_comment', type='http', auth="user")
def mail_update_message(self, res_id, message, message_id, **post):
res_model = 'slide.channel' # keep this mecanism intern to slide
message_body = plaintext2html(message)
@http.route([
'/slides/mail/update_comment',
'/mail/chatter_update',
], type='http', auth="user")
def mail_update_message(self, res_model, res_id, message, message_id, redirect=None, attachment_ids='', attachment_tokens='', **post):
# keep this mecanism intern to slide currently (saas 12.5) as it is
# considered experimental
if res_model != 'slide.channel':
raise Forbidden()
res_id = int(res_id)
message_id = int(message_id)
pid = int(post['pid']) if post.get('pid') else False
attachment_ids = [int(attachment_id) for attachment_id in attachment_ids.split(',') if attachment_id]
attachment_tokens = [attachment_token for attachment_token in attachment_tokens.split(',') if attachment_token]
self._portal_post_check_attachments(attachment_ids, attachment_tokens)
pid = int(post['pid']) if post.get('pid') else False
if not _check_special_access(res_model, res_id, token=post.get('token'), _hash=post.get('hash'), pid=pid):
raise Forbidden()
# update mail.message
# fetch and update mail.message
message_id = int(message_id)
message_body = plaintext2html(message)
domain = [
('model', '=', res_model),
('res_id', '=', res_id),
@@ -40,7 +58,8 @@ class SlidesMail(http.Controller):
if not message:
raise NotFound()
message.write({
'body': message_body
'body': message_body,
'attachment_ids': [(4, aid) for aid in attachment_ids],
})
# update rating
@@ -51,4 +70,6 @@ class SlidesMail(http.Controller):
'rating': float(post['rating_value'])
})
return werkzeug.utils.redirect(request.httprequest.referrer, 302)
# redirect to specified or referrer or simply channel page as fallback
redirect_url = redirect or (request.httprequest.referrer and request.httprequest.referrer + '#review') or '/slides/%s' % res_id
return werkzeug.utils.redirect(redirect_url, 302)
+21 -12
View File
@@ -2,19 +2,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import itertools
import json
import logging
import werkzeug
import math
from odoo import http, modules, tools, _
from odoo.exceptions import AccessError, UserError
from odoo.http import request
from odoo.osv import expression
from odoo import http, tools, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.addons.website_profile.controllers.main import WebsiteProfile
from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.exceptions import AccessError, UserError
from odoo.http import request
from odoo.osv import expression
_logger = logging.getLogger(__name__)
@@ -419,18 +418,28 @@ class WebsiteSlides(WebsiteProfile):
'enable_slide_upload': 'enable_slide_upload' in kw,
}
if not request.env.user._is_public():
last_message_values = request.env['mail.message'].search([
last_message = request.env['mail.message'].search([
('model', '=', channel._name),
('res_id', '=', channel.id),
('author_id', '=', request.env.user.partner_id.id),
('message_type', '=', 'comment'),
('website_published', '=', True)
], order='write_date DESC', limit=1).read(['body', 'rating_value'])
last_message_data = last_message_values[0] if last_message_values else {}
], order='write_date DESC', limit=1)
if last_message:
last_message_values = last_message.read(['body', 'rating_value', 'attachment_ids'])[0]
last_message_attachment_ids = last_message_values.pop('attachment_ids', [])
if last_message_attachment_ids:
last_message_attachment_ids = json.dumps(request.env['ir.attachment'].browse(last_message_attachment_ids).read(
['id', 'name', 'mimetype', 'file_size', 'access_token']
))
else:
last_message_values = {}
last_message_attachment_ids = []
values.update({
'last_message_id': last_message_data.get('id'),
'last_message': tools.html2plaintext(last_message_data.get('body', '')),
'last_rating_value': last_message_data.get('rating_value'),
'last_message_id': last_message_values.get('id'),
'last_message': tools.html2plaintext(last_message_values.get('body', '')),
'last_rating_value': last_message_values.get('rating_value'),
'last_message_attachment_ids': last_message_attachment_ids,
})
if channel.can_review:
values.update({
@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.mail import PortalChatter
class SlidesPortalChatter(PortalChatter):
@http.route(['/mail/chatter_post'], type='http', methods=['POST'], auth='public', website=True)
def portal_chatter_post(self, res_model, res_id, message, **kw):
result = super(SlidesPortalChatter, self).portal_chatter_post(res_model, res_id, message, **kw)
if res_model == 'slide.channel':
rating_value = kw.get('rating_value', False)
slide_channel = request.env[res_model].sudo().browse(int(res_id))
if rating_value and slide_channel and request.env.user.partner_id.id == int(kw.get('pid')):
# apply karma gain rule only once
request.env.user.add_karma(slide_channel.karma_gen_channel_rank)
return result
@@ -147,9 +147,10 @@
<t t-set="token" t-value="channel.access_token"/>
<t t-set="hash" t-value="message_post_hash"/>
<t t-set="pid" t-value="message_post_pid"/>
<t t-set="default_message_id" t-value="last_message_id"/>
<t t-set="default_message" t-value="last_message"/>
<t t-set="default_message_id" t-value="last_message_id"/>
<t t-set="default_rating_value" t-value="last_rating_value"/>
<t t-set="default_attachment_ids" t-value="last_message_attachment_ids"/>
<t t-set="force_submit_url" t-value="'/slides/mail/update_comment' if last_message_id else False"/>
<t t-set="disable_composer" t-value="not channel.can_review"/>
</t>