diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/rating/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/rating/models')
| -rw-r--r-- | addons/rating/models/__init__.py | 6 | ||||
| -rw-r--r-- | addons/rating/models/mail_message.py | 27 | ||||
| -rw-r--r-- | addons/rating/models/mail_thread.py | 29 | ||||
| -rw-r--r-- | addons/rating/models/rating.py | 176 | ||||
| -rw-r--r-- | addons/rating/models/rating_mixin.py | 273 |
5 files changed, 511 insertions, 0 deletions
diff --git a/addons/rating/models/__init__.py b/addons/rating/models/__init__.py new file mode 100644 index 00000000..44c09740 --- /dev/null +++ b/addons/rating/models/__init__.py @@ -0,0 +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/mail_message.py b/addons/rating/models/mail_message.py new file mode 100644 index 00000000..015f21d3 --- /dev/null +++ b/addons/rating/models/mail_message.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class MailMessage(models.Model): + _inherit = 'mail.message' + + rating_ids = fields.One2many('rating.rating', 'message_id', groups='base.group_user', string='Related ratings') + rating_value = fields.Float( + 'Rating Value', compute='_compute_rating_value', compute_sudo=True, + store=False, search='_search_rating_value') + + @api.depends('rating_ids', 'rating_ids.rating') + def _compute_rating_value(self): + ratings = self.env['rating.rating'].search([('message_id', 'in', self.ids), ('consumed', '=', True)], order='create_date DESC') + mapping = dict((r.message_id.id, r.rating) for r in ratings) + for message in self: + message.rating_value = mapping.get(message.id, 0.0) + + def _search_rating_value(self, operator, operand): + ratings = self.env['rating.rating'].sudo().search([ + ('rating', operator, operand), + ('message_id', '!=', False) + ]) + return [('id', 'in', ratings.mapped('message_id').ids)] diff --git a/addons/rating/models/mail_thread.py b/addons/rating/models/mail_thread.py new file mode 100644 index 00000000..e2182ea9 --- /dev/null +++ b/addons/rating/models/mail_thread.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class MailThread(models.AbstractModel): + _inherit = 'mail.thread' + + @api.returns('mail.message', lambda value: value.id) + def message_post(self, **kwargs): + rating_value = kwargs.pop('rating_value', False) + rating_feedback = kwargs.pop('rating_feedback', False) + message = super(MailThread, self).message_post(**kwargs) + + # 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: + ir_model = self.env['ir.model'].sudo().search([('model', '=', self._name)]) + self.env['rating.rating'].sudo().create({ + 'rating': float(rating_value) if rating_value is not None else False, + 'feedback': rating_feedback, + 'res_model_id': ir_model.id, + 'res_id': self.id, + 'message_id': message.id, + 'consumed': True, + 'partner_id': self.env.user.partner_id.id, + }) + return message diff --git a/addons/rating/models/rating.py b/addons/rating/models/rating.py new file mode 100644 index 00000000..c5fa842b --- /dev/null +++ b/addons/rating/models/rating.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import uuid + +from odoo import api, fields, models + +from odoo.modules.module import get_resource_path + +RATING_LIMIT_SATISFIED = 5 +RATING_LIMIT_OK = 3 +RATING_LIMIT_MIN = 1 + + +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): + return uuid.uuid4().hex + + @api.model + def _selection_target_model(self): + return [(model.model, model.name) for model in self.env['ir.model'].search([])] + + create_date = fields.Datetime(string="Submitted on") + res_name = fields.Char(string='Resource name', compute='_compute_res_name', store=True, help="The name of the rated resource.") + res_model_id = fields.Many2one('ir.model', 'Related Document Model', index=True, ondelete='cascade', help='Model of the followed resource') + res_model = fields.Char(string='Document Model', related='res_model_id.model', store=True, index=True, readonly=True) + res_id = fields.Integer(string='Document', required=True, help="Identifier of the rated object", 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", help="Owner of the rated resource") + 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([ + ('satisfied', 'Satisfied'), + ('not_satisfied', 'Not satisfied'), + ('highly_dissatisfied', 'Highly dissatisfied'), + ('no_rating', 'No Rating yet')], 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", + index=True, ondelete='cascade', + help="Associated message when posting a review. Mainly used in website addons.") + 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, help="Access token to set the rating of the value") + consumed = fields.Boolean(string="Filled Rating", help="Enabled if the rating has been filled.") + + @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).name_get() + name = name and name[0][1] or ('%s/%s') % (rating.parent_res_model, rating.parent_res_id) + rating.parent_res_name = name + + 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 + + def _compute_rating_image(self): + for rating in self: + try: + image_path = get_resource_path('rating', 'static/src/img', rating._get_rating_image_filename()) + rating.rating_image = base64.b64encode(open(image_path, 'rb').read()) if image_path else False + except (IOError, OSError): + rating.rating_image = False + + @api.depends('rating') + def _compute_rating_text(self): + for rating in self: + if rating.rating >= RATING_LIMIT_SATISFIED: + rating.rating_text = 'satisfied' + elif rating.rating >= RATING_LIMIT_OK: + rating.rating_text = 'not_satisfied' + elif rating.rating >= RATING_LIMIT_MIN: + rating.rating_text = 'highly_dissatisfied' + else: + rating.rating_text = 'no_rating' + + @api.model + def create(self, values): + if values.get('res_model_id') and values.get('res_id'): + values.update(self._find_parent_data(values)) + return super(Rating, self).create(values) + + 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 + + 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']] + } diff --git a/addons/rating/models/rating_mixin.py b/addons/rating/models/rating_mixin.py new file mode 100644 index 00000000..62d0d6c1 --- /dev/null +++ b/addons/rating/models/rating_mixin.py @@ -0,0 +1,273 @@ +# -*- 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, RATING_LIMIT_MIN +from odoo.osv import expression + + +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") + + @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 += [('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}} + 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'] + + # compute percentage per parent + for record in self: + repartition = grades_per_parent.get(record.id, default_grades) + 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', groups='base.group_user', domain=lambda self: [('res_model', '=', self._name)], auto_join=True) + rating_last_value = fields.Float('Rating Last Value', groups='base.group_user', compute='_compute_rating_last_value', compute_sudo=True, store=True) + 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("Rating Average", compute='_compute_rating_stats', compute_sudo=True) + + @api.depends('rating_ids.rating', 'rating_ids.consumed') + def _compute_rating_last_value(self): + for record in self: + ratings = self.env['rating.rating'].search([('res_model', '=', self._name), ('res_id', '=', record.id), ('consumed', '=', True)], limit=1) + record.rating_last_value = ratings and ratings.rating or 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_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: + record.rating_count = mapping.get(record.id, {}).get('rating_count', 0) + record.rating_avg = mapping.get(record.id, {}).get('rating_avg', 0) + + 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'] + 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 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_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_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): + """ 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_rights('read') + self.check_access_rule('read') + if not partner: + partner = self.rating_get_partner_id() + rated_partner = self.rating_get_rated_partner_id() + ratings = self.rating_ids.sudo().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'].sudo().create({ + 'partner_id': partner.id, + 'rated_partner_id': rated_partner.id, + 'res_model_id': record_model_id, + 'res_id': self.id, + 'is_internal': False, + }) + else: + rating = ratings[0] + return rating.access_token + + 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) # default value is True, should be set to false if not? + for record in self: + record.message_post_with_template( + template.id, + composition_mode=composition_mode, + email_layout_xmlid=notif_layout if notif_layout is not None else 'mail.mail_notification_light', + subtype_id=subtype_id + ) + + def rating_apply(self, rate, token=None, feedback=None, subtype_xmlid=None): + """ Apply a rating given a token. 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 because of rating + manipulation (either employee, either sudo-ed in public controllers after + security check granting access). + + :param float rate : the rating value to apply + :param string token : access token + :param string feedback : additional feedback + :param string subtype_xmlid : xml id of a valid mail.message.subtype + + :returns rating.rating record + """ + rating = None + if token: + rating = self.env['rating.rating'].search([('access_token', '=', token)], limit=1) + else: + rating = self.env['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="<img src='/rating/static/src/img/rating_%s.png' alt=':%s/10' style='width:18px;height:18px;float:left;margin-right: 5px;'/>%s" + % (rate, rate, feedback), + subtype_xmlid=subtype_xmlid 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 > 2: + self.write({'kanban_state': 'done'}) + else: + self.write({'kanban_state': 'blocked'}) + return rating + + 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 + 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) + # 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 + + 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 + + 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 |
