[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:
Odoo's Mergebot
2022-02-22 20:47:55 +01:00
committed by GitHub
17 changed files with 273 additions and 193 deletions
+33 -25
View File
@@ -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):
+11 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+2
View File
@@ -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
+14 -38
View File
@@ -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):
+73
View File
@@ -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
+21 -114
View File
@@ -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)]
+8 -1
View File
@@ -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>