diff --git a/addons/rating/models/__init__.py b/addons/rating/models/__init__.py index 8ef312cfec8..44c09740ff3 100644 --- a/addons/rating/models/__init__.py +++ b/addons/rating/models/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from . import rating +from . import rating_mixin from . import mail_thread from . import mail_message diff --git a/addons/rating/models/rating.py b/addons/rating/models/rating.py index d5d4306a081..52630375c38 100644 --- a/addons/rating/models/rating.py +++ b/addons/rating/models/rating.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 -from datetime import timedelta import uuid -from odoo import api, fields, models, tools, _ +from odoo import api, fields, models from odoo.modules.module import get_resource_path @@ -132,259 +131,3 @@ class Rating(models.Model): 'res_id': self.res_id, 'views': [[False, 'form']] } - - -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', domain=lambda self: [('parent_res_model', '=', self._name)], auto_join=True) - rating_percentage_satisfaction = fields.Integer("Rating Satisfaction", compute="_compute_rating_percentage_satisfaction", store=False, help="Percentage of happy ratings") - - @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', '>=', 1), ('consumed', '=', True)] - if self._rating_satisfaction_days: - domain += [('create_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}} - for item in data: - parent_id = item['parent_res_id'] - rating = item['rating'] - if rating >= RATING_LIMIT_SATISFIED: - grades_per_parent[parent_id]['great'] += item['__count'] - elif rating > RATING_LIMIT_OK: - grades_per_parent[parent_id]['okay'] += item['__count'] - else: - grades_per_parent[parent_id]['bad'] += item['__count'] - - # compute percentage per parent - for record in self: - repartition = grades_per_parent.get(record.id) - record.rating_percentage_satisfaction = repartition['great'] * 100 / sum(repartition.values()) if sum(repartition.values()) else -1 - - -class RatingMixin(models.AbstractModel): - _name = 'rating.mixin' - _description = "Rating Mixin" - - rating_ids = fields.One2many('rating.rating', 'res_id', string='Rating', domain=lambda self: [('res_model', '=', self._name)], auto_join=True) - rating_last_value = fields.Float('Rating Last Value', compute='_compute_rating_last_value', compute_sudo=True, store=True) - rating_last_feedback = fields.Text('Rating Last Feedback', related='rating_ids.feedback', readonly=False) - rating_last_image = fields.Binary('Rating Last Image', related='rating_ids.rating_image', readonly=False) - rating_count = fields.Integer('Rating count', compute="_compute_rating_count") - - @api.multi - @api.depends('rating_ids.rating') - def _compute_rating_last_value(self): - for record in self: - ratings = self.env['rating.rating'].search([('res_model', '=', self._name), ('res_id', '=', record.id)], limit=1) - if ratings: - record.rating_last_value = ratings.rating - - @api.multi - @api.depends('rating_ids') - def _compute_rating_count(self): - read_group_res = self.env['rating.rating'].read_group( - [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('consumed', '=', True)], - ['res_id'], groupby=['res_id']) - result = dict.fromkeys(self.ids, 0) - for data in read_group_res: - result[data['res_id']] += data['res_id_count'] - for record in self: - record.rating_count = result.get(record.id) - - 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""" - with self.env.norecompute(): - 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'] - record.rating_ids._recompute_todo(res_name_field) - if record._rating_get_parent_field_name() in values: - record.rating_ids.write({'parent_res_id': record[record._rating_get_parent_field_name()].id}) - - if self.env.recompute and self._context.get('recompute', True): # trigger the recomputation of all field marked as "to recompute" - self.recompute() - - return result - - def unlink(self): - """ When removing a record, its rating should be deleted too. """ - record_ids = self.ids - result = super(RatingMixin, self).unlink() - self.env['rating.rating'].sudo().search([('res_model', '=', self._name), ('res_id', 'in', record_ids)]).unlink() - return result - - def _rating_get_parent_field_name(self): - """Return the parent relation field name - Should return a Many2One""" - return None - - def rating_get_partner_id(self): - if hasattr(self, 'partner_id') and self.partner_id: - return self.partner_id - return self.env['res.partner'] - - def rating_get_rated_partner_id(self): - if hasattr(self, 'user_id') and self.user_id.partner_id: - return self.user_id.partner_id - return self.env['res.partner'] - - def rating_get_access_token(self, partner=None): - if not partner: - partner = self.rating_get_partner_id() - rated_partner = self.rating_get_rated_partner_id() - ratings = self.rating_ids.filtered(lambda x: x.partner_id.id == partner.id and not x.consumed) - if not ratings: - record_model_id = self.env['ir.model'].sudo().search([('model', '=', self._name)], limit=1).id - rating = self.env['rating.rating'].create({ - 'partner_id': partner.id, - 'rated_partner_id': rated_partner.id, - 'res_model_id': record_model_id, - 'res_id': self.id - }) - else: - rating = ratings[0] - return rating.access_token - - @api.multi - def rating_send_request(self, template, lang=False, subtype_id=False, force_send=True, composition_mode='comment', notif_layout=None): - """ This method send rating request by email, using a template given - in parameter. - - :param template: a mail.template record used to compute the message body; - :param lang: optional lang; it can also be specified directly on the template - itself in the lang field; - :param subtype_id: optional subtype to use when creating the message; is - a note by default to avoid spamming followers; - :param force_send: whether to send the request directly or use the mail - queue cron (preferred option); - :param composition_mode: comment (message_post) or mass_mail (template.send_mail); - :param notif_layout: layout used to encapsulate the content when sending email; - """ - if lang: - template = template.with_context(lang=lang) - if subtype_id is False: - subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note') - if force_send: - self = self.with_context(mail_notify_force_send=True) - for record in self: - record.message_post_with_template( - template.id, - composition_mode=composition_mode, - notif_layout=notif_layout if notif_layout is not None else 'mail.mail_notification_light', - subtype_id=subtype_id - ) - - @api.multi - def rating_apply(self, rate, token=None, feedback=None, subtype=None): - """ Apply a rating given a token. If the current model inherits from - mail.thread mixing, a message is posted on its chatter. - :param rate : the rating value to apply - :type rate : float - :param token : access token - :param feedback : additional feedback - :type feedback : string - :param subtype : subtype for mail - :type subtype : string - :returns rating.rating record - """ - Rating, rating = self.env['rating.rating'], None - if token: - rating = self.env['rating.rating'].search([('access_token', '=', token)], limit=1) - else: - rating = Rating.search([('res_model', '=', self._name), ('res_id', '=', self.ids[0])], limit=1) - if rating: - rating.write({'rating': rate, 'feedback': feedback, 'consumed': True}) - if hasattr(self, 'message_post'): - feedback = tools.plaintext2html(feedback or '') - self.message_post( - body=":%s/10%s" - % (rate, rate, feedback), - subtype=subtype or "mail.mt_comment", - author_id=rating.partner_id and rating.partner_id.id or None # None will set the default author in mail_thread.py - ) - if hasattr(self, 'stage_id') and self.stage_id and hasattr(self.stage_id, 'auto_validation_kanban_state') and self.stage_id.auto_validation_kanban_state: - if rating.rating > 5: - self.write({'kanban_state': 'done'}) - if rating.rating < 5: - self.write({'kanban_state': 'blocked'}) - return rating - - @api.multi - 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 = [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('rating', '>=', 1), ('consumed', '=', True)] - if domain: - base_domain += domain - 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, 11), 0) - values.update((d['rating'], d['rating_count']) for d in data) - # 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), - } - return result - return values - - @api.multi - 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: - if key >= RATING_LIMIT_SATISFIED: - res['great'] += data[key] - elif key > RATING_LIMIT_OK: - res['okay'] += data[key] - else: - res['bad'] += data[key] - return res - - @api.multi - 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 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, 11), 0), - } - for rate in data['repartition']: - result['percent'][rate] = (data['repartition'][rate] * 100) / data['total'] if data['total'] > 0 else 0 - return result diff --git a/addons/rating/models/rating_mixin.py b/addons/rating/models/rating_mixin.py new file mode 100644 index 00000000000..c2a58345c05 --- /dev/null +++ b/addons/rating/models/rating_mixin.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import timedelta + +from odoo import api, fields, models, tools +from odoo.addons.rating.models.rating import RATING_LIMIT_SATISFIED, RATING_LIMIT_OK + + +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', domain=lambda self: [('parent_res_model', '=', self._name)], auto_join=True) + rating_percentage_satisfaction = fields.Integer("Rating Satisfaction", compute="_compute_rating_percentage_satisfaction", store=False, help="Percentage of happy ratings") + + @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', '>=', 1), ('consumed', '=', True)] + if self._rating_satisfaction_days: + domain += [('create_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}} + for item in data: + parent_id = item['parent_res_id'] + rating = item['rating'] + if rating >= RATING_LIMIT_SATISFIED: + grades_per_parent[parent_id]['great'] += item['__count'] + elif rating > RATING_LIMIT_OK: + grades_per_parent[parent_id]['okay'] += item['__count'] + else: + grades_per_parent[parent_id]['bad'] += item['__count'] + + # compute percentage per parent + for record in self: + repartition = grades_per_parent.get(record.id) + record.rating_percentage_satisfaction = repartition['great'] * 100 / sum(repartition.values()) if sum(repartition.values()) else -1 + + +class RatingMixin(models.AbstractModel): + _name = 'rating.mixin' + _description = "Rating Mixin" + + rating_ids = fields.One2many('rating.rating', 'res_id', string='Rating', domain=lambda self: [('res_model', '=', self._name)], auto_join=True) + rating_last_value = fields.Float('Rating Last Value', compute='_compute_rating_last_value', compute_sudo=True, store=True) + rating_last_feedback = fields.Text('Rating Last Feedback', related='rating_ids.feedback', readonly=False) + rating_last_image = fields.Binary('Rating Last Image', related='rating_ids.rating_image', readonly=False) + rating_count = fields.Integer('Rating count', compute="_compute_rating_count") + + @api.multi + @api.depends('rating_ids.rating') + def _compute_rating_last_value(self): + for record in self: + ratings = self.env['rating.rating'].search([('res_model', '=', self._name), ('res_id', '=', record.id)], limit=1) + if ratings: + record.rating_last_value = ratings.rating + + @api.multi + @api.depends('rating_ids') + def _compute_rating_count(self): + read_group_res = self.env['rating.rating'].read_group( + [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('consumed', '=', True)], + ['res_id'], groupby=['res_id']) + result = dict.fromkeys(self.ids, 0) + for data in read_group_res: + result[data['res_id']] += data['res_id_count'] + for record in self: + record.rating_count = result.get(record.id) + + 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""" + with self.env.norecompute(): + 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'] + record.rating_ids._recompute_todo(res_name_field) + if record._rating_get_parent_field_name() in values: + record.rating_ids.write({'parent_res_id': record[record._rating_get_parent_field_name()].id}) + + if self.env.recompute and self._context.get('recompute', True): # trigger the recomputation of all field marked as "to recompute" + self.recompute() + + return result + + def unlink(self): + """ When removing a record, its rating should be deleted too. """ + record_ids = self.ids + result = super(RatingMixin, self).unlink() + self.env['rating.rating'].sudo().search([('res_model', '=', self._name), ('res_id', 'in', record_ids)]).unlink() + return result + + def _rating_get_parent_field_name(self): + """Return the parent relation field name + Should return a Many2One""" + return None + + def rating_get_partner_id(self): + if hasattr(self, 'partner_id') and self.partner_id: + return self.partner_id + return self.env['res.partner'] + + def rating_get_rated_partner_id(self): + if hasattr(self, 'user_id') and self.user_id.partner_id: + return self.user_id.partner_id + return self.env['res.partner'] + + def rating_get_access_token(self, partner=None): + if not partner: + partner = self.rating_get_partner_id() + rated_partner = self.rating_get_rated_partner_id() + ratings = self.rating_ids.filtered(lambda x: x.partner_id.id == partner.id and not x.consumed) + if not ratings: + record_model_id = self.env['ir.model'].sudo().search([('model', '=', self._name)], limit=1).id + rating = self.env['rating.rating'].create({ + 'partner_id': partner.id, + 'rated_partner_id': rated_partner.id, + 'res_model_id': record_model_id, + 'res_id': self.id + }) + else: + rating = ratings[0] + return rating.access_token + + @api.multi + def rating_send_request(self, template, lang=False, subtype_id=False, force_send=True, composition_mode='comment', notif_layout=None): + """ This method send rating request by email, using a template given + in parameter. + + :param template: a mail.template record used to compute the message body; + :param lang: optional lang; it can also be specified directly on the template + itself in the lang field; + :param subtype_id: optional subtype to use when creating the message; is + a note by default to avoid spamming followers; + :param force_send: whether to send the request directly or use the mail + queue cron (preferred option); + :param composition_mode: comment (message_post) or mass_mail (template.send_mail); + :param notif_layout: layout used to encapsulate the content when sending email; + """ + if lang: + template = template.with_context(lang=lang) + if subtype_id is False: + subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note') + if force_send: + self = self.with_context(mail_notify_force_send=True) + for record in self: + record.message_post_with_template( + template.id, + composition_mode=composition_mode, + notif_layout=notif_layout if notif_layout is not None else 'mail.mail_notification_light', + subtype_id=subtype_id + ) + + @api.multi + def rating_apply(self, rate, token=None, feedback=None, subtype=None): + """ Apply a rating given a token. If the current model inherits from + mail.thread mixing, a message is posted on its chatter. + :param rate : the rating value to apply + :type rate : float + :param token : access token + :param feedback : additional feedback + :type feedback : string + :param subtype : subtype for mail + :type subtype : string + :returns rating.rating record + """ + Rating, rating = self.env['rating.rating'], None + if token: + rating = self.env['rating.rating'].search([('access_token', '=', token)], limit=1) + else: + rating = Rating.search([('res_model', '=', self._name), ('res_id', '=', self.ids[0])], limit=1) + if rating: + rating.write({'rating': rate, 'feedback': feedback, 'consumed': True}) + if hasattr(self, 'message_post'): + feedback = tools.plaintext2html(feedback or '') + self.message_post( + body=":%s/10%s" + % (rate, rate, feedback), + subtype=subtype or "mail.mt_comment", + author_id=rating.partner_id and rating.partner_id.id or None # None will set the default author in mail_thread.py + ) + if hasattr(self, 'stage_id') and self.stage_id and hasattr(self.stage_id, 'auto_validation_kanban_state') and self.stage_id.auto_validation_kanban_state: + if rating.rating > 5: + self.write({'kanban_state': 'done'}) + if rating.rating < 5: + self.write({'kanban_state': 'blocked'}) + return rating + + @api.multi + 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 = [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('rating', '>=', 1), ('consumed', '=', True)] + if domain: + base_domain += domain + 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, 11), 0) + values.update((d['rating'], d['rating_count']) for d in data) + # 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), + } + return result + return values + + @api.multi + 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: + if key >= RATING_LIMIT_SATISFIED: + res['great'] += data[key] + elif key > RATING_LIMIT_OK: + res['okay'] += data[key] + else: + res['bad'] += data[key] + return res + + @api.multi + 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 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, 11), 0), + } + for rate in data['repartition']: + result['percent'][rate] = (data['repartition'][rate] * 100) / data['total'] if data['total'] > 0 else 0 + return result