[IMP] mail: add moderation on channels

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.
This commit is contained in:
Mathieu Duckaerts-Antoine
2018-06-06 16:01:33 +02:00
committed by Thibault Delavallée
parent eeab18d753
commit 299ebb2cdf
28 changed files with 2347 additions and 91 deletions
+1
View File
@@ -32,6 +32,7 @@
'views/mail_templates.xml',
'wizard/email_template_preview_view.xml',
'views/mail_template_views.xml',
'views/mail_moderation_views.xml',
'views/ir_actions_views.xml',
'views/ir_model_views.xml',
'views/res_partner_views.xml',
+3
View File
@@ -228,5 +228,8 @@ class MailController(http.Controller):
'mention_partner_suggestions': request.env['res.partner'].get_static_mention_suggestions(),
'shortcodes': request.env['mail.shortcode'].sudo().search_read([], ['source', 'substitution', 'description']),
'menu_id': request.env['ir.model.data'].xmlid_to_res_id('mail.mail_channel_menu_root_chat'),
'is_moderator': request.env.user.is_moderator,
'moderation_counter': request.env.user.moderation_counter,
'moderation_channel_ids': request.env.user.moderation_channel_ids.ids,
}
return values
+14 -1
View File
@@ -26,5 +26,18 @@
<field eval="False" name="doall" />
<field name="priority">1000</field>
</record>
<record id="ir_cron_mail_notify_channel_moderators" model="ir.cron">
<field name="name">Mail: Notify channel moderators</field>
<field name="model_id" ref="model_mail_message"/>
<field name="state">code</field>
<field name="code">model._notify_moderators</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
<field name="priority">1000</field>
</record>
</data>
</odoo>
</odoo>
+32
View File
@@ -226,5 +226,37 @@
</td></tr>
</table>
</template>
<!-- Channel and moderation related data -->
<template id="mail_channel_notify_moderation">
<div>
<p>Hello <t t-esc='record.name'/></p>
<p>You have messages to moderate, please go for the proceedings.</p><br/><br/>
<div style="text-align: center;">
<a href="/web#action=mail.mail_channel_action_client_chat&amp;active_id=channel_moderation" style="background-color: #1abc9c; padding: 20px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;" class="o_default_snippet_text">Moderate Messages</a>
<br/><br/><br/>
</div>
<p>Thank you!</p>
</div>
</template>
<record id="mail_template_channel_send_guidelines" model="mail.template">
<field name="name">Partner: Send channel guidelines</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="email_from">${object.company_id.catchall or object.company_id.email|safe}</field>
<field name="partner_to">${object.id}</field>
<field name="subject">Guidelines of channel ${ctx['channel'].name}</field>
<field name="body_html" type="xml">
<div>
<p>Hello ${object.name or ''},</p>
<p>Please find below the guidelines of the ${ctx['channel'].name} channel.</p>
<p>
${ctx['channel'].moderation_guidelines_msg}
</p>
<p></p>
</div>
</field>
<field name="user_signature" eval="False"/>
</record>
</data>
</odoo>
+1
View File
@@ -14,6 +14,7 @@ from . import mail_template
from . import mail_shortcode
from . import res_partner
from . import res_users
from . import res_company
from . import res_config_settings
from . import update
from . import ir_actions
+212 -8
View File
@@ -1,17 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from email.utils import formataddr
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
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
from odoo.tools import ormcache
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'
@@ -28,6 +33,22 @@ class ChannelPartner(models.Model):
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. """
@@ -90,12 +111,61 @@ class Channel(models.Model):
"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([
@@ -113,6 +183,26 @@ class Channel(models.Model):
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
@@ -154,10 +244,25 @@ class Channel(models.Model):
@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):
@@ -240,12 +345,54 @@ class Channel(models.Model):
}
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('self', lambda value: value.id)
def message_post(self, **kwargs):
# auto pin 'direct_message' channel partner
@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(**kwargs)
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):
@@ -264,6 +411,58 @@ class Channel(models.Model):
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
#------------------------------------------------------
@@ -347,6 +546,8 @@ class Channel(models.Model):
'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:
@@ -572,6 +773,9 @@ class Channel(models.Model):
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
+278 -17
View File
@@ -107,6 +107,13 @@ class Message(models.Model):
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')
@api.multi
def _get_needaction(self):
@@ -138,6 +145,23 @@ class Message(models.Model):
return [('starred_partner_ids', 'in', [self.env.user.partner_id.id])]
return [('starred_partner_ids', 'not in', [self.env.user.partner_id.id])]
@api.multi
def _compute_need_moderation(self):
for message in self:
self.need_moderation = False
@api.model
def _search_need_moderation(self, operator, operand):
if operator == '=' and operand:
return ['&', '&',
('moderation_status', '=', 'pending_moderation'),
('model', '=', 'mail.channel'),
('res_id', 'in', self.env.user.moderation_channel_ids.ids)]
return ['|', '|',
('moderation_status', '!=', 'pending_moderation'),
('model', '!=', 'mail.channel'),
('res_id', 'not in', self.env.user.moderation_channel_ids.ids)]
#------------------------------------------------------
# Notification API
#------------------------------------------------------
@@ -395,6 +419,7 @@ class Message(models.Model):
'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([
@@ -403,6 +428,7 @@ class Message(models.Model):
'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',
])
message_tree = dict((m.id, m) for m in self.sudo())
self._message_read_dict_postprocess(message_values, message_tree)
@@ -474,8 +500,7 @@ class Message(models.Model):
- if author_id == pid, uid is the author, OR
- uid belongs to a notified channel, OR
- uid is in the specified recipients, OR
- uid has a notification on the message, OR
- uid have read access to the related document is model, res_id
- uid has a notification on the message
- otherwise: remove the id
"""
# Rules do not apply to administrator
@@ -517,6 +542,7 @@ class Message(models.Model):
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, partner_id, channel_id in self._cr.fetchall():
if author_id == pid:
@@ -558,10 +584,12 @@ class Message(models.Model):
- write: if
- author_id == pid, uid is the author, OR
- uid is in the recipients (partner_ids) OR
- uid has write or create access on the related document if model, res_id
- 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 has write or create access on the related document if model, res_id
- 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
@@ -595,9 +623,9 @@ class Message(models.Model):
# Read mail_message.ids to have their values
message_values = dict((res_id, {}) for res_id in self.ids)
if operation in ['read', 'write']:
if operation == 'read':
self._cr.execute("""
SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id,
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
FROM "%s" m
@@ -612,24 +640,84 @@ class Message(models.Model):
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 in self._cr.fetchall():
for mid, rmod, rid, author_id, parent_id, partner_id, channel_id, moderation_status 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))
}
else:
self._cr.execute("""SELECT DISTINCT id, model, res_id, author_id, parent_id FROM "%s" WHERE id = ANY (%%s)""" % self._table, (self.ids,))
for mid, rmod, rid, author_id, parent_id in self._cr.fetchall():
message_values[mid] = {'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id}
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
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 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))
}
elif operation == 'create':
self._cr.execute("""SELECT DISTINCT id, model, res_id, author_id, parent_id, moderation_status FROM "%s" WHERE id = ANY (%%s)""" % self._table, (self.ids,))
for mid, rmod, rid, author_id, parent_id, moderation_status 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
}
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
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 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
}
# Author condition (READ, WRITE, CREATE (private))
author_ids = []
if operation == 'read' or operation == 'write':
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 message.get('model') and not message.get('res_id')]
@@ -654,8 +742,13 @@ class Message(models.Model):
notified_ids += [mid for mid, message in message_values.items()
if message.get('parent_id') in not_parent_ids]
# 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')]
# Recipients condition, for read and write (partner_ids) and create (message_follower_ids)
other_ids = set(self.ids).difference(set(author_ids), set(notified_ids))
other_ids = set(self.ids).difference(set(author_ids), set(notified_ids), set(moderator_ids))
model_record_ids = _generate_model_record_ids(message_values, other_ids)
if operation in ['read', 'write']:
notified_ids = [mid for mid, message in message_values.items() if message.get('notified')]
@@ -681,8 +774,13 @@ class Message(models.Model):
DocumentModel.check_mail_message_access(mids.ids, operation) # ?? mids ?
else:
self.env['mail.thread'].check_mail_message_access(mids.ids, operation, model_name=model)
document_related_ids += [mid for mid, message in message_values.items()
if message.get('model') == model and message.get('res_id') in mids.ids]
if operation in ['write', 'unlink']:
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('moderation_status') != 'pending_moderation']
else:
document_related_ids += [mid for mid, message in message_values.items()
if message.get('model') == model and message.get('res_id') in mids.ids]
# Calculate remaining ids: if not void, raise an error
other_ids = other_ids.difference(set(document_related_ids))
@@ -841,7 +939,6 @@ class Message(models.Model):
# remove author from notified partners
if not self._context.get('mail_notify_author', False) and self_sudo.author_id:
partners_sudo = partners_sudo - self_sudo.author_id
# update message, with maybe custom values
message_values = {}
if channels_sudo:
@@ -858,6 +955,7 @@ class Message(models.Model):
# have no access to partner model. Maybe propagating a real uid could be necessary.
email_channels = channels_sudo.filtered(lambda channel: channel.email_send)
notif_partners = partners_sudo.filtered(lambda partner: 'inbox' in partner.mapped('user_ids.notification_type'))
if email_channels or partners_sudo - notif_partners:
partners_sudo.search([
'|',
@@ -867,7 +965,170 @@ class Message(models.Model):
])._notify(self, layout=layout, force_send=force_send, send_after_commit=send_after_commit, values=values)
notif_partners._notify_by_chat(self)
channels_sudo._notify(self)
return True
# --------------------------------------------------
# Moderation
# --------------------------------------------------
@api.multi
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)
@api.multi
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:
message._notify()
@api.multi
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.user.company_id.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)
@api.multi
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
@api.multi
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(
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,
)
+14 -10
View File
@@ -1294,7 +1294,7 @@ class MailThread(models.AbstractModel):
post_params['model'] = model
new_msg = thread.message_post(**post_params)
if original_partner_ids:
if new_msg and original_partner_ids:
# postponed after message_post, because this is an external message and we don't want to create
# duplicate emails due to notifications
new_msg.write({'partner_ids': original_partner_ids})
@@ -1869,7 +1869,7 @@ class MailThread(models.AbstractModel):
return m2m_attachment_ids
@api.multi
@api.returns('self', lambda value: value.id)
@api.returns('mail.message', lambda value: value.id)
def message_post(self, body='', subject=None,
message_type='notification', subtype=None,
parent_id=False, attachments=None,
@@ -1891,6 +1891,7 @@ class MailThread(models.AbstractModel):
to the related document. Should only be set by Chatter.
:return int: ID of newly created mail.message
"""
if attachments is None:
attachments = {}
if self.ids and not self.ensure_one():
@@ -1998,15 +1999,18 @@ class MailThread(models.AbstractModel):
message and computed value are given, to try to lessen query count by
using already-computed values instead of having to rebrowse things. """
# Notify recipients of the newly-created message (Inbox / Email + channels)
message._notify(
layout=notif_layout,
force_send=self.env.context.get('mail_notify_force_send', True),
values=notif_values,
)
if values.get('moderation_status') != 'pending_moderation':
message._notify(
layout=notif_layout,
force_send=self.env.context.get('mail_notify_force_send', True),
values=notif_values,
)
# Post-process: subscribe author
if values['author_id'] and values['model'] and self.ids and values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'):
self._message_subscribe([values['author_id']])
# Post-process: subscribe author
if values['author_id'] and values['model'] and self.ids and values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'):
self._message_subscribe([values['author_id']])
else:
message._notify_pending_by_chat()
@api.multi
def message_post_with_view(self, views_or_xmlid, **kwargs):
+23
View File
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields
class Company(models.Model):
_name = 'res.company'
_inherit = 'res.company'
catchall = fields.Char(string="Catchall Email", compute="_compute_catchall")
@api.multi
def _compute_catchall(self):
ConfigParameter = self.env['ir.config_parameter'].sudo()
alias = ConfigParameter.get_param('mail.catchall.alias')
domain = ConfigParameter.get_param('mail.catchall.domain')
if alias and domain:
for company in self:
company.catchall = '%s@%s' % (alias, domain)
else:
for company in self:
company.catchall = ''
+34 -1
View File
@@ -11,7 +11,7 @@ class Users(models.Model):
- make a new user follow itself
- add a welcome message
- add suggestion preference
- if adding groups to an user, check mail.channels linked to this user
- if adding groups to a user, check mail.channels linked to this user
group, and the user. This is done by overriding the write method.
"""
_name = 'res.users'
@@ -31,6 +31,39 @@ class Users(models.Model):
help="Policy on how to handle Chatter notifications:\n"
"- Handle by Emails: notifications are sent to your email address\n"
"- Handle in Odoo: notifications appear in your Odoo Inbox")
# channel-specific: moderation
is_moderator = fields.Boolean(string='Is moderator', compute='_compute_is_moderator')
moderation_counter = fields.Integer(string='Moderation count', compute='_compute_moderation_counter')
moderation_channel_ids = fields.Many2many(
'mail.channel', 'mail_channel_moderator_rel',
string='Moderated channels')
@api.depends('moderation_channel_ids.moderation', 'moderation_channel_ids.moderator_ids')
@api.multi
def _compute_is_moderator(self):
moderated = self.env['mail.channel'].search([
('id', 'in', self.mapped('moderation_channel_ids').ids),
('moderation', '=', True),
('moderator_ids', 'in', self.ids)
])
user_ids = moderated.mapped('moderator_ids')
for user in self:
user.is_moderator = user in user_ids
@api.multi
def _compute_moderation_counter(self):
self._cr.execute("""
SELECT channel_moderator.res_users_id, COUNT(msg.id)
FROM "mail_channel_moderator_rel" AS channel_moderator
JOIN "mail_message" AS msg
ON channel_moderator.mail_channel_id = msg.res_id
AND channel_moderator.res_users_id IN %s
AND msg.model = 'mail.channel'
AND msg.moderation_status = 'pending_moderation'
GROUP BY channel_moderator.res_users_id""", [tuple(self.ids)])
result = dict(self._cr.fetchall())
for user in self:
user.moderation_counter = result.get(user.id, 0)
def __init__(self, pool, cr):
""" Override of __init__ to add access rights on notification_email_send
+1
View File
@@ -18,6 +18,7 @@ access_mail_channel_user,mail.group.user,model_mail_channel,base.group_user,1,1,
access_mail_channel_partner_public,mail.channel.partner.public,model_mail_channel_partner,base.group_public,1,0,0,0
access_mail_channel_partner_portal,mail.channel.partner.portal,model_mail_channel_partner,base.group_portal,1,1,1,1
access mail_channel_partner_user,mail.channel.partner.user,model_mail_channel_partner,base.group_user,1,1,1,1
access_mail_moderation_user,mail.moderation.user,model_mail_moderation,base.group_user,1,1,1,1
access_mail_alias_all,mail.alias.all,model_mail_alias,,1,0,0,0
access_mail_alias_user,mail.alias.user,model_mail_alias,base.group_user,1,1,1,1
access_mail_alias_system,mail.alias.system,model_mail_alias,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
18 access_mail_channel_partner_public mail.channel.partner.public model_mail_channel_partner base.group_public 1 0 0 0
19 access_mail_channel_partner_portal mail.channel.partner.portal model_mail_channel_partner base.group_portal 1 1 1 1
20 access mail_channel_partner_user mail.channel.partner.user model_mail_channel_partner base.group_user 1 1 1 1
21 access_mail_moderation_user mail.moderation.user model_mail_moderation base.group_user 1 1 1 1
22 access_mail_alias_all mail.alias.all model_mail_alias 1 0 0 0
23 access_mail_alias_user mail.alias.user model_mail_alias base.group_user 1 1 1 1
24 access_mail_alias_system mail.alias.system model_mail_alias base.group_system 1 1 1 1
+5
View File
@@ -51,5 +51,10 @@
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_moderation_rule_user" model="ir.rule">
<field name="name">White/Black List: moderators: moderated channels only</field>
<field name="model_id" ref="model_mail_moderation"/>
<field name="domain_force">[('channel_id.moderator_ids', 'in', user.id)]</field>
</record>
</data>
</odoo>
+327 -32
View File
@@ -106,10 +106,73 @@ var PartnerInviteDialog = Dialog.extend({
},
});
/**
* Widget : Moderator reject message dialog
*
* Popup containing message title and reject message body.
* This let the moderator provide a reason for rejecting the messages.
*/
var ModeratorRejectMessageDialog = Dialog.extend({
template: "mail.ModeratorRejectMessageDialog",
/**
* @override
* @param {web.Widget} parent
* @param {Object} params
* @param {integer[]} params.messageIDs list of message IDs to send
* 'reject' decision reason
* @param {function} params.proceedReject a function to call when the
* moderator confirms the reason for rejecting the messages. This
* function passes an object as the reason for reject, which is
* structured as follow:
*
* {
* title: <string>,
* comment: <string>,
* }
*/
init: function (parent, params) {
this._messageIDs = params.messageIDs;
this._proceedReject = params.proceedReject;
this._super(parent, {
title: _t('Send explanation to author'),
size: "medium",
buttons: [{
text: _t("Send"),
close: true,
classes: "btn-primary",
click: _.bind(this._onSendClicked, this),
}],
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when the moderator would like to submit reason for rejecting the
* messages.
*
* @private
*/
_onSendClicked: function () {
var title = this.$('#message_title').val();
var comment = this.$('#reject_message').val();
if (title && comment) {
this._proceedReject({
title: title,
comment: comment
});
}
},
});
var Discuss = AbstractAction.extend(ControlPanelMixin, {
template: 'mail.discuss',
custom_events: {
message_moderation: '_onMessageModeration',
search: '_onSearch',
update_moderation_buttons: '_onUpdateModerationButtons',
},
events: {
'blur .o_mail_add_channel input': '_onAddChannelBlur',
@@ -239,6 +302,49 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
// Private
//--------------------------------------------------------------------------
/**
* Ban the authors of the messages with ID in `messageIDs`
* Show a confirmation dialog to the moderator.
*
* @private
* @param {integer[]} messageIDs IDs of messages for which we should ban authors
*/
_banAuthorsFromMessageIDs: function (messageIDs) {
var self = this;
var emailList = _.map(messageIDs, function (messageID) {
return self.call('chat_manager', 'getMessage', messageID).email_from;
}).join(", ");
var text = _.str.sprintf(_t("You are going to ban: %s. Do you confirm the action?"), emailList);
var options = {
confirm_callback: function () {
self._moderateMessages(messageIDs, 'ban');
}
};
Dialog.confirm(this, text, options);
},
/**
* Discard the messages with ID in `messageIDs`
* Show a confirmation dialog to the moderator.
*
* @private
* @param {integer[]]} messageIDs list of message IDs to discard
*/
_discardMessages: function (messageIDs) {
var self = this;
var num = messageIDs.length;
var text;
if (num > 1) {
text = _.str.sprintf(_t("You are going to discard %s messages. Do you confirm the action?"), num);
} else if (num === 1) {
text = _t("You are going to discard 1 message. Do you confirm the action?");
}
var options = {
confirm_callback: function () {
self._moderateMessages(messageIDs, 'discard');
}
};
Dialog.confirm(this, text, options);
},
/**
* @private
* @returns {$.Promise}
@@ -278,11 +384,35 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
squash_close_messages: this.channel.type !== 'static' && !this.channel.mass_mailing,
display_empty_channel: !messages.length && !this.domain.length,
display_no_match: !messages.length && this.domain.length,
display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox",
display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox" || this.channel.id === "channel_moderation",
display_email_icon: false,
display_reply_icon: true,
};
},
/**
* Determine the action to apply on messages with ID in `messageIDs`
* based on the moderation decision `decision`.
*
* @private
* @param {number[]} messageIDs list of message ids that are moderated
* @param {string} decision of the moderator, could be either 'reject',
* 'discard', 'ban', 'accept', 'allow'.
*/
_handleModerationDecision: function (messageIDs, decision) {
if (messageIDs) {
if (decision === 'reject') {
this._rejectMessages(messageIDs);
} else if (decision === 'discard') {
this._discardMessages(messageIDs);
} else if (decision === 'ban') {
this._banAuthorsFromMessageIDs(messageIDs);
} else {
// other decisions do not need more information,
// confirmation dialog, etc.
this._moderateMessages(messageIDs, decision);
}
}
},
/**
* Ensures that enough messages have been loaded to fill the entire screen
* (this is particularily important because remaining messages can only be
@@ -325,6 +455,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
self.thread.scroll_to({offset: offset});
});
},
/**
* Apply the moderation decision `decision` on the messages with ID in
* `messageIDs`.
*
* @private
* @param {integer[]} messageIDs list of message IDs to apply the
* moderation decision.
* @param {string} decision the moderation decision to apply on the
* messages. Could be either 'reject', 'discard', 'ban', 'accept',
* or 'allow'.
* @param {Object|undefined} [kwargs] optional data to pass on
* message moderation. This is provided when rejecting the messages
* for which title and comment give reason(s) for reject.
* @param {string} [kwargs.title]
* @param {string} [kwargs.comment]
* @return {undefined|$.Promise}
*/
_moderateMessages: function (messageIDs, decision, kwargs) {
if (messageIDs.length && decision) {
return this._rpc({
model: 'mail.message',
method: 'moderate',
args: [messageIDs, decision],
kwargs: kwargs
});
}
},
/**
* Binds handlers on the given $input to make them autocomplete and/or
* create channels.
@@ -393,6 +550,24 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
});
}
},
/**
* Reject the messages
*
* The moderator must provide a reason for reject, and may also
* cancel his action.
*
* @private
* @param {number[]]} messageIDs list of message IDs to reject
*/
_rejectMessages: function (messageIDs) {
var self = this;
new ModeratorRejectMessageDialog(this, {
messageIDs: messageIDs,
proceedReject: function (reason) {
self._moderateMessages(messageIDs, 'reject', reason);
}
}).open();
},
/**
* @private
*/
@@ -402,6 +577,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this.$buttons.on('click', '.o_mail_chat_button_invite', this._onInviteButtonClicked.bind(this));
this.$buttons.on('click', '.o_mail_chat_button_mark_read', this._onMarkAllReadClicked.bind(this));
this.$buttons.on('click', '.o_mail_chat_button_unstar_all', this._onUnstarAllClicked.bind(this));
this.$buttons.on('click', '.o_mail_chat_button_moderate_all', this._onModerateAllClicked.bind(this));
this.$buttons.on('click', '.o_mail_chat_button_select_all', this._onSelectAllClicked.bind(this));
this.$buttons.on('click', '.o_mail_chat_button_unselect_all', this._onUnselectAllClicked.bind(this));
},
/**
* @private
@@ -454,25 +632,23 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @returns {Deferred}
*/
_renderThread: function () {
var self = this;
this.thread = new ChatThread(this, {display_help: true, loadMoreOnScroll: true});
this.thread.on('redirect', this, function (resModel, resID) {
self.call('chat_manager', 'redirect', resModel, resID, self._setChannel.bind(self));
this.call('chat_manager', 'redirect', resModel, resID, this._setChannel.bind(this));
});
this.thread.on('redirect_to_channel', this, function (channelID) {
self.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this));
this.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this));
});
this.thread.on('load_more_messages', this, this._loadMoreMessages);
this.thread.on('mark_as_read', this, function (messageID) {
self.call('chat_manager', 'markAsRead', [messageID]);
this.call('chat_manager', 'markAsRead', [messageID]);
});
this.thread.on('toggle_star_status', this, function (messageID) {
self.call('chat_manager', 'toggleStarStatus', messageID);
this.call('chat_manager', 'toggleStarStatus', messageID);
});
this.thread.on('select_message', this, this._selectMessage);
this.thread.on('unselect_message', this, this._unselectMessage);
return this.thread.appendTo(this.$('.o_mail_chat_content'));
},
/**
@@ -612,6 +788,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
chatBus.on('update_channel_unread_counter', this, this.throttledUpdateChannels);
chatBus.on('update_dm_presence', this, this.throttledUpdateChannels);
chatBus.on('activity_updated', this, this.throttledUpdateChannels);
chatBus.on('update_moderation_counter', this, this.throttledUpdateChannels);
},
/**
* Stores the scroll position and composer state of the current channel
@@ -663,6 +840,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
channels: this.call('chat_manager', 'getChannels'),
needaction_counter: this.call('chat_manager', 'getNeedactionCounter'),
starred_counter: this.call('chat_manager', 'getStarredCounter'),
moderationCounter: this.call('chat_manager', 'getModerationCounter'),
isModerator: this.call('chat_manager', 'isModerator'),
});
this.$(".o_mail_chat_sidebar").html($sidebar.contents());
_.each(['dm', 'public', 'private'], function (type) {
@@ -694,6 +873,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
.find('.o_mail_chat_button_unstar_all')
.toggleClass('disabled', disabled);
}
if ((this.channel.isModerated && this.channel.isModerator) || this.channel.id === "channel_moderation") {
this._updateModerationButtons();
}
},
/**
* @private
@@ -727,13 +909,69 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
.find('.o_mail_chat_button_unstar_all')
.toggle(channel.id === "channel_starred")
.removeClass("o_hidden");
this.$buttons
.find('.o_mail_chat_button_select_all')
.toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation")
.removeClass("o_hidden");
this.$buttons
.find('.o_mail_chat_button_unselect_all')
.toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation")
.removeClass("o_hidden");
this.$buttons.find('.o_mail_chat_button_moderate_all').hide();
this.$('.o_mail_chat_channel_item')
.removeClass('o_active')
.filter('[data-channel-id=' + channel.id + ']')
.removeClass('o_unread_message')
.addClass('o_active');
},
/**
* Update the moderation buttons.
*
* @private
*/
_updateModerationButtons: function () {
this._updateSelectUnselectAllButtons();
this._updateModerationDecisionButton();
},
/**
* Display/hide the "moderate all" button based on whether
* some moderation checkboxes are checked or not.
* If some checkboxes are checked, display this button,
* otherwise hide it.
*
* @private
*/
_updateModerationDecisionButton: function () {
if (this.thread.$('.moderation_checkbox:checked').length) {
this.$buttons.find('.o_mail_chat_button_moderate_all').show();
} else {
this.$buttons.find('.o_mail_chat_button_moderate_all').hide();
}
},
/**
* @private
*/
_updateSelectUnselectAllButtons: function () {
var buttonSelect = this.$buttons.find('.o_mail_chat_button_select_all');
var buttonUnselect = this.$buttons.find('.o_mail_chat_button_unselect_all');
var numCheckboxes = this.thread.$('.moderation_checkbox').length;
var numCheckboxesChecked = this.thread.$('.moderation_checkbox:checked').length;
if (numCheckboxes) {
if (numCheckboxesChecked === numCheckboxes) {
buttonSelect.toggleClass('disabled', true);
buttonUnselect.toggleClass('disabled', false);
} else if (numCheckboxesChecked === 0) {
buttonSelect.toggleClass('disabled', false);
buttonUnselect.toggleClass('disabled', true);
} else {
buttonSelect.toggleClass('disabled', false);
buttonUnselect.toggleClass('disabled', false);
}
} else {
buttonSelect.toggleClass('disabled', true);
buttonUnselect.toggleClass('disabled', true);
}
},
//--------------------------------------------------------------------------
// Handlers
@@ -778,6 +1016,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this._updateChannels();
delete this.channelsScrolltop[channelID];
},
/**
* @private
*/
_onCloseNotificationBar: function () {
this.$(".o_mail_annoying_notification_bar").slideUp();
},
/**
* @private
*/
@@ -788,6 +1032,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
composer.mention_set_enabled_commands(commands);
composer.mention_set_prefetched_partners(partners);
},
/**
* @private
*/
_onInviteButtonClicked: function () {
var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name);
new PartnerInviteDialog(this, title, this.channel.id).open();
},
/**
* @private
* @param {KeyEvent} event
*/
_onKeydown: function (event) {
if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) {
this._unselectMessage();
}
},
/**
* @private
*/
@@ -803,6 +1063,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
var self = this;
var currentChannelID = this.channel.id;
if ((currentChannelID === "channel_starred" && !message.is_starred) ||
(currentChannelID === "channel_moderation" && !message.needsModeration) ||
(currentChannelID === "channel_inbox" && !message.is_needaction)) {
this.call('chat_manager', 'getMessages', {
channelID: this.channel.id,
@@ -814,10 +1075,34 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
self._updateButtonStatus(messages.length === 0, type);
});
});
} else if (_.contains(message.channel_ids, currentChannelID)) {
} else if (_.contains(message.channel_ids, currentChannelID) || (message.res_id === currentChannelID)) {
this._fetchAndRenderThread();
}
},
/**
* @private
* @param {MouseEvent} ev
*/
_onModerateAllClicked: function (ev) {
var decision = $(ev.target).data('decision');
var messageIDs = this.thread.$('.moderation_checkbox:checked')
.map(function () {
return $(this).data('message-id');
})
.get();
this._handleModerationDecision(messageIDs, decision);
},
/**
* @private
* @param {OdooEvent} ev
* @param {integer} ev.data.messageID ID of the moderated message
* @param {string} ev.data.decision can be 'reject', 'discard', 'ban', 'accept', 'allow'.
*/
_onMessageModeration: function (ev) {
var messageIDs = [ev.data.messageID];
var decision = ev.data.decision;
this._handleModerationDecision(messageIDs, decision);
},
/**
* @private
* @param {Object} channel
@@ -872,7 +1157,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
}
})
.fail(function () {
// todo: display notification
// todo: display notifications
});
},
/**
@@ -889,28 +1174,6 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
on_reverse_breadcrumb: this.on_reverse_breadcrumb,
});
},
/**
* @private
*/
_onCloseNotificationBar: function () {
this.$(".o_mail_annoying_notification_bar").slideUp();
},
/**
* @private
*/
_onInviteButtonClicked: function () {
var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name);
new PartnerInviteDialog(this, title, this.channel.id).open();
},
/**
* @private
* @param {KeyEvent} event
*/
_onKeydown: function (event) {
if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) {
this._unselectMessage();
}
},
/**
* @private
* @param {MouseEvent} event
@@ -950,6 +1213,17 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
// this will be done as soon as the default channel is set
this._fetchAndRenderThread();
}
},
/**
* @private
* @param {MouseEvent} ev
*/
_onSelectAllClicked: function (ev) {
var $button = $(ev.target);
if (!$button.hasClass('disabled')) {
this.thread.toggleModerationCheckboxes(true);
this._updateModerationButtons();
}
},
/**
* @private
@@ -975,12 +1249,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
var channel = this.call('chat_manager', 'getChannel', channelID);
this.call('chat_manager', 'unsubscribe', channel);
},
/**
* @private
* @param {MouseEvent} ev
*/
_onUnselectAllClicked: function (ev) {
var $button = $(ev.target);
if (!$button.hasClass('disabled')) {
this.thread.toggleModerationCheckboxes(false);
this._updateModerationButtons();
}
},
/**
* @private
*/
_onUnstarAllClicked: function () {
this.call('chat_manager', 'unstarAll');
},
/**
* Update the moderation buttons.
* This is triggered when a moderation checkbox
* has its checked property changed.
*
* @private
*/
_onUpdateModerationButtons: function () {
this._updateModerationButtons();
},
});
core.action_registry.add('mail.chat.instant_messaging', Discuss);
@@ -125,6 +125,8 @@ var ChatManager = AbstractService.extend({
this.emojis = [];
this.needactionCounter = 0;
this.starredCounter = 0;
this.moderationCounter = 0;
this.moderatedChannelIDs = [];
this.mentionPartnerSuggestions = [];
this.cannedResponses = [];
this.commands = [];
@@ -521,6 +523,14 @@ var ChatManager = AbstractService.extend({
}
return $.when([]);
},
/**
* Returns the number of messages that the user needs to moderate
*
* @return {integer}
*/
getModerationCounter: function () {
return this.moderationCounter;
},
/**
* Returns the number of messages received from followed channels
* + all messages where the current user is notified.
@@ -556,6 +566,12 @@ var ChatManager = AbstractService.extend({
isAllHistoryLoaded: function (channel, domain) {
return this._getChannelCache(channel, domain).all_history_loaded;
},
/**
* @return {boolean}
*/
isModerator: function () {
return this._isModerator;
},
/**
* @return {$.Promise}
*/
@@ -733,7 +749,7 @@ var ChatManager = AbstractService.extend({
subtype: 'mail.mt_comment',
command: data.command,
}),
});
});
}
if ('model' in options && 'res_id' in options) {
// post a message in a chatter
@@ -1034,6 +1050,15 @@ var ChatManager = AbstractService.extend({
}
} else if (options.domain && options.domain !== []) {
this._addToCache(msg, options.domain);
} else if (data.moderation_status === 'accepted') {
msg.channel_ids = data.channel_ids;
msg.needsModeration = false;
if (msg.isModerator) {
this.moderationCounter--;
this._removeMessageFromChannel("channel_moderation", msg);
this.chatBus.trigger('update_moderation_counter');
}
this.chatBus.trigger('update_message', msg);
}
return msg;
},
@@ -1110,9 +1135,16 @@ var ChatManager = AbstractService.extend({
var self = this;
options = options || {};
var domain =
(channel.id === "channel_inbox") ? [['needaction', '=', true]] :
(channel.id === "channel_starred") ? [['starred', '=', true]] :
[['channel_ids', 'in', channel.id]];
(channel.id === 'channel_inbox') ? [['needaction', '=', true]] :
(channel.id === 'channel_starred') ? [['starred', '=', true]] :
(channel.id === 'channel_moderation') ? [['need_moderation', '=', true]] :
['|',
'&', '&',
['model', '=', 'mail.channel'],
['res_id', 'in', [channel.id]],
['need_moderation', '=', true],
['channel_ids', 'in', [channel.id]]
];
var cache = this._getChannelCache(channel, options.domain);
if (options.domain) {
@@ -1185,6 +1217,18 @@ var ChatManager = AbstractService.extend({
});
self.needactionCounter = result.needaction_inbox_counter || 0;
self.starredCounter = result.starred_counter || 0;
self.moderationCounter = result.moderation_counter;
self.moderatedChannelIDs = result.moderation_channel_ids;
self._isModerator = result.is_moderator;
//if user is moderator then add moderation channel
if (self._isModerator) {
self._addChannel({
id: "channel_moderation",
name: _lt("Moderate Messages"),
type: "static"
});
}
self.commands = _.map(result.commands, function (command) {
return _.extend({ id: command.name }, command);
});
@@ -1233,17 +1277,19 @@ var ChatManager = AbstractService.extend({
* @param {Object[]} [data.direct_partner]
* @param {boolean} [data.group_based_subscription]
* @param {integer|string} data.id
* @param {boolean} data.is_moderator
* @param {boolean} data.is_minimized
* @param {string} [data.last_message_date]
* @param {boolean} data.mass_mailing
* @param {integer} [data.message_needaction_counter]
* @param {integer} [data.message_unread_counter]
* @param {boolean} data.moderation whether this channel is moderated or not
* @param {string} data.name
* @param {string} [data.public]
* @param {integer} data.seen_message_id
* @param {string} [data.state]
* @param {string} [data.type]
* @param {string} channel.uuid
* @param {string} data.uuid
* @param {Object} options
* @param {boolean} [options.autoswitch]
* @param {boolean} options.displayNeedactions
@@ -1264,6 +1310,8 @@ var ChatManager = AbstractService.extend({
hidden: options.hidden,
display_needactions: options.displayNeedactions,
mass_mailing: data.mass_mailing,
isModerated: data.moderation,
isModerator: data.is_moderator,
group_based_subscription: data.group_based_subscription,
needaction_counter: data.message_needaction_counter || 0,
unread_counter: 0,
@@ -1314,6 +1362,7 @@ var ChatManager = AbstractService.extend({
* @param {boolean} data.is_note
* @param {boolean} data.is_notification
* @param {string} data.message_type
* @param {string} [data.moderation_status]
* @param {string} [data.model]
* @param {boolean} data.module_icon src url of the module icon
* @param {string} data.record_name
@@ -1333,6 +1382,9 @@ var ChatManager = AbstractService.extend({
message_type: data.message_type,
subtype_description: data.subtype_description,
is_author: data.author_id && data.author_id[0] === session.partner_id,
isModerator: data.model === 'mail.channel' &&
_.contains(this.moderatedChannelIDs, data.res_id) &&
data.moderation_status === 'pending_moderation',
is_note: data.is_note,
is_discussion: data.is_discussion,
is_notification: data.is_notification,
@@ -1384,6 +1436,7 @@ var ChatManager = AbstractService.extend({
Object.defineProperties(msg, {
is_starred: propertyDescr("channel_starred"),
is_needaction: propertyDescr("channel_inbox"),
needsModerationByUser: propertyDescr("channel_moderation"),
});
if (_.contains(data.needaction_partner_ids, session.partner_id)) {
@@ -1392,8 +1445,17 @@ var ChatManager = AbstractService.extend({
if (_.contains(data.starred_partner_ids, session.partner_id)) {
msg.is_starred = true;
}
if (data.moderation_status === 'pending_moderation') {
msg.needsModeration = true;
msg.needsModerationByUser = msg.isModerator;
// the message is not linked to the moderated channel on the
// server, therefore this message has not this channel in
// channel_ids. Here, just to show this message in the channel
//visually, it links this message to the channel
msg.channel_ids.push(msg.res_id);
}
if (msg.model === 'mail.channel') {
var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred');
var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred', 'channel_moderation');
var origin = realChannels.length === 1 ? realChannels[0] : undefined;
var channel = origin && this.getChannel(origin);
if (channel) {
@@ -1467,6 +1529,13 @@ var ChatManager = AbstractService.extend({
_manageActivityUpdateNotification: function (data) {
this.chatBus.trigger('activity_updated', data);
},
/**
* @private
* @param {Object} data
*/
_manageAuthorNotification: function (data) {
this._addMessage(data.message);
},
/**
* @private
* @param {Object} message
@@ -1545,6 +1614,27 @@ var ChatManager = AbstractService.extend({
}
}
},
/**
* @private
* @param {Object} data
* @param {Object[]} [data.message_ids]
*/
_manageDeletionNotification: function (data) {
var self = this;
_.each(data.message_ids, function (msgID) {
var message = _.findWhere(self.messages, { id: msgID });
if (message) {
if (message.isModerator) {
self._removeMessageFromChannel("channel_moderation", message);
self.moderationCounter--;
}
message.needsModeration = false;
self._removeMessageFromChannel(message.res_id, message);
self.chatBus.trigger('update_message', message);
}
});
this.chatBus.trigger('update_moderation_counter');
},
/**
* Updates channel_inbox when a message has marked as read.
*
@@ -1579,6 +1669,16 @@ var ChatManager = AbstractService.extend({
this.needactionCounter = Math.max(this.needactionCounter - data.message_ids.length, 0);
this.chatBus.trigger('update_needaction', this.needactionCounter);
},
/**
* @private
* @param {Object} data notification data
* @param {Object} data.message
*/
_manageModeratorNotification: function (data) {
this.moderationCounter++;
this._addMessage(data.message);
this.chatBus.trigger('update_moderation_counter');
},
/**
* @private
* @param {Object} message
@@ -1626,6 +1726,12 @@ var ChatManager = AbstractService.extend({
this._manageToggleStarNotification(data);
} else if (data.type === 'mark_as_read') {
this._manageMarkAsReadNotification(data);
} else if (data.type === 'moderator') {
this._manageModeratorNotification(data);
} else if (data.type === 'author') {
this._manageAuthorNotification(data);
} else if (data.type === 'deletion') {
this._manageDeletionNotification(data);
} else if (data.info === 'channel_seen') {
this._manageChannelSeenNotification(data);
} else if (data.info === 'transient_message') {
+28 -1
View File
@@ -38,6 +38,8 @@ var Thread = Widget.extend({
var message_id = $(event.currentTarget).data('message-id');
this.trigger("mark_as_read", message_id);
},
"click .o_thread_message_moderation": "_onClickMessageModeration",
"change .moderation_checkbox": "_onChangeModerationCheckbox",
"click .o_thread_message_star": function (event) {
var message_id = $(event.currentTarget).data('message-id');
this.trigger("toggle_star_status", message_id);
@@ -141,7 +143,6 @@ var Thread = Widget.extend({
}
prev_msg = msg;
});
this.$el.html(QWeb.render('mail.ChatThread', {
messages: msgs,
options: options,
@@ -313,6 +314,15 @@ var Thread = Widget.extend({
destroy: function () {
clearInterval(this.update_timestamps_interval);
},
/**
* Toggle all the moderation checkboxes in the thread
*
* @param {boolean} checked if true, check the boxes,
* otherwise uncheck them.
*/
toggleModerationCheckboxes: function (checked) {
this.$('.moderation_checkbox').prop('checked', checked);
},
//--------------------------------------------------------------------------
// Private
@@ -387,6 +397,23 @@ var Thread = Widget.extend({
attachmentViewer.appendTo($('body'));
}
},
/**
* @private
* @param {MouseEvent} ev
*/
_onChangeModerationCheckbox: function (ev) {
this.trigger_up('update_moderation_buttons');
},
/**
* @private
* @param {MouseEvent} ev
*/
_onClickMessageModeration: function (ev) {
var $button = $(ev.currentTarget);
var messageID = $button.data('message-id');
var decision = $button.data('decision');
this.trigger_up('message_moderation', { messageID: messageID, decision: decision });
},
});
Thread.ORDER = ORDER;
+15
View File
@@ -29,6 +29,12 @@
<t t-set="counter" t-value="starred_counter"/>
<t t-call="mail.chat.SidebarNeedaction"/>
</div>
<div t-if="isModerator" t-attf-class="o_mail_chat_title_main o_mail_chat_channel_item #{(active_channel_id == 'channel_moderation') ? 'o_active': ''}"
data-channel-id="channel_moderation">
<span class="o_channel_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span>
<t t-set="counter" t-value="moderationCounter"/>
<t t-call="mail.chat.SidebarNeedaction"/>
</div>
<hr class="mb8"/>
<t t-set="channel_type" t-value="'public'"/>
@@ -137,6 +143,11 @@
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_unstar_all" title="Unstar all messages">Unstar all</button>
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_dm visible-xs" title="New Message">New Message</button>
<button t-if="!disable_add_channel" type="button" class="btn btn-default btn-sm o_mail_chat_button_public o_mail_chat_button_private visible-xs" title="New Channel">New Channel</button>
<button type="button" class="btn btn-default btn-sm o_hidden o_mail_chat_button_select_all" title="Select all messages to moderate">Select All</button>
<button type="button" class="btn btn-default btn-sm o_hidden o_mail_chat_button_unselect_all" title="Unselect all messages to moderate">Unselect All</button>
<button type="button" data-decision="accept" class="btn btn-default btn-sm o_mail_chat_button_moderate_all" title="Accept selected messages">Accept</button>
<button type="button" data-decision="reject" class="btn btn-default btn-sm o_mail_chat_button_moderate_all" title="Reject selected messages">Reject</button>
<button type="button" data-decision="discard" class="btn btn-default btn-sm o_mail_chat_button_moderate_all" title="Discard selected messages">Discard</button>
</div>
</t>
@@ -145,6 +156,10 @@
<input type="text" class="o_input o_mail_chat_partner_invite_input" id="mail_search_partners"/>
</div>
<div t-name="mail.ModeratorRejectMessageDialog">
<input class="form-control" type="text" id="message_title" placeholder="Subject" value="Message Rejected"/>
<textarea class="form-control mt16" id="reject_message" placeholder="Mail Body">Your message was rejected by moderator.</textarea>
</div>
<!-- Mobile templates -->
<t t-name="mail.discuss_mobile">
+35 -1
View File
@@ -103,6 +103,10 @@
<div class="o_thread_title">No starred message</div>
<div>You can mark any message as 'starred', and it shows up in this channel.</div>
</t>
<t t-if="options.channel_id==='channel_moderation'">
<div class="o_thread_title">You have no message to moderate</div>
<div>Pending moderation messages appear here.</div>
</t>
</div>
<t t-name="mail.ChatThread.Content">
@@ -144,6 +148,7 @@
</div>
<div class="o_thread_message_core">
<p t-if="message.display_author" class="o_mail_info text-muted">
<input t-if="message.needsModeration and message.isModerator" type="checkbox" class="moderation_checkbox" t-att-data-message-id="message.id"/>
<t t-if="message.is_note">
Note by
</t>
@@ -185,7 +190,7 @@
</span>
</span>
<span t-attf-class="o_thread_icons">
<i t-if="options.display_stars &amp;&amp; !message.is_system_notification"
<i t-if="options.display_stars and !message.is_system_notification and !message.needsModeration"
t-att-class="'fa fa-lg o_thread_icon o_thread_message_star ' + (message.is_starred ? 'fa-star' : 'fa-star-o')"
t-att-data-message-id="message.id" title="Mark as Todo"/>
<i t-if="message.record_name &amp;&amp; message.model != 'mail.channel' &amp;&amp; options.display_reply_icon"
@@ -194,6 +199,15 @@
<i t-if="message.is_needaction &amp;&amp; options.display_needactions"
class="fa fa-check o_thread_icon o_thread_message_needaction"
t-att-data-message-id="message.id" title="Mark as Read"/>
<t t-if="message.needsModeration and message.model === 'mail.channel'">
<t t-if="message.isModerator">
<t t-call="mail.MessageModerationDecision"/>
</t>
<t t-elif="message.is_author">
<t t-call="mail.MessageModerationFeedback"/>
</t>
</t>
</span>
</p>
<div class="o_thread_message_content">
@@ -227,6 +241,26 @@
</t>
</t>
<t t-name="mail.MessageModerationDecision">
<i class="o_thread_icon ml4 o_thread_message_moderation text-success"
t-att-data-message-id="message.id" data-decision="accept" title="Accept"><b>Accept |</b></i>
<i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
t-att-data-message-id="message.id" data-decision="reject" title="Remove message with explanation"><b>Reject |</b></i>
<i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
t-att-data-message-id="message.id" data-decision="discard" title="Remove message without explanation"><b>Discard |</b></i>
<i class="o_thread_icon ml4 o_thread_message_moderation text-success"
t-att-data-message-id="message.id" data-decision="allow" title="Add this email address to white list of people"><b>Always Allow |</b></i>
<i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
t-att-data-message-id="message.id" data-decision="ban" title="Ban this email address"><b>Ban</b></i>
</t>
<t t-name="mail.MessageModerationFeedback">
<i class="text-danger" title="Your message is pending moderation"><b>Pending moderation</b></i>
</t>
<t t-name="mail.MessagesSeparator">
<div class="o_thread_new_messages_separator">
<span class="o_thread_separator_label">New messages</span>
@@ -0,0 +1,822 @@
odoo.define('mail.discuss_moderation_tests', function (require) {
"use strict";
var ChatManager = require('mail.ChatManager');
var mailTestUtils = require('mail.testUtils');
var Bus = require('web.Bus');
var createBusService = mailTestUtils.createBusService;
var createDiscuss = mailTestUtils.createDiscuss;
QUnit.module('mail', {}, function () {
QUnit.module('Discuss moderation', {
beforeEach: function () {
// patch _.debounce and _.throttle to be fast and synchronous
this.underscoreDebounce = _.debounce;
this.underscoreThrottle = _.throttle;
_.debounce = _.identity;
_.throttle = _.identity;
this.data = {
'mail.message': {
fields: {
body: {
string: "Contents",
type: 'html',
},
author_id: {
string: "Author",
relation: 'res.partner',
},
channel_ids: {
string: "Channels",
type: 'many2many',
relation: 'mail.channel',
},
starred: {
string: "Starred",
type: 'boolean',
},
needaction: {
string: "Need Action",
type: 'boolean',
},
starred_partner_ids: {
string: "partner ids",
type: 'integer',
},
model: {
string: "Related Document model",
type: 'char',
},
res_id: {
string: "Related Document ID",
type: 'integer',
},
need_moderation: {
string: "Need moderation",
type: 'boolean',
},
moderation_status: {
string: "Moderation Status",
type: 'integer',
selection: [
['pending_moderation', 'Pending Moderation'],
['accepted', 'Accepted'],
['rejected', 'Rejected']
],
},
},
},
};
this.services = [ChatManager, createBusService()];
},
afterEach: function () {
// unpatch _.debounce and _.throttle
_.debounce = this.underscoreDebounce;
_.throttle = this.underscoreThrottle;
}
});
QUnit.test('moderator: display moderation box', function (assert) {
assert.expect(1);
var done = assert.async();
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
};
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
})
.then(function (discuss) {
var moderationBoxSelector = '.o_mail_chat_channel_item' +
'[data-channel-id="channel_moderation"]';
assert.strictEqual(discuss.$(moderationBoxSelector).length, 1,
"there should be a moderation mailbox");
discuss.destroy();
done();
});
});
QUnit.test('moderator: moderated channel with pending moderation message', function (assert) {
assert.expect(33);
var done = assert.async();
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [1],
};
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 1,
}];
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
})
.then(function (discuss) {
var $moderationBox = discuss.$(
'.o_mail_chat_channel_item' +
'[data-channel-id="channel_moderation"]');
var $mailboxCounter = $moderationBox.find('.o_mail_sidebar_needaction.badge');
assert.strictEqual($mailboxCounter.length, 1,
"there should be a counter next to the moderation mailbox in the sidebar");
assert.strictEqual($mailboxCounter.text().trim(), "1",
"the mailbox counter of the moderation mailbox should display '1'");
// 1. go to moderation mailbox
$moderationBox.click();
var $message = discuss.$('.o_thread_message');
// check message
assert.strictEqual($message.length, 1,
"there should be one message in the moderation box");
assert.strictEqual($message.data('message-id'), 100,
"this message pending moderation should have correct ID");
assert.strictEqual($message.find('a[data-oe-id="1"]').text(), "#general",
"the message pending moderation should have correct origin as its linked document");
assert.strictEqual($message.find('.moderation_checkbox').length, 1,
"there should be a moderation checkbox next to the message");
assert.notOk($message.find('.moderation_checkbox').prop('checked'), false,
"the moderation checkbox should be unchecked by default");
// check moderation actions next to message
assert.strictEqual(discuss.$('.o_thread_message_moderation').length, 5,
"there should be 5 contextual moderation decisions next to the message");
assert.strictEqual(discuss.$('.o_thread_message_moderation[data-decision="accept"]').length, 1,
"there should be a contextual moderation decision to accept the message");
assert.strictEqual(discuss.$('.o_thread_message_moderation[data-decision="reject"]').length, 1,
"there should be a contextual moderation decision to reject the message");
assert.strictEqual(discuss.$('.o_thread_message_moderation[data-decision="discard"]').length, 1,
"there should be a contextual moderation decision to discard the message");
assert.strictEqual(discuss.$('.o_thread_message_moderation[data-decision="allow"]').length, 1,
"there should be a contextual moderation decision to allow the user of the message)");
assert.strictEqual(discuss.$('.o_thread_message_moderation[data-decision="ban"]').length, 1,
"there should be a contextual moderation decision to ban the user of the message");
// check select all (enabled) / unselect all (disabled) buttons
assert.strictEqual($('.o_mail_chat_button_select_all').length, 1,
"there should be a 'Select All' button in the control panel");
assert.notOk($('.o_mail_chat_button_select_all').hasClass('disabled'),
"the 'Select All' button should not be disabled");
assert.strictEqual($('.o_mail_chat_button_unselect_all').length, 1,
"there should be a 'Unselect All' button in the control panel");
assert.ok($('.o_mail_chat_button_unselect_all').hasClass('disabled'),
"the 'Unselect All' button should be disabled");
// check moderate all buttons (invisible)
var moderateAllSelector = '.o_mail_chat_button_moderate_all';
assert.strictEqual($(moderateAllSelector).length, 3,
"there should be 3 buttons to moderate selected messages in the control panel");
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').length, 1,
"there should one moderate button to accept messages pending moderation");
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'),
'display: none;', 'the moderate button "Accept" should be invisible by default');
assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').length, 1,
"there should one moderate button to reject messages pending moderation");
assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').attr('style'),
'display: none;', 'the moderate button "Reject" should be invisible by default');
assert.strictEqual($(moderateAllSelector + '[data-decision="discard"]').length, 1,
"there should one moderate button to discard messages pending moderation");
assert.strictEqual($(moderateAllSelector + '[data-decision="discard"]').attr('style'),
'display: none;', 'the moderate button "Discard" should be invisible by default');
// click on message moderation checkbox
$message.find('.moderation_checkbox').click();
assert.ok($message.find('.moderation_checkbox').prop('checked'),
"the moderation checkbox should become checked after click");
// check select all (disabled) / unselect all buttons (enabled)
assert.ok($('.o_mail_chat_button_select_all').hasClass('disabled'),
"the 'Select All' button should be disabled");
assert.notOk($('.o_mail_chat_button_unselect_all').hasClass('disabled'),
"the 'Unselect All' button should not be disabled");
// check moderate all buttons updated (visible)
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'),
'display: inline-block;', 'the moderate button "Accept" should become visible');
assert.strictEqual($(moderateAllSelector + '[data-decision="reject"]').attr('style'),
'display: inline-block;', 'the moderate button "Reject" should become visible');
assert.strictEqual($(moderateAllSelector + '[data-decision="discard"]').attr('style'),
'display: inline-block;', 'the moderate button "Discard" should become visible');
// 2. go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
$message = discuss.$('.o_thread_message');
// check correct message
assert.strictEqual($message.length, 1,
"there should be one message in the general channel");
assert.strictEqual($message.data('message-id'), 100,
"this message should have correct ID");
assert.notOk($message.find('.moderation_checkbox').prop('checked'),
"the moderation checkbox should be unchecked by default");
// don't check moderation actions visibility, since it is similar to moderation box.
discuss.destroy();
done();
});
});
QUnit.test('moderator: accept pending moderation message', function (assert) {
assert.expect(12);
var done = assert.async();
var bus = new Bus();
var BusService = createBusService(bus);
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [1],
};
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 1,
}];
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: [ChatManager, BusService],
mockRPC: function (route, args) {
if (args.method === 'moderate') {
assert.step('moderate');
var messageIDs = args.args[0];
var decision = args.args[1];
assert.strictEqual(messageIDs.length, 1, "should moderate one message");
assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
assert.strictEqual(decision, 'accept', "should accept the message");
// simulate notification back (new accepted message in channel)
var dbName = undefined; // useless for tests
var messageData = {
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'accepted'
};
var metaData = [dbName, 'mail.channel'];
var notification = [metaData, messageData];
bus.trigger('notification', [notification]);
return $.when();
}
return this._super.apply(this, arguments);
},
})
.then(function (discuss) {
// 1. go to moderation box
var $moderationBox = discuss.$(
'.o_mail_chat_channel_item' +
'[data-channel-id="channel_moderation"]');
$moderationBox.click();
// check there is a message to moderate
var $message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"there should be one message in the moderation box");
assert.strictEqual($message.data('message-id'), 100,
"this message should have correct ID");
assert.strictEqual($message.find('.moderation_checkbox').length, 1,
"the message should have a moderation checkbox");
// accept the message pending moderation
discuss.$('.o_thread_message_moderation[data-decision="accept"]').click();
assert.verifySteps(['moderate']);
// stop the fadeout animation and immediately remove the element
discuss.$('.o_thread_message').stop(false, true);
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
"should now have no message displayed in moderation box");
// 2. go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
$message = discuss.$('.o_thread_message');
// check message is there and has no moderate checkbox
assert.strictEqual($message.length, 1,
"there should be one message in the general channel");
assert.strictEqual($message.data('message-id'), 100,
"this message should have correct ID");
assert.strictEqual($message.find('.moderation_checkbox').length, 0,
"the message should not have any moderation checkbox");
discuss.destroy();
done();
});
});
QUnit.test('moderator: reject pending moderation message (reject with explanation)', function (assert) {
assert.expect(21);
var done = assert.async();
var bus = new Bus();
var BusService = createBusService(bus);
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [1],
};
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 1,
}];
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: [ChatManager, BusService],
mockRPC: function (route, args) {
if (args.method === 'moderate') {
assert.step('moderate');
var messageIDs = args.args[0];
var decision = args.args[1];
var kwargs = args.kwargs;
assert.strictEqual(messageIDs.length, 1, "should moderate one message");
assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
assert.strictEqual(decision, 'reject', "should reject the message");
assert.strictEqual(kwargs.title, "Message Rejected",
"should have correct reject message title");
assert.strictEqual(kwargs.comment, "Your message was rejected by moderator.",
"should have correct reject message body / comment");
// simulate notification back (deletion of rejected message in channel)
var dbName = undefined; // useless for tests
var notifData = {
message_ids: messageIDs,
type: "deletion",
};
var metaData = [dbName, 'res.partner'];
var notification = [metaData, notifData];
bus.trigger('notification', [notification]);
return $.when();
}
return this._super.apply(this, arguments);
},
})
.then(function (discuss) {
// 1. go to moderation box
var $moderationBox = discuss.$(
'.o_mail_chat_channel_item' +
'[data-channel-id="channel_moderation"]');
$moderationBox.click();
// check there is a message to moderate
var $message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"there should be one message in the moderation box");
assert.strictEqual($message.data('message-id'), 100,
"this message should have correct ID");
assert.strictEqual($message.find('.moderation_checkbox').length, 1,
"the message should have a moderation checkbox");
// reject the message pending moderation
discuss.$('.o_thread_message_moderation[data-decision="reject"]').click();
// check reject dialog prompt
assert.strictEqual($('.modal-dialog').length, 1,
"a dialog should be prompt to the moderator on click reject");
assert.strictEqual($('.modal-title').text(), "Send explanation to author",
"dialog should have correct title");
var $messageTitle = $('.modal-body input[id="message_title"]');
assert.strictEqual($messageTitle.length, 1,
"should have a title of message for rejecting the message");
assert.strictEqual($messageTitle.attr('placeholder'), "Subject",
"message title for reject reason should have correct placeholder");
assert.strictEqual($messageTitle.val(), "Message Rejected",
"message title for reject reason should have correct default value");
var $messageBody = $('.modal-body textarea[id="reject_message"]');
assert.strictEqual($messageBody.length, 1,
"should have a body of message for rejecting the message");
assert.strictEqual($messageBody.attr('placeholder'), "Mail Body",
"message body for reject reason should have correct placeholder");
assert.strictEqual($messageBody.text(), "Your message was rejected by moderator.",
"message body for reject reason should have correct default text content");
assert.strictEqual($('.modal-footer button').text(), "Send",
"should have a send button on the reject dialog");
// send mesage
$('.modal-footer button').click();
assert.verifySteps(['moderate']);
// stop the fadeout animation and immediately remove the element
discuss.$('.o_thread_message').stop(false, true);
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
"should now have no message displayed in moderation box");
// 2. go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
"should now have no message displayed in moderated channel");
discuss.destroy();
done();
});
});
QUnit.test('moderator: discard pending moderation message (reject without explanation)', function (assert) {
assert.expect(15);
var done = assert.async();
var bus = new Bus();
var BusService = createBusService(bus);
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [1],
};
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 1,
}];
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: [ChatManager, BusService],
mockRPC: function (route, args) {
if (args.method === 'moderate') {
assert.step('moderate');
var messageIDs = args.args[0];
var decision = args.args[1];
assert.strictEqual(messageIDs.length, 1, "should moderate one message");
assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
assert.strictEqual(decision, 'discard', "should discard the message");
// simulate notification back (deletion of rejected message in channel)
var dbName = undefined; // useless for tests
var notifData = {
message_ids: messageIDs,
type: 'deletion',
};
var metaData = [dbName, 'res.partner'];
var notification = [metaData, notifData];
bus.trigger('notification', [notification]);
return $.when();
}
return this._super.apply(this, arguments);
},
})
.then(function (discuss) {
// 1. go to moderation box
var $moderationBox = discuss.$(
'.o_mail_chat_channel_item' +
'[data-channel-id="channel_moderation"]');
$moderationBox.click();
// check there is a message to moderate
var $message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"there should be one message in the moderation box");
assert.strictEqual($message.data('message-id'), 100,
"this message should have correct ID");
assert.strictEqual($message.find('.moderation_checkbox').length, 1,
"the message should have a moderation checkbox");
// discard the message pending moderation
discuss.$('.o_thread_message_moderation[data-decision="discard"]').click();
// check discard dialog prompt
assert.strictEqual($('.modal-dialog').length, 1,
"a dialog should be prompt to the moderator on click discard");
assert.strictEqual($('.modal-body').text(),
"You are going to discard 1 message. Do you confirm the action?",
"should warn the user on discard action");
assert.strictEqual($('.modal-footer button').length, 2,
"should have two buttons in the footer of the dialog");
assert.strictEqual($('.modal-footer button.btn-primary').text(), "Ok",
"should have a confirm button in the dialog for discard");
assert.strictEqual($('.modal-footer button.btn-default').text(), "Cancel",
"should have a cancel button in the dialog for discard");
// discard mesage
$('.modal-footer button.btn-primary').click();
assert.verifySteps(['moderate']);
// stop the fadeout animation and immediately remove the element
discuss.$('.o_thread_message').stop(false, true);
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
"should now have no message displayed in moderation box");
// 2. go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
"should now have no message displayed in moderated channel");
discuss.destroy();
done();
});
});
QUnit.test('author: send message in moderated channel', function (assert) {
assert.expect(4);
var done = assert.async();
var bus = new Bus();
var BusService = createBusService(bus);
var messagePostDef = $.Deferred();
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
};
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: [ChatManager, BusService],
mockRPC: function (route, args) {
if (args.method === 'message_post') {
var message = {
id: 100,
author_id: [2, 'Someone'],
body: args.kwargs.body,
message_type: args.kwargs.message_type,
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 1,
};
var metaData = [undefined, 'res.partner'];
var notifData = {
type: 'author',
message: message,
};
var notification = [metaData, notifData];
bus.trigger('notification', [notification]);
messagePostDef.resolve();
return $.when(message.id);
}
return this._super.apply(this, arguments);
},
session: {
partner_id: 2,
},
})
.then(function (discuss) {
// go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
// post a message
discuss.$('.o_composer_input textarea').first().val("some text");
discuss.$('.o_composer_send button').click();
messagePostDef
.then(function () {
var $message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"should have a message in the thread");
assert.strictEqual($message.data('message-id'), 100,
"message should have ID returned from 'message_post'");
assert.strictEqual($message.find('.o_thread_author').text().trim(),
"Someone", "message should have correct author displayed name");
assert.strictEqual(discuss.$('.o_thread_icons i.text-danger').text(),
"Pending moderation", "the message should be pending moderation");
discuss.destroy();
done();
});
});
});
QUnit.test('author: sent message accepted in moderated channel', function (assert) {
assert.expect(8);
var done = assert.async();
var bus = new Bus();
var BusService = createBusService(bus);
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
};
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 1,
}];
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: [ChatManager, BusService],
session: {
partner_id: 2,
},
})
.then(function (discuss) {
// go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
// check message is pending
var $message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"should have a message in the thread");
assert.strictEqual($message.data('message-id'), 100,
"message should have ID returned from 'message_post'");
assert.strictEqual($message.find('.o_thread_author').text().trim(),
"Someone", "message should have correct author displayed name");
assert.strictEqual(discuss.$('.o_thread_icons i.text-danger').text(),
"Pending moderation", "the message should be pending moderation");
// simulate accepted message
var dbName = undefined; // useless for tests
var messageData = {
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'accepted'
};
var metaData = [dbName, 'mail.channel'];
var notification = [metaData, messageData];
bus.trigger('notification', [notification]);
// // check message is accepted
$message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"should still have a message in the thread");
assert.strictEqual($message.data('message-id'), 100,
"message should still have ID returned from 'message_post'");
assert.strictEqual($message.find('.o_thread_author').text().trim(),
"Someone", "message should still have correct author displayed name");
assert.strictEqual(discuss.$('.o_thread_icons i.text-danger').length, 0,
"the message should not be in pending moderation anymore");
discuss.destroy();
done();
});
});
QUnit.test('author: sent message rejected in moderated channel', function (assert) {
assert.expect(5);
var done = assert.async();
var bus = new Bus();
var BusService = createBusService(bus);
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
};
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [],
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 1,
}];
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: [ChatManager, BusService],
session: {
partner_id: 2,
},
})
.then(function (discuss) {
// go to channel 'general'
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
// check message is pending
var $message = discuss.$('.o_thread_message');
assert.strictEqual($message.length, 1,
"should have a message in the thread");
assert.strictEqual($message.data('message-id'), 100,
"message should have ID returned from 'message_post'");
assert.strictEqual($message.find('.o_thread_author').text().trim(),
"Someone", "message should have correct author displayed name");
assert.strictEqual(discuss.$('.o_thread_icons i.text-danger').text(),
"Pending moderation", "the message should be pending moderation");
// simulate reject from moderator
var dbName = undefined; // useless for tests
var notifData = {
type: 'deletion',
message_ids: [100],
};
var metaData = [dbName, 'res.partner'];
var notification = [metaData, notifData];
bus.trigger('notification', [notification]);
// // check no message
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
"message should be removed from channel after reject");
discuss.destroy();
done();
});
});
});
});
+15 -7
View File
@@ -44,13 +44,21 @@ QUnit.module('Discuss client action', {
type: 'boolean',
},
needaction: {
string: "Need Action",
type: 'boolean',
},
starred_partner_ids: {
string: "partner ids",
type: 'integer',
}
string: "Need Action",
type: 'boolean',
},
starred_partner_ids: {
string: "partner ids",
type: 'integer',
},
model: {
string: "Related Document model",
type: 'char',
},
res_id: {
string: "Related Document ID",
type: 'integer',
},
},
},
};
+21 -2
View File
@@ -83,7 +83,16 @@
<field name="priority" eval="10"/>
<field name="arch" type="xml">
<form string="Mail Channel Form">
<header>
<button name="send_guidelines" type="object" string="Send guidelines" confirm="You are going to send the guidelines to all the subscribers. Do you confirm the action?" attrs="{'invisible':['|',('moderation_guidelines','=',False), ('is_moderator', '=', False)]}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<field name="is_moderator" invisible="1"/>
<button class="btn btn-sm oe_stat_button" name="mail.mail_moderation_action" type="action" attrs="{'invisible':[('is_moderator', '=', False)]}" icon="fa-bars" context="{'search_default_channel_id': active_id}">
<field string="Ban List" name="moderation_count" widget="statinfo"/>
</button>
</div>
<div class="oe_button_box" name="button_box"/>
<field name="image" widget="image" class="oe_avatar" options="{'preview_image': 'image_medium', 'size': [90, 90]}"/>
<div class="oe_title">
@@ -96,6 +105,7 @@
</div>
<group class="o_label_nowrap">
<field name="email_send"/>
<field name="moderation" attrs="{'invisible': [('email_send', '=', False)]}"/>
<field name="description" placeholder="Topics discussed in this group..."/>
</group>
<group name="group_alias" attrs="{'invisible': [('alias_domain', '=', False)]}">
@@ -123,10 +133,19 @@
<field name="channel_last_seen_partner_ids" mode="tree" context="{'active_test': False}">
<tree string="Members" editable="bottom">
<field name="partner_id"/>
<field name="partner_email"/>
<field name="partner_email" readonly="1"/>
</tree>
</field>
</page>
<page string="Moderation" attrs="{'invisible': [('moderation', '=', False)]}">
<group>
<field name="moderator_ids" widget="many2many_tags"/>
<field name="moderation_notify"/>
<field name="moderation_notify_msg" attrs="{'invisible': [('moderation_notify', '=', False)]}"/>
<field name="moderation_guidelines"/>
<field name="moderation_guidelines_msg" attrs="{'invisible':[('moderation_guidelines', '=', False)]}"/>
</group>
</page>
<page string="Integrations" invisible="0" name="mail_channel_integrations"></page>
</notebook>
<div class="oe_chatter" groups="base.group_no_one">
@@ -136,7 +155,7 @@
</form>
</field>
</record>
<record id="mail_channel_view_tree" model="ir.ui.view">
<field name="name">mail.channel.tree</field>
<field name="model">mail.channel</field>
+2
View File
@@ -32,6 +32,7 @@
<field name="email_from"/>
<field name="author_id"/>
<field name="record_name"/>
<field name="moderator_id"/>
</group>
<group>
<field name="parent_id"/>
@@ -39,6 +40,7 @@
<field name="res_id"/>
<field name="message_type"/>
<field name="subtype_id"/>
<field name="moderation_status"/>
</group>
</group>
<notebook>
@@ -0,0 +1,52 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="mail_moderation_view_tree" model="ir.ui.view">
<field name="name">mail.moderation.view.tree</field>
<field name="model">mail.moderation</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<tree string="Moderation Lists" editable="bottom">
<field name="channel_id"/>
<field name="email"/>
<field name="status"/>
</tree>
</field>
</record>
<record id="mail_moderation_view_search" model="ir.ui.view">
<field name="name">mail.moderation.view.search</field>
<field name="model">mail.moderation</field>
<field name="priority">25</field>
<field name="arch" type="xml">
<search string="Search Moderation List">
<field name="channel_id"/>
<field name="email"/>
<field name="status"/>
<filter string="Is Banned"
name="status_ban" help="Banned Emails"
domain="[('status', '=', 'ban')]"/>
<separator/>
<filter string="Is Allowed"
name="status_allow" help="Allowed Emails"
domain="[('status', '=', 'allow')]"/>
</search>
</field>
</record>
<record id="mail_moderation_action" model="ir.actions.act_window">
<field name="name">Moderation</field>
<field name="res_model">mail.moderation</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="mail_moderation_view_search"/>
<field name="context">{'search_default_status_ban': 1}</field>
</record>
<!-- Add menu entry in Settings/Email -->
<menuitem name="White List / Black List"
id="mail_moderation_menu"
parent="base.menu_email"
action="mail_moderation_action"/>
</data>
</odoo>
+1
View File
@@ -53,6 +53,7 @@
<script type="text/javascript" src="/mail/static/tests/mail_utils_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/discuss_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/discuss_moderation_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
</xpath>
+2 -1
View File
@@ -13,4 +13,5 @@ from . import test_invite
from . import test_ir_actions
from . import test_update_notification
from . import test_discuss
from . import test_performance
from . import test_performance
from . import test_res_users
+57 -1
View File
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from json import dumps, loads
import json
@@ -7,7 +8,7 @@ from email.utils import formataddr
from odoo import api
from odoo.addons.bus.models.bus import json_dump
from odoo.tests import common
from odoo.tests import common, tagged
class BaseFunctionalTest(common.SavepointCase):
@@ -244,6 +245,7 @@ class MockEmails(common.SingleTransactionCase):
super(MockEmails, cls).tearDownClass()
def _init_mock_build_email(self):
self.env['mail.mail'].search([]).unlink()
self._mails_args[:] = []
self._mails[:] = []
@@ -260,3 +262,57 @@ class MockEmails(common.SingleTransactionCase):
mail = self.format(template, to=to, subject=subject, cc=cc, extra=extra, email_from=email_from, msg_id=msg_id)
self.env['mail.thread'].with_context(mail_channel_noautofollow=True).message_process(model, mail)
return self.env[target_model].search([(target_field, '=', subject)])
@tagged('moderation')
class Moderation(MockEmails, BaseFunctionalTest):
@classmethod
def setUpClass(cls):
super(Moderation, cls).setUpClass()
Channel = cls.env['mail.channel']
Users = cls.env['res.users'].with_context(cls._quick_create_user_ctx)
cls.channel_moderation_1 = Channel.create({
'name': 'Moderation_1',
'email_send': True,
'moderation': True
})
cls.channel_1 = cls.channel_moderation_1
cls.channel_moderation_2 = Channel.create({
'name': 'Moderation_2',
'email_send': True,
'moderation': True
})
cls.channel_2 = cls.channel_moderation_2
cls.user_employee.write({'moderation_channel_ids': [(6, 0, [cls.channel_1.id])]})
cls.user_employee_2 = Users.create({
'name': 'Roboute',
'login': 'roboute',
'email': 'roboute@guilliman.com',
'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])],
'moderation_channel_ids': [(6, 0, [cls.channel_2.id])]
})
cls.partner_employee_2 = cls.user_employee_2.partner_id
cls.channel_moderation_1.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': cls.partner_employee.id})]})
cls.channel_moderation_2.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': cls.partner_employee_2.id})]})
def _create_new_message(self, channel_id, status='pending_moderation', author=None, body='', message_type="email"):
author = author if author else self.env.user.partner_id
message = self.env['mail.message'].create({
'model': 'mail.channel',
'res_id': channel_id,
'message_type': 'email',
'body': body,
'moderation_status': status,
'author_id': author.id,
'email_from': formataddr((author.name, author.email)),
'subtype_id': self.env['mail.message.subtype'].search([('name', '=', 'Discussions')]).id
})
return message
def _clear_bus(self):
self.env['bus.bus'].search([]).unlink()
+102 -2
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
from email.utils import formataddr
from odoo.tests import tagged
from odoo.addons.test_mail.tests import common
from odoo.exceptions import AccessError, except_orm
from odoo.exceptions import AccessError, except_orm, ValidationError, UserError
from odoo.tools import mute_logger
@@ -195,3 +195,103 @@ class TestChannelFeatures(common.BaseFunctionalTest, common.MockEmails):
self.assertIn(
email['email_to'][0],
[formataddr((self.user_employee.name, self.user_employee.email)), formataddr((self.test_partner.name, self.test_partner.email))])
@tagged('moderation')
class TestChannelModeration(common.Moderation):
@classmethod
def setUpClass(cls):
super(TestChannelModeration, cls).setUpClass()
def test_moderator_consistency(self):
with self.assertRaises(ValidationError):
self.channel_1.write({'moderator_ids': [(4, self.user_employee_2.id)]})
self.channel_1.write({'channel_partner_ids': [(4, self.partner_employee_2.id)]})
with self.assertRaises(ValidationError):
self.user_employee_2.write({'email': False})
self.channel_1.write({'moderator_ids': [(4, self.user_employee_2.id)]})
def test_channel_moderation_parameters(self):
with self.assertRaises(ValidationError):
self.channel_1.write({'email_send': False})
with self.assertRaises(ValidationError):
self.channel_1.write({'moderator_ids': [(5, 0)]})
def test_moderation_count(self):
self.assertEqual(self.channel_1.moderation_count, 0)
self.channel_1.write({'moderation_ids': [
(0, 0, {'email': 'test0@example.com', 'status': 'allow'}),
(0, 0, {'email': 'test1@example.com', 'status': 'ban'})
]})
self.assertEqual(self.channel_1.moderation_count, 2)
@mute_logger('odoo.addons.mail.models.mail_channel', 'odoo.models.unlink')
def test_send_guidelines(self):
self.channel_1.write({'channel_partner_ids': [(4, self.partner_employee_2.id), (4, self.partner_admin.id)]})
self.channel_1._update_moderation_email([self.partner_admin.email], 'ban')
self._init_mock_build_email()
self.channel_1.sudo(self.user_employee).send_guidelines()
self.env['mail.mail'].process_email_queue()
self.assertEmails(False, self.partner_employee | self.partner_employee_2, email_from=self.env.user.company_id.catchall or self.env.user.company_id.email)
def test_send_guidelines_crash(self):
with self.assertRaises(UserError):
self.channel_1.sudo(self.user_employee_2).send_guidelines()
def test_update_moderation_email(self):
self.channel_1.write({'moderation_ids': [
(0, 0, {'email': 'test0@example.com', 'status': 'allow'}),
(0, 0, {'email': 'test1@example.com', 'status': 'ban'})
]})
self.channel_1._update_moderation_email(['test0@example.com', 'test3@example.com'], 'ban')
self.assertEqual(len(self.channel_1.moderation_ids), 3)
self.assertTrue(all(status == 'ban' for status in self.channel_1.moderation_ids.mapped('status')))
def test_moderation_reset(self):
self._create_new_message(self.channel_1.id)
self._create_new_message(self.channel_1.id, status='accepted')
self._create_new_message(self.channel_2.id)
self.channel_1.write({'moderation': False})
self.assertEqual(self.env['mail.message'].search_count([
('moderation_status', '=', 'pending_moderation'),
('model', '=', 'mail.channel'),
('res_id', '=', self.channel_1.id)
]), 0)
self.assertEqual(self.env['mail.message'].search_count([
('moderation_status', '=', 'pending_moderation'),
('model', '=', 'mail.channel'),
('res_id', '=', self.channel_2.id)
]), 1)
self.channel_2.write({'moderation': False})
self.assertEqual(self.env['mail.message'].search_count([
('moderation_status', '=', 'pending_moderation'),
('model', '=', 'mail.channel'),
('res_id', '=', self.channel_2.id)
]), 0)
@mute_logger('odoo.models.unlink')
def test_message_post(self):
email1 = 'test0@example.com'
email2 = 'test1@example.com'
self.channel_1._update_moderation_email([email1], 'ban')
self.channel_1._update_moderation_email([email2], 'allow')
msg_admin = self.channel_1.message_post(message_type='email', subtype='mt_comment', author_id=self.partner_admin.id)
msg_moderator = self.channel_1.message_post(message_type='comment', subtype='mt_comment', author_id=self.partner_employee.id)
msg_email1 = self.channel_1.message_post(message_type='comment', subtype='mt_comment', email_from=formataddr(("MyName", email1)))
msg_email2 = self.channel_1.message_post(message_type='email', subtype='mt_comment', email_from=email2)
msg_notif = self.channel_1.message_post()
messages = self.env['mail.message'].search([('model', '=', 'mail.channel'), ('res_id', '=', self.channel_1.id)])
pending_messages = messages.filtered(lambda m: m.moderation_status == 'pending_moderation')
accepted_messages = messages.filtered(lambda m: m.moderation_status == 'accepted')
self.assertFalse(msg_email1)
self.assertEqual(msg_admin, pending_messages)
self.assertEqual(accepted_messages, msg_moderator | msg_email2 | msg_notif)
self.assertFalse(msg_admin.channel_ids)
self.assertEqual(msg_email2.channel_ids, self.channel_1)
+104 -1
View File
@@ -5,6 +5,7 @@ import base64
from odoo.addons.test_mail.tests import common
from odoo.exceptions import AccessError, except_orm
from odoo.tools import mute_logger
from odoo.tests import tagged
class TestMessageValues(common.BaseFunctionalTest, common.MockEmails):
@@ -213,6 +214,10 @@ class TestMessageAccess(common.BaseFunctionalTest, common.MockEmails):
messages = self.env['mail.message'].sudo(self.user_portal).search([('subject', 'like', '_ZTest')])
self.assertEqual(messages, msg4 | msg5)
# --------------------------------------------------
# READ
# --------------------------------------------------
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.models')
def test_mail_message_access_read_crash(self):
# TODO: Change the except_orm to Warning ( Because here it's call check_access_rule
@@ -251,6 +256,16 @@ class TestMessageAccess(common.BaseFunctionalTest, common.MockEmails):
# Test: Bert reads the message, ok because linked to a doc he is allowed to read
self.message.sudo(self.user_employee).read()
def test_mail_message_access_read_crash_moderation(self):
# with self.assertRaises(AccessError):
self.message.write({'model': 'mail.channel', 'res_id': self.group_public.id, 'moderation_status': 'pending_moderation'})
# Test: Bert reads the message, ok because linked to a doc he is allowed to read
self.message.sudo(self.user_employee).read()
# --------------------------------------------------
# CREATE
# --------------------------------------------------
@mute_logger('odoo.addons.base.models.ir_model')
def test_mail_message_access_create_crash_public(self):
# Do: Bert creates a message on Pigs -> ko, no creation rights
@@ -284,6 +299,25 @@ class TestMessageAccess(common.BaseFunctionalTest, common.MockEmails):
self.message.write({'partner_ids': [(4, self.user_employee.partner_id.id)]})
self.env['mail.message'].sudo(self.user_employee).create({'model': 'mail.channel', 'res_id': self.group_private.id, 'body': 'Test', 'parent_id': self.message.id})
# --------------------------------------------------
# WRITE
# --------------------------------------------------
def test_mail_message_access_write_moderation(self):
""" Only moderators can modify pending messages """
self.group_public.write({
'email_send': True,
'moderation': True,
'channel_partner_ids': [(4, self.partner_employee.id)],
'moderator_ids': [(4, self.user_employee.id)],
})
self.message.write({'model': 'mail.channel', 'res_id': self.group_public.id, 'moderation_status': 'pending_moderation'})
self.message.sudo(self.user_employee).write({'moderation_status': 'accepted'})
def test_mail_message_access_write_crash_moderation(self):
self.message.write({'model': 'mail.channel', 'res_id': self.group_public.id, 'moderation_status': 'pending_moderation'})
with self.assertRaises(AccessError):
self.message.sudo(self.user_employee).write({'moderation_status': 'accepted'})
@mute_logger('openerp.addons.mail.models.mail_mail')
def test_mark_all_as_read(self):
@@ -350,4 +384,73 @@ class TestMessageAccess(common.BaseFunctionalTest, common.MockEmails):
portal_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[])
na_count = portal_partner.get_needaction_count()
self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones")
self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones")
@tagged('moderation')
class TestMessageModeration(common.Moderation):
@classmethod
def setUpClass(cls):
super(TestMessageModeration, cls).setUpClass()
cls.msg_admin_pending_c1 = cls._create_new_message(cls, cls.channel_1.id, status='pending_moderation', author=cls.partner_admin)
cls.msg_admin_pending_c1_2 = cls._create_new_message(cls, cls.channel_1.id, status='pending_moderation', author=cls.partner_admin)
cls.msg_emp2_pending_c1 = cls._create_new_message(cls, cls.channel_1.id, status='pending_moderation', author=cls.partner_employee_2)
@mute_logger('odoo.models.unlink')
def test_moderate_accept(self):
self._clear_bus()
# A pending moderation message needs to have field channel_ids empty. Moderators
# need to be able to notify a pending moderation message (in a channel they moderate).
self.assertFalse(self.msg_admin_pending_c1.channel_ids)
self.msg_admin_pending_c1.sudo(self.user_employee)._moderate('accept')
self.assertEqual(self.msg_admin_pending_c1.channel_ids, self.channel_1)
self.assertEqual(self.msg_admin_pending_c1.moderation_status, 'accepted')
self.assertEqual(self.msg_admin_pending_c1_2.moderation_status, 'pending_moderation')
self.assertBusNotification([(self.cr.dbname, 'mail.channel', self.channel_1.id)])
@mute_logger('odoo.models.unlink')
def test_moderate_allow(self):
self._clear_bus()
# A pending moderation message needs to have field channel_ids empty. Moderators
# need to be able to notify a pending moderation message (in a channel they moderate).
self.assertFalse(self.msg_admin_pending_c1.channel_ids)
self.assertFalse(self.msg_admin_pending_c1_2.channel_ids)
self.msg_admin_pending_c1.sudo(self.user_employee)._moderate('allow')
self.assertEqual(self.msg_admin_pending_c1.channel_ids, self.channel_1)
self.assertEqual(self.msg_admin_pending_c1_2.channel_ids, self.channel_1)
self.assertEqual(self.msg_admin_pending_c1.moderation_status, 'accepted')
self.assertEqual(self.msg_admin_pending_c1_2.moderation_status, 'accepted')
self.assertBusNotification([
(self.cr.dbname, 'mail.channel', self.channel_1.id),
(self.cr.dbname, 'mail.channel', self.channel_1.id)])
@mute_logger('odoo.models.unlink')
def test_moderate_reject(self):
self._init_mock_build_email()
(self.msg_admin_pending_c1 | self.msg_emp2_pending_c1).sudo(self.user_employee)._moderate_send_reject_email('Title', 'Message to author')
self.env['mail.mail'].process_email_queue()
self.assertEmails(self.partner_employee, self.partner_employee_2 | self.partner_admin, subject='Title', body_content='Message to author')
def test_moderate_discard(self):
self._clear_bus()
id1, id2 = self.msg_admin_pending_c1.id, self.msg_emp2_pending_c1.id # save ids because unlink will discard them
(self.msg_admin_pending_c1 | self.msg_emp2_pending_c1).sudo(self.user_employee)._moderate_discard()
self.assertBusNotification(
[(self.cr.dbname, 'res.partner', self.partner_admin.id),
(self.cr.dbname, 'res.partner', self.partner_employee_2.id),
(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{'type': 'deletion', 'message_ids': [id1]}, # admin: one message deleted because discarded
{'type': 'deletion', 'message_ids': [id2]}, # employee_2: one message delete because discarded
{'type': 'deletion', 'message_ids': [id1, id2]}] # employee: two messages deleted because moderation done
)
@mute_logger('odoo.models.unlink')
def test_notify_moderators(self):
# create pending messages in another channel to have two notification to push
msg_emp_pending_c2 = self._create_new_message(self.channel_2.id, status='pending_moderation', author=self.partner_employee)
self.env['mail.message']._notify_moderators()
self.assertEmails(False, self.partner_employee | self.partner_employee_2, subject='Message are pending moderation', email_from=self.env.user.company_id.catchall or self.env.user.company_id.email)
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests import common
from odoo.tests import tagged
@tagged('moderation')
class TestMessageModeration(common.Moderation):
@classmethod
def setUpClass(cls):
super(TestMessageModeration, cls).setUpClass()
def test_is_moderator(self):
self.assertTrue(self.user_employee.is_moderator)
self.assertFalse(self.user_admin.is_moderator)
self.assertTrue(self.user_employee_2.is_moderator)
def test_moderation_counter(self):
self._create_new_message(self.channel_1.id, status='pending_moderation', author=self.partner_admin)
self._create_new_message(self.channel_1.id, status='accepted', author=self.partner_admin)
self._create_new_message(self.channel_1.id, status='accepted', author=self.partner_employee)
self._create_new_message(self.channel_1.id, status='pending_moderation', author=self.partner_employee)
self._create_new_message(self.channel_1.id, status='accepted', author=self.partner_employee_2)
self.assertEqual(self.user_employee.moderation_counter, 2)
self.assertEqual(self.user_employee_2.moderation_counter, 0)
self.assertEqual(self.user_admin.moderation_counter, 0)
self.channel_1.write({'channel_partner_ids': [(4, self.partner_employee_2.id)], 'moderator_ids': [(4, self.user_employee_2.id)]})
self.assertEqual(self.user_employee.moderation_counter, 2)
self.assertEqual(self.user_employee_2.moderation_counter, 0)
self.assertEqual(self.user_admin.moderation_counter, 0)