Purpose of this commit is to allow moderation on incoming messages in discussion channels. On some channels on which moderation is required messages should be in a pending moderation stage. Moderators can accept or refuse messages as well as always allow or ban messages coming from a given set of emails. Channels now have an option to be moderated. Moderators can be added on channels. They have access to a specific UI in Discuss to see and take action on messages waiting for moderation. Concerning mail.thread message that are pending moderation are not notified. It means nobody receives a notification about them. Moderation process calls the notification once the message is validated. Various features included in this commit : * a model is added to store the decision about emails, allow or ban; * access rights are updated so that only moderators can modify moderation fields on message; * specific bus notifications are send to moderated people as well as to moderators on incoming emails as well as when a decision is taken; * options are added on channels to send explanations to moderated emails; * options are added on channels to write and send guidelines explaining why and how moderation is performed; * a reminder is send daily to moderators with remaining messages to moderate; * discuss UI is adapted and a new channel is added below Inbox and Starred giving access to moderation tools; * chanenl UI is adapted allowing to moderate directly inside channels; This commit is linked to task ID 29521. Closes #21921.
928 lines
45 KiB
Python
928 lines
45 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import logging
|
|
import re
|
|
|
|
from email.utils import formataddr
|
|
from uuid import uuid4
|
|
|
|
from odoo import _, api, fields, models, modules, tools
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.osv import expression
|
|
from odoo.tools import ormcache, pycompat
|
|
from odoo.tools.safe_eval import safe_eval
|
|
|
|
MODERATION_FIELDS = ['moderation', 'moderator_ids', 'moderation_ids', 'moderation_notify', 'moderation_notify_msg', 'moderation_guidelines', 'moderation_guidelines_msg']
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ChannelPartner(models.Model):
|
|
_name = 'mail.channel.partner'
|
|
_description = 'Listeners of a Channel'
|
|
_table = 'mail_channel_partner'
|
|
_rec_name = 'partner_id'
|
|
|
|
partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade')
|
|
partner_email = fields.Char('Email', related='partner_id.email')
|
|
channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade')
|
|
seen_message_id = fields.Many2one('mail.message', string='Last Seen')
|
|
fold_state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Conversation Fold State', default='open')
|
|
is_minimized = fields.Boolean("Conversation is minimized")
|
|
is_pinned = fields.Boolean("Is pinned on the interface", default=True)
|
|
|
|
|
|
class Moderation(models.Model):
|
|
_name = 'mail.moderation'
|
|
_description = 'Channel black/white list'
|
|
|
|
email = fields.Char(string="Email", index=True, required=True)
|
|
status = fields.Selection([
|
|
('allow', 'Always Allow'),
|
|
('ban', 'Permanent Ban')],
|
|
string="Status", required=True)
|
|
channel_id = fields.Many2one('mail.channel', string="Channel", index=True, required=True)
|
|
|
|
_sql_constraints = [
|
|
('channel_email_uniq', 'unique (email,channel_id)', 'The email address must be unique per channel !')
|
|
]
|
|
|
|
|
|
class Channel(models.Model):
|
|
""" A mail.channel is a discussion group that may behave like a listener
|
|
on documents. """
|
|
_description = 'Discussion channel'
|
|
_name = 'mail.channel'
|
|
_mail_flat_thread = False
|
|
_mail_post_access = 'read'
|
|
_inherit = ['mail.thread', 'mail.alias.mixin']
|
|
|
|
MAX_BOUNCE_LIMIT = 10
|
|
|
|
def _get_default_image(self):
|
|
image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png')
|
|
return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read()))
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
res = super(Channel, self).default_get(fields)
|
|
if not res.get('alias_contact') and (not fields or 'alias_contact' in fields):
|
|
res['alias_contact'] = 'everyone' if res.get('public', 'private') == 'public' else 'followers'
|
|
return res
|
|
|
|
name = fields.Char('Name', required=True, translate=True)
|
|
channel_type = fields.Selection([
|
|
('chat', 'Chat Discussion'),
|
|
('channel', 'Channel')],
|
|
'Channel Type', default='channel')
|
|
description = fields.Text('Description')
|
|
uuid = fields.Char('UUID', size=50, index=True, default=lambda self: str(uuid4()), copy=False)
|
|
email_send = fields.Boolean('Send messages by email', default=False)
|
|
# multi users channel
|
|
channel_last_seen_partner_ids = fields.One2many('mail.channel.partner', 'channel_id', string='Last Seen')
|
|
channel_partner_ids = fields.Many2many('res.partner', 'mail_channel_partner', 'channel_id', 'partner_id', string='Listeners')
|
|
channel_message_ids = fields.Many2many('mail.message', 'mail_message_mail_channel_rel')
|
|
is_member = fields.Boolean('Is a member', compute='_compute_is_member')
|
|
# access
|
|
public = fields.Selection([
|
|
('public', 'Everyone'),
|
|
('private', 'Invited people only'),
|
|
('groups', 'Selected group of users')],
|
|
'Privacy', required=True, default='groups',
|
|
help='This group is visible by non members. Invisible groups can add members through the invite button.')
|
|
group_public_id = fields.Many2one('res.groups', string='Authorized Group',
|
|
default=lambda self: self.env.ref('base.group_user'))
|
|
group_ids = fields.Many2many(
|
|
'res.groups', string='Auto Subscription',
|
|
help="Members of those groups will automatically added as followers. "
|
|
"Note that they will be able to manage their subscription manually "
|
|
"if necessary.")
|
|
# image: all image fields are base64 encoded and PIL-supported
|
|
image = fields.Binary("Photo", default=_get_default_image, attachment=True,
|
|
help="This field holds the image used as photo for the group, limited to 1024x1024px.")
|
|
image_medium = fields.Binary('Medium-sized photo', attachment=True,
|
|
help="Medium-sized photo of the group. It is automatically "
|
|
"resized as a 128x128px image, with aspect ratio preserved. "
|
|
"Use this field in form views or some kanban views.")
|
|
image_small = fields.Binary('Small-sized photo', attachment=True,
|
|
help="Small-sized photo of the group. It is automatically "
|
|
"resized as a 64x64px image, with aspect ratio preserved. "
|
|
"Use this field anywhere a small image is required.")
|
|
is_subscribed = fields.Boolean(
|
|
'Is Subscribed', compute='_compute_is_subscribed')
|
|
# moderation
|
|
moderation = fields.Boolean(string='Moderate this channel')
|
|
moderator_ids = fields.Many2many('res.users', 'mail_channel_moderator_rel', string='Moderators')
|
|
is_moderator = fields.Boolean(help="Current user is a moderator of the channel", string='Moderator', compute="_compute_is_moderator")
|
|
moderation_ids = fields.One2many(
|
|
'mail.moderation', 'channel_id', string='Moderated Emails',
|
|
groups="base.group_user")
|
|
moderation_count = fields.Integer(
|
|
string='Moderated emails count', compute='_compute_moderation_count',
|
|
groups="base.group_user")
|
|
moderation_notify = fields.Boolean(string="Automatic notification", help="People receive an automatic notification about their message being waiting for moderation.")
|
|
moderation_notify_msg = fields.Text(string="Notification message")
|
|
moderation_guidelines = fields.Boolean(string="Send guidelines to new subscribers", help="Newcomers on this moderated channel will automatically receive the guidelines.")
|
|
moderation_guidelines_msg = fields.Text(string="Guidelines")
|
|
|
|
@api.one
|
|
@api.depends('channel_partner_ids')
|
|
def _compute_is_subscribed(self):
|
|
self.is_subscribed = self.env.user.partner_id in self.channel_partner_ids
|
|
|
|
@api.multi
|
|
@api.depends('moderator_ids')
|
|
def _compute_is_moderator(self):
|
|
for channel in self:
|
|
channel.is_moderator = self.env.user in channel.moderator_ids
|
|
|
|
@api.multi
|
|
@api.depends('moderation_ids')
|
|
def _compute_moderation_count(self):
|
|
read_group_res = self.env['mail.moderation'].read_group([('channel_id', 'in', self.ids)], ['channel_id'], 'channel_id')
|
|
data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res)
|
|
for channel in self:
|
|
channel.moderation_count = data.get(channel.id, 0)
|
|
|
|
@api.constrains('moderator_ids')
|
|
def _check_moderator_email(self):
|
|
if any(not moderator.email for channel in self for moderator in channel.moderator_ids):
|
|
raise ValidationError("Moderators must have an email address.")
|
|
|
|
@api.constrains('moderator_ids', 'channel_partner_ids', 'channel_last_seen_partner_ids')
|
|
def _check_moderator_is_member(self):
|
|
for channel in self:
|
|
if not (channel.mapped('moderator_ids.partner_id') <= channel.channel_partner_ids):
|
|
raise ValidationError("Moderators should be members of the channel they moderate.")
|
|
|
|
@api.constrains('moderation', 'email_send')
|
|
def _check_moderation_parameters(self):
|
|
if any(not channel.email_send and channel.moderation for channel in self):
|
|
raise ValidationError('Only mailing lists can be moderated.')
|
|
|
|
@api.constrains('moderator_ids')
|
|
def _check_moderator_existence(self):
|
|
if any(not channel.moderator_ids for channel in self if channel.moderation):
|
|
raise ValidationError('Moderated channels must have moderators.')
|
|
|
|
@api.multi
|
|
def _compute_is_member(self):
|
|
memberships = self.env['mail.channel.partner'].sudo().search([
|
|
('channel_id', 'in', self.ids),
|
|
('partner_id', '=', self.env.user.partner_id.id),
|
|
])
|
|
membership_ids = memberships.mapped('channel_id')
|
|
for record in self:
|
|
record.is_member = record in membership_ids
|
|
|
|
@api.onchange('public')
|
|
def _onchange_public(self):
|
|
if self.public == 'public':
|
|
self.alias_contact = 'everyone'
|
|
else:
|
|
self.alias_contact = 'followers'
|
|
|
|
@api.onchange('moderator_ids')
|
|
def _onchange_moderator_ids(self):
|
|
missing_partners = self.mapped('moderator_ids.partner_id') - self.channel_partner_ids
|
|
if missing_partners:
|
|
self.channel_partner_ids |= missing_partners
|
|
|
|
@api.onchange('email_send')
|
|
def _onchange_email_send(self):
|
|
if not self.email_send:
|
|
self.moderation = False
|
|
|
|
@api.onchange('moderation')
|
|
def _onchange_moderation(self):
|
|
if not self.moderation:
|
|
self.moderation_notify = False
|
|
self.moderation_guidelines = False
|
|
self.moderator_ids = False
|
|
else:
|
|
self.moderator_ids |= self.env.user
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
# ensure image at quick create
|
|
if not vals.get('image'):
|
|
defaults = self.default_get(['image'])
|
|
vals['image'] = defaults['image']
|
|
|
|
tools.image_resize_images(vals)
|
|
# Create channel and alias
|
|
channel = super(Channel, self.with_context(
|
|
alias_model_name=self._name, alias_parent_model_name=self._name, mail_create_nolog=True, mail_create_nosubscribe=True)
|
|
).create(vals)
|
|
channel.alias_id.write({"alias_force_thread_id": channel.id, 'alias_parent_thread_id': channel.id})
|
|
|
|
if vals.get('group_ids'):
|
|
channel._subscribe_users()
|
|
|
|
# make channel listen itself: posting on a channel notifies the channel
|
|
if not self._context.get('mail_channel_noautofollow'):
|
|
channel.message_subscribe(channel_ids=[channel.id])
|
|
|
|
return channel
|
|
|
|
@api.multi
|
|
def unlink(self):
|
|
aliases = self.mapped('alias_id')
|
|
|
|
# Delete mail.channel
|
|
try:
|
|
all_emp_group = self.env.ref('mail.channel_all_employees')
|
|
except ValueError:
|
|
all_emp_group = None
|
|
if all_emp_group and all_emp_group in self:
|
|
raise UserError(_('You cannot delete those groups, as the Whole Company group is required by other modules.'))
|
|
res = super(Channel, self).unlink()
|
|
# Cascade-delete mail aliases as well, as they should not exist without the mail.channel.
|
|
aliases.sudo().unlink()
|
|
return res
|
|
|
|
@api.multi
|
|
def write(self, vals):
|
|
# First checks if user tries to modify moderation fields and has not the right to do it.
|
|
if any(key for key in MODERATION_FIELDS if vals.get(key)) and any(self.env.user not in channel.moderator_ids for channel in self if channel.moderation):
|
|
if not self.env.user.has_group('base.group_system'):
|
|
raise UserError("You do not possess the rights to modify fields related to moderation on one of the channels you are modifying.")
|
|
|
|
tools.image_resize_images(vals)
|
|
result = super(Channel, self).write(vals)
|
|
|
|
if vals.get('group_ids'):
|
|
self._subscribe_users()
|
|
|
|
# avoid keeping messages to moderate and accept them
|
|
if vals.get('moderation') is False:
|
|
self.env['mail.message'].search([
|
|
('moderation_status', '=', 'pending_moderation'),
|
|
('model', '=', 'mail.channel'),
|
|
('res_id', 'in', self.ids)
|
|
])._moderate_accept()
|
|
|
|
return result
|
|
|
|
def get_alias_model_name(self, vals):
|
|
return vals.get('alias_model', 'mail.channel')
|
|
|
|
def _subscribe_users(self):
|
|
for mail_channel in self:
|
|
mail_channel.write({'channel_partner_ids': [(4, pid) for pid in mail_channel.mapped('group_ids').mapped('users').mapped('partner_id').ids]})
|
|
|
|
@api.multi
|
|
def action_follow(self):
|
|
self.ensure_one()
|
|
channel_partner = self.mapped('channel_last_seen_partner_ids').filtered(lambda cp: cp.partner_id == self.env.user.partner_id)
|
|
if not channel_partner:
|
|
return self.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id})]})
|
|
|
|
@api.multi
|
|
def action_unfollow(self):
|
|
return self._action_unfollow(self.env.user.partner_id)
|
|
|
|
@api.multi
|
|
def _action_unfollow(self, partner):
|
|
channel_info = self.channel_info('unsubscribe')[0] # must be computed before leaving the channel (access rights)
|
|
result = self.write({'channel_partner_ids': [(3, partner.id)]})
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner.id), channel_info)
|
|
if not self.email_send:
|
|
notification = _('<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
|
|
# post 'channel left' message as root since the partner just unsubscribed from the channel
|
|
self.sudo().message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id)
|
|
return result
|
|
|
|
@api.multi
|
|
def _notify_get_groups(self, message, groups):
|
|
""" All recipients of a message on a channel are considered as partners.
|
|
This means they will receive a minimal email, without a link to access
|
|
in the backend. Mailing lists should indeed send minimal emails to avoid
|
|
the noise. """
|
|
groups = super(Channel, self)._notify_get_groups(message, groups)
|
|
for (index, (group_name, group_func, group_data)) in enumerate(groups):
|
|
if group_name != 'customer':
|
|
groups[index] = (group_name, lambda partner: False, group_data)
|
|
return groups
|
|
|
|
@api.multi
|
|
def _notify_specific_email_values(self, message):
|
|
res = super(Channel, self)._notify_specific_email_values(message)
|
|
try:
|
|
headers = safe_eval(res.get('headers', dict()))
|
|
except Exception:
|
|
headers = {}
|
|
headers['Precedence'] = 'list'
|
|
# avoid out-of-office replies from MS Exchange
|
|
# http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx
|
|
headers['X-Auto-Response-Suppress'] = 'OOF'
|
|
if self.alias_domain and self.alias_name:
|
|
headers['List-Id'] = '<%s.%s>' % (self.alias_name, self.alias_domain)
|
|
headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain)
|
|
# Avoid users thinking it was a personal message
|
|
# X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server
|
|
list_to = '"%s" <%s@%s>' % (self.name, self.alias_name, self.alias_domain)
|
|
headers['X-Forge-To'] = list_to
|
|
res['headers'] = repr(headers)
|
|
return res
|
|
|
|
@api.multi
|
|
def message_receive_bounce(self, email, partner, mail_id=None):
|
|
""" Override bounce management to unsubscribe bouncing addresses """
|
|
for p in partner:
|
|
if p.message_bounce >= self.MAX_BOUNCE_LIMIT:
|
|
self._action_unfollow(p)
|
|
return super(Channel, self).message_receive_bounce(email, partner, mail_id=mail_id)
|
|
|
|
@api.multi
|
|
def _notify_email_recipients(self, message, recipient_ids):
|
|
# real mailing list: multiple recipients (hidden by X-Forge-To)
|
|
if self.alias_domain and self.alias_name:
|
|
return {
|
|
'email_to': ','.join(formataddr((partner.name, partner.email)) for partner in self.env['res.partner'].sudo().browse(recipient_ids)),
|
|
'recipient_ids': [],
|
|
}
|
|
return super(Channel, self)._notify_email_recipients(message, recipient_ids)
|
|
|
|
def _extract_moderation_values(self, message_type, **kwargs):
|
|
""" This method is used to compute moderation status before the creation
|
|
of a message. For this operation the message's author email address is required.
|
|
This address is returned with status for other computations. """
|
|
moderation_status = 'accepted'
|
|
email = ''
|
|
if self.moderation and message_type in ['email', 'comment']:
|
|
author_id = kwargs.get('author_id')
|
|
if author_id and isinstance(author_id, pycompat.integer_types):
|
|
email = self.env['res.partner'].browse([author_id]).email
|
|
elif author_id:
|
|
email = author_id.email
|
|
elif kwargs.get('email_from'):
|
|
email = tools.email_split(kwargs['email_from'])[0]
|
|
else:
|
|
email = self.env.user.email
|
|
if email in self.mapped('moderator_ids.email'):
|
|
return moderation_status, email
|
|
status = self.env['mail.moderation'].sudo().search([('email', '=', email), ('channel_id', 'in', self.ids)]).mapped('status')
|
|
if status and status[0] == 'allow':
|
|
moderation_status = 'accepted'
|
|
elif status and status[0] == 'ban':
|
|
moderation_status = 'rejected'
|
|
else:
|
|
moderation_status = 'pending_moderation'
|
|
return moderation_status, email
|
|
|
|
@api.multi
|
|
@api.returns('mail.message', lambda value: value.id)
|
|
def message_post(self, message_type='notification', **kwargs):
|
|
moderation_status, email = self._extract_moderation_values(message_type, **kwargs)
|
|
if moderation_status == 'rejected':
|
|
return self.env['mail.message']
|
|
|
|
self.filtered(lambda channel: channel.channel_type == 'chat').mapped('channel_last_seen_partner_ids').write({'is_pinned': True})
|
|
|
|
message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs)
|
|
|
|
# Notifies the message author when his message is pending moderation if required on channel.
|
|
# The fields "email_from" and "reply_to" are filled in automatically by method create in model mail.message.
|
|
if self.moderation_notify and self.moderation_notify_msg and message_type == 'email' and moderation_status == 'pending_moderation':
|
|
self.env['mail.mail'].create({
|
|
'body_html': self.moderation_notify_msg,
|
|
'subject': 'Re: %s' % (kwargs.get('subject', '')),
|
|
'email_to': email,
|
|
'auto_delete': True,
|
|
'state': 'outgoing'
|
|
})
|
|
return message
|
|
|
|
def _alias_check_contact(self, message, message_dict, alias):
|
|
if alias.alias_contact == 'followers' and self.ids:
|
|
author = self.env['res.partner'].browse(message_dict.get('author_id', False))
|
|
if not author or author not in self.channel_partner_ids:
|
|
return {
|
|
'error_message': _('restricted to channel members'),
|
|
}
|
|
return True
|
|
return super(Channel, self)._alias_check_contact(message, message_dict, alias)
|
|
|
|
@api.model_cr
|
|
def init(self):
|
|
self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_channel_partner_seen_message_id_idx',))
|
|
if not self._cr.fetchone():
|
|
self._cr.execute('CREATE INDEX mail_channel_partner_seen_message_id_idx ON mail_channel_partner (channel_id,partner_id,seen_message_id)')
|
|
|
|
# --------------------------------------------------
|
|
# Moderation
|
|
# --------------------------------------------------
|
|
|
|
@api.multi
|
|
def send_guidelines(self):
|
|
""" Send guidelines to all channel members. """
|
|
if self.env.user in self.moderator_ids or self.env.user.has_group('base.group_system'):
|
|
success = self._send_guidelines(self.channel_partner_ids)
|
|
if not success:
|
|
raise UserError('Template "mail.mail_template_channel_send_guidelines" was not found. No email has been sent. Please contact an administrator to fix this issue.')
|
|
else:
|
|
raise UserError("Only an administrator or a moderator can send guidelines to channel members!")
|
|
|
|
@api.multi
|
|
def _send_guidelines(self, partners):
|
|
""" Send guidelines of a given channel. Returns False if template used for guidelines
|
|
not found. Caller may have to handle this return value. """
|
|
self.ensure_one()
|
|
template = self.env.ref('mail.mail_template_channel_send_guidelines', raise_if_not_found=False)
|
|
if not template:
|
|
_logger.warning('Template "mail.mail_template_channel_send_guidelines" was not found.')
|
|
return False
|
|
banned_emails = self.env['mail.moderation'].sudo().search([
|
|
('status', '=', 'ban'),
|
|
('channel_id', 'in', self.ids)
|
|
]).mapped('email')
|
|
for partner in partners.filtered(lambda p: p.email and not (p.email in banned_emails)):
|
|
# the sudo is needed because because send_mail will create a message (a mail). As the template is
|
|
# linked to res.partner model the current user could not have the rights to modify this model. It
|
|
# means it could prevent users to create the mail.message necessary for the mail.mail.
|
|
template.with_context(lang=partner.lang, channel=self).sudo().send_mail(partner.id)
|
|
return True
|
|
|
|
@api.multi
|
|
def _update_moderation_email(self, emails, status):
|
|
""" This method adds emails into either white or black of the channel list of emails
|
|
according to status. If an email in emails is already moderated, the method updates the email status.
|
|
:param emails: list of email addresses to put in white or black list of channel.
|
|
:param status: value is 'allow' or 'ban'. Emails are put in white list if 'allow', in black list if 'ban'.
|
|
"""
|
|
self.ensure_one()
|
|
splitted_emails = [tools.email_split(email)[0] for email in emails if tools.email_split(email)]
|
|
moderated = self.env['mail.moderation'].sudo().search([
|
|
('email', 'in', splitted_emails),
|
|
('channel_id', 'in', self.ids)
|
|
])
|
|
cmds = [(1, record.id, {'status': status}) for record in moderated]
|
|
not_moderated = [email for email in splitted_emails if email not in moderated.mapped('email')]
|
|
cmds += [(0, 0, {'email': email, 'status': status}) for email in not_moderated]
|
|
return self.write({'moderation_ids': cmds})
|
|
|
|
#------------------------------------------------------
|
|
# Instant Messaging API
|
|
#------------------------------------------------------
|
|
# A channel header should be broadcasted:
|
|
# - when adding user to channel (only to the new added partners)
|
|
# - when folding/minimizing a channel (only to the user making the action)
|
|
# A message should be broadcasted:
|
|
# - when a message is posted on a channel (to the channel, using _notify() method)
|
|
|
|
# Anonymous method
|
|
@api.multi
|
|
def _broadcast(self, partner_ids):
|
|
""" Broadcast the current channel header to the given partner ids
|
|
:param partner_ids : the partner to notify
|
|
"""
|
|
notifications = self._channel_channel_notifications(partner_ids)
|
|
self.env['bus.bus'].sendmany(notifications)
|
|
|
|
@api.multi
|
|
def _channel_channel_notifications(self, partner_ids):
|
|
""" Generate the bus notifications of current channel for the given partner ids
|
|
:param partner_ids : the partner to send the current channel header
|
|
:returns list of bus notifications (tuple (bus_channe, message_content))
|
|
"""
|
|
notifications = []
|
|
for partner in self.env['res.partner'].browse(partner_ids):
|
|
user_id = partner.user_ids and partner.user_ids[0] or False
|
|
if user_id:
|
|
for channel_info in self.sudo(user_id).channel_info():
|
|
notifications.append([(self._cr.dbname, 'res.partner', partner.id), channel_info])
|
|
return notifications
|
|
|
|
@api.multi
|
|
def _notify(self, message):
|
|
""" Broadcast the given message on the current channels.
|
|
Send the message on the Bus Channel (uuid for public mail.channel, and partner private bus channel (the tuple)).
|
|
A partner will receive only on message on its bus channel, even if this message belongs to multiple mail channel. Then 'channel_ids' field
|
|
of the received message indicates on wich mail channel the message should be displayed.
|
|
:param : mail.message to broadcast
|
|
"""
|
|
if not self:
|
|
return
|
|
message.ensure_one()
|
|
notifications = self._channel_message_notifications(message)
|
|
self.env['bus.bus'].sendmany(notifications)
|
|
|
|
@api.multi
|
|
def _channel_message_notifications(self, message):
|
|
""" Generate the bus notifications for the given message
|
|
:param message : the mail.message to sent
|
|
:returns list of bus notifications (tuple (bus_channe, message_content))
|
|
"""
|
|
message_values = message.message_format()[0]
|
|
notifications = []
|
|
for channel in self:
|
|
notifications.append([(self._cr.dbname, 'mail.channel', channel.id), dict(message_values)])
|
|
# add uuid to allow anonymous to listen
|
|
if channel.public == 'public':
|
|
notifications.append([channel.uuid, dict(message_values)])
|
|
return notifications
|
|
|
|
@api.multi
|
|
def channel_info(self, extra_info = False):
|
|
""" Get the informations header for the current channels
|
|
:returns a list of channels values
|
|
:rtype : list(dict)
|
|
"""
|
|
channel_infos = []
|
|
partner_channels = self.env['mail.channel.partner']
|
|
# find the channel partner state, if logged user
|
|
if self.env.user and self.env.user.partner_id:
|
|
partner_channels = self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', 'in', self.ids)])
|
|
# for each channel, build the information header and include the logged partner information
|
|
for channel in self:
|
|
info = {
|
|
'id': channel.id,
|
|
'name': channel.name,
|
|
'uuid': channel.uuid,
|
|
'state': 'open',
|
|
'is_minimized': False,
|
|
'channel_type': channel.channel_type,
|
|
'public': channel.public,
|
|
'mass_mailing': channel.email_send,
|
|
'moderation': channel.moderation,
|
|
'is_moderator': self.env.uid in channel.moderator_ids.ids,
|
|
'group_based_subscription': bool(channel.group_ids),
|
|
}
|
|
if extra_info:
|
|
info['info'] = extra_info
|
|
# add the partner for 'direct mesage' channel
|
|
if channel.channel_type == 'chat':
|
|
info['direct_partner'] = (channel.sudo()
|
|
.with_context(active_test=False)
|
|
.channel_partner_ids
|
|
.filtered(lambda p: p.id != self.env.user.partner_id.id)
|
|
.read(['id', 'name', 'im_status']))
|
|
|
|
# add last message preview (only used in mobile)
|
|
if self._context.get('isMobile', False):
|
|
last_message = channel.channel_fetch_preview()
|
|
if last_message:
|
|
info['last_message'] = last_message[0].get('last_message')
|
|
|
|
# add user session state, if available and if user is logged
|
|
if partner_channels.ids:
|
|
partner_channel = partner_channels.filtered(lambda c: channel.id == c.channel_id.id)
|
|
if len(partner_channel) >= 1:
|
|
partner_channel = partner_channel[0]
|
|
info['state'] = partner_channel.fold_state or 'open'
|
|
info['is_minimized'] = partner_channel.is_minimized
|
|
info['seen_message_id'] = partner_channel.seen_message_id.id
|
|
# add needaction and unread counter, since the user is logged
|
|
info['message_needaction_counter'] = channel.message_needaction_counter
|
|
info['message_unread_counter'] = channel.message_unread_counter
|
|
channel_infos.append(info)
|
|
return channel_infos
|
|
|
|
@api.multi
|
|
def channel_fetch_message(self, last_id=False, limit=20):
|
|
""" Return message values of the current channel.
|
|
:param last_id : last message id to start the research
|
|
:param limit : maximum number of messages to fetch
|
|
:returns list of messages values
|
|
:rtype : list(dict)
|
|
"""
|
|
self.ensure_one()
|
|
domain = [("channel_ids", "in", self.ids)]
|
|
if last_id:
|
|
domain.append(("id", "<", last_id))
|
|
return self.env['mail.message'].message_fetch(domain=domain, limit=limit)
|
|
|
|
# User methods
|
|
@api.model
|
|
def channel_get(self, partners_to, pin=True):
|
|
""" Get the canonical private channel between some partners, create it if needed.
|
|
To reuse an old channel (conversation), this one must be private, and contains
|
|
only the given partners.
|
|
:param partners_to : list of res.partner ids to add to the conversation
|
|
:param pin : True if getting the channel should pin it for the current user
|
|
:returns a channel header, or False if the users_to was False
|
|
:rtype : dict
|
|
"""
|
|
if partners_to:
|
|
partners_to.append(self.env.user.partner_id.id)
|
|
# determine type according to the number of partner in the channel
|
|
self.env.cr.execute("""
|
|
SELECT P.channel_id as channel_id
|
|
FROM mail_channel C, mail_channel_partner P
|
|
WHERE P.channel_id = C.id
|
|
AND C.public LIKE 'private'
|
|
AND P.partner_id IN %s
|
|
AND channel_type LIKE 'chat'
|
|
GROUP BY P.channel_id
|
|
HAVING COUNT(P.partner_id) = %s
|
|
""", (tuple(partners_to), len(partners_to),))
|
|
result = self.env.cr.dictfetchall()
|
|
if result:
|
|
# get the existing channel between the given partners
|
|
channel = self.browse(result[0].get('channel_id'))
|
|
# pin up the channel for the current partner
|
|
if pin:
|
|
self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]).write({'is_pinned': True})
|
|
else:
|
|
# create a new one
|
|
channel = self.create({
|
|
'channel_partner_ids': [(4, partner_id) for partner_id in partners_to],
|
|
'public': 'private',
|
|
'channel_type': 'chat',
|
|
'email_send': False,
|
|
'name': ', '.join(self.env['res.partner'].sudo().browse(partners_to).mapped('name')),
|
|
})
|
|
# broadcast the channel header to the other partner (not me)
|
|
channel._broadcast(partners_to)
|
|
return channel.channel_info()[0]
|
|
return False
|
|
|
|
@api.model
|
|
def channel_get_and_minimize(self, partners_to):
|
|
channel = self.channel_get(partners_to)
|
|
if channel:
|
|
self.channel_minimize(channel['uuid'])
|
|
return channel
|
|
|
|
@api.model
|
|
def channel_fold(self, uuid, state=None):
|
|
""" Update the fold_state of the given session. In order to syncronize web browser
|
|
tabs, the change will be broadcast to himself (the current user channel).
|
|
Note: the user need to be logged
|
|
:param state : the new status of the session for the current user.
|
|
"""
|
|
domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)]
|
|
for session_state in self.env['mail.channel.partner'].search(domain):
|
|
if not state:
|
|
state = session_state.fold_state
|
|
if session_state.fold_state == 'open':
|
|
state = 'folded'
|
|
else:
|
|
state = 'open'
|
|
session_state.write({
|
|
'fold_state': state,
|
|
'is_minimized': bool(state != 'closed'),
|
|
})
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), session_state.channel_id.channel_info()[0])
|
|
|
|
@api.model
|
|
def channel_minimize(self, uuid, minimized=True):
|
|
values = {
|
|
'fold_state': minimized and 'open' or 'closed',
|
|
'is_minimized': minimized
|
|
}
|
|
domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)]
|
|
channel_partners = self.env['mail.channel.partner'].search(domain)
|
|
channel_partners.write(values)
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_partners.channel_id.channel_info()[0])
|
|
|
|
@api.model
|
|
def channel_pin(self, uuid, pinned=False):
|
|
# add the person in the channel, and pin it (or unpin it)
|
|
channel = self.search([('uuid', '=', uuid)])
|
|
channel_partners = self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)])
|
|
if not pinned:
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel.channel_info('unsubscribe')[0])
|
|
if channel_partners:
|
|
channel_partners.write({'is_pinned': pinned})
|
|
|
|
@api.multi
|
|
def channel_seen(self):
|
|
self.ensure_one()
|
|
if self.channel_message_ids.ids:
|
|
last_message_id = self.channel_message_ids.ids[0] # zero is the index of the last message
|
|
self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id)]).write({'seen_message_id': last_message_id})
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), {'info': 'channel_seen', 'id': self.id, 'last_message_id': last_message_id})
|
|
return last_message_id
|
|
|
|
@api.multi
|
|
def channel_invite(self, partner_ids):
|
|
""" Add the given partner_ids to the current channels and broadcast the channel header to them.
|
|
:param partner_ids : list of partner id to add
|
|
"""
|
|
partners = self.env['res.partner'].browse(partner_ids)
|
|
# add the partner
|
|
for channel in self:
|
|
partners_to_add = partners - channel.channel_partner_ids
|
|
channel.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': partner_id}) for partner_id in partners_to_add.ids]})
|
|
for partner in partners_to_add:
|
|
if partner.id != self.env.user.partner_id.id:
|
|
notification = _('<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>') % {
|
|
'author': self.env.user.display_name,
|
|
'new_partner': partner.display_name,
|
|
'channel_id': channel.id,
|
|
'channel_name': channel.name,
|
|
}
|
|
else:
|
|
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (channel.id, channel.name,)
|
|
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id)
|
|
|
|
# broadcast the channel header to the added partner
|
|
self._broadcast(partner_ids)
|
|
|
|
#------------------------------------------------------
|
|
# Instant Messaging View Specific (Slack Client Action)
|
|
#------------------------------------------------------
|
|
@api.model
|
|
def channel_fetch_slot(self):
|
|
""" Return the channels of the user grouped by 'slot' (channel, direct_message or private_group), and
|
|
the mapping between partner_id/channel_id for direct_message channels.
|
|
:returns dict : the grouped channels and the mapping
|
|
"""
|
|
values = {}
|
|
my_partner_id = self.env.user.partner_id.id
|
|
pinned_channels = self.env['mail.channel.partner'].search([('partner_id', '=', my_partner_id), ('is_pinned', '=', True)]).mapped('channel_id')
|
|
|
|
# get the group/public channels
|
|
values['channel_channel'] = self.search([('channel_type', '=', 'channel'), ('public', 'in', ['public', 'groups']), ('channel_partner_ids', 'in', [my_partner_id])]).channel_info()
|
|
|
|
# get the pinned 'direct message' channel
|
|
direct_message_channels = self.search([('channel_type', '=', 'chat'), ('id', 'in', pinned_channels.ids)])
|
|
values['channel_direct_message'] = direct_message_channels.channel_info()
|
|
|
|
# get the private group
|
|
values['channel_private_group'] = self.search([('channel_type', '=', 'channel'), ('public', '=', 'private'), ('channel_partner_ids', 'in', [my_partner_id])]).channel_info()
|
|
return values
|
|
|
|
@api.model
|
|
def channel_search_to_join(self, name=None, domain=None):
|
|
""" Return the channel info of the channel the current partner can join
|
|
:param name : the name of the researched channels
|
|
:param domain : the base domain of the research
|
|
:returns dict : channel dict
|
|
"""
|
|
if not domain:
|
|
domain = []
|
|
domain = expression.AND([
|
|
[('channel_type', '=', 'channel')],
|
|
[('channel_partner_ids', 'not in', [self.env.user.partner_id.id])],
|
|
[('public', '!=', 'private')],
|
|
domain
|
|
])
|
|
if name:
|
|
domain = expression.AND([domain, [('name', 'ilike', '%'+name+'%')]])
|
|
return self.search(domain).read(['name', 'public', 'uuid', 'channel_type'])
|
|
|
|
@api.multi
|
|
def channel_join_and_get_info(self):
|
|
self.ensure_one()
|
|
if self.channel_type == 'channel' and not self.email_send:
|
|
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
|
|
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment")
|
|
self.action_follow()
|
|
|
|
if self.moderation_guidelines:
|
|
self._send_guidelines(self.env.user.partner_id)
|
|
|
|
channel_info = self.channel_info()[0]
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info)
|
|
return channel_info
|
|
|
|
@api.model
|
|
def channel_create(self, name, privacy='public'):
|
|
""" Create a channel and add the current partner, broadcast it (to make the user directly
|
|
listen to it when polling)
|
|
:param name : the name of the channel to create
|
|
:param privacy : privacy of the channel. Should be 'public' or 'private'.
|
|
:return dict : channel header
|
|
"""
|
|
# create the channel
|
|
new_channel = self.create({
|
|
'name': name,
|
|
'public': privacy,
|
|
'email_send': False,
|
|
'channel_partner_ids': [(4, self.env.user.partner_id.id)]
|
|
})
|
|
notification = _('<div class="o_mail_notification">created <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (new_channel.id, new_channel.name,)
|
|
new_channel.message_post(body=notification, message_type="notification", subtype="mail.mt_comment")
|
|
channel_info = new_channel.channel_info('creation')[0]
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info)
|
|
return channel_info
|
|
|
|
@api.model
|
|
def get_mention_suggestions(self, search, limit=8):
|
|
""" Return 'limit'-first channels' id, name and public fields such that the name matches a
|
|
'search' string. Exclude channels of type chat (DM), and private channels the current
|
|
user isn't registered to. """
|
|
domain = expression.AND([
|
|
[('name', 'ilike', search)],
|
|
[('channel_type', '=', 'channel')],
|
|
expression.OR([
|
|
[('public', '!=', 'private')],
|
|
[('channel_partner_ids', 'in', [self.env.user.partner_id.id])]
|
|
])
|
|
])
|
|
return self.search_read(domain, ['id', 'name', 'public'], limit=limit)
|
|
|
|
@api.model
|
|
def channel_fetch_listeners(self, uuid):
|
|
""" Return the id, name and email of partners listening to the given channel """
|
|
self._cr.execute("""
|
|
SELECT P.id, P.name, P.email
|
|
FROM mail_channel_partner CP
|
|
INNER JOIN res_partner P ON CP.partner_id = P.id
|
|
INNER JOIN mail_channel C ON CP.channel_id = C.id
|
|
WHERE C.uuid = %s""", (uuid,))
|
|
return self._cr.dictfetchall()
|
|
|
|
@api.multi
|
|
def channel_fetch_preview(self):
|
|
""" Return the last message of the given channels """
|
|
self._cr.execute("""
|
|
SELECT mail_channel_id AS id, MAX(mail_message_id) AS message_id
|
|
FROM mail_message_mail_channel_rel
|
|
WHERE mail_channel_id IN %s
|
|
GROUP BY mail_channel_id
|
|
""", (tuple(self.ids),))
|
|
channels_preview = dict((r['message_id'], r) for r in self._cr.dictfetchall())
|
|
last_messages = self.env['mail.message'].browse(channels_preview).message_format()
|
|
for message in last_messages:
|
|
channel = channels_preview[message['id']]
|
|
del(channel['message_id'])
|
|
channel['last_message'] = message
|
|
return list(channels_preview.values())
|
|
|
|
#------------------------------------------------------
|
|
# Commands
|
|
#------------------------------------------------------
|
|
@api.model
|
|
@ormcache()
|
|
def get_mention_commands(self):
|
|
""" Returns the allowed commands in channels """
|
|
commands = []
|
|
for n in dir(self):
|
|
match = re.search('^_define_command_(.+?)$', n)
|
|
if match:
|
|
command = getattr(self, n)()
|
|
command['name'] = match.group(1)
|
|
commands.append(command)
|
|
return commands
|
|
|
|
@api.multi
|
|
def execute_command(self, command='', **kwargs):
|
|
""" Executes a given command """
|
|
self.ensure_one()
|
|
command_callback = getattr(self, '_execute_command_' + command, False)
|
|
if command_callback:
|
|
command_callback(**kwargs)
|
|
|
|
def _send_transient_message(self, partner_to, content):
|
|
""" Notifies partner_to that a message (not stored in DB) has been
|
|
written in this channel """
|
|
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_to.id), {
|
|
'body': "<span class='o_mail_notification'>" + content + "</span>",
|
|
'channel_ids': [self.id],
|
|
'info': 'transient_message',
|
|
})
|
|
|
|
def _define_command_help(self):
|
|
return {'help': _("Show an helper message")}
|
|
|
|
def _execute_command_help(self, **kwargs):
|
|
partner = self.env.user.partner_id
|
|
if self.channel_type == 'channel':
|
|
msg = _("You are in channel <b>#%s</b>.") % self.name
|
|
if self.public == 'private':
|
|
msg += _(" This channel is private. People must be invited to join it.")
|
|
else:
|
|
channel_partners = self.env['mail.channel.partner'].search([('partner_id', '!=', partner.id), ('channel_id', '=', self.id)])
|
|
msg = _("You are in a private conversation with <b>@%s</b>.") % (channel_partners[0].partner_id.name if channel_partners else _('Anonymous'))
|
|
msg += _("""<br><br>
|
|
You can mention someone by typing <b>@username</b>, this will grab its attention.<br>
|
|
You can mention a channel by typing <b>#channel</b>.<br>
|
|
You can execute a command by typing <b>/command</b>.<br>
|
|
You can insert canned responses in your message by typing <b>:shortcut</b>.<br>""")
|
|
|
|
self._send_transient_message(partner, msg)
|
|
|
|
def _define_command_leave(self):
|
|
return {'help': _("Leave this channel")}
|
|
|
|
def _execute_command_leave(self, **kwargs):
|
|
if self.channel_type == 'channel':
|
|
self.action_unfollow()
|
|
else:
|
|
self.channel_pin(self.uuid, False)
|
|
|
|
def _define_command_who(self):
|
|
return {
|
|
'channel_types': ['channel', 'chat'],
|
|
'help': _("List users in the current channel")
|
|
}
|
|
|
|
def _execute_command_who(self, **kwargs):
|
|
partner = self.env.user.partner_id
|
|
members = [
|
|
'<a href="#" data-oe-id='+str(p.id)+' data-oe-model="res.partner">@'+p.name+'</a>'
|
|
for p in self.channel_partner_ids[:30] if p != partner
|
|
]
|
|
if len(members) == 0:
|
|
msg = _("You are alone in this channel.")
|
|
else:
|
|
dots = "..." if len(members) != len(self.channel_partner_ids) - 1 else ""
|
|
msg = _("Users in this channel: %s %s and you.") % (", ".join(members), dots)
|
|
|
|
self._send_transient_message(partner, msg)
|