[MERGE] portal_rating, website_slides: ease and improve review submission
PURPOSE Make it easier to submit reviews and improve rating display. SPECIFICATIONS Remove useless and empty "Published on" + DATE MISSING when creating and/or updating a comment response. Fix various display of rating values when containing more than 2 decimals. Add rounding in both frontend display as well as backend statistics computation. In order to reduce friction and ease the rating process for attendees we do not force the constraint stating that the message must contain a message or an attachment onto the users. Do that only for courses Fix various display issues (star titles, spaces, ...) Task-2728564 closes odoo/odoo#82792 Related: odoo/enterprise#24640 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -112,6 +112,10 @@ class PortalChatter(http.Controller):
|
||||
except (AccessError, MissingError):
|
||||
raise UserError(_("The attachment %s does not exist or you do not have the rights to access it.", attachment_id))
|
||||
|
||||
def _portal_post_has_content(self, res_model, res_id, message, attachment_ids=None, **kw):
|
||||
""" Tells if we can effectively post on the model based on content. """
|
||||
return bool(message) or bool(attachment_ids)
|
||||
|
||||
@http.route(['/mail/chatter_post'], type='json', methods=['POST'], auth='public', website=True)
|
||||
def portal_chatter_post(self, res_model, res_id, message, attachment_ids=None, attachment_tokens=None, **kw):
|
||||
"""Create a new `mail.message` with the given `message` and/or `attachment_ids` and return new message values.
|
||||
@@ -120,38 +124,42 @@ class PortalChatter(http.Controller):
|
||||
`res_model`. The user must have access rights on this target document or
|
||||
must provide valid identifiers through `kw`. See `_message_post_helper`.
|
||||
"""
|
||||
if not self._portal_post_has_content(res_model, res_id, message,
|
||||
attachment_ids=attachment_ids, attachment_tokens=attachment_tokens,
|
||||
**kw):
|
||||
return
|
||||
|
||||
res_id = int(res_id)
|
||||
|
||||
self._portal_post_check_attachments(attachment_ids, attachment_tokens)
|
||||
|
||||
if message or attachment_ids:
|
||||
result = {'default_message': message}
|
||||
# message is received in plaintext and saved in html
|
||||
if message:
|
||||
message = plaintext2html(message)
|
||||
post_values = {
|
||||
'res_model': res_model,
|
||||
'res_id': res_id,
|
||||
'message': message,
|
||||
'send_after_commit': False,
|
||||
'attachment_ids': False, # will be added afterward
|
||||
}
|
||||
post_values.update((fname, kw.get(fname)) for fname in self._portal_post_filter_params())
|
||||
message = _message_post_helper(**post_values)
|
||||
result.update({'default_message_id': message.id})
|
||||
result = {'default_message': message}
|
||||
# message is received in plaintext and saved in html
|
||||
if message:
|
||||
message = plaintext2html(message)
|
||||
post_values = {
|
||||
'res_model': res_model,
|
||||
'res_id': res_id,
|
||||
'message': message,
|
||||
'send_after_commit': False,
|
||||
'attachment_ids': False, # will be added afterward
|
||||
}
|
||||
post_values.update((fname, kw.get(fname)) for fname in self._portal_post_filter_params())
|
||||
message = _message_post_helper(**post_values)
|
||||
result.update({'default_message_id': message.id})
|
||||
|
||||
if attachment_ids:
|
||||
# sudo write the attachment to bypass the read access
|
||||
# verification in mail message
|
||||
record = request.env[res_model].browse(res_id)
|
||||
message_values = {'res_id': res_id, 'model': res_model}
|
||||
attachments = record._message_post_process_attachments([], attachment_ids, message_values)
|
||||
if attachment_ids:
|
||||
# sudo write the attachment to bypass the read access
|
||||
# verification in mail message
|
||||
record = request.env[res_model].browse(res_id)
|
||||
message_values = {'res_id': res_id, 'model': res_model}
|
||||
attachments = record._message_post_process_attachments([], attachment_ids, message_values)
|
||||
|
||||
if attachments.get('attachment_ids'):
|
||||
message.sudo().write(attachments)
|
||||
if attachments.get('attachment_ids'):
|
||||
message.sudo().write(attachments)
|
||||
|
||||
result.update({'default_attachment_ids': message.attachment_ids.sudo().read(['id', 'name', 'mimetype', 'file_size', 'access_token'])})
|
||||
return result
|
||||
result.update({'default_attachment_ids': message.attachment_ids.sudo().read(['id', 'name', 'mimetype', 'file_size', 'access_token'])})
|
||||
return result
|
||||
|
||||
@http.route('/mail/chatter_init', type='json', auth='public', website=True)
|
||||
def portal_chatter_init(self, res_model, res_id, domain=False, limit=False, **kwargs):
|
||||
|
||||
@@ -158,9 +158,9 @@ var PortalComposer = publicWidget.Widget.extend({
|
||||
*/
|
||||
_onSubmitButtonClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.$inputTextarea.val().trim() && !this.attachments.length) {
|
||||
const error = this._onSubmitCheckContent();
|
||||
if (error) {
|
||||
this.$inputTextarea.addClass('border-danger');
|
||||
const error = _t('Some fields are required. Please make sure to write a message or attach a document');
|
||||
this.$(".o_portal_chatter_composer_error").text(error).removeClass('d-none');
|
||||
return Promise.reject();
|
||||
} else {
|
||||
@@ -168,6 +168,15 @@ var PortalComposer = publicWidget.Widget.extend({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onSubmitCheckContent: function () {
|
||||
if (!this.$inputTextarea.val().trim() && !this.attachments.length) {
|
||||
return _t('Some fields are required. Please make sure to write a message or attach a document');
|
||||
};
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -138,13 +138,14 @@ PortalChatter.include({
|
||||
if (!result['rating_stats']) {
|
||||
return;
|
||||
}
|
||||
const self = this;
|
||||
const ratingData = {
|
||||
'avg': Math.round(result['rating_stats']['avg'] * 100) / 100,
|
||||
'percent': [],
|
||||
};
|
||||
_.each(_.keys(result['rating_stats']['percent']).reverse(), function (rating) {
|
||||
_.each(_.sortBy(_.keys(result['rating_stats']['percent'])).reverse(), function (rating) {
|
||||
ratingData['percent'].push({
|
||||
'num': rating,
|
||||
'num': self.roundToHalf(rating),
|
||||
'percent': utils.round_precision(result['rating_stats']['percent'][rating], 0.01),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,10 @@ PortalComposer.include({
|
||||
|
||||
// default options
|
||||
this.options = _.defaults(this.options, {
|
||||
'rate_with_void_content': false,
|
||||
'default_message': false,
|
||||
'default_message_id': false,
|
||||
'default_rating_value': 0.0,
|
||||
'default_rating_value': 4.0,
|
||||
'force_submit_url': false,
|
||||
});
|
||||
// star input widget
|
||||
@@ -61,6 +62,10 @@ PortalComposer.include({
|
||||
// rating stars
|
||||
self.$input = self.$('input[name="rating_value"]');
|
||||
self.$star_list = self.$('.stars').find('i');
|
||||
// if this is the first review, we do not use grey color contrast, even with default rating value.
|
||||
if (!self.options.default_message_id) {
|
||||
self.$star_list.removeClass('text-black-25');
|
||||
}
|
||||
|
||||
// set the default value to trigger the display of star widget and update the hidden input value.
|
||||
self.set("star_value", self.options.default_rating_value);
|
||||
@@ -149,5 +154,19 @@ PortalComposer.include({
|
||||
$modal.modal('hide');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onSubmitCheckContent: function (ev) {
|
||||
if (this.options.rate_with_void_content) {
|
||||
if (this.$input.val() === 0) {
|
||||
return _t('The rating is required. Please make sure to select one before sending your review.')
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,13 +51,14 @@ $o-w-rating-star-color: #FACC2E;
|
||||
.o_rating_star_card{
|
||||
margin-bottom: 5px;
|
||||
.stars {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
color: #FACC2E;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.stars i {
|
||||
margin-right: -3px;
|
||||
padding-right: 1px;
|
||||
padding-left: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
<div class="media-body">
|
||||
<div class="o_portal_chatter_message_title">
|
||||
<h5 class='mb-1'><t t-esc="rating.publisher_name"/></h5>
|
||||
<p>Published on <t t-esc="rating.publisher_datetime"/></p>
|
||||
</div>
|
||||
<textarea rows="3" class="form-control o_portal_rating_comment_input"><t t-esc="rating.publisher_comment"/></textarea>
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<t t-set="val_integer" t-value="Math.floor(val)"/>
|
||||
<t t-set="val_decimal" t-value="val - val_integer"/>
|
||||
<t t-set="empty_star" t-value="5 - (val_integer+Math.ceil(val_decimal))"/>
|
||||
<div class="o_website_rating_static" t-att-style="inline_mode ? 'display:inline' : ''" t-attf-aria-label="#{val} stars on 5" t-attf-title="#{val} stars on 5">
|
||||
<div class="o_website_rating_static"
|
||||
t-att-style="inline_mode ? 'display:inline' : ''"
|
||||
t-attf-aria-label="#{Math.round(val * 100) / 100} stars on 5"
|
||||
t-attf-title="#{Math.round(val * 100) / 100} stars on 5">
|
||||
<t t-foreach="_.range(0, val_integer)" t-as="num">
|
||||
<i class="fa fa-star" role="img"></i>
|
||||
</t>
|
||||
@@ -71,13 +74,13 @@
|
||||
|
||||
<div class="stars enabled">
|
||||
<t t-foreach="_.range(0, val_integer)" t-as="num">
|
||||
<i class="fa fa-star" role="img" aria-label="One star" title="One star"></i>
|
||||
<i class="fa fa-star" role="img" aria-label="Full star"></i>
|
||||
</t>
|
||||
<t t-if="val_decimal">
|
||||
<i class="fa fa-star-half-o" role="img" aria-label="Half a star" title="Half a star"></i>
|
||||
<i class="fa fa-star-half-o" role="img" aria-label="Half a star"></i>
|
||||
</t>
|
||||
<t t-foreach="_.range(0, empty_star)" t-as="num" role="img" t-attf-aria-label="#{empty_star} on 5" t-attf-title="#{empty_star} on 5">
|
||||
<i class="fa fa-star-o text-black-25"></i>
|
||||
<t t-foreach="_.range(0, empty_star)" t-as="num">
|
||||
<i class="fa fa-star-o text-black-25" role="img" aria-label="Empty star"></i>
|
||||
</t>
|
||||
</div>
|
||||
<div class="rate_text">
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
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-rate_with_void_content="rate_with_void_content"
|
||||
t-att-data-disable_composer="disable_composer"
|
||||
t-att-data-display_composer="display_composer"
|
||||
t-att-data-link_btn_classes="_link_btn_classes"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from odoo import fields, models, tools
|
||||
|
||||
from odoo.addons.rating.models.rating import RATING_LIMIT_MIN
|
||||
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
|
||||
|
||||
class ReportProjectTaskUser(models.Model):
|
||||
_name = "report.project.task.user"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import rating
|
||||
from . import rating_data
|
||||
from . import rating_mixin
|
||||
from . import rating_parent_mixin
|
||||
from . import mail_thread
|
||||
from . import mail_message
|
||||
|
||||
@@ -4,34 +4,15 @@ import base64
|
||||
import uuid
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.rating.models import rating_data
|
||||
from odoo.modules.module import get_resource_path
|
||||
|
||||
RATING_LIMIT_SATISFIED = 4
|
||||
RATING_LIMIT_OK = 3
|
||||
RATING_LIMIT_MIN = 1
|
||||
RATING_TEXT = [
|
||||
('top', 'Satisfied'),
|
||||
('ok', 'Okay'),
|
||||
('ko', 'Dissatisfied'),
|
||||
('none', 'No Rating yet'),
|
||||
]
|
||||
|
||||
|
||||
class Rating(models.Model):
|
||||
_name = "rating.rating"
|
||||
_description = "Rating"
|
||||
_order = 'write_date desc'
|
||||
_rec_name = 'res_name'
|
||||
_sql_constraints = [
|
||||
('rating_range', 'check(rating >= 0 and rating <= 5)', 'Rating should be between 0 and 5'),
|
||||
]
|
||||
|
||||
@api.depends('res_model', 'res_id')
|
||||
def _compute_res_name(self):
|
||||
for rating in self:
|
||||
name = self.env[rating.res_model].sudo().browse(rating.res_id).name_get()
|
||||
rating.res_name = name and name[0][1] or ('%s/%s') % (rating.res_model, rating.res_id)
|
||||
|
||||
@api.model
|
||||
def _default_access_token(self):
|
||||
@@ -61,7 +42,7 @@ class Rating(models.Model):
|
||||
partner_id = fields.Many2one('res.partner', string='Customer', help="Author of the rating")
|
||||
rating = fields.Float(string="Rating Value", group_operator="avg", default=0, help="Rating value: 0=Unhappy, 5=Happy")
|
||||
rating_image = fields.Binary('Image', compute='_compute_rating_image')
|
||||
rating_text = fields.Selection(RATING_TEXT, string='Rating', store=True, compute='_compute_rating_text', readonly=True)
|
||||
rating_text = fields.Selection(rating_data.RATING_TEXT, string='Rating', store=True, compute='_compute_rating_text', readonly=True)
|
||||
feedback = fields.Text('Comment', help="Reason of the rating")
|
||||
message_id = fields.Many2one(
|
||||
'mail.message', string="Message",
|
||||
@@ -71,6 +52,16 @@ class Rating(models.Model):
|
||||
access_token = fields.Char('Security Token', default=_default_access_token, help="Access token to set the rating of the value")
|
||||
consumed = fields.Boolean(string="Filled Rating", help="Enabled if the rating has been filled.")
|
||||
|
||||
_sql_constraints = [
|
||||
('rating_range', 'check(rating >= 0 and rating <= 5)', 'Rating should be between 0 and 5'),
|
||||
]
|
||||
|
||||
@api.depends('res_model', 'res_id')
|
||||
def _compute_res_name(self):
|
||||
for rating in self:
|
||||
name = self.env[rating.res_model].sudo().browse(rating.res_id).name_get()
|
||||
rating.res_name = name and name[0][1] or ('%s/%s') % (rating.res_model, rating.res_id)
|
||||
|
||||
@api.depends('res_model', 'res_id')
|
||||
def _compute_resource_ref(self):
|
||||
for rating in self:
|
||||
@@ -98,15 +89,7 @@ class Rating(models.Model):
|
||||
|
||||
def _get_rating_image_filename(self):
|
||||
self.ensure_one()
|
||||
if self.rating >= RATING_LIMIT_SATISFIED:
|
||||
rating_int = 5
|
||||
elif self.rating >= RATING_LIMIT_OK:
|
||||
rating_int = 3
|
||||
elif self.rating >= RATING_LIMIT_MIN:
|
||||
rating_int = 1
|
||||
else:
|
||||
rating_int = 0
|
||||
return 'rating_%s.png' % rating_int
|
||||
return 'rating_%s.png' % rating_data._rating_to_threshold(self.rating)
|
||||
|
||||
def _compute_rating_image(self):
|
||||
for rating in self:
|
||||
@@ -119,14 +102,7 @@ class Rating(models.Model):
|
||||
@api.depends('rating')
|
||||
def _compute_rating_text(self):
|
||||
for rating in self:
|
||||
if rating.rating >= RATING_LIMIT_SATISFIED:
|
||||
rating.rating_text = 'top'
|
||||
elif rating.rating >= RATING_LIMIT_OK:
|
||||
rating.rating_text = 'ok'
|
||||
elif rating.rating >= RATING_LIMIT_MIN:
|
||||
rating.rating_text = 'ko'
|
||||
else:
|
||||
rating.rating_text = 'none'
|
||||
rating.rating_text = rating_data._rating_to_text(rating.rating)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import operator
|
||||
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
RATING_AVG_TOP = 3.66
|
||||
RATING_AVG_OK = 2.33
|
||||
RATING_AVG_MIN = 1
|
||||
|
||||
RATING_LIMIT_SATISFIED = 4
|
||||
RATING_LIMIT_OK = 3
|
||||
RATING_LIMIT_MIN = 1
|
||||
RATING_TEXT = [
|
||||
('top', 'Satisfied'),
|
||||
('ok', 'Okay'),
|
||||
('ko', 'Dissatisfied'),
|
||||
('none', 'No Rating yet'),
|
||||
]
|
||||
|
||||
OPERATOR_MAPPING = {
|
||||
'=': operator.eq,
|
||||
'!=': operator.ne,
|
||||
'<': operator.lt,
|
||||
'<=': operator.le,
|
||||
'>': operator.gt,
|
||||
'>=': operator.ge,
|
||||
}
|
||||
|
||||
def _rating_avg_to_text(rating_avg):
|
||||
if float_compare(rating_avg, RATING_AVG_TOP, 2) >= 0:
|
||||
return 'top'
|
||||
if float_compare(rating_avg, RATING_AVG_OK, 2) >= 0:
|
||||
return 'ok'
|
||||
if float_compare(rating_avg, RATING_AVG_MIN, 2) >= 0:
|
||||
return 'ko'
|
||||
return 'none'
|
||||
|
||||
def _rating_assert_value(rating_value):
|
||||
assert 0 <= rating_value <= 5
|
||||
|
||||
def _rating_to_grade(rating_value):
|
||||
""" From a rating value give a text-based mean value. """
|
||||
_rating_assert_value(rating_value)
|
||||
if rating_value >= RATING_LIMIT_SATISFIED:
|
||||
return 'great'
|
||||
if rating_value >= RATING_LIMIT_OK:
|
||||
return 'okay'
|
||||
return 'bad'
|
||||
|
||||
def _rating_to_text(rating_value):
|
||||
""" From a rating value give a text-based mean value. """
|
||||
_rating_assert_value(rating_value)
|
||||
if rating_value >= RATING_LIMIT_SATISFIED:
|
||||
return 'top'
|
||||
if rating_value >= RATING_LIMIT_OK:
|
||||
return 'ok'
|
||||
if rating_value >= RATING_LIMIT_MIN:
|
||||
return 'ko'
|
||||
return 'none'
|
||||
|
||||
def _rating_to_threshold(rating_value):
|
||||
""" From a rating value, return the thresholds in form of 0-1-3-5 used
|
||||
notably for images. """
|
||||
_rating_assert_value(rating_value)
|
||||
if rating_value >= RATING_LIMIT_SATISFIED:
|
||||
return 5
|
||||
if rating_value >= RATING_LIMIT_OK:
|
||||
return 3
|
||||
if rating_value >= RATING_LIMIT_MIN:
|
||||
return 1
|
||||
return 0
|
||||
@@ -1,93 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import operator
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.addons.rating.models.rating import RATING_LIMIT_SATISFIED, RATING_LIMIT_OK, RATING_LIMIT_MIN, RATING_TEXT
|
||||
from odoo.addons.rating.models import rating_data
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
OPERATOR_MAPPING = {
|
||||
'=': operator.eq,
|
||||
'!=': operator.ne,
|
||||
'<': operator.lt,
|
||||
'<=': operator.le,
|
||||
'>': operator.gt,
|
||||
'>=': operator.ge,
|
||||
}
|
||||
RATING_AVG_TOP = 3.66
|
||||
RATING_AVG_OK = 2.33
|
||||
RATING_AVG_MIN = RATING_LIMIT_MIN
|
||||
|
||||
|
||||
class RatingParentMixin(models.AbstractModel):
|
||||
_name = 'rating.parent.mixin'
|
||||
_description = "Rating Parent Mixin"
|
||||
_rating_satisfaction_days = False # Number of last days used to compute parent satisfaction. Set to False to include all existing rating.
|
||||
|
||||
rating_ids = fields.One2many(
|
||||
'rating.rating', 'parent_res_id', string='Ratings',
|
||||
auto_join=True, groups='base.group_user',
|
||||
domain=lambda self: [('parent_res_model', '=', self._name)])
|
||||
rating_percentage_satisfaction = fields.Integer(
|
||||
"Rating Satisfaction",
|
||||
compute="_compute_rating_percentage_satisfaction", compute_sudo=True,
|
||||
store=False, help="Percentage of happy ratings")
|
||||
rating_count = fields.Integer(string='# Ratings', compute="_compute_rating_percentage_satisfaction", compute_sudo=True)
|
||||
rating_avg = fields.Float('Average Rating', groups='base.group_user',
|
||||
compute='_compute_rating_percentage_satisfaction', compute_sudo=True, search='_search_rating_avg')
|
||||
rating_avg_percentage = fields.Float('Average Rating (%)', groups='base.group_user',
|
||||
compute='_compute_rating_percentage_satisfaction', compute_sudo=True)
|
||||
rating_last_value = fields.Float('Rating Last Value', groups='base.group_user', related='rating_ids.rating')
|
||||
|
||||
@api.depends('rating_ids.rating', 'rating_ids.consumed')
|
||||
def _compute_rating_percentage_satisfaction(self):
|
||||
# build domain and fetch data
|
||||
domain = [('parent_res_model', '=', self._name), ('parent_res_id', 'in', self.ids), ('rating', '>=', RATING_LIMIT_MIN), ('consumed', '=', True)]
|
||||
if self._rating_satisfaction_days:
|
||||
domain += [('write_date', '>=', fields.Datetime.to_string(fields.datetime.now() - timedelta(days=self._rating_satisfaction_days)))]
|
||||
data = self.env['rating.rating'].read_group(domain, ['parent_res_id', 'rating'], ['parent_res_id', 'rating'], lazy=False)
|
||||
|
||||
# get repartition of grades per parent id
|
||||
default_grades = {'great': 0, 'okay': 0, 'bad': 0}
|
||||
grades_per_parent = dict((parent_id, dict(default_grades)) for parent_id in self.ids) # map: {parent_id: {'great': 0, 'bad': 0, 'ok': 0}}
|
||||
rating_scores_per_parent = defaultdict(int) # contains the total of the rating values per record
|
||||
for item in data:
|
||||
parent_id = item['parent_res_id']
|
||||
rating = item['rating']
|
||||
if rating > RATING_LIMIT_OK:
|
||||
grades_per_parent[parent_id]['great'] += item['__count']
|
||||
elif rating > RATING_LIMIT_MIN:
|
||||
grades_per_parent[parent_id]['okay'] += item['__count']
|
||||
else:
|
||||
grades_per_parent[parent_id]['bad'] += item['__count']
|
||||
rating_scores_per_parent[parent_id] += rating * item['__count']
|
||||
|
||||
# compute percentage per parent
|
||||
for record in self:
|
||||
repartition = grades_per_parent.get(record.id, default_grades)
|
||||
rating_count = sum(repartition.values())
|
||||
record.rating_count = rating_count
|
||||
record.rating_percentage_satisfaction = repartition['great'] * 100 / rating_count if rating_count else -1
|
||||
record.rating_avg = rating_scores_per_parent[record.id] / rating_count if rating_count else 0
|
||||
record.rating_avg_percentage = record.rating_avg / 5
|
||||
|
||||
def _search_rating_avg(self, operator, value):
|
||||
if operator not in OPERATOR_MAPPING:
|
||||
raise NotImplementedError('This operator %s is not supported in this search method.' % operator)
|
||||
domain = [('parent_res_model', '=', self._name), ('consumed', '=', True), ('rating', '>=', RATING_LIMIT_MIN)]
|
||||
if self._rating_satisfaction_days:
|
||||
min_date = fields.datetime.now() - timedelta(days=self._rating_satisfaction_days)
|
||||
domain = expression.AND([domain, [('write_date', '>=', fields.Datetime.to_string(min_date))]])
|
||||
rating_read_group = self.env['rating.rating'].sudo().read_group(domain, ['parent_res_id', 'rating_avg:avg(rating)'], ['parent_res_id'])
|
||||
parent_res_ids = [
|
||||
res['parent_res_id']
|
||||
for res in rating_read_group
|
||||
if OPERATOR_MAPPING[operator](float_compare(res['rating_avg'], value, 2), 0)
|
||||
]
|
||||
return [('id', 'in', parent_res_ids)]
|
||||
from odoo.tools.float_utils import float_compare, float_round
|
||||
|
||||
|
||||
class RatingMixin(models.AbstractModel):
|
||||
@@ -101,7 +20,7 @@ class RatingMixin(models.AbstractModel):
|
||||
rating_count = fields.Integer('Rating count', compute="_compute_rating_stats", compute_sudo=True)
|
||||
rating_avg = fields.Float("Average Rating", groups='base.group_user',
|
||||
compute='_compute_rating_stats', compute_sudo=True, search='_search_rating_avg')
|
||||
rating_avg_text = fields.Selection(RATING_TEXT, groups='base.group_user',
|
||||
rating_avg_text = fields.Selection(rating_data.RATING_TEXT, groups='base.group_user',
|
||||
compute='_compute_rating_avg_text', compute_sudo=True)
|
||||
rating_percentage_satisfaction = fields.Float("Rating Satisfaction", compute='_compute_rating_satisfaction', compute_sudo=True)
|
||||
rating_last_text = fields.Selection(string="Rating Text", groups='base.group_user', related="rating_ids.rating_text")
|
||||
@@ -115,7 +34,7 @@ class RatingMixin(models.AbstractModel):
|
||||
@api.depends('rating_ids.res_id', 'rating_ids.rating')
|
||||
def _compute_rating_stats(self):
|
||||
""" Compute avg and count in one query, as thoses fields will be used together most of the time. """
|
||||
domain = expression.AND([self._rating_domain(), [('rating', '>=', RATING_LIMIT_MIN)]])
|
||||
domain = expression.AND([self._rating_domain(), [('rating', '>=', rating_data.RATING_LIMIT_MIN)]])
|
||||
read_group_res = self.env['rating.rating'].read_group(domain, ['rating:avg'], groupby=['res_id'], lazy=False) # force average on rating column
|
||||
mapping = {item['res_id']: {'rating_count': item['__count'], 'rating_avg': item['rating']} for item in read_group_res}
|
||||
for record in self:
|
||||
@@ -123,48 +42,38 @@ class RatingMixin(models.AbstractModel):
|
||||
record.rating_avg = mapping.get(record.id, {}).get('rating_avg', 0)
|
||||
|
||||
def _search_rating_avg(self, operator, value):
|
||||
if operator not in OPERATOR_MAPPING:
|
||||
if operator not in rating_data.OPERATOR_MAPPING:
|
||||
raise NotImplementedError('This operator %s is not supported in this search method.' % operator)
|
||||
rating_read_group = self.env['rating.rating'].sudo().read_group(
|
||||
[('res_model', '=', self._name), ('consumed', '=', True), ('rating', '>=', RATING_LIMIT_MIN)],
|
||||
[('res_model', '=', self._name), ('consumed', '=', True), ('rating', '>=', rating_data.RATING_LIMIT_MIN)],
|
||||
['res_id', 'rating_avg:avg(rating)'], ['res_id'])
|
||||
res_ids = [
|
||||
res['res_id']
|
||||
for res in rating_read_group
|
||||
if OPERATOR_MAPPING[operator](float_compare(res['rating_avg'], value, 2), 0)
|
||||
if rating_data.OPERATOR_MAPPING[operator](float_compare(res['rating_avg'], value, 2), 0)
|
||||
]
|
||||
return [('id', 'in', res_ids)]
|
||||
|
||||
@api.depends('rating_avg')
|
||||
def _compute_rating_avg_text(self):
|
||||
for record in self:
|
||||
if float_compare(record.rating_avg, RATING_AVG_TOP, 2) >= 0:
|
||||
record.rating_avg_text = 'top'
|
||||
elif float_compare(record.rating_avg, RATING_AVG_OK, 2) >= 0:
|
||||
record.rating_avg_text = 'ok'
|
||||
elif float_compare(record.rating_avg, RATING_AVG_MIN, 2) >= 0:
|
||||
record.rating_avg_text = 'ko'
|
||||
else:
|
||||
record.rating_avg_text = 'none'
|
||||
record.rating_avg_text = rating_data._rating_avg_to_text(record.rating_avg)
|
||||
|
||||
@api.depends('rating_ids.res_id', 'rating_ids.rating')
|
||||
def _compute_rating_satisfaction(self):
|
||||
""" Compute the rating satisfaction percentage, this is done separately from rating_count and rating_avg
|
||||
since the query is different, to avoid computing if it is not necessary"""
|
||||
domain = expression.AND([self._rating_domain(), [('rating', '>=', RATING_LIMIT_MIN)]])
|
||||
domain = expression.AND([self._rating_domain(), [('rating', '>=', rating_data.RATING_LIMIT_MIN)]])
|
||||
# See `_compute_rating_percentage_satisfaction` above
|
||||
read_group_res = self.env['rating.rating'].read_group(domain, ['res_id', 'rating'], groupby=['res_id', 'rating'], lazy=False)
|
||||
default_grades = {'great': 0, 'okay': 0, 'bad': 0}
|
||||
grades_per_record = {record_id: default_grades.copy() for record_id in self.ids}
|
||||
|
||||
for group in read_group_res:
|
||||
record_id = group['res_id']
|
||||
rating = group['rating']
|
||||
if rating > RATING_LIMIT_OK:
|
||||
grades_per_record[record_id]['great'] += group['__count']
|
||||
elif rating > RATING_LIMIT_MIN:
|
||||
grades_per_record[record_id]['okay'] += group['__count']
|
||||
else:
|
||||
grades_per_record[record_id]['bad'] += group['__count']
|
||||
grade = rating_data._rating_to_grade(group['rating'])
|
||||
grades_per_record[record_id][grade] += group['__count']
|
||||
|
||||
for record in self:
|
||||
grade_repartition = grades_per_record.get(record.id, default_grades)
|
||||
grade_count = sum(grade_repartition.values())
|
||||
@@ -315,17 +224,19 @@ class RatingMixin(models.AbstractModel):
|
||||
base_domain = expression.AND([self._rating_domain(), [('rating', '>=', 1)]])
|
||||
if domain:
|
||||
base_domain += domain
|
||||
data = self.env['rating.rating'].read_group(base_domain, ['rating'], ['rating', 'res_id'])
|
||||
rg_data = self.env['rating.rating'].read_group(base_domain, ['rating'], ['rating', 'res_id'])
|
||||
# init dict with all posible rate value, except 0 (no value for the rating)
|
||||
values = dict.fromkeys(range(1, 6), 0)
|
||||
values.update((d['rating'], d['rating_count']) for d in data)
|
||||
for rating_rg in rg_data:
|
||||
rating_val_round = float_round(rating_rg['rating'], precision_digits=1)
|
||||
values[rating_val_round] = values.get(rating_val_round, 0) + rating_rg['rating_count']
|
||||
# add other stats
|
||||
if add_stats:
|
||||
rating_number = sum(values.values())
|
||||
result = {
|
||||
'repartition': values,
|
||||
'avg': sum(float(key * values[key]) for key in values) / rating_number if rating_number > 0 else 0,
|
||||
'total': sum(it['rating_count'] for it in data),
|
||||
'total': sum(it['rating_count'] for it in rg_data),
|
||||
}
|
||||
return result
|
||||
return values
|
||||
@@ -341,12 +252,8 @@ class RatingMixin(models.AbstractModel):
|
||||
data = self._rating_get_repartition(domain=domain)
|
||||
res = dict.fromkeys(['great', 'okay', 'bad'], 0)
|
||||
for key in data:
|
||||
if key >= RATING_LIMIT_SATISFIED:
|
||||
res['great'] += data[key]
|
||||
elif key >= RATING_LIMIT_OK:
|
||||
res['okay'] += data[key]
|
||||
else:
|
||||
res['bad'] += data[key]
|
||||
grade = rating_data._rating_to_grade(key)
|
||||
res[grade] += data[key]
|
||||
return res
|
||||
|
||||
def rating_get_stats(self, domain=None):
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.rating.models import rating_data
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
|
||||
class RatingParentMixin(models.AbstractModel):
|
||||
_name = 'rating.parent.mixin'
|
||||
_description = "Rating Parent Mixin"
|
||||
_rating_satisfaction_days = False # Number of last days used to compute parent satisfaction. Set to False to include all existing rating.
|
||||
|
||||
rating_ids = fields.One2many(
|
||||
'rating.rating', 'parent_res_id', string='Ratings',
|
||||
auto_join=True, groups='base.group_user',
|
||||
domain=lambda self: [('parent_res_model', '=', self._name)])
|
||||
rating_percentage_satisfaction = fields.Integer(
|
||||
"Rating Satisfaction",
|
||||
compute="_compute_rating_percentage_satisfaction", compute_sudo=True,
|
||||
store=False, help="Percentage of happy ratings")
|
||||
rating_count = fields.Integer(string='# Ratings', compute="_compute_rating_percentage_satisfaction", compute_sudo=True)
|
||||
rating_avg = fields.Float('Average Rating', groups='base.group_user',
|
||||
compute='_compute_rating_percentage_satisfaction', compute_sudo=True, search='_search_rating_avg')
|
||||
rating_avg_percentage = fields.Float('Average Rating (%)', groups='base.group_user',
|
||||
compute='_compute_rating_percentage_satisfaction', compute_sudo=True)
|
||||
rating_last_value = fields.Float('Rating Last Value', groups='base.group_user', related='rating_ids.rating')
|
||||
|
||||
@api.depends('rating_ids.rating', 'rating_ids.consumed')
|
||||
def _compute_rating_percentage_satisfaction(self):
|
||||
# build domain and fetch data
|
||||
domain = [('parent_res_model', '=', self._name), ('parent_res_id', 'in', self.ids), ('rating', '>=', rating_data.RATING_LIMIT_MIN), ('consumed', '=', True)]
|
||||
if self._rating_satisfaction_days:
|
||||
domain += [('write_date', '>=', fields.Datetime.to_string(fields.datetime.now() - timedelta(days=self._rating_satisfaction_days)))]
|
||||
data = self.env['rating.rating'].read_group(domain, ['parent_res_id', 'rating'], ['parent_res_id', 'rating'], lazy=False)
|
||||
|
||||
# get repartition of grades per parent id
|
||||
default_grades = {'great': 0, 'okay': 0, 'bad': 0}
|
||||
grades_per_parent = dict((parent_id, dict(default_grades)) for parent_id in self.ids) # map: {parent_id: {'great': 0, 'bad': 0, 'ok': 0}}
|
||||
rating_scores_per_parent = defaultdict(int) # contains the total of the rating values per record
|
||||
for item in data:
|
||||
parent_id = item['parent_res_id']
|
||||
grade = rating_data._rating_to_grade(item['rating'])
|
||||
grades_per_parent[parent_id][grade] += item['__count']
|
||||
rating_scores_per_parent[parent_id] += item['rating'] * item['__count']
|
||||
|
||||
# compute percentage per parent
|
||||
for record in self:
|
||||
repartition = grades_per_parent.get(record.id, default_grades)
|
||||
rating_count = sum(repartition.values())
|
||||
record.rating_count = rating_count
|
||||
record.rating_percentage_satisfaction = repartition['great'] * 100 / rating_count if rating_count else -1
|
||||
record.rating_avg = rating_scores_per_parent[record.id] / rating_count if rating_count else 0
|
||||
record.rating_avg_percentage = record.rating_avg / 5
|
||||
|
||||
def _search_rating_avg(self, operator, value):
|
||||
if operator not in rating_data.OPERATOR_MAPPING:
|
||||
raise NotImplementedError('This operator %s is not supported in this search method.' % operator)
|
||||
domain = [('parent_res_model', '=', self._name), ('consumed', '=', True), ('rating', '>=', rating_data.RATING_LIMIT_MIN)]
|
||||
if self._rating_satisfaction_days:
|
||||
min_date = fields.datetime.now() - timedelta(days=self._rating_satisfaction_days)
|
||||
domain = expression.AND([domain, [('write_date', '>=', fields.Datetime.to_string(min_date))]])
|
||||
rating_read_group = self.env['rating.rating'].sudo().read_group(domain, ['parent_res_id', 'rating_avg:avg(rating)'], ['parent_res_id'])
|
||||
parent_res_ids = [
|
||||
res['parent_res_id']
|
||||
for res in rating_read_group
|
||||
if rating_data.OPERATOR_MAPPING[operator](float_compare(res['rating_avg'], value, 2), 0)
|
||||
]
|
||||
return [('id', 'in', parent_res_ids)]
|
||||
@@ -13,10 +13,17 @@ from odoo.tools import plaintext2html, html2plaintext
|
||||
|
||||
class SlidesPortalChatter(PortalChatter):
|
||||
|
||||
def _portal_post_has_content(self, res_model, res_id, message, attachment_ids=None, **kw):
|
||||
""" Relax constraint on slide model: having a rating value is sufficient
|
||||
to consider we have a content. """
|
||||
if res_model == 'slide.channel' and kw.get('rating_value'):
|
||||
return True
|
||||
return super()._portal_post_has_content(res_model, res_id, message, attachment_ids=attachment_ids, **kw)
|
||||
|
||||
@http.route(['/mail/chatter_post'], type='json', 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':
|
||||
if result and 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')):
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<field name="partner_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<t t-set="val_stars" t-value="record.rating.raw_value"/>
|
||||
<t t-set="val_stars" t-value="Math.round(record.rating.raw_value * 10) / 10"/>
|
||||
<t t-set="val_integer" t-value="Math.floor(val_stars)"/>
|
||||
<t t-set="val_decimal" t-value="val_stars - val_integer"/>
|
||||
<t t-set="empty_star" t-value="5 - (val_integer + Math.ceil(val_decimal))"/>
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
<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="rate_with_void_content" t-value="True"/>
|
||||
<t t-set="disable_composer" t-value="not channel.can_review"/>
|
||||
<t t-set="_link_btn_classes" t-value="'btn-sm btn-link text-white mx-3'"/>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user