squashed commit

This commit is contained in:
hoangvv
2026-09-18 13:55:25 +07:00
commit 039c98d4d0
45882 changed files with 24235585 additions and 0 deletions
+8
View File
@@ -0,0 +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
+55
View File
@@ -0,0 +1,55 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.mail.tools.discuss import Store
class MailMessage(models.Model):
_inherit = 'mail.message'
rating_ids = fields.One2many("rating.rating", "message_id", string="Related ratings")
rating_id = fields.Many2one("rating.rating", compute="_compute_rating_id")
rating_value = fields.Float(
'Rating Value', compute='_compute_rating_value', compute_sudo=True,
store=False, search='_search_rating_value')
@api.depends("rating_ids.consumed")
def _compute_rating_id(self):
for message in self:
message.rating_id = message.rating_ids.filtered(lambda rating: rating.consumed).sorted(
"create_date", reverse=True
)[:1]
@api.depends('rating_ids', 'rating_ids.rating')
def _compute_rating_value(self):
for message in self:
message.rating_value = message.rating_id.rating if message.rating_id else 0.0
def _search_rating_value(self, operator, operand):
ratings = self.env['rating.rating'].sudo().search([
('rating', operator, operand),
('message_id', '!=', False),
("consumed", "=", True),
])
return [('id', 'in', ratings.mapped('message_id').ids)]
def _to_store(self, store: Store, /, *, fields=None, **kwargs):
super()._to_store(store, fields=fields, **kwargs)
if fields is None:
fields = ["rating_id", "record_rating"]
if "rating_id" in fields:
for message in self:
# sudo: mail.message - guest and portal user can receive rating of accessible message
store.add(message, {"rating_id": Store.one(message.sudo().rating_id)})
if "record_rating" in fields:
for records in self._records_by_model_name().values():
if issubclass(self.pool[records._name], self.pool["rating.mixin"]):
store.add(records, fields=["rating_avg", "rating_count"], as_thread=True)
if records._allow_publish_rating_stats():
for record in records:
store.add(
record, {"rating_stats": record.rating_get_stats()}, as_thread=True
)
def _is_empty(self):
return super()._is_empty() and not self.rating_id
+205
View File
@@ -0,0 +1,205 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import markupsafe
from odoo import _, api, fields, models, tools
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
rating_ids = fields.One2many('rating.rating', 'res_id', string='Ratings', groups='base.group_user',
domain=lambda self: [('res_model', '=', self._name)], auto_join=True)
# MAIL OVERRIDES
# --------------------------------------------------
def unlink(self):
""" When removing a record, its rating should be deleted too. """
record_ids = self.ids
result = super().unlink()
self.env['rating.rating'].sudo().search([('res_model', '=', self._name), ('res_id', 'in', record_ids)]).unlink()
return result
def _get_message_create_ignore_field_names(self):
return super()._get_message_create_ignore_field_names() | {"rating_id"}
# RATING CONFIGURATION
# --------------------------------------------------
def _rating_apply_get_default_subtype_id(self):
return self.env['ir.model.data']._xmlid_to_res_id("mail.mt_comment")
def _rating_get_operator(self):
""" Return the operator (partner) that is the person who is rated.
:return record: res.partner singleton
"""
if 'user_id' in self and self.user_id.partner_id:
return self.user_id.partner_id
return self.env['res.partner']
def _rating_get_partner(self):
""" Return the customer (partner) that performs the rating.
:return record: res.partner singleton
"""
if 'partner_id' in self and self.partner_id:
return self.partner_id
return self.env['res.partner']
# RATING SUPPORT
# --------------------------------------------------
def _rating_get_access_token(self, partner=None):
""" Return access token linked to existing ratings, or create a new rating
that will create the asked token. An explicit call to access rights is
performed as sudo is used afterwards as this method could be used from
different sources, notably templates. """
self.check_access('read')
if not partner:
partner = self._rating_get_partner()
rated_partner = self._rating_get_operator()
rating = next(
(r for r in self.rating_ids.sudo()
if r.partner_id.id == partner.id and not r.consumed),
None)
if not rating:
rating = self.env['rating.rating'].sudo().create({
'partner_id': partner.id,
'rated_partner_id': rated_partner.id,
'res_model_id': self.env['ir.model']._get_id(self._name),
'res_id': self.id,
'is_internal': False,
})
return rating.access_token
# EXPOSED API
# --------------------------------------------------
def rating_send_request(self, template, lang=False, force_send=True):
""" This method send rating request by email, using a template given in parameter.
:param record template: a mail.template record used to compute the message body;
:param str lang: optional lang; it can also be specified directly on the template
itself in the lang field;
:param bool force_send: whether to send the request directly or use the mail
queue cron (preferred option);
"""
if lang:
template = template.with_context(lang=lang)
self.with_context(mail_notify_force_send=force_send).message_post_with_source(
template,
email_layout_xmlid='mail.mail_notification_light',
force_send=force_send,
subtype_xmlid='mail.mt_note',
)
def rating_apply(self, rate, token=None, rating=None, feedback=None,
subtype_xmlid=None, notify_delay_send=False):
""" Apply a rating to the record. This rating can either be linked to a
token (customer flow) or directly a rating record (code flow).
If the current model inherits from mail.thread mixin a message is posted
on its chatter. User going through this method should have at least
employee rights as well as rights on the current record because of rating
manipulation and chatter post (either employee, either sudo-ed in public
controllers after security check granting access).
:param float rate: the rating value to apply (from 0 to 5);
:param string token: access token to fetch the rating to apply (optional);
:param record rating: rating.rating to apply (if no token);
:param string feedback: additional feedback (plaintext);
:param string subtype_xmlid: xml id of a valid mail.message.subtype used
to post the message (if it applies). If not given a classic comment is
posted;
:param notify_delay_send: Delay the sending by 2 hours of the email so the user
can still change his feedback. If False, the email will be sent immediately.
:returns rating: rating.rating record
"""
if rate < 0 or rate > 5:
raise ValueError(_('Wrong rating value. A rate should be between 0 and 5 (received %d).', rate))
if token:
rating = self.env['rating.rating'].search([('access_token', '=', token)], limit=1)
if not rating:
raise ValueError(_('Invalid token or rating.'))
rating.write({'rating': rate, 'feedback': feedback, 'consumed': True})
if isinstance(self, self.env.registry['mail.thread']):
if subtype_xmlid is None:
subtype_id = self._rating_apply_get_default_subtype_id()
else:
subtype_id = False
feedback = tools.plaintext2html(feedback or '', with_paragraph=False)
scheduled_datetime = (
fields.Datetime.now() + datetime.timedelta(hours=2)
if notify_delay_send else None
)
rating_body = (
markupsafe.Markup(
"<img src='%s' alt=':%s/5' style='width:18px;height:18px;float:left;margin-right: 5px;'/>%s"
) % (rating.rating_image_url, rate, feedback)
)
if rating.message_id:
self._message_update_content(
rating.message_id, rating_body,
scheduled_date=scheduled_datetime,
strict=False
)
else:
self.message_post(
author_id=rating.partner_id.id or None, # None will set the default author in mail/mail_thread.py
body=rating_body,
rating_id=rating.id,
scheduled_date=scheduled_datetime,
subtype_id=subtype_id,
subtype_xmlid=subtype_xmlid,
)
return rating
@api.returns('mail.message', lambda value: value.id)
def message_post(self, **kwargs):
rating_id = kwargs.pop('rating_id', False)
rating_value = kwargs.pop('rating_value', False)
# create rating.rating record linked to given rating_value. Using sudo as portal users may have
# rights to create messages and therefore ratings (security should be checked beforehand)
if rating_value:
rating_vals = {
'rating': float(rating_value) if rating_value is not None else False,
'feedback': tools.html2plaintext(kwargs.get('body', '')),
'res_model_id': self.env['ir.model']._get_id(self._name),
'res_id': self.id,
'consumed': True,
'partner_id': self.env.user.partner_id.id,
}
rating_id = self.env["rating.rating"].sudo().create(rating_vals).id
if rating_id:
kwargs["rating_id"] = rating_id
return super().message_post(**kwargs)
def _message_post_after_hook(self, message, msg_values):
"""Override to link rating to message as sudo. This is done in
_message_post_after_hook to be before _notify_thread."""
# sudo: rating.rating - can link rating to message from same author and thread
rating = self.env["rating.rating"].browse(msg_values.get("rating_id")).sudo()
same_author = rating.partner_id and rating.partner_id == message.author_id
if same_author and rating.res_model == message.model and rating.res_id == message.res_id:
rating.message_id = message.id
super()._message_post_after_hook(message, msg_values)
@api.model
def _get_allowed_message_update_params(self):
return super()._get_allowed_message_update_params() | {"rating_value"}
def _message_update_content(self, message, body, *args, rating_value=None, **kwargs):
if rating_value is False:
rating_ids = message.rating_ids
rating_ids.message_id = False
rating_ids.unlink()
return super()._message_update_content(
message, body, *args, rating_value=rating_value, **kwargs
)
+201
View File
@@ -0,0 +1,201 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import uuid
from odoo import api, fields, models
from odoo.addons.mail.tools.discuss import Store
from odoo.addons.rating.models import rating_data
from odoo.tools.misc import file_open
class Rating(models.Model):
_name = "rating.rating"
_description = "Rating"
_order = 'write_date desc, id desc'
_rec_name = 'res_name'
@api.model
def _default_access_token(self):
return uuid.uuid4().hex
@api.model
def _selection_target_model(self):
return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])]
create_date = fields.Datetime(string="Submitted on")
res_name = fields.Char(string='Resource name', compute='_compute_res_name', store=True)
res_model_id = fields.Many2one('ir.model', 'Related Document Model', index=True, ondelete='cascade')
res_model = fields.Char(string='Document Model', related='res_model_id.model', store=True, index=True, readonly=True)
res_id = fields.Many2oneReference(string='Document', model_field='res_model', required=True, index=True)
resource_ref = fields.Reference(
string='Resource Ref', selection='_selection_target_model',
compute='_compute_resource_ref', readonly=True)
parent_res_name = fields.Char('Parent Document Name', compute='_compute_parent_res_name', store=True)
parent_res_model_id = fields.Many2one('ir.model', 'Parent Related Document Model', index=True, ondelete='cascade')
parent_res_model = fields.Char('Parent Document Model', store=True, related='parent_res_model_id.model', index=True, readonly=False)
parent_res_id = fields.Integer('Parent Document', index=True)
parent_ref = fields.Reference(
string='Parent Ref', selection='_selection_target_model',
compute='_compute_parent_ref', readonly=True)
rated_partner_id = fields.Many2one('res.partner', string="Rated Operator")
rated_partner_name = fields.Char(related="rated_partner_id.name")
partner_id = fields.Many2one('res.partner', string='Customer')
rating = fields.Float(string="Rating Value", aggregator="avg", default=0)
rating_image = fields.Binary('Image', compute='_compute_rating_image')
rating_image_url = fields.Char('Image URL', compute='_compute_rating_image')
rating_text = fields.Selection(rating_data.RATING_TEXT, string='Rating', store=True, compute='_compute_rating_text', readonly=True)
feedback = fields.Text('Comment')
message_id = fields.Many2one(
'mail.message', string="Message",
index=True, ondelete='cascade')
is_internal = fields.Boolean('Visible Internally Only', readonly=False, related='message_id.is_internal', store=True)
access_token = fields.Char('Security Token', default=_default_access_token)
consumed = fields.Boolean(string="Filled Rating")
_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).display_name
rating.res_name = name or f'{rating.res_model}/{rating.res_id}'
@api.depends('res_model', 'res_id')
def _compute_resource_ref(self):
for rating in self:
if rating.res_model and rating.res_model in self.env:
rating.resource_ref = '%s,%s' % (rating.res_model, rating.res_id or 0)
else:
rating.resource_ref = None
@api.depends('parent_res_model', 'parent_res_id')
def _compute_parent_ref(self):
for rating in self:
if rating.parent_res_model and rating.parent_res_model in self.env:
rating.parent_ref = '%s,%s' % (rating.parent_res_model, rating.parent_res_id or 0)
else:
rating.parent_ref = None
@api.depends('parent_res_model', 'parent_res_id')
def _compute_parent_res_name(self):
for rating in self:
name = False
if rating.parent_res_model and rating.parent_res_id:
name = self.env[rating.parent_res_model].sudo().browse(rating.parent_res_id).display_name
name = name or f'{rating.parent_res_model}/{rating.parent_res_id}'
rating.parent_res_name = name
def _get_rating_image_filename(self):
self.ensure_one()
return 'rating_%s.png' % rating_data._rating_to_threshold(self.rating)
@api.depends('rating')
def _compute_rating_image(self):
self.rating_image_url = False
self.rating_image = False
for rating in self:
image_path = f'rating/static/src/img/{rating._get_rating_image_filename()}'
rating.rating_image_url = f'/{image_path}'
try:
rating.rating_image = base64.b64encode(
file_open(image_path, 'rb', filter_ext=('.png',)).read())
except (IOError, OSError, FileNotFoundError):
rating.rating_image = False
@api.depends('rating')
def _compute_rating_text(self):
for rating in self:
rating.rating_text = rating_data._rating_to_text(rating.rating)
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if values.get('res_model_id') and values.get('res_id'):
values.update(self._find_parent_data(values))
return super().create(vals_list)
def write(self, values):
if values.get('res_model_id') and values.get('res_id'):
values.update(self._find_parent_data(values))
return super(Rating, self).write(values)
def unlink(self):
# OPW-2181568: Delete the chatter message too
self.env['mail.message'].search([('rating_ids', 'in', self.ids)]).unlink()
return super(Rating, self).unlink()
def _find_parent_data(self, values):
""" Determine the parent res_model/res_id, based on the values to create or write """
current_model_name = self.env['ir.model'].sudo().browse(values['res_model_id']).model
current_record = self.env[current_model_name].browse(values['res_id'])
data = {
'parent_res_model_id': False,
'parent_res_id': False,
}
if hasattr(current_record, '_rating_get_parent_field_name'):
current_record_parent = current_record._rating_get_parent_field_name()
if current_record_parent:
parent_res_model = getattr(current_record, current_record_parent)
data['parent_res_model_id'] = self.env['ir.model']._get(parent_res_model._name).id
data['parent_res_id'] = parent_res_model.id
return data
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
def reset(self):
for record in self:
record.write({
'rating': 0,
'access_token': record._default_access_token(),
'feedback': False,
'consumed': False,
})
def action_open_rated_object(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': self.res_model,
'res_id': self.res_id,
'views': [[False, 'form']]
}
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def _classify_by_model(self):
""" To ease batch computation of various ratings related methods they
are classified by model. Ratings not linked to a valid record through
res_model / res_id are ignored.
:return dict: for each model having at least one rating in self, have
a sub-dict containing
* ratings: ratings related to that model;
* record IDs: records linked to the ratings of that model, in same
order;
"""
data_by_model = {}
for rating in self.filtered(lambda act: act.res_model and act.res_id):
if rating.res_model not in data_by_model:
data_by_model[rating.res_model] = {
'ratings': self.env['rating.rating'],
'record_ids': [],
}
data_by_model[rating.res_model]['ratings'] += rating
data_by_model[rating.res_model]['record_ids'].append(rating.res_id)
return data_by_model
def _to_store(self, store: Store, /, *, fields=None):
if fields is None:
fields = ["rating", "rating_image_url", "rating_text"]
store.add(self._name, self._read_format(fields, load=False))
+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
+187
View File
@@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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, float_round
class RatingMixin(models.AbstractModel):
"""This mixin adds rating statistics to mail.thread that already support ratings."""
_name = 'rating.mixin'
_description = "Rating Mixin"
_inherit = 'mail.thread'
rating_last_value = fields.Float('Rating Last Value', groups='base.group_user', compute='_compute_rating_last_value', compute_sudo=True, store=True, aggregator="avg")
rating_last_feedback = fields.Text('Rating Last Feedback', groups='base.group_user', related='rating_ids.feedback')
rating_last_image = fields.Binary('Rating Last Image', groups='base.group_user', related='rating_ids.rating_image')
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_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")
@api.depends('rating_ids', 'rating_ids.rating', 'rating_ids.consumed')
def _compute_rating_last_value(self):
# Pure SQL instead of calling read_group to allow ordering array_agg
self.flush_model(['rating_ids'])
self.env['rating.rating'].flush_model(['consumed', 'rating'])
if not self.ids:
self.rating_last_value = 0
return
self.env.cr.execute("""
SELECT
array_agg(rating ORDER BY write_date DESC, id DESC) AS "ratings",
res_id as res_id
FROM "rating_rating"
WHERE
res_model = %s
AND res_id in %s
AND consumed = true
GROUP BY res_id""", [self._name, tuple(self.ids)])
read_group_raw = self.env.cr.dictfetchall()
rating_by_res_id = {e['res_id']: e['ratings'][0] for e in read_group_raw}
for record in self:
record.rating_last_value = rating_by_res_id.get(record.id, 0)
@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_data.RATING_LIMIT_MIN)]])
read_group_res = self.env['rating.rating']._read_group(domain, ['res_id'], aggregates=['__count', 'rating:avg']) # force average on rating column
mapping = {res_id: {'rating_count': count, 'rating_avg': rating_avg} for res_id, count, rating_avg in read_group_res}
for record in self:
record.rating_count = mapping.get(record.id, {}).get('rating_count', 0)
record.rating_avg = mapping.get(record.id, {}).get('rating_avg', 0)
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)
rating_read_group = self.env['rating.rating'].sudo()._read_group(
[('res_model', '=', self._name), ('consumed', '=', True), ('rating', '>=', rating_data.RATING_LIMIT_MIN)],
['res_id'], ['rating:avg'])
res_ids = [
res_id
for res_id, rating_avg in rating_read_group
if rating_data.OPERATOR_MAPPING[operator](float_compare(rating_avg, value, 2), 0)
]
return [('id', 'in', res_ids)]
@api.depends('rating_avg')
def _compute_rating_avg_text(self):
for record in self:
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_data.RATING_LIMIT_MIN)]])
# See `_compute_rating_percentage_satisfaction` above
read_group_res = self.env['rating.rating']._read_group(domain, ['res_id', 'rating'], aggregates=['__count'])
default_grades = {'great': 0, 'okay': 0, 'bad': 0}
grades_per_record = {record_id: default_grades.copy() for record_id in self.ids}
for record_id, rating, count in read_group_res:
grade = rating_data._rating_to_grade(rating)
grades_per_record[record_id][grade] += count
for record in self:
grade_repartition = grades_per_record.get(record.id, default_grades)
grade_count = sum(grade_repartition.values())
record.rating_percentage_satisfaction = grade_repartition['great'] * 100 / grade_count if grade_count else -1
def write(self, values):
""" If the rated ressource name is modified, we should update the rating res_name too.
If the rated ressource parent is changed we should update the parent_res_id too"""
result = super(RatingMixin, self).write(values)
for record in self:
if record._rec_name in values: # set the res_name of ratings to be recomputed
res_name_field = self.env['rating.rating']._fields['res_name']
self.env.add_to_compute(res_name_field, record.rating_ids)
if record._rating_get_parent_field_name() in values:
record.rating_ids.sudo().write({'parent_res_id': record[record._rating_get_parent_field_name()].id})
return result
def _rating_get_parent_field_name(self):
"""Return the parent relation field name. Should return a Many2One"""
return None
def _rating_domain(self):
""" Returns a normalized domain on rating.rating to select the records to
include in count, avg, ... computation of current model.
"""
return ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', self.ids), ('consumed', '=', True)]
def _rating_get_repartition(self, add_stats=False, domain=None):
""" get the repatition of rating grade for the given res_ids.
:param add_stats : flag to add stat to the result
:type add_stats : boolean
:param domain : optional extra domain of the rating to include/exclude in repartition
:return dictionnary
if not add_stats, the dict is like
- key is the rating value (integer)
- value is the number of object (res_model, res_id) having the value
otherwise, key is the value of the information (string) : either stat name (avg, total, ...) or 'repartition'
containing the same dict if add_stats was False.
"""
base_domain = expression.AND([self._rating_domain(), [('rating', '>=', 1)]])
if domain:
base_domain += domain
rg_data = self.env['rating.rating']._read_group(base_domain, ['rating'], ['__count'])
# init dict with all possible rate value, except 0 (no value for the rating)
values = dict.fromkeys(range(1, 6), 0)
for rating, count in rg_data:
rating_val_round = float_round(rating, precision_digits=1)
values[rating_val_round] = values.get(rating_val_round, 0) + count
# add other stats
if add_stats:
rating_number = sum(values.values())
return {
'repartition': values,
'avg': sum(float(key * values[key]) for key in values) / rating_number if rating_number > 0 else 0,
'total': sum(count for __, count in rg_data),
}
return values
def rating_get_grades(self, domain=None):
""" get the repatition of rating grade for the given res_ids.
:param domain : optional domain of the rating to include/exclude in grades computation
:return dictionnary where the key is the grade (great, okay, bad), and the value, the number of object (res_model, res_id) having the grade
the grade are compute as 0-30% : Bad
31-69%: Okay
70-100%: Great
"""
data = self._rating_get_repartition(domain=domain)
res = dict.fromkeys(['great', 'okay', 'bad'], 0)
for key in data:
grade = rating_data._rating_to_grade(key)
res[grade] += data[key]
return res
def rating_get_stats(self, domain=None):
""" get the statistics of the rating repatition
:param domain : optional domain of the rating to include/exclude in statistic computation
:return dictionnary where
- key is the name of the information (stat name)
- value is statistic value : 'percent' contains the repartition in percentage, 'avg' is the average rate
and 'total' is the number of rating
"""
data = self._rating_get_repartition(domain=domain, add_stats=True)
result = {
'avg': data['avg'],
'total': data['total'],
'percent': dict.fromkeys(range(1, 6), 0),
}
for rate in data['repartition']:
result['percent'][rate] = (data['repartition'][rate] * 100) / data['total'] if data['total'] > 0 else 0
return result
@api.model
def _allow_publish_rating_stats(self):
"""Override to allow the rating stats to be demonstrated."""
return False
@@ -0,0 +1,71 @@
# -*- 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)
@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'], ['__count'])
# 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 parent_id, rating, count in data:
grade = rating_data._rating_to_grade(rating)
grades_per_parent[parent_id][grade] += count
rating_scores_per_parent[parent_id] += rating * 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'])
parent_res_ids = [
parent_res_id
for parent_res_id, rating_avg in rating_read_group
if rating_data.OPERATOR_MAPPING[operator](float_compare(rating_avg, value, 2), 0)
]
return [('id', 'in', parent_res_ids)]