There are too many image sizes. Since they are stored resized this takes time to generate when saving a new image, it's more rows on the attachment table, more files on the disk, ... 64px is close enough to 128px that it can be removed without a big impact on download size. It will even reduce download and number of requests when both images are displayed because now only one has to be downloaded and then benefit from cache. The difference between the two is typically around 1.5kB which is negligible these days, especially when the request overhead is around 0.5kB already, not even taking into account other factors such as latency. If a 64px image must absolutely be returned, it is still possible to pass the size parameters to the image route. But the current guideline is to handle resizing in the views when necessary. Views ===== - remove width and height attributes when existing CSS rules are overriding them (eg. `.oe_kanban_avatar` in the right context) - add CSS rules instead of width and height attributes when possible - use `object-fit: cover;` where width and height are forced to avoid distortion of non-square images - for products, use `object-fit: contain;` instead, keep ratio but without crop - add new CSS rules where the expected size was max 64px*64px before due to the image size itself - remove `img-fluid` where using size classes to avoid conflicting rules task-2060865 closes odoo/odoo#36147 Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
1274 lines
66 KiB
Python
1274 lines
66 KiB
Python
# -*- 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 email.utils import formataddr
|
|
from odoo.http import request
|
|
|
|
from odoo import _, api, fields, models, modules, tools
|
|
from odoo.exceptions import UserError, AccessError
|
|
from odoo.osv import expression
|
|
from odoo.tools import groupby
|
|
|
|
_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'
|
|
|
|
_message_read_limit = 30
|
|
|
|
@api.model
|
|
def _get_default_from(self):
|
|
if self.env.user.email:
|
|
return formataddr((self.env.user.name, self.env.user.email))
|
|
raise UserError(_("Unable to post message, please configure the sender's email address."))
|
|
|
|
@api.model
|
|
def _get_default_author(self):
|
|
return self.env.user.partner_id
|
|
|
|
# content
|
|
subject = fields.Char('Subject')
|
|
date = fields.Datetime('Date', default=fields.Datetime.now)
|
|
body = fields.Html('Contents', default='', sanitize_style=True)
|
|
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')
|
|
# origin
|
|
email_from = fields.Char(
|
|
'From', default=_get_default_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', default=_get_default_author,
|
|
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', 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_no_one",
|
|
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')
|
|
canned_response_ids = fields.One2many('mail.shortcode', 'message_ids', string="Canned Responses", store=False)
|
|
|
|
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):
|
|
if operator == '=' and operand:
|
|
return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', False)]
|
|
return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', True)]
|
|
|
|
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
|
|
return ValueError(_('Unsupported search filter on moderation status'))
|
|
|
|
#------------------------------------------------------
|
|
# Notification 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_ids = self.search(domain).ids # need sudo?
|
|
notif_domain = expression.AND([notif_domain, [('mail_message_id', 'in', 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': notifications}
|
|
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:
|
|
notification = {'type': 'mark_as_read', 'message_ids': msg_ids, 'channel_ids': [c.id for c in channel_ids]}
|
|
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)
|
|
|
|
#------------------------------------------------------
|
|
# Message loading for web interface
|
|
#------------------------------------------------------
|
|
|
|
@api.model
|
|
def _message_read_dict_postprocess(self, messages, message_tree):
|
|
""" Post-processing on values given by message_read. This method will
|
|
handle partners in batch to avoid doing numerous queries.
|
|
|
|
:param list messages: list of message, as get_dict result
|
|
:param dict message_tree: {[msg.id]: msg browse record as super user}
|
|
"""
|
|
# 1. Aggregate partners (author_id and partner_ids), attachments and tracking values
|
|
partners = self.env['res.partner'].sudo()
|
|
attachments = self.env['ir.attachment']
|
|
message_ids = list(message_tree.keys())
|
|
email_notification_tree = {}
|
|
for message in message_tree.values():
|
|
if message.author_id:
|
|
partners |= message.author_id
|
|
# find all notified partners
|
|
email_notification_tree[message.id] = message.notification_ids.filtered(
|
|
lambda n: n.notification_type == 'email' and n.res_partner_id.active and
|
|
(n.notification_status in ('bounce', 'exception', 'canceled') or n.res_partner_id.partner_share))
|
|
if message.attachment_ids:
|
|
attachments |= message.attachment_ids
|
|
partners |= self.env['mail.notification'].concat(*email_notification_tree.values()).mapped('res_partner_id')
|
|
# Read partners as SUPERUSER -> message being browsed as SUPERUSER it is already the case
|
|
partners_names = partners.name_get()
|
|
partner_tree = dict((partner[0], partner) for partner in partners_names)
|
|
|
|
# 2. Attachments as SUPERUSER, because could receive msg and attachments for doc uid cannot see
|
|
attachments_data = attachments.sudo().read(['id', 'name', 'mimetype'])
|
|
safari = request and request.httprequest.user_agent.browser == 'safari'
|
|
attachments_tree = dict((attachment['id'], {
|
|
'id': attachment['id'],
|
|
'filename': attachment['name'],
|
|
'name': attachment['name'],
|
|
'mimetype': 'application/octet-stream' if safari and attachment['mimetype'] and 'video' in attachment['mimetype'] else attachment['mimetype'],
|
|
}) for attachment in attachments_data)
|
|
|
|
# 3. Tracking values
|
|
tracking_values = self.env['mail.tracking.value'].sudo().search([('mail_message_id', 'in', message_ids)])
|
|
message_to_tracking = dict()
|
|
tracking_tree = dict.fromkeys(tracking_values.ids, False)
|
|
for tracking in tracking_values:
|
|
groups = tracking.field_groups
|
|
if not groups or self.env.is_superuser() or self.user_has_groups(groups):
|
|
message_to_tracking.setdefault(tracking.mail_message_id.id, list()).append(tracking.id)
|
|
tracking_tree[tracking.id] = {
|
|
'id': tracking.id,
|
|
'changed_field': tracking.field_desc,
|
|
'old_value': tracking.get_old_display_value()[0],
|
|
'new_value': tracking.get_new_display_value()[0],
|
|
'field_type': tracking.field_type,
|
|
}
|
|
|
|
# 4. Update message dictionaries
|
|
for message_dict in messages:
|
|
message_id = message_dict.get('id')
|
|
message = message_tree[message_id]
|
|
if message.author_id:
|
|
author = partner_tree[message.author_id.id]
|
|
else:
|
|
author = (0, message.email_from)
|
|
customer_email_status = (
|
|
(all(n.notification_status == 'sent' for n in message.notification_ids if n.notification_type == 'email') and 'sent') or
|
|
(any(n.notification_status == 'exception' for n in message.notification_ids if n.notification_type == 'email') and 'exception') or
|
|
(any(n.notification_status == 'bounce' for n in message.notification_ids if n.notification_type == 'email') and 'bounce') or
|
|
'ready'
|
|
)
|
|
customer_email_data = []
|
|
for notification in email_notification_tree[message.id]:
|
|
customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.notification_status))
|
|
|
|
has_access_to_model = message.model and self.env[message.model].check_access_rights('read', raise_exception=False)
|
|
if message.attachment_ids and message.res_id and issubclass(self.pool[message.model], self.pool['mail.thread']) and has_access_to_model:
|
|
main_attachment = self.env[message.model].browse(message.res_id).message_main_attachment_id
|
|
attachment_ids = []
|
|
for attachment in message.attachment_ids:
|
|
if attachment.id in attachments_tree:
|
|
attachments_tree[attachment.id]['is_main'] = main_attachment == attachment
|
|
attachment_ids.append(attachments_tree[attachment.id])
|
|
tracking_value_ids = []
|
|
for tracking_value_id in message_to_tracking.get(message_id, list()):
|
|
if tracking_value_id in tracking_tree:
|
|
tracking_value_ids.append(tracking_tree[tracking_value_id])
|
|
|
|
message_dict.update({
|
|
'author_id': author,
|
|
'customer_email_status': customer_email_status,
|
|
'customer_email_data': customer_email_data,
|
|
'attachment_ids': attachment_ids,
|
|
'tracking_value_ids': tracking_value_ids,
|
|
})
|
|
|
|
return True
|
|
|
|
def message_fetch_failed(self):
|
|
messages = self.search([
|
|
('has_error', '=', True),
|
|
('author_id.id', '=', self.env.user.partner_id.id),
|
|
('res_id', '!=', 0),
|
|
('model', '!=', False),
|
|
('message_type', '!=', 'user_notification')
|
|
])
|
|
return messages._format_mail_failures()
|
|
|
|
@api.model
|
|
def message_fetch(self, domain, limit=20, moderated_channel_ids=None):
|
|
""" Get a limited amount of formatted messages with provided domain.
|
|
:param domain: the domain to filter messages;
|
|
:param limit: the maximum amount of messages to get;
|
|
:param list(int) moderated_channel_ids: if set, it contains the ID
|
|
of a moderated channel. Fetched messages should include pending
|
|
moderation messages for moderators. If the current user is not
|
|
moderator, it should still get self-authored messages that are
|
|
pending moderation;
|
|
:returns list(dict).
|
|
"""
|
|
messages = self.search(domain, limit=limit)
|
|
if moderated_channel_ids:
|
|
# Split load moderated and regular messages, as the ORed domain can
|
|
# cause performance issues on large databases.
|
|
moderated_messages_dom = [('model', '=', 'mail.channel'),
|
|
('res_id', 'in', moderated_channel_ids),
|
|
'|',
|
|
('author_id', '=', self.env.user.partner_id.id),
|
|
('need_moderation', '=', True)]
|
|
messages |= self.search(moderated_messages_dom, limit=limit)
|
|
# Truncate the results to `limit`
|
|
messages = messages.sorted(key='id', reverse=True)[:limit]
|
|
return messages.message_format()
|
|
|
|
def message_format(self):
|
|
""" Get the message values in the format for web client. Since message values can be broadcasted,
|
|
computed fields MUST NOT BE READ and broadcasted.
|
|
:returns list(dict).
|
|
Example :
|
|
{
|
|
'body': HTML content of the message
|
|
'model': u'res.partner',
|
|
'record_name': u'Agrolait',
|
|
'attachment_ids': [
|
|
{
|
|
'file_type_icon': u'webimage',
|
|
'id': 45,
|
|
'name': u'sample.png',
|
|
'filename': u'sample.png'
|
|
}
|
|
],
|
|
'needaction_partner_ids': [], # list of partner ids
|
|
'res_id': 7,
|
|
'tracking_value_ids': [
|
|
{
|
|
'old_value': "",
|
|
'changed_field': "Customer",
|
|
'id': 2965,
|
|
'new_value': "Axelor"
|
|
}
|
|
],
|
|
'author_id': (3, u'Administrator'),
|
|
'email_from': 'sacha@pokemon.com' # email address or False
|
|
'subtype_id': (1, u'Discussions'),
|
|
'channel_ids': [], # list of channel ids
|
|
'date': '2015-06-30 08:22:33',
|
|
'partner_ids': [[7, "Sacha Du Bourg-Palette"]], # list of partner name_get
|
|
'message_type': u'comment',
|
|
'id': 59,
|
|
'subject': False
|
|
'is_note': True # only if the message is a note (subtype == note)
|
|
'is_discussion': False # only if the message is a discussion (subtype == discussion)
|
|
'is_notification': False # only if the message is a note but is a notification aka not linked to a document like assignation
|
|
'moderation_status': 'pending_moderation'
|
|
}
|
|
"""
|
|
message_values = self.read(self._get_message_format_fields())
|
|
message_tree = dict((m.id, m) for m in self.sudo())
|
|
self._message_read_dict_postprocess(message_values, message_tree)
|
|
|
|
# add subtype data (is_note flag, is_discussion flag , subtype_description). Do it as sudo
|
|
# because portal / public may have to look for internal subtypes
|
|
subtype_ids = [msg['subtype_id'][0] for msg in message_values if msg['subtype_id']]
|
|
subtypes = self.env['mail.message.subtype'].sudo().browse(subtype_ids).read(['internal', 'description','id'])
|
|
subtypes_dict = dict((subtype['id'], subtype) for subtype in subtypes)
|
|
|
|
com_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
|
|
note_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
|
|
|
|
# fetch notification status
|
|
|
|
notif_dict = defaultdict(lambda: defaultdict(list))
|
|
notifs = self.env['mail.notification'].sudo().search([('mail_message_id', 'in', list(mid for mid in message_tree)), ('res_partner_id', '!=', False)])
|
|
|
|
for notif in notifs:
|
|
mid = notif.mail_message_id.id
|
|
if notif.is_read:
|
|
notif_dict[mid]['history_partner_ids'].append(notif.res_partner_id.id)
|
|
else:
|
|
notif_dict[mid]['needaction_partner_ids'].append(notif.res_partner_id.id)
|
|
|
|
for message in message_values:
|
|
message.update({
|
|
'needaction_partner_ids': notif_dict[message['id']]['needaction_partner_ids'],
|
|
'history_partner_ids': notif_dict[message['id']]['history_partner_ids'],
|
|
'is_note': message['subtype_id'] and subtypes_dict[message['subtype_id'][0]]['id'] == note_id,
|
|
'is_discussion': message['subtype_id'] and subtypes_dict[message['subtype_id'][0]]['id'] == com_id,
|
|
'subtype_description': message['subtype_id'] and subtypes_dict[message['subtype_id'][0]]['description']
|
|
})
|
|
message['is_notification'] = message['message_type'] == 'user_notification'
|
|
|
|
if message['model'] and self.env[message['model']]._original_module:
|
|
message['module_icon'] = modules.module.get_module_icon(self.env[message['model']]._original_module)
|
|
return message_values
|
|
|
|
def _get_message_format_fields(self):
|
|
return [
|
|
'id', 'body', 'date', 'author_id', 'email_from', # base message fields
|
|
'message_type', 'subtype_id', 'subject', # message specific
|
|
'model', 'res_id', 'record_name', # document related
|
|
'channel_ids', 'partner_ids', # recipients
|
|
'starred_partner_ids', # list of partner ids for whom the message is starred
|
|
'moderation_status',
|
|
]
|
|
|
|
def _get_mail_failure_dict(self):
|
|
return {
|
|
'message_id': self.id,
|
|
'record_name': self.record_name,
|
|
'model_name': self.env['ir.model']._get(self.model).display_name,
|
|
'uuid': self.message_id,
|
|
'res_id': self.res_id,
|
|
'model': self.model,
|
|
'last_message_date': self.date,
|
|
'module_icon': '/mail/static/src/img/smiley/mailfailure.jpg',
|
|
}
|
|
|
|
def _format_mail_failures(self):
|
|
""" A shorter message to notify a failure update """
|
|
failures_infos = []
|
|
|
|
# prepare notifications computation in batch
|
|
all_notifications = self.env['mail.notification'].sudo().search([
|
|
('mail_message_id', 'in', self.ids)
|
|
])
|
|
msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo())
|
|
for notif in all_notifications:
|
|
msgid_to_notif[notif.mail_message_id.id] += notif
|
|
|
|
# for each channel, build the information header and include the logged partner information
|
|
for message in self:
|
|
notifications = msgid_to_notif[message.id]
|
|
if not any(notification.notification_type == 'email' for notification in notifications):
|
|
continue
|
|
info = dict(message._get_mail_failure_dict(),
|
|
failure_type='mail',
|
|
notifications=dict((notif.res_partner_id.id, (notif.notification_status, notif.res_partner_id.name)) for notif in notifications))
|
|
failures_infos.append(info)
|
|
return failures_infos
|
|
|
|
def _notify_mail_failure_update(self):
|
|
messages = self.env['mail.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 he doesn't
|
|
# have access to the record related to the notification. In this case, we skip it.
|
|
if message.model and message.res_id:
|
|
record = self.env[message.model].browse(message.res_id)
|
|
try:
|
|
record.check_access_rights('read')
|
|
record.check_access_rule('read')
|
|
except AccessError:
|
|
continue
|
|
else:
|
|
messages |= message
|
|
|
|
for author, author_messages in groupby(messages, itemgetter('author_id')):
|
|
self.env['bus.bus'].sendone(
|
|
(self._cr.dbname, 'res.partner', author.id),
|
|
{'type': 'mail_failure', 'elements': self.env['mail.message'].concat(*author_messages)._format_mail_failures()}
|
|
)
|
|
|
|
#------------------------------------------------------
|
|
# mail_message internals
|
|
#------------------------------------------------------
|
|
|
|
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)""")
|
|
|
|
def is_thread_message(self, vals=None):
|
|
if vals:
|
|
res_id = vals.get('res_id')
|
|
model = vals.get('model')
|
|
message_type = vals.get('message_type')
|
|
else:
|
|
self.ensure_one()
|
|
res_id = self.res_id
|
|
model = self.model
|
|
message_type = self.message_type
|
|
return res_id and model and message_type != 'user_notification'
|
|
|
|
@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
|
|
|
|
@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 (aka, no internal logs)
|
|
if not self.env['res.users'].has_group('base.group_user'):
|
|
args = ['&', '&', ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)] + list(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'])
|
|
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=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
|
|
|
|
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.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
|
|
def _get_record_name(self, values):
|
|
""" Return the related document name, using name_get. It is done using
|
|
SUPERUSER_ID, to be sure to have the record name correctly stored. """
|
|
model = values.get('model', self.env.context.get('default_model'))
|
|
res_id = values.get('res_id', self.env.context.get('default_res_id'))
|
|
if not model or not res_id or model not in self.env:
|
|
return False
|
|
return self.env[model].sudo().browse(res_id).display_name
|
|
|
|
@api.model
|
|
def _get_reply_to(self, values):
|
|
""" Return a specific reply_to for the document """
|
|
model = values.get('model', self._context.get('default_model'))
|
|
res_id = values.get('res_id', self._context.get('default_res_id'))
|
|
email_from = values.get('email_from')
|
|
message_type = values.get('message_type')
|
|
records = None
|
|
if self.is_thread_message({'model': model, 'res_id': res_id, 'message_type': message_type}):
|
|
records = self.env[model].browse([res_id])
|
|
else:
|
|
res_id = False
|
|
return self.env['mail.thread']._notify_get_reply_to_on_records(default=email_from, records=records)[res_id]
|
|
|
|
@api.model
|
|
def _get_message_id(self, values):
|
|
if values.get('no_auto_thread', False) is True:
|
|
message_id = tools.generate_tracking_message_id('reply_to')
|
|
elif self.is_thread_message(values):
|
|
message_id = tools.generate_tracking_message_id('%(res_id)s-%(model)s' % values)
|
|
else:
|
|
message_id = tools.generate_tracking_message_id('private')
|
|
return message_id
|
|
|
|
def _invalidate_documents(self, model=None, res_id=None):
|
|
""" Invalidate the cache of the documents followed by ``self``. """
|
|
for record in self:
|
|
model = model or record.model
|
|
res_id = res_id or record.res_id
|
|
if issubclass(self.pool[model], self.pool['mail.thread']):
|
|
self.env[model].invalidate_cache(fnames=[
|
|
'message_ids',
|
|
'message_unread',
|
|
'message_unread_counter',
|
|
'message_needaction',
|
|
'message_needaction_counter',
|
|
], ids=[res_id])
|
|
|
|
@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
|
|
values['email_from'] = self._get_default_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']
|
|
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()
|
|
|
|
# --------------------------------------------------
|
|
# Moderation
|
|
# --------------------------------------------------
|
|
|
|
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
|
|
if self.env.user.partner_id.email:
|
|
email_from = formataddr((self.env.user.partner_id.name, self.env.user.partner_id.email))
|
|
else:
|
|
email_from = self.env.company.catchall
|
|
|
|
body_html = tools.append_content_to_html('<div>%s</div>' % tools.ustr(comment), msg.body)
|
|
vals = {
|
|
'subject': subject,
|
|
'body_html': body_html,
|
|
'email_from': email_from,
|
|
'email_to': msg.email_from,
|
|
'auto_delete': True,
|
|
'state': 'outgoing'
|
|
}
|
|
self.env['mail.mail'].sudo().create(vals)
|
|
|
|
def _search_from_same_authors(self):
|
|
""" Returns all pending moderation messages that have same email_from and
|
|
same res_id as given recordset. """
|
|
messages = self.env['mail.message'].sudo()
|
|
for message in self:
|
|
messages |= messages.search([
|
|
('moderation_status', '=', 'pending_moderation'),
|
|
('email_from', '=', message.email_from),
|
|
('model', '=', 'mail.channel'),
|
|
('res_id', '=', message.res_id)
|
|
])
|
|
return messages
|
|
|
|
def _moderate_discard(self):
|
|
""" Notify deletion of messages to their moderators and authors and then delete them.
|
|
"""
|
|
channel_ids = self.mapped('res_id')
|
|
moderators = self.env['mail.channel'].browse(channel_ids).mapped('moderator_ids')
|
|
authors = self.mapped('author_id')
|
|
partner_to_pid = {}
|
|
for moderator in moderators:
|
|
partner_to_pid.setdefault(moderator.partner_id.id, set())
|
|
partner_to_pid[moderator.partner_id.id] |= set([message.id for message in self if message.res_id in moderator.moderation_channel_ids.ids])
|
|
for author in authors:
|
|
partner_to_pid.setdefault(author.id, set())
|
|
partner_to_pid[author.id] |= set([message.id for message in self if message.author_id == author])
|
|
|
|
notifications = []
|
|
for partner_id, message_ids in partner_to_pid.items():
|
|
notifications.append([
|
|
(self._cr.dbname, 'res.partner', partner_id),
|
|
{'type': 'deletion', 'message_ids': list(message_ids)}
|
|
])
|
|
self.env['bus.bus'].sendmany(notifications)
|
|
self.unlink()
|
|
|
|
def _notify_pending_by_chat(self):
|
|
""" Generate the bus notifications for the given message and send them
|
|
to the appropriate moderators and the author (if the author has not been
|
|
elected moderator meanwhile). The author notification can be considered
|
|
as a feedback to the author.
|
|
"""
|
|
self.ensure_one()
|
|
message = self.message_format()[0]
|
|
partners = self.env['mail.channel'].browse(self.res_id).mapped('moderator_ids.partner_id')
|
|
notifications = []
|
|
for partner in partners:
|
|
notifications.append([
|
|
(self._cr.dbname, 'res.partner', partner.id),
|
|
{'type': 'moderator', 'message': message}
|
|
])
|
|
if self.author_id not in partners:
|
|
notifications.append([
|
|
(self._cr.dbname, 'res.partner', self.author_id.id),
|
|
{'type': 'author', 'message': message}
|
|
])
|
|
self.env['bus.bus'].sendmany(notifications)
|
|
|
|
@api.model
|
|
def _notify_moderators(self):
|
|
""" Push a notification (Inbox/email) to moderators having messages
|
|
waiting for moderation. This method is called once a day by a cron.
|
|
"""
|
|
channels = self.env['mail.channel'].browse(self.search([('moderation_status', '=', 'pending_moderation')]).mapped('res_id'))
|
|
moderators_to_notify = channels.mapped('moderator_ids')
|
|
template = self.env.ref('mail.mail_channel_notify_moderation', raise_if_not_found=False)
|
|
if not template:
|
|
_logger.warning('Template "mail.mail_channel_notify_moderation" was not found. Cannot send reminder notifications.')
|
|
return
|
|
MailThread = self.env['mail.thread'].with_context(mail_notify_author=True)
|
|
for moderator in moderators_to_notify:
|
|
MailThread.message_notify(
|
|
partner_ids=moderator.partner_id.ids,
|
|
subject=_('Message are pending moderation'), # tocheck: target language
|
|
body=template.render({'record': moderator.partner_id}, engine='ir.qweb', minimal_qcontext=True),
|
|
email_from=moderator.company_id.catchall or moderator.company_id.email,
|
|
)
|