oca-ocb-core/odoo-bringout-oca-ocb-mail/mail/models/mail_message.py
Ernad Husremovic 2d3ee4855a 19.0 vanilla
2026-03-09 09:30:27 +01:00

1473 lines
70 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from __future__ import annotations
import contextlib
import logging
import re
import textwrap
from binascii import Error as binascii_error
from collections import defaultdict
from lxml import html
from odoo import _, api, fields, models, modules, tools
from odoo.exceptions import AccessError, MissingError
from odoo.fields import Command, Domain
from odoo.tools import clean_context, groupby, SQL
from odoo.tools.misc import OrderedSet
from odoo.addons.mail.tools.discuss import Store
_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 MailMessage(models.Model):
""" Message model (from notifications to user input).
Note:: State management / Error codes / Failure types summary
* mail.notification
* notification_status
'ready', 'sent', 'bounce', 'exception', 'canceled'
* notification_type
'inbox', 'email', 'sms' (SMS addon), 'snail' (snailmail addon)
* failure_type
# generic
unknown,
# mail
"mail_email_invalid", "mail_smtp", "mail_email_missing",
"mail_from_invalid", "mail_from_missing",
"mail_spam"
# sms (SMS addon)
'sms_number_missing', 'sms_number_format', 'sms_credit',
'sms_server', 'sms_acc'
# snailmail (snailmail addon)
'sn_credit', 'sn_trial', 'sn_price', 'sn_fields',
'sn_format', 'sn_error'
* mail.mail
* state
'outgoing', 'sent', 'received', 'exception', 'cancel'
* failure_reason: text
* sms.sms (SMS addon)
* state
'outgoing', 'sent', 'error', 'canceled'
* error_code
'sms_number_missing', 'sms_number_format', 'sms_credit',
'sms_server', 'sms_acc',
# mass mode specific codes
'sms_blacklist', 'sms_duplicate'
* snailmail.letter (snailmail addon)
* state
'pending', 'sent', 'error', 'canceled'
* error_code
'CREDIT_ERROR', 'TRIAL_ERROR', 'NO_PRICE_AVAILABLE', 'FORMAT_ERROR',
'UNKNOWN_ERROR',
See ``mailing.trace`` model in mass_mailing application for mailing trace
information.
"""
_name = 'mail.message'
_inherit = ["bus.listener.mixin"]
_description = 'Message'
_order = 'id desc'
_rec_name = 'subject'
@api.model
def default_get(self, fields):
res = super().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'))
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)
preview = fields.Char(
'Preview', compute='_compute_preview',
help='The text-only beginning of the body used as email preview.')
linked_message_ids = fields.Many2many("mail.message", compute="_compute_linked_message_ids")
message_link_preview_ids = fields.One2many(
"mail.message.link.preview", "message_id", groups="base.group_erp_manager"
)
reaction_ids = fields.One2many(
'mail.message.reaction', 'message_id', string="Reactions",
groups="base.group_system")
# Attachments are linked to a document through model / res_id and to the message through this field.
attachment_ids = fields.Many2many(
'ir.attachment', 'message_attachment_rel',
'message_id', 'attachment_id',
string='Attachments', bypass_search_access=True)
parent_id = fields.Many2one(
'mail.message', 'Parent Message', index='btree_not_null', ondelete='set null')
child_ids = fields.One2many('mail.message', 'parent_id', 'Child Messages')
# related document
model = fields.Char('Related Document Model')
res_id = fields.Many2oneReference('Related Document ID', model_field='model')
record_name = fields.Char('Message Record Name', compute='_compute_record_name', store=False)
record_alias_domain_id = fields.Many2one('mail.alias.domain', 'Alias Domain', ondelete='set null')
record_company_id = fields.Many2one('res.company', 'Company', ondelete='set null')
# characteristics
message_type = fields.Selection([
('email', 'Incoming Email'),
('comment', 'Comment'),
('email_outgoing', 'Outgoing Email'),
('notification', 'System notification'),
# somehow generated by system but with specific meaning / computation
('auto_comment', 'Automated Targeted Notification'),
('out_of_office', 'Out-of-office Message'),
('user_notification', 'User Specific Notification'),
],
'Type', required=True, default='comment',
help="Used to categorize message generator"
"\n'email': generated by an incoming email e.g. mailgateway"
"\n'comment': generated by user input e.g. through discuss or composer"
"\n'email_outgoing': generated by a mailing"
"\n'notification': generated by system e.g. tracking messages"
"\n'auto_comment': generated by automated notification mechanism e.g. acknowledgment"
"\n'user_notification': generated for a specific recipient"
)
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='btree_not_null', 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.avatar_128', depends=['author_id'], readonly=False)
author_guest_id = fields.Many2one(string="Guest", comodel_name='mail.guest')
is_current_user_or_guest_author = fields.Boolean(compute='_compute_is_current_user_or_guest_author')
# 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})
# email recipients of incoming emails: comma separated list of emails (not necessarily normalized)
incoming_email_to = fields.Text('Emails To')
incoming_email_cc = fields.Char('Emails Cc')
# email recipients of outgoing emails: comma separated list of emails (not necessarily normalized)
outgoing_email_to = fields.Char('emails To')
# 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_notification', string='Partners with Need Action',
context={'active_test': False}, depends=['notification_ids'], copy=False)
needaction = fields.Boolean(
'Need Action', compute='_compute_needaction', search='_search_needaction')
has_error = fields.Boolean(
'Has error', compute='_compute_has_error', search='_search_has_error')
# notifications
notification_ids = fields.One2many(
'mail.notification', 'mail_message_id', 'Notifications',
bypass_search_access=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')
pinned_at = fields.Datetime('Pinned', help='Datetime at which the message has been pinned')
starred = fields.Boolean(
'Starred', compute='_compute_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
reply_to_force_new = fields.Boolean(
'No threading for answers',
help='If true, answers do not go in the original document discussion thread. Instead, it will check for the reply_to in tracking message-id and redirected accordingly. This has an impact on the generated message-id.')
message_id = fields.Char('Message-Id', help='Message unique identifier', index='btree', readonly=True, 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')
# send notification information (for resend / reschedule)
email_layout_xmlid = fields.Char('Layout', copy=False) # xml id of layout
email_add_signature = 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")
_model_res_id_idx = models.Index("(model, res_id)")
_model_res_id_id_idx = models.Index("(model, res_id, id)")
@api.depends('body')
def _compute_preview(self):
""" Returns an un-formatted version of the message body. Output is capped
at 100 chars with a ' [...]' suffix if applicable. It is the longest
known mail client preview length (Outlook 2013)."""
for message in self:
plaintext_ct = tools.mail.html_to_inner_content(message.body)
message.preview = textwrap.shorten(plaintext_ct, 190)
@api.depends_context("uid")
@api.depends("body")
def _compute_linked_message_ids(self):
""" Compute the linked messages from the body of the message."""
message_ids_by_message = defaultdict(list)
for message in self:
if tools.is_html_empty(message.body):
continue
str_ids = html.fromstring(message.body).xpath(
"//a[contains(@class, 'o_message_redirect') and @data-oe-model='mail.message']/@data-oe-id",
)
for str_id in str_ids:
with contextlib.suppress(ValueError, TypeError):
message_ids_by_message[message].append(int(str_id))
mids = [mid for mids in message_ids_by_message.values() for mid in mids]
if not mids:
self.linked_message_ids = self.env["mail.message"]
return
# Remove any potential sudo from the env as linked messages are user input, returning them
# as sudo could lead to users being able to read any arbitrary message through this feature.
# Only allowed messages for the current user are acceptable.
linked_messages = self.sudo(False).search(Domain("id", "in", mids))
for message in self:
message.linked_message_ids = linked_messages.filtered(
lambda m, message=message: m.id in message_ids_by_message[message],
)
@api.depends('model', 'res_id')
def _compute_record_name(self):
free = self.filtered(lambda m: not m.model or not m.res_id or m.model not in self.env)
free.record_name = False
# sudo here, as it behaves like a m2o -> can read message, can read name_get
for message, record in (self - free)._record_by_message().items():
try:
message.record_name = record.sudo().display_name
except MissingError:
message.record_name = False
@api.depends('author_id', 'author_guest_id')
@api.depends_context('guest', 'uid')
def _compute_is_current_user_or_guest_author(self):
user = self.env.user
guest = self.env['mail.guest']._get_guest_from_context()
for message in self:
if not user._is_public() and (message.author_id and message.author_id == user.partner_id):
message.is_current_user_or_guest_author = True
elif message.author_guest_id and message.author_guest_id == guest:
message.is_current_user_or_guest_author = True
else:
message.is_current_user_or_guest_author = False
def _compute_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):
if operator not in ('in', 'not in'):
return NotImplemented
is_read = operator == 'not in'
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 != 'in':
return NotImplemented
return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))]
@api.depends('starred_partner_ids')
@api.depends_context('uid')
def _compute_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 != 'in':
return NotImplemented
return [('starred_partner_ids', 'in', self.env.user.partner_id.ids)]
# ------------------------------------------------------
# CRUD / ORM
# ------------------------------------------------------
@api.model
def _search(self, domain, offset=0, limit=None, order=None, *, bypass_access=False, **kwargs):
""" 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() for more details about those rules.
Non employees users see only message with subtype, and cannot see
internal messages, either coming from message 'is_internal' flag,
subtype 'internal' flag, or being pure logs (no subtype). See
`_get_search_domain_share` which generates the domain.
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, OR
- uid has acces to the message linked document for messages that are not
'user_notification'
- otherwise: remove the id
"""
# Rules do not apply to administrator
if self.env.is_superuser() or bypass_access:
return super()._search(domain, offset, limit, order, bypass_access=True, **kwargs)
# Non-employee see only messages with a subtype and not internal
if not self.env.user._is_internal():
domain = self._get_search_domain_share() & Domain(domain)
# make the search query with the default rules
query = super()._search(domain, offset, limit, order, **kwargs)
# retrieve matching records and determine which ones are truly accessible
self.flush_model(['model', 'res_id', 'author_id', 'message_type', 'partner_ids'])
self.env['mail.notification'].flush_model(['mail_message_id', 'res_partner_id'])
pid = self.env.user.partner_id.id
ids = []
allowed_ids = set()
model_ids = defaultdict(lambda: defaultdict(set))
rel_alias = query.make_alias(self._table, 'partner_ids')
query.add_join("LEFT JOIN", rel_alias, 'mail_message_res_partner_rel', SQL(
"%s = %s AND %s = %s",
SQL.identifier(self._table, 'id'),
SQL.identifier(rel_alias, 'mail_message_id'),
SQL.identifier(rel_alias, 'res_partner_id'),
pid,
))
notif_alias = query.make_alias(self._table, 'notification_ids')
query.add_join("LEFT JOIN", notif_alias, 'mail_notification', SQL(
"%s = %s AND %s = %s",
SQL.identifier(self._table, 'id'),
SQL.identifier(notif_alias, 'mail_message_id'),
SQL.identifier(notif_alias, 'res_partner_id'),
pid,
))
self.env.cr.execute(query.select(
SQL.identifier(self._table, 'id'),
SQL.identifier(self._table, 'model'),
SQL.identifier(self._table, 'res_id'),
SQL.identifier(self._table, 'author_id'),
SQL.identifier(self._table, 'message_type'),
SQL(
"COALESCE(%s, %s)",
SQL.identifier(rel_alias, 'res_partner_id'),
SQL.identifier(notif_alias, 'res_partner_id'),
),
))
for id_, model, res_id, author_id, message_type, partner_id in self.env.cr.fetchall():
ids.append(id_)
if author_id == pid:
allowed_ids.add(id_)
elif partner_id == pid:
allowed_ids.add(id_)
elif model and res_id and message_type != 'user_notification':
model_ids[model][res_id].add(id_)
allowed_ids.update(self._find_allowed_doc_ids(model_ids))
allowed = self.browse(id_ for id_ in ids if id_ in allowed_ids)
return allowed._as_query(order)
def _get_search_domain_share(self):
return Domain(['&', '&', ('is_internal', '=', False), ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)])
def _filter_records_for_message_operation(self, doc_model, doc_res_ids, operation):
""" Helper returning records on which 'operation' on mail.message is
allowed, based on '_mail_group_by_operation_for_mail_message_operation' behavior and potential
model override. """
documents_all = self.env[doc_model].with_context(active_test=False).browse(doc_res_ids)
operation_res_ids = documents_all._mail_group_by_operation_for_mail_message_operation(operation)
# group documents per operation to check, based on mail.message access
# note that some ids may be filtered out if (e.g. group limitation, ...)
allowed_ids = []
for record_operation, records in operation_res_ids.items():
forbidden_doc_ids = set()
try:
operation_result = records._check_access(record_operation)
except MissingError:
existing = records.exists()
forbidden_doc_ids = set((records - existing).ids)
operation_result = existing._check_access(record_operation)
forbidden_doc_ids |= set((operation_result or [self.env[doc_model]])[0]._ids)
# keep actually returned records for the operation, that are not forbidden
allowed_ids += [
record.id for record in records
if record.id not in forbidden_doc_ids
]
return self.env[doc_model].browse(allowed_ids)
@api.model
def _find_allowed_doc_ids(self, model_ids):
""" Filters out messages user cannot read due to missing document access.
:param dict model_ids: dictionary like {
'document_model_name': {
'document_id_1': set(message IDs),
'document_id_2': set(message IDs),
},
[...]
}
:return: set of allowed message IDs to read, based on document check
:rtype: set
"""
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 = self._filter_records_for_message_operation(doc_model, list(doc_dict), 'read')
allowed_ids |= {
msg_id for document_id in allowed.ids for msg_id in doc_dict[document_id]
}
return allowed_ids
def _check_access(self, operation: str) -> tuple | None:
""" Access rules of mail.message:
- read: if
- author_id == pid, uid is the author OR
- create_uid == uid, uid is the creator OR
- uid is in the recipients (partner_ids) OR
- uid has been notified (needaction) 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 has write or create access on the related document if model, res_id
- otherwise: raise
- unlink: if
- uid has write or create access on the related document
- otherwise: raise
Specific case: non employee users cannot see internal messages (aka logs):
'is_internal' flag on message, 'internal' flag on subtype.
"""
result = super()._check_access(operation)
if not self:
return result
# discard forbidden records, and check remaining ones
messages = self - result[0] if result else self
if messages and (forbidden := messages._get_forbidden_access(operation)):
if result:
result = (result[0] + forbidden, result[1])
else:
result = (forbidden, lambda: forbidden._make_access_error(operation))
return result
def _get_forbidden_access(self, operation: str) -> api.Self:
""" Return the subset of ``self`` that does not satisfy the specific
conditions for messages.
"""
forbidden = self.browse()
# Non employees see only messages with a subtype (aka, not internal logs)
if not self.env.user._is_internal():
message_type_condition = ''
if operation in ('create', 'read'):
message_type_condition = "message.message_type = 'comment' AND"
rows = self.env.execute_query(SQL(
''' SELECT message.id
FROM "mail_message" AS message
LEFT JOIN "mail_message_subtype" as subtype ON message.subtype_id = subtype.id
WHERE %s message.id = ANY (%%s)
AND (message.is_internal IS TRUE OR message.subtype_id IS NULL OR subtype.internal IS TRUE)
''' % message_type_condition,
self.ids,
))
if rows:
internal = self.browse(id_ for [id_] in rows)
forbidden += internal
self -= internal # noqa: PLW0642
if not self:
return forbidden
# Read the value of messages in order to determine their accessibility.
# The values are put in 'messages_to_check', and entries are popped
# once we know they are accessible. At the end, the remaining entries
# are the invalid ones.
self.flush_recordset(['model', 'res_id', 'author_id', 'create_uid', 'parent_id', 'message_type', 'partner_ids'])
self.env['mail.notification'].flush_model(['mail_message_id', 'res_partner_id'])
if operation in ('read', 'write'):
query = SQL(
""" SELECT m.id, m.model, m.res_id, m.author_id, m.create_uid, m.parent_id,
bool_or(partner_rel.res_partner_id IS NOT NULL OR needaction_rel.res_partner_id IS NOT NULL) AS notified,
m.message_type
FROM "mail_message" 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_notification" needaction_rel
ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %(pid)s
WHERE m.id = ANY(%(ids)s)
GROUP BY m.id
""",
pid=self.env.user.partner_id.id, ids=self.ids,
)
elif operation in ('create', 'unlink'):
query = SQL(
""" SELECT id, model, res_id, author_id, parent_id, message_type
FROM "mail_message"
WHERE id = ANY(%s)
""", self.ids,
)
else:
raise ValueError(_('Wrong operation name (%s)', operation))
# trick: messages_to_check doesn't contain missing records from messages
messages_to_check = {
values['id']: values
for values in self.env.execute_query_dict(query)
}
# Author condition (READ, WRITE, CREATE (private))
partner_id = self.env.user.partner_id.id
if operation == 'read':
for mid, message in list(messages_to_check.items()):
if (message.get('author_id') == partner_id
or message.get('create_uid') == self.env.uid):
messages_to_check.pop(mid)
elif operation == 'write':
for mid, message in list(messages_to_check.items()):
if message.get('author_id') == partner_id:
messages_to_check.pop(mid)
elif operation == 'create':
for mid, message in list(messages_to_check.items()):
if not self._is_thread_message_visible(vals=message):
messages_to_check.pop(mid)
if not messages_to_check:
return forbidden
# Recipients condition, for read and write (partner_ids)
# keep on top, usefull for systray notifications
if operation in ('read', 'write'):
for mid, message in list(messages_to_check.items()):
if message.get('notified'):
messages_to_check.pop(mid)
if not messages_to_check:
return forbidden
# CRUD: Access rights related to the document
# {document_model_name: {document_id: message_ids}}
model_docid_msgids = defaultdict(lambda: defaultdict(list))
for mid, message in messages_to_check.items():
if (message.get('model') and message.get('res_id') and
message.get('message_type') != 'user_notification'):
model_docid_msgids[message['model']][message['res_id']].append(mid)
for model, docid_msgids in model_docid_msgids.items():
allowed = self._filter_records_for_message_operation(model, docid_msgids, operation)
for doc_id, msg_ids in docid_msgids.items():
if doc_id in allowed.ids:
for mid in msg_ids:
messages_to_check.pop(mid)
if not messages_to_check:
return forbidden
# Parent condition, for create (check for received notifications for the created message parent)
if operation == 'create':
parent_ids_msg_ids = defaultdict(list)
for mid, message in messages_to_check.items():
if message.get('parent_id'):
parent_ids_msg_ids[message['parent_id']].append(mid)
if parent_ids_msg_ids:
query = SQL(
""" SELECT m.id
FROM "mail_message" m
JOIN "mail_message_res_partner_rel" partner_rel
ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %s
WHERE m.id = ANY(%s) """,
self.env.user.partner_id.id, list(parent_ids_msg_ids),
)
for [parent_id] in self.env.execute_query(query):
for mid in parent_ids_msg_ids[parent_id]:
messages_to_check.pop(mid)
if not messages_to_check:
return forbidden
# Recipients condition for create (message_follower_ids)
for model, docid_msgids in model_docid_msgids.items():
domain = [
('res_model', '=', model),
('res_id', 'in', list(docid_msgids)),
('partner_id', '=', self.env.user.partner_id.id),
]
followers = self.env['mail.followers'].sudo().search_fetch(domain, ['res_id'])
for follower in followers:
for mid in docid_msgids[follower.res_id]:
messages_to_check.pop(mid)
if not messages_to_check:
return forbidden
forbidden += self.browse(messages_to_check)
return forbidden
def _make_access_error(self, operation: str) -> AccessError:
return AccessError(_(
"The requested operation cannot be completed due to security restrictions. "
"Please contact your system administrator.\n\n"
"(Document type: %(type)s, Operation: %(operation)s)\n\n"
"Records: %(records)s, User: %(user)s",
type=self._description,
operation=operation,
records=self.ids[:6],
user=self.env.uid,
))
@api.model
def _get_with_access(self, message_id, mode="read", **kwargs):
message = self.browse(message_id).exists()
if not message:
return message
# sanity check on kwargs
allowed_params = self.env[message.sudo().model or 'mail.thread']._get_allowed_access_params()
if invalid := (set((kwargs or {}).keys()) - allowed_params):
_logger.warning("Invalid parameters to _get_with_access: %s", invalid)
if self.env.user._is_public() and self.env["mail.guest"]._get_guest_from_context():
# Don't check_access_rights for public user with a guest, as the rules are
# incorrect due to historically having no reason to allow operations on messages to
# public user before the introduction of guests. Even with ignoring the rights,
# check_access_rule and its sub methods are already covering all the cases properly.
if not message.sudo(False)._get_forbidden_access(mode):
return message
else:
if message.sudo(False).has_access(mode):
return message
if message.model and message.res_id:
thread_su = self.env[message.model].browse(message.res_id).sudo()
access_mode = thread_su._mail_get_operation_for_mail_message_operation(mode)[thread_su]
if access_mode and self.env[message.model]._get_thread_with_access(message.res_id, mode=access_mode, **kwargs):
return message
return self.browse()
@api.model_create_multi
def create(self, vals_list):
tracking_values_list = []
for values in vals_list:
if not (self.env.su or self.env.user.has_group('base.group_user')):
values.pop('author_id', None)
values.pop('email_from', None)
self = self.with_context({k: v for k, v in self.env.context.items() if k not in ['default_author_id', 'default_email_from']}) # noqa: PLW0642
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)
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 not values.get('attachment_ids', True):
# pop empty values
del values['attachment_ids']
# extract base64 images
if 'body' in values:
Attachments = self.env['ir.attachment'].with_context(clean_context(self.env.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()
attachments = values.setdefault('attachment_ids', [])
attachments.append((4, attachment.id))
data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name, attachment.id]
# data-attachment-id helps identify image attachments that are already inserted in the body
# this is notably used to avoid displaying them twice in the chatter
return f'{data_to_url[key][0]}{match.group(3)} alt="{data_to_url[key][1]}" data-attachment-id="{data_to_url[key][2]}"'
values['body'] = _image_dataurl.sub(base64_to_boundary, values['body'] or '')
# 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().create(vals_list)
# link back attachments to records, to filter out attachments linked to
# the same records as the message (considered as ok if message is ok)
# and check rights on other documents
attachments_tocheck = self.env['ir.attachment']
doc_to_attachment_ids = defaultdict(set)
if all(isinstance(command, int) or command[0] in (4, 6)
for values in vals_list
for command in values.get('attachment_ids', ())):
for values in vals_list:
message_attachment_ids = set()
for command in values.get('attachment_ids', ()):
if isinstance(command, int):
message_attachment_ids.add(command)
elif command[0] == 6:
message_attachment_ids |= set(command[2])
else: # command[0] == 4:
message_attachment_ids.add(command[1])
if message_attachment_ids:
key = (values.get('model'), values.get('res_id'))
doc_to_attachment_ids[key] |= message_attachment_ids
attachment_ids_all = {
attachment_id
for doc_attachment_ids in doc_to_attachment_ids
for attachment_id in doc_attachment_ids
}
AttachmentSudo = self.env['ir.attachment'].sudo().with_prefetch(list(attachment_ids_all))
for (model, res_id), doc_attachment_ids in doc_to_attachment_ids.items():
# check only attachments belonging to another model, access already
# checked on message for other attachments
attachments_tocheck += AttachmentSudo.browse(doc_attachment_ids).filtered(
lambda att: att.res_model != model or att.res_id != res_id
).sudo(False)
else:
attachments_tocheck = messages.attachment_ids # fallback on read if any unknown command
if attachments_tocheck:
attachments_tocheck.check_access('read')
for message, values, tracking_values_cmd in zip(messages, vals_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_visible(vals=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(), that is not called
by the ORM. It instead directly fetches ir.rules and apply them. """
self.check_access('read')
return super().read(fields=fields, load=load)
def copy_data(self, default=None):
""" Make is symmetric to read, to avoid spurious issues with recordsets
differences. """
self.check_access('read')
return super().copy_data(default=default)
def fetch(self, field_names=None):
# This freaky hack is aimed at reading data without the overhead of
# checking that "self" is accessible, which is already done above in
# methods read() and _search(). It reproduces the existing behavior
# before the introduction of method fetch(), where the low-lever
# reading method _read() did not enforce any actual permission.
self = self.sudo()
return super().fetch(field_names)
def write(self, vals):
if not (self.env.su or self.env.user.has_group('base.group_user')):
vals.pop('author_id', None)
vals.pop('email_from', None)
record_changed = 'model' in vals or 'res_id' in vals
if record_changed and not self.env.is_system():
raise AccessError(_("Only administrators can modify 'model' and 'res_id' fields."))
if record_changed or 'message_type' in vals:
self._invalidate_documents()
res = super().write(vals)
if vals.get('attachment_ids'):
self.attachment_ids.check_access('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).
# the cache of the related document doesn't need to be invalidate (see @_invalidate_documents)
# because the unlink method invalidates the whole cache anyway
if not self:
return True
self.check_access('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()
messages_by_partner = defaultdict(lambda: self.env['mail.message'])
partners_with_user = self.partner_ids.filtered('user_ids')
for elem in self:
for partner in (
elem.partner_ids & partners_with_user | elem.notification_ids.author_id
):
messages_by_partner[partner] |= elem
# Notify front-end of messages deletion for partners having a user
for partner, messages in messages_by_partner.items():
partner._bus_send("mail.message/delete", {"message_ids": messages.ids})
return super().unlink()
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().export_data(fields_to_export)
# ------------------------------------------------------
# ACTIONS
# ----------------------------------------------------
def action_open_document(self):
""" Opens the related record based on the model and ID """
self.ensure_one()
return {
'res_id': self.res_id,
'res_model': self.model,
'target': 'current',
'type': 'ir.actions.act_window',
'view_mode': 'form',
}
# ------------------------------------------------------
# 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.
notif_domain = [
('res_partner_id', '=', self.env.user.partner_id.id),
('is_read', '=', False)]
if domain:
messages = self.search(domain)
messages.set_message_done()
return messages.ids
notifications = self.env['mail.notification'].sudo().search_fetch(notif_domain, ['mail_message_id'])
notifications.write({'is_read': True})
self.env.user._bus_send(
"mail.message/mark_as_read",
{
"message_ids": notifications.mail_message_id.ids,
"needaction_inbox_counter": self.env.user.partner_id._get_needaction_count(),
},
)
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_fetch([
('mail_message_id', 'in', self.ids),
('res_partner_id', '=', partner_id.id),
('is_read', '=', False),
], ['mail_message_id'])
if not notifications:
return
notifications.write({'is_read': True})
# notifies changes in messages through the bus.
self.env.user._bus_send(
"mail.message/mark_as_read",
{
"message_ids": notifications.mail_message_id.ids,
"needaction_inbox_counter": self.env.user.partner_id._get_needaction_count(),
},
)
@api.model
def unstar_all(self):
""" Unstar messages for the current partner. """
starred_messages = self.search([("starred_partner_ids", "in", self.env.user.partner_id.id)])
# sudo: mail.message - a user can unstar messages they can read
starred_messages.sudo().starred_partner_ids = [Command.unlink(self.env.user.partner_id.id)]
self.env.user._bus_send(
"mail.message/toggle_star", {"message_ids": starred_messages.ids, "starred": False}
)
def toggle_message_starred(self):
""" Toggle messages as (un)starred. Technically, the notifications related
to uid are set to (un)starred.
"""
self.ensure_one()
self.check_access('read')
starred = not self.starred
if starred:
# sudo: mail.message - a user can star a message they can read
self.sudo().starred_partner_ids = [Command.link(self.env.user.partner_id.id)]
else:
# sudo: mail.message - a user can unstar a message they can read
self.sudo().starred_partner_ids = [Command.unlink(self.env.user.partner_id.id)]
self.env.user._bus_send(
"mail.message/toggle_star", {"message_ids": [self.id], "starred": starred}
)
return Store().add(self, {"starred": self.starred}).get_result()
@api.model
def _message_fetch(self, domain, *, thread=None, search_term=None, is_notification=None, before=None, after=None, around=None, limit=30):
res = {}
domain = Domain(True if domain is None else domain)
if thread:
domain &= (
Domain("res_id", "=", thread.id)
& Domain("model", "=", thread._name)
& Domain("message_type", "!=", "user_notification")
)
if is_notification is True:
domain &= Domain("message_type", "=", "notification")
elif is_notification is False:
domain &= Domain("message_type", "!=", "notification")
if search_term:
# we replace every space by a % to avoid hard spacing matching
search_term = search_term.replace(" ", "%")
message_domain = Domain.OR([
# sudo: access to attachment is allowed if you have access to the parent model
[("attachment_ids", "in", self.env["ir.attachment"].sudo()._search([("name", "ilike", search_term)]))],
[("body", "ilike", search_term)],
[("subject", "ilike", search_term)],
[("subtype_id.description", "ilike", search_term)],
])
if thread and is_notification is not False:
tracking_value_domain = (
Domain("mail_message_id.res_id", "=", thread.id)
& Domain("mail_message_id.model", "=", thread._name)
& self._get_tracking_values_domain(search_term)
)
# sudo: mail.tracking.value - searching allowed tracking values for acessible records
tracking_values = self.env["mail.tracking.value"].sudo().search(tracking_value_domain)
accessible_tracking_value_ids = tracking_values._filter_has_field_access(self.env)
message_domain |= Domain("id", "in", accessible_tracking_value_ids.mail_message_id.ids)
domain &= message_domain
res["count"] = self.search_count(domain)
if around is not None:
messages_before = self.search(domain & Domain('id', '<=', around), limit=limit // 2, order="id DESC")
messages_after = self.search(domain & Domain('id', '>', around), limit=limit // 2, order='id ASC')
return {**res, "messages": (messages_after + messages_before).sorted('id', reverse=True)}
if before:
domain &= Domain('id', '<', before)
if after:
domain &= Domain('id', '>', after)
res["messages"] = self.search(domain, limit=limit, order='id ASC' if after else 'id DESC')
if after:
res["messages"] = res["messages"].sorted('id', reverse=True)
return res
def _get_tracking_values_domain(self, search_term):
"""Get the domain to search for tracking values."""
numeric_term = None
# try to convert the search term to a number
with contextlib.suppress(ValueError, TypeError):
numeric_term = float(search_term)
domain = Domain.OR(
Domain(field_name, "ilike", search_term)
for field_name in (
"old_value_char",
"new_value_char",
"old_value_text",
"new_value_text",
"old_value_datetime",
"new_value_datetime",
"field_id.name",
"field_id.field_description",
)
)
if numeric_term:
epsilon = 1e-9 # small epsilon to allow for floating point precision
domain |= Domain.OR(
Domain(field_name, ">=", numeric_term - epsilon)
& Domain(field_name, "<=", numeric_term + epsilon)
for field_name in ("old_value_float", "new_value_float")
)
if numeric_term.is_integer():
domain |= Domain.OR(
Domain(field_name, "=", int(numeric_term))
for field_name in ("old_value_integer", "new_value_integer")
)
return domain
def _message_reaction(self, content, action, partner, guest, store: Store = None):
self.ensure_one()
# search for existing reaction
domain = [
("message_id", "=", self.id),
("partner_id", "=", partner.id),
("guest_id", "=", guest.id),
("content", "=", content),
]
reaction = self.env["mail.message.reaction"].search(domain)
# create/unlink reaction if necessary
if action == "add" and not reaction:
create_values = {
"message_id": self.id,
"content": content,
"partner_id": partner.id,
"guest_id": guest.id,
}
self.env["mail.message.reaction"].create(create_values)
if action == "remove" and reaction:
reaction.unlink()
if store:
# fill the store to use for non logged in portal users in mail_message_reaction()
self._reaction_group_to_store(store, content)
# send the reaction group to bus for logged in users
self._bus_send_reaction_group(content)
def _bus_send_reaction_group(self, content):
store = Store(bus_channel=self._bus_channel())
self._reaction_group_to_store(store, content)
store.bus_send()
def _reaction_group_to_store(self, store: Store, content):
group_domain = [("message_id", "=", self.id), ("content", "=", content)]
reactions = self.env["mail.message.reaction"].search(group_domain)
reaction_group = (
Store.Many(reactions, mode="ADD")
if reactions
else [("DELETE", {"message": self.id, "content": content})]
)
store.add(self, {"reactions": reaction_group})
# ------------------------------------------------------
# STORE / NOTIFICATIONS
# ------------------------------------------------------
def _field_store_repr(self, field_name):
"""Return the default Store representation of the given field name, which can be passed as
param to the various Store methods."""
if field_name == "message_link_preview_ids":
return [
Store.Many(
"message_link_preview_ids",
value=lambda m: m.sudo()
.message_link_preview_ids.filtered(
lambda message_link_preview: not message_link_preview.is_hidden
)
.sorted(
lambda message_link_preview: (
message_link_preview.sequence,
message_link_preview.id,
)
),
)
]
return [field_name]
def _to_store_defaults(self, target: Store.Target):
field_names = [
# sudo: mail.message - reading attachments on accessible message is allowed
Store.Many(
"attachment_ids",
sort="id",
dynamic_fields=lambda m: m._get_store_attachment_fields(target),
sudo=True,
),
# sudo: mail.message: access to author_guest_id is allowed
Store.One("author_guest_id", ["avatar_128", "name"], sudo=True),
# sudo: mail.message: access to author_id is allowed
Store.One(
"author_id",
["avatar_128", "is_company", Store.One("main_user_id", ["partner_id", "share"])],
dynamic_fields=lambda m: m._get_store_partner_name_fields(),
sudo=True,
),
"body",
"create_date",
"date",
Store.Attr(
"email_from",
predicate=lambda m: target.is_internal(self.env)
or (not m.author_id and not m.author_guest_id),
),
"incoming_email_cc",
"incoming_email_to",
# sudo: mail.message - reading link preview on accessible message is allowed
"message_format",
"message_link_preview_ids",
"message_type",
"model", # keep for iOS app
# sudo: res.partner: reading limited data of recipients is acceptable
Store.Many(
"partner_ids",
"avatar_128",
dynamic_fields=lambda m: m._get_store_partner_name_fields(),
sort="id",
sudo=True,
),
"pinned_at",
# sudo: mail.message - reading reactions on accessible message is allowed
Store.Attr("reactions", value=lambda m: Store.Many(m.sudo().reaction_ids)),
"record_name", # keep for iOS app
"res_id", # keep for iOS app
"subject",
# sudo: mail.message.subtype - reading subtype on accessible message is allowed
Store.One("subtype_id", ["description"], sudo=True),
"write_date",
*self._get_store_linked_messages_fields(),
]
if target.is_internal(self.env):
# sudo - mail.notification: internal users can access notifications.
field_names.append(
Store.Many(
"notification_ids",
value=lambda m: m.sudo().notification_ids._filtered_for_web_client(),
),
)
return field_names
def _to_store(self, store: Store, fields, *, format_reply=True, msg_vals=False, add_followers=False, followers=None):
"""Add the messages to the given store.
:param format_reply: if True, also get data about the parent message if it exists.
Only makes sense for discuss channel.
:param msg_vals: dictionary of values used to create the message. If
given it may be used to access values related to ``message`` without
accessing it directly. It lessens query count in some optimized use
cases by avoiding access message content in db;
:param add_followers: if True, also add followers of the current target for each thread of
each message. Only applicable if ``store.target`` is a specific user.
:param followers: if given, use this pre-computed list of followers instead of fetching
them. It lessen query count in some optimized use cases.
Only applicable if ``add_followers`` is True.
"""
if "message_format" not in fields:
store.add_records_fields(self, fields)
return
fields.remove("message_format")
# fetch scheduled notifications once, only if msg_vals is not given to
# avoid useless queries when notifying Inbox right after a message_post
scheduled_dt_by_msg_id = {}
if msg_vals:
scheduled_dt_by_msg_id = {msg.id: msg_vals.get("scheduled_date", False) for msg in self}
elif self:
schedulers = self.env["mail.message.schedule"].sudo().search([("mail_message_id", "in", self.ids)])
for scheduler in schedulers:
scheduled_dt_by_msg_id[scheduler.mail_message_id.id] = scheduler.scheduled_datetime
record_by_message = self._record_by_message()
records = record_by_message.values()
non_channel_records = filter(lambda record: record._name != "discuss.channel", records)
target_user = store.target.get_user(self.env)
if target_user and add_followers and non_channel_records:
if followers is None:
domain = Domain.OR(
[("res_model", "=", model), ("res_id", "in", [r.id for r in records])]
for model, records in groupby(non_channel_records, key=lambda r: r._name)
)
domain &= Domain("partner_id", "=", target_user.partner_id.id)
# sudo: mail.followers - reading followers of current partner
followers = self.env["mail.followers"].sudo().search(domain)
follower_by_record_and_partner = {
(
self.env[follower.res_model].browse(follower.res_id),
follower.partner_id,
): follower
for follower in followers
}
record_fields = [
# sudo: mail.thread - if mentionned in a non accessible thread, name is allowed
Store.Attr("display_name", sudo=True),
Store.Attr(
"module_icon",
lambda record: modules.module.get_module_icon(self.env[record._name]._original_module),
predicate=lambda record: self.env[record._name]._original_module,
),
]
if target_user and add_followers and non_channel_records:
record_fields.append(
Store.One(
"selfFollower",
["is_active", Store.One("partner_id", [])],
value=lambda r: follower_by_record_and_partner.get((r, target_user.partner_id)),
),
)
for record in records:
store.add(record, record_fields, as_thread=True)
if store.target.is_current_user(self.env):
fields.append("starred")
store.add(self, fields)
for message in self:
record = record_by_message.get(message)
if record:
try:
if hasattr(record, "_message_compute_subject"):
# sudo: if mentionned in a non accessible thread, user should be able to see the subject
default_subject = record.sudo()._message_compute_subject()
else:
default_subject = message.record_name
except MissingError:
record = None
default_subject = False
else:
default_subject = False
data = {
"default_subject": default_subject,
"scheduledDatetime": scheduled_dt_by_msg_id.get(message.id, False),
"thread": Store.One(record, [], as_thread=True),
}
if message.incoming_email_cc:
data["incoming_email_cc"] = tools.mail.email_split_tuples(message.incoming_email_cc)
if message.incoming_email_to:
data["incoming_email_to"] = tools.mail.email_split_tuples(message.incoming_email_to)
if store.target.is_current_user(self.env):
# sudo: mail.message - filtering allowed tracking values
displayed_tracking_ids = message.sudo().tracking_value_ids._filter_has_field_access(
self.env
)
if record and hasattr(record, "_track_filter_for_display"):
displayed_tracking_ids = record._track_filter_for_display(
displayed_tracking_ids
)
# sudo: mail.message - checking whether there is a notification for the current user is acceptable
notifications_partners = message.sudo().notification_ids.filtered(
lambda n: not n.is_read
).res_partner_id
data["needaction"] = (
not self.env.user._is_public()
and self.env.user.partner_id in notifications_partners
)
data["trackingValues"] = displayed_tracking_ids._tracking_value_format()
store.add(message, data)
# Add extras at the end to guarantee order in result. In particular, the parent message
# needs to be after the current message (client code assuming the first received message is
# the one just posted for example, and not the message being replied to).
self._extras_to_store(store, format_reply=format_reply)
def _get_store_partner_name_fields(self):
self.ensure_one()
return ["name"]
def _get_store_attachment_fields(self, target):
self.ensure_one()
if target.is_current_user(self.env) and self.is_current_user_or_guest_author:
return self.env["ir.attachment"]._get_store_ownership_fields()
return []
def _get_store_linked_messages_fields(self):
"""Add the messages that are referenced by the current message's body to the given store.
This method should only return message data that are not sensitive to be broadcasted to
other users, as it doesn't check store.target by simplicity and the target might not
necessarily have permission to read the linked messages."""
record_by_message = self.linked_message_ids._record_by_message()
return [
Store.Many(
"linked_message_ids",
[
"model",
"res_id",
Store.Attr(
"thread",
lambda m: Store.One(
record_by_message.get(m),
# sudo: mail.thread - reading record name of accessible message is acceptable
[Store.Attr("display_name", sudo=True)],
as_thread=True,
),
),
],
only_data=True,
),
]
def _extras_to_store(self, store: Store, format_reply):
pass
def _message_notifications_to_store(self, store: Store):
"""Returns the current messages and their corresponding notifications in
the format expected by the web client.
Notifications hold the information about each recipient of a message: if
the message was successfully sent or if an exception or bounce occurred.
"""
store.add(
self,
[
Store.One("author_id", []),
Store.One("author_guest_id", []),
"body",
"date",
"message_type",
Store.Many(
"notification_ids",
value=lambda m: m.notification_ids._filtered_for_web_client(),
),
Store.One(
"thread",
[
Store.Attr(
"modelName",
lambda thread: self.env["ir.model"]._get(thread._name).display_name,
),
"display_name",
],
as_thread=True,
),
],
)
def _notify_message_notification_update(self):
"""Send bus notifications to update status of notifications in the web
client. Purpose is to send the updated status per author."""
messages = self.env['mail.message']
record_by_message = self._record_by_message()
for message in self:
# Check if user has access to the record before displaying a notification about it.
# In case the user switches from one company to another, it might happen that they don't
# have access to the record related to the notification. In this case, we skip it.
# YTI FIXME: check allowed_company_ids if necessary
if record := record_by_message.get(message):
try:
if record.has_access('read'):
_dummy = record.display_name # access anything to make sure record exists
messages += message
except (MissingError):
# record has been removed from db without cascading notif -> avoid crash at least
continue
messages_per_partner = defaultdict(lambda: self.env['mail.message'])
for message in messages:
if not self.env.user._is_public():
messages_per_partner[self.env.user.partner_id] |= message
if message.author_id and not any(user._is_public() for user in message.author_id.with_context(active_test=False).user_ids):
messages_per_partner[message.author_id] |= message
for partner, messages in messages_per_partner.items():
if user := partner.main_user_id:
store = Store(bus_channel=user)
messages.with_user(user)._message_notifications_to_store(store)
store.bus_send()
def _bus_channel(self):
return self.env.user
# ------------------------------------------------------
# TOOLS
# ------------------------------------------------------
def _filter_empty(self):
""" Return subset of "void" messages """
return self.filtered(lambda message: message._is_empty())
def _is_empty(self):
self.ensure_one()
return (
(not self.body or tools.is_html_empty(self.body))
and (not self.subtype_id or not self.subtype_id.description)
and not self.attachment_ids
and not (
self._has_field_access(self._fields["tracking_value_ids"], "read")
and self.tracking_value_ids
)
)
@api.model
def _get_reply_to(self, values):
""" Return a specific reply_to for the document """
author_id = values.get('author_id')
model = values.get('model', self.env.context.get('default_model'))
res_id = values.get('res_id', self.env.context.get('default_res_id')) or False
email_from = values.get('email_from')
message_type = values.get('message_type')
records = None
if self._is_thread_message(vals={'model': model, 'res_id': res_id, 'message_type': message_type}):
records = self.env[model].browse([res_id])
else:
records = self.env[model] if model else self.env['mail.thread']
return records.sudo()._notify_get_reply_to(default=email_from, author_id=author_id)[res_id]
@api.model
def _get_message_id(self, values):
if values.get('reply_to_force_new', False) is True:
message_id = tools.mail.generate_tracking_message_id('reply_to')
elif self._is_thread_message(vals=values):
message_id = tools.mail.generate_tracking_message_id('%(res_id)s-%(model)s' % values)
else:
message_id = tools.mail.generate_tracking_message_id('private')
return message_id
def _is_thread_message(self, vals=False, thread=None):
""" Tool method to compute thread validity in notification methods. """
vals = vals or {}
res_model = vals['model'] if 'model' in vals else thread._name if thread else self.model
res_id = vals['res_id'] if 'res_id' in vals else thread.ids[0] if thread and thread.ids else self.res_id
return bool(res_id) if (res_model and res_model != 'mail.thread') else False
def _is_thread_message_visible(self, vals=False, thread=None):
""" In addition to being a thread message, it should not be a user specific
notification that is recipient-specific. Used mainly for ACL purpose. """
is_thread = self._is_thread_message(vals=vals, thread=thread)
if is_thread:
message_type = (vals or {}).get('message_type') or self.message_type
return is_thread and message_type != 'user_notification'
return is_thread
def _invalidate_documents(self, model=None, res_id=None):
""" Invalidate the cache of the documents followed by ``self``. """
fnames = ['message_ids', 'message_needaction', 'message_needaction_counter']
self.flush_recordset(['model', 'res_id'])
for record in self:
model = model or record.model
res_id = res_id or record.res_id
if model in self.pool and issubclass(self.pool[model], self.pool['mail.thread']):
self.env[model].browse(res_id).invalidate_recordset(fnames)
def _records_by_model_name(self):
ids_by_model = defaultdict(OrderedSet)
prefetch_ids_by_model = defaultdict(OrderedSet)
prefetch_messages = self | self.browse(self._prefetch_ids)
for message in prefetch_messages.filtered(lambda m: m.model and m.res_id):
target = ids_by_model if message in self else prefetch_ids_by_model
target[message.model].add(message.res_id)
return {
model_name: self.env[model_name].browse(ids)
.with_prefetch(tuple(ids_by_model[model_name] | prefetch_ids_by_model[model_name]))
for model_name, ids in ids_by_model.items()
}
def _record_by_message(self):
records_by_model_name = self._records_by_model_name()
return {
message: self.env[message.model].browse(message.res_id)
.with_prefetch(records_by_model_name[message.model]._prefetch_ids)
for message in self.filtered(lambda m: m.model and m.res_id)
}