# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import re from binascii import Error as binascii_error from collections import defaultdict from operator import itemgetter from odoo import _, api, fields, models, modules, tools from odoo.exceptions import AccessError, UserError from odoo.http import request from odoo.osv import expression from odoo.tools import groupby from odoo.tools.misc import clean_context _logger = logging.getLogger(__name__) _image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])(?: data-filename="([^"]*)")?', re.I) class Message(models.Model): """ Messages model: system notification (replacing res.log notifications), comments (OpenChatter discussion) and incoming emails. """ _name = 'mail.message' _description = 'Message' _order = 'id desc' _rec_name = 'record_name' @api.model def default_get(self, fields): res = super(Message, self).default_get(fields) missing_author = 'author_id' in fields and 'author_id' not in res missing_email_from = 'email_from' in fields and 'email_from' not in res if missing_author or missing_email_from: author_id, email_from = self.env['mail.thread']._message_compute_author(res.get('author_id'), res.get('email_from'), raise_exception=False) if missing_email_from: res['email_from'] = email_from if missing_author: res['author_id'] = author_id return res # content subject = fields.Char('Subject') date = fields.Datetime('Date', default=fields.Datetime.now) body = fields.Html('Contents', default='', sanitize_style=True) description = fields.Char( 'Short description', compute="_compute_description", help='Message description: either the subject, or the beginning of the body') attachment_ids = fields.Many2many( 'ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', string='Attachments', help='Attachments are linked to a document through model / res_id and to the message ' 'through this field.') parent_id = fields.Many2one( 'mail.message', 'Parent Message', index=True, ondelete='set null', help="Initial thread message.") child_ids = fields.One2many('mail.message', 'parent_id', 'Child Messages') # related document model = fields.Char('Related Document Model', index=True) res_id = fields.Many2oneReference('Related Document ID', index=True, model_field='model') record_name = fields.Char('Message Record Name', help="Name get of the related document.") # characteristics message_type = fields.Selection([ ('email', 'Email'), ('comment', 'Comment'), ('notification', 'System notification'), ('user_notification', 'User Specific Notification')], 'Type', required=True, default='email', help="Message type: email for email message, notification for system " "message, comment for other messages such as user replies", ) subtype_id = fields.Many2one('mail.message.subtype', 'Subtype', ondelete='set null', index=True) mail_activity_type_id = fields.Many2one( 'mail.activity.type', 'Mail Activity Type', index=True, ondelete='set null') is_internal = fields.Boolean('Employee Only', help='Hide to public / portal users, independently from subtype configuration.') # origin email_from = fields.Char('From', help="Email address of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter.") author_id = fields.Many2one( 'res.partner', 'Author', index=True, ondelete='set null', help="Author of the message. If not set, email_from may hold an email address that did not match any partner.") author_avatar = fields.Binary("Author's avatar", related='author_id.image_128', depends=['author_id'], readonly=False) # recipients: include inactive partners (they may have been archived after # the message was sent, but they should remain visible in the relation) partner_ids = fields.Many2many('res.partner', string='Recipients', context={'active_test': False}) # list of partner having a notification. Caution: list may change over time because of notif gc cron. # mainly usefull for testing notified_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_needaction_rel', string='Partners with Need Action', context={'active_test': False}, depends=['notification_ids']) needaction = fields.Boolean( 'Need Action', compute='_get_needaction', search='_search_needaction', help='Need Action') has_error = fields.Boolean( 'Has error', compute='_compute_has_error', search='_search_has_error', help='Has error') channel_ids = fields.Many2many( 'mail.channel', 'mail_message_mail_channel_rel', string='Channels') # notifications notification_ids = fields.One2many( 'mail.notification', 'mail_message_id', 'Notifications', auto_join=True, copy=False, depends=['notified_partner_ids']) # user interface starred_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By') starred = fields.Boolean( 'Starred', compute='_get_starred', search='_search_starred', compute_sudo=False, help='Current user has a starred notification linked to this message') # tracking tracking_value_ids = fields.One2many( 'mail.tracking.value', 'mail_message_id', string='Tracking values', groups="base.group_system", help='Tracked values are stored in a separate model. This field allow to reconstruct ' 'the tracking and to generate statistics on the model.') # mail gateway no_auto_thread = fields.Boolean( 'No threading for answers', help='Answers do not go in the original document discussion thread. This has an impact on the generated message-id.') message_id = fields.Char('Message-Id', help='Message unique identifier', index=True, readonly=1, copy=False) reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.') mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') # moderation moderation_status = fields.Selection([ ('pending_moderation', 'Pending Moderation'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], string="Moderation Status", index=True) moderator_id = fields.Many2one('res.users', string="Moderated By", index=True) need_moderation = fields.Boolean('Need moderation', compute='_compute_need_moderation', search='_search_need_moderation') # keep notification layout informations to be able to generate mail again email_layout_xmlid = fields.Char('Layout', copy=False) # xml id of layout add_sign = fields.Boolean(default=True) # `test_adv_activity`, `test_adv_activity_full`, `test_message_assignation_inbox`,... # By setting an inverse for mail.mail_message_id, the number of SQL queries done by `modified` is reduced. # 'mail.mail' inherits from `mail.message`: `_inherits = {'mail.message': 'mail_message_id'}` # Therefore, when changing a field on `mail.message`, this triggers the modification of the same field on `mail.mail` # By setting up the inverse one2many, we avoid to have to do a search to find the mails linked to the `mail.message` # as the cache value for this inverse one2many is up-to-date. # Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing. mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system") canned_response_ids = fields.One2many('mail.shortcode', 'message_ids', string="Canned Responses", store=False) def _compute_description(self): for message in self: if message.subject: message.description = message.subject else: plaintext_ct = '' if not message.body else tools.html2plaintext(message.body) message.description = plaintext_ct[:30] + '%s' % (' [...]' if len(plaintext_ct) >= 30 else '') def _get_needaction(self): """ Need action on a mail.message = notified on my channel """ my_messages = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', False)]).mapped('mail_message_id') for message in self: message.needaction = message in my_messages @api.model def _search_needaction(self, operator, operand): is_read = False if operator == '=' and operand else True notification_ids = self.env['mail.notification']._search([('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', is_read)]) return [('notification_ids', 'in', notification_ids)] def _compute_has_error(self): error_from_notification = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('notification_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id') for message in self: message.has_error = message in error_from_notification def _search_has_error(self, operator, operand): if operator == '=' and operand: return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))] return ['!', ('notification_ids.notification_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" @api.depends('starred_partner_ids') @api.depends_context('uid') def _get_starred(self): """ Compute if the message is starred by the current user. """ # TDE FIXME: use SQL starred = self.sudo().filtered(lambda msg: self.env.user.partner_id in msg.starred_partner_ids) for message in self: message.starred = message in starred @api.model def _search_starred(self, operator, operand): if operator == '=' and operand: return [('starred_partner_ids', 'in', [self.env.user.partner_id.id])] return [('starred_partner_ids', 'not in', [self.env.user.partner_id.id])] def _compute_need_moderation(self): for message in self: message.need_moderation = False @api.model def _search_need_moderation(self, operator, operand): if operator == '=' and operand is True: return ['&', '&', ('moderation_status', '=', 'pending_moderation'), ('model', '=', 'mail.channel'), ('res_id', 'in', self.env.user.moderation_channel_ids.ids)] # no support for other operators raise UserError(_('Unsupported search filter on moderation status')) # ------------------------------------------------------ # CRUD / ORM # ------------------------------------------------------ def init(self): self._cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""") if not self._cr.fetchone(): self._cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""") @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Override that adds specific access rights of mail.message, to remove ids uid could not see according to our custom rules. Please refer to check_access_rule for more details about those rules. Non employees users see only message with subtype (aka do not see internal logs). After having received ids of a classic search, keep only: - if author_id == pid, uid is the author, OR - uid belongs to a notified channel, OR - uid is in the specified recipients, OR - uid has a notification on the message - otherwise: remove the id """ # Rules do not apply to administrator if self.env.is_superuser(): return super(Message, self)._search( args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) # Non-employee see only messages with a subtype and not internal if not self.env['res.users'].has_group('base.group_user'): args = expression.AND([self._get_search_domain_share(), args]) # Perform a super with count as False, to have the ids, not a counter ids = super(Message, self)._search( args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid) if not ids and count: return 0 elif not ids: return ids pid = self.env.user.partner_id.id author_ids, partner_ids, channel_ids, allowed_ids = set([]), set([]), set([]), set([]) model_ids = {} # check read access rights before checking the actual rules on the given ids super(Message, self.with_user(access_rights_uid or self._uid)).check_access_rights('read') self.flush(['model', 'res_id', 'author_id', 'message_type', 'partner_ids', 'channel_ids']) self.env['mail.notification'].flush(['mail_message_id', 'res_partner_id']) self.env['mail.channel'].flush(['channel_message_ids']) self.env['mail.channel.partner'].flush(['channel_id', 'partner_id']) for sub_ids in self._cr.split_for_in_conditions(ids): self._cr.execute(""" SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.message_type, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), channel_partner.channel_id as channel_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=pid, ids=list(sub_ids))) for id, rmod, rid, author_id, message_type, partner_id, channel_id in self._cr.fetchall(): if author_id == pid: author_ids.add(id) elif partner_id == pid: partner_ids.add(id) elif channel_id: channel_ids.add(id) elif rmod and rid and message_type != 'user_notification': model_ids.setdefault(rmod, {}).setdefault(rid, set()).add(id) allowed_ids = self._find_allowed_doc_ids(model_ids) final_ids = author_ids | partner_ids | channel_ids | allowed_ids if count: return len(final_ids) else: # re-construct a list based on ids, because set did not keep the original order id_list = [id for id in ids if id in final_ids] return id_list @api.model def _find_allowed_model_wise(self, doc_model, doc_dict): doc_ids = list(doc_dict) allowed_doc_ids = self.env[doc_model].with_context(active_test=False).search([('id', 'in', doc_ids)]).ids return set([message_id for allowed_doc_id in allowed_doc_ids for message_id in doc_dict[allowed_doc_id]]) @api.model def _find_allowed_doc_ids(self, model_ids): IrModelAccess = self.env['ir.model.access'] allowed_ids = set() for doc_model, doc_dict in model_ids.items(): if not IrModelAccess.check(doc_model, 'read', False): continue allowed_ids |= self._find_allowed_model_wise(doc_model, doc_dict) return allowed_ids def check_access_rule(self, operation): """ Access rules of mail.message: - read: if - author_id == pid, uid is the author OR - uid is in the recipients (partner_ids) OR - uid has been notified (needaction) OR - uid is member of a listern channel (channel_ids.partner_ids) OR - uid have read access to the related document if model, res_id - otherwise: raise - create: if - no model, no res_id (private message) OR - pid in message_follower_ids if model, res_id OR - uid can read the parent OR - uid have write or create access on the related document if model, res_id, OR - otherwise: raise - write: if - author_id == pid, uid is the author, OR - uid is in the recipients (partner_ids) OR - uid is moderator of the channel and moderation_status is pending_moderation OR - uid has write or create access on the related document if model, res_id and moderation_status is not pending_moderation - otherwise: raise - unlink: if - uid is moderator of the channel and moderation_status is pending_moderation OR - uid has write or create access on the related document if model, res_id and moderation_status is not pending_moderation - otherwise: raise Specific case: non employee users see only messages with subtype (aka do not see internal logs). """ def _generate_model_record_ids(msg_val, msg_ids): """ :param model_record_ids: {'model': {'res_id': (msg_id, msg_id)}, ... } :param message_values: {'msg_id': {'model': .., 'res_id': .., 'author_id': ..}} """ model_record_ids = {} for id in msg_ids: vals = msg_val.get(id, {}) if vals.get('model') and vals.get('res_id'): model_record_ids.setdefault(vals['model'], set()).add(vals['res_id']) return model_record_ids if self.env.is_superuser(): return # Non employees see only messages with a subtype (aka, not internal logs) if not self.env['res.users'].has_group('base.group_user'): self._cr.execute('''SELECT DISTINCT message.id, message.subtype_id, subtype.internal FROM "%s" AS message LEFT JOIN "mail_message_subtype" as subtype ON message.subtype_id = subtype.id WHERE message.message_type = %%s AND (message.is_internal IS TRUE OR message.subtype_id IS NULL OR subtype.internal IS TRUE) AND message.id = ANY (%%s)''' % (self._table), ('comment', self.ids,)) if self._cr.fetchall(): raise AccessError( _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)', self._description, operation) + ' - ({} {}, {} {})'.format(_('Records:'), self.ids[:6], _('User:'), self._uid) ) # Read mail_message.ids to have their values message_values = dict((message_id, {}) for message_id in self.ids) self.flush(['model', 'res_id', 'author_id', 'parent_id', 'moderation_status', 'message_type', 'partner_ids', 'channel_ids']) self.env['mail.notification'].flush(['mail_message_id', 'res_partner_id']) self.env['mail.channel'].flush(['channel_message_ids', 'moderator_ids']) self.env['mail.channel.partner'].flush(['channel_id', 'partner_id']) self.env['res.users'].flush(['moderation_channel_ids']) if operation == 'read': self._cr.execute(""" SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), channel_partner.channel_id as channel_id, m.moderation_status, m.message_type as message_type FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, ids=self.ids)) for mid, rmod, rid, author_id, parent_id, partner_id, channel_id, moderation_status, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'moderation_status': moderation_status, 'moderator_id': False, 'notified': any((message_values[mid].get('notified'), partner_id, channel_id)), 'message_type': message_type, } elif operation == 'write': self._cr.execute(""" SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, m.moderation_status, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), channel_partner.channel_id as channel_id, channel_moderator_rel.res_users_id as moderator_id, m.message_type as message_type FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s LEFT JOIN "mail_channel" moderated_channel ON m.moderation_status = 'pending_moderation' AND m.res_id = moderated_channel.id LEFT JOIN "mail_channel_moderator_rel" channel_moderator_rel ON channel_moderator_rel.mail_channel_id = moderated_channel.id AND channel_moderator_rel.res_users_id = %%(uid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, uid=self.env.user.id, ids=self.ids)) for mid, rmod, rid, author_id, parent_id, moderation_status, partner_id, channel_id, moderator_id, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'moderation_status': moderation_status, 'moderator_id': moderator_id, 'notified': any((message_values[mid].get('notified'), partner_id, channel_id)), 'message_type': message_type, } elif operation == 'create': self._cr.execute("""SELECT DISTINCT id, model, res_id, author_id, parent_id, moderation_status, message_type FROM "%s" WHERE id = ANY (%%s)""" % self._table, (self.ids,)) for mid, rmod, rid, author_id, parent_id, moderation_status, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'moderation_status': moderation_status, 'moderator_id': False, 'message_type': message_type, } else: # unlink self._cr.execute("""SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, m.moderation_status, channel_moderator_rel.res_users_id as moderator_id, m.message_type as message_type FROM "%s" m LEFT JOIN "mail_channel" moderated_channel ON m.moderation_status = 'pending_moderation' AND m.res_id = moderated_channel.id LEFT JOIN "mail_channel_moderator_rel" channel_moderator_rel ON channel_moderator_rel.mail_channel_id = moderated_channel.id AND channel_moderator_rel.res_users_id = (%%s) WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.id, self.ids,)) for mid, rmod, rid, author_id, parent_id, moderation_status, moderator_id, message_type in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'moderation_status': moderation_status, 'moderator_id': moderator_id, 'message_type': message_type, } # Author condition (READ, WRITE, CREATE (private)) author_ids = [] if operation == 'read': author_ids = [mid for mid, message in message_values.items() if message.get('author_id') and message.get('author_id') == self.env.user.partner_id.id] elif operation == 'write': author_ids = [mid for mid, message in message_values.items() if message.get('moderation_status') != 'pending_moderation' and message.get('author_id') == self.env.user.partner_id.id] elif operation == 'create': author_ids = [mid for mid, message in message_values.items() if not self.is_thread_message(message)] # Moderator condition: allow to WRITE, UNLINK if moderator of a pending message moderator_ids = [] if operation in ['write', 'unlink']: moderator_ids = [mid for mid, message in message_values.items() if message.get('moderator_id')] messages_to_check = self.ids messages_to_check = set(messages_to_check).difference(set(author_ids), set(moderator_ids)) if not messages_to_check: return # Recipients condition, for read and write (partner_ids) # keep on top, usefull for systray notifications notified_ids = [] model_record_ids = _generate_model_record_ids(message_values, messages_to_check) if operation in ['read', 'write']: notified_ids = [mid for mid, message in message_values.items() if message.get('notified')] messages_to_check = set(messages_to_check).difference(set(notified_ids)) if not messages_to_check: return # CRUD: Access rights related to the document document_related_ids = [] document_related_candidate_ids = [mid for mid, message in message_values.items() if (message.get('model') and message.get('res_id') and message.get('message_type') != 'user_notification' and (message.get('moderation_status') != 'pending_moderation' or operation not in ['write', 'unlink']))] model_record_ids = _generate_model_record_ids(message_values, document_related_candidate_ids) for model, doc_ids in model_record_ids.items(): DocumentModel = self.env[model] if hasattr(DocumentModel, '_get_mail_message_access'): check_operation = DocumentModel._get_mail_message_access(doc_ids, operation) ## why not giving model here? else: check_operation = self.env['mail.thread']._get_mail_message_access(doc_ids, operation, model_name=model) records = DocumentModel.browse(doc_ids) records.check_access_rights(check_operation) mids = records.browse(doc_ids)._filter_access_rules(check_operation) document_related_ids += [ mid for mid, message in message_values.items() if (message.get('model') == model and message.get('res_id') in mids.ids and message.get('message_type') != 'user_notification' and (message.get('moderation_status') != 'pending_moderation' or operation not in ['write', 'unlink']))] messages_to_check = messages_to_check.difference(set(document_related_ids)) if not messages_to_check: return # Parent condition, for create (check for received notifications for the created message parent) notified_ids = [] if operation == 'create': # TDE: probably clean me parent_ids = [message.get('parent_id') for message in message_values.values() if message.get('parent_id')] self._cr.execute("""SELECT DISTINCT m.id, partner_rel.res_partner_id, channel_partner.partner_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, self.env.user.partner_id.id, parent_ids,)) not_parent_ids = [mid[0] for mid in self._cr.fetchall() if any([mid[1], mid[2]])] notified_ids += [mid for mid, message in message_values.items() if message.get('parent_id') in not_parent_ids] messages_to_check = messages_to_check.difference(set(notified_ids)) if not messages_to_check: return # Recipients condition for create (message_follower_ids) if operation == 'create': for doc_model, doc_ids in model_record_ids.items(): followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', doc_model), ('res_id', 'in', list(doc_ids)), ('partner_id', '=', self.env.user.partner_id.id), ]) fol_mids = [follower.res_id for follower in followers] notified_ids += [mid for mid, message in message_values.items() if message.get('model') == doc_model and message.get('res_id') in fol_mids and message.get('message_type') != 'user_notification' ] messages_to_check = messages_to_check.difference(set(notified_ids)) if not messages_to_check: return if not self.browse(messages_to_check).exists(): return raise AccessError( _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)', self._description, operation) + ' - ({} {}, {} {})'.format(_('Records:'), list(messages_to_check)[:6], _('User:'), self._uid) ) @api.model_create_multi def create(self, values_list): tracking_values_list = [] for values in values_list: if 'email_from' not in values: # needed to compute reply_to author_id, email_from = self.env['mail.thread']._message_compute_author(values.get('author_id'), email_from=None, raise_exception=False) values['email_from'] = email_from if not values.get('message_id'): values['message_id'] = self._get_message_id(values) if 'reply_to' not in values: values['reply_to'] = self._get_reply_to(values) if 'record_name' not in values and 'default_record_name' not in self.env.context: values['record_name'] = self._get_record_name(values) if 'attachment_ids' not in values: values['attachment_ids'] = [] # extract base64 images if 'body' in values: Attachments = self.env['ir.attachment'].with_context(clean_context(self._context)) data_to_url = {} def base64_to_boundary(match): key = match.group(2) if not data_to_url.get(key): name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url) try: attachment = Attachments.create({ 'name': name, 'datas': match.group(2), 'res_model': values.get('model'), 'res_id': values.get('res_id'), }) except binascii_error: _logger.warning("Impossible to create an attachment out of badly formated base64 embedded image. Image has been removed.") return match.group(3) # group(3) is the url ending single/double quote matched by the regexp else: attachment.generate_access_token() values['attachment_ids'].append((4, attachment.id)) data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name] return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1]) values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body'])) # delegate creation of tracking after the create as sudo to avoid access rights issues tracking_values_list.append(values.pop('tracking_value_ids', False)) messages = super(Message, self).create(values_list) check_attachment_access = [] if all(isinstance(command, int) or command[0] in (4, 6) for values in values_list for command in values.get('attachment_ids')): for values in values_list: for command in values.get('attachment_ids'): if isinstance(command, int): check_attachment_access += [command] elif command[0] == 6: check_attachment_access += command[2] else: # command[0] == 4: check_attachment_access += [command[1]] else: check_attachment_access = messages.mapped('attachment_ids').ids # fallback on read if any unknow command if check_attachment_access: self.env['ir.attachment'].browse(check_attachment_access).check(mode='read') for message, values, tracking_values_cmd in zip(messages, values_list, tracking_values_list): if tracking_values_cmd: vals_lst = [dict(cmd[2], mail_message_id=message.id) for cmd in tracking_values_cmd if len(cmd) == 3 and cmd[0] == 0] other_cmd = [cmd for cmd in tracking_values_cmd if len(cmd) != 3 or cmd[0] != 0] if vals_lst: self.env['mail.tracking.value'].sudo().create(vals_lst) if other_cmd: message.sudo().write({'tracking_value_ids': tracking_values_cmd}) if message.is_thread_message(values): message._invalidate_documents(values.get('model'), values.get('res_id')) return messages def read(self, fields=None, load='_classic_read'): """ Override to explicitely call check_access_rule, that is not called by the ORM. It instead directly fetches ir.rules and apply them. """ self.check_access_rule('read') return super(Message, self).read(fields=fields, load=load) def write(self, vals): record_changed = 'model' in vals or 'res_id' in vals if record_changed or 'message_type' in vals: self._invalidate_documents() res = super(Message, self).write(vals) if vals.get('attachment_ids'): for mail in self: mail.attachment_ids.check(mode='read') if 'notification_ids' in vals or record_changed: self._invalidate_documents() return res def unlink(self): # cascade-delete attachments that are directly attached to the message (should only happen # for mail.messages that act as parent for a standalone mail.mail record). if not self: return True self.check_access_rule('unlink') self.mapped('attachment_ids').filtered( lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0) ).unlink() for elem in self: if elem.is_thread_message(): elem._invalidate_documents() return super(Message, self).unlink() @api.model def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): if not self.env.is_admin(): raise AccessError(_("Only administrators are allowed to use grouped read on message model")) return super(Message, self)._read_group_raw( domain=domain, fields=fields, groupby=groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy, ) def export_data(self, fields_to_export): if not self.env.is_admin(): raise AccessError(_("Only administrators are allowed to export mail message")) return super(Message, self).export_data(fields_to_export) # ------------------------------------------------------ # DISCUSS API # ------------------------------------------------------ @api.model def mark_all_as_read(self, domain=None): # not really efficient method: it does one db request for the # search, and one for each message in the result set is_read to True in the # current notifications from the relation. partner_id = self.env.user.partner_id.id notif_domain = [ ('res_partner_id', '=', partner_id), ('is_read', '=', False)] if domain: messages = self.search(domain) messages.set_message_done() return messages.ids notifications = self.env['mail.notification'].sudo().search(notif_domain) notifications.write({'is_read': True}) ids = [n['mail_message_id'] for n in notifications.read(['mail_message_id'])] notification = {'type': 'mark_as_read', 'message_ids': [id[0] for id in ids], 'needaction_inbox_counter': self.env.user.partner_id.get_needaction_count()} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id), notification) return ids def set_message_done(self): """ Remove the needaction from messages for the current partner. """ partner_id = self.env.user.partner_id notifications = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('res_partner_id', '=', partner_id.id), ('is_read', '=', False)]) if not notifications: return # notifies changes in messages through the bus. To minimize the number of # notifications, we need to group the messages depending on their channel_ids groups = [] messages = notifications.mapped('mail_message_id') current_channel_ids = messages[0].channel_ids current_group = [] for record in messages: if record.channel_ids == current_channel_ids: current_group.append(record.id) else: groups.append((current_group, current_channel_ids)) current_group = [record.id] current_channel_ids = record.channel_ids groups.append((current_group, current_channel_ids)) current_group = [record.id] current_channel_ids = record.channel_ids notifications.write({'is_read': True}) for (msg_ids, channel_ids) in groups: # channel_ids in result is deprecated and will be removed in a future version notification = {'type': 'mark_as_read', 'message_ids': msg_ids, 'channel_ids': [c.id for c in channel_ids], 'needaction_inbox_counter': self.env.user.partner_id.get_needaction_count()} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id.id), notification) @api.model def unstar_all(self): """ Unstar messages for the current partner. """ partner_id = self.env.user.partner_id.id starred_messages = self.search([('starred_partner_ids', 'in', partner_id)]) starred_messages.write({'starred_partner_ids': [(3, partner_id)]}) ids = [m.id for m in starred_messages] notification = {'type': 'toggle_star', 'message_ids': ids, 'starred': False} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) def toggle_message_starred(self): """ Toggle messages as (un)starred. Technically, the notifications related to uid are set to (un)starred. """ # a user should always be able to star a message he can read self.check_access_rule('read') starred = not self.starred if starred: self.sudo().write({'starred_partner_ids': [(4, self.env.user.partner_id.id)]}) else: self.sudo().write({'starred_partner_ids': [(3, self.env.user.partner_id.id)]}) notification = {'type': 'toggle_star', 'message_ids': [self.id], 'starred': starred} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) # -------------------------------------------------- # MODERATION API # -------------------------------------------------- def moderate(self, decision, **kwargs): """ Moderate messages. A check is done on moderation status of the current user to ensure we only moderate valid messages. """ moderated_channels = self.env.user.moderation_channel_ids to_moderate = [message.id for message in self if message.model == 'mail.channel' and message.res_id in moderated_channels.ids and message.moderation_status == 'pending_moderation'] if to_moderate: self.browse(to_moderate)._moderate(decision, **kwargs) def _moderate(self, decision, **kwargs): """ :param decision * accept - moderate message and broadcast that message to followers of relevant channels. * reject - message will be deleted from the database without broadcast an email sent to the author with an explanation that the moderators can edit. * discard - message will be deleted from the database without broadcast. * allow - add email address to white list people of specific channel, so that next time if a message come from same email address on same channel, it will be automatically broadcasted to relevant channels without any approval from moderator. * ban - add email address to black list of emails for the specific channel. From next time, a person sending a message using that email address will not need moderation. message_post will not create messages with the corresponding expeditor. """ if decision == 'accept': self._moderate_accept() elif decision == 'reject': self._moderate_send_reject_email(kwargs.get('title'), kwargs.get('comment')) self._moderate_discard() elif decision == 'discard': self._moderate_discard() elif decision == 'allow': channels = self.env['mail.channel'].browse(self.mapped('res_id')) for channel in channels: channel._update_moderation_email( list({message.email_from for message in self if message.res_id == channel.id}), 'allow' ) self._search_from_same_authors()._moderate_accept() elif decision == 'ban': channels = self.env['mail.channel'].browse(self.mapped('res_id')) for channel in channels: channel._update_moderation_email( list({message.email_from for message in self if message.res_id == channel.id}), 'ban' ) self._search_from_same_authors()._moderate_discard() def _moderate_accept(self): self.write({ 'moderation_status': 'accepted', 'moderator_id': self.env.uid }) # proceed with notification process to send notification emails and Inbox messages for message in self: if message.is_thread_message(): # note, since we will only intercept _notify_thread for message posted on channel, # message will always be a thread_message. This check should always be true. self.env[message.model].browse(message.res_id)._notify_thread(message) def _moderate_send_reject_email(self, subject, comment): for msg in self: if not msg.email_from: continue body_html = tools.append_content_to_html('