summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
Diffstat (limited to 'addons')
-rw-r--r--addons/mail/__manifest__.py1
-rw-r--r--addons/mail/controllers/main.py3
-rw-r--r--addons/mail/data/ir_cron_data.xml15
-rw-r--r--addons/mail/data/mail_data.xml32
-rw-r--r--addons/mail/models/__init__.py1
-rw-r--r--addons/mail/models/mail_channel.py220
-rw-r--r--addons/mail/models/mail_message.py295
-rw-r--r--addons/mail/models/mail_thread.py24
-rw-r--r--addons/mail/models/res_company.py23
-rw-r--r--addons/mail/models/res_users.py35
-rw-r--r--addons/mail/security/ir.model.access.csv1
-rw-r--r--addons/mail/security/mail_security.xml5
-rw-r--r--addons/mail/static/src/js/discuss.js359
-rw-r--r--addons/mail/static/src/js/services/chat_manager.js118
-rw-r--r--addons/mail/static/src/js/thread.js29
-rw-r--r--addons/mail/static/src/xml/discuss.xml15
-rw-r--r--addons/mail/static/src/xml/thread.xml36
-rw-r--r--addons/mail/static/tests/discuss_moderation_tests.js822
-rw-r--r--addons/mail/static/tests/discuss_tests.js22
-rw-r--r--addons/mail/views/mail_channel_views.xml23
-rw-r--r--addons/mail/views/mail_message_views.xml2
-rw-r--r--addons/mail/views/mail_moderation_views.xml52
-rw-r--r--addons/mail/views/mail_templates.xml1
-rw-r--r--addons/test_mail/tests/__init__.py3
-rw-r--r--addons/test_mail/tests/common.py58
-rw-r--r--addons/test_mail/tests/test_mail_channel.py104
-rw-r--r--addons/test_mail/tests/test_mail_message.py105
-rw-r--r--addons/test_mail/tests/test_res_users.py34
28 files changed, 2347 insertions, 91 deletions
diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index 5bdfbc3ae61..1638baae47e 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -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',
diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py
index 9a57808f057..f4b6f6b8d5d 100644
--- a/addons/mail/controllers/main.py
+++ b/addons/mail/controllers/main.py
@@ -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
diff --git a/addons/mail/data/ir_cron_data.xml b/addons/mail/data/ir_cron_data.xml
index 8b742a7c02d..d1012c9cae6 100644
--- a/addons/mail/data/ir_cron_data.xml
+++ b/addons/mail/data/ir_cron_data.xml
@@ -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> \ No newline at end of file
+</odoo>
diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml
index 11f0117066f..fbbc41f5202 100644
--- a/addons/mail/data/mail_data.xml
+++ b/addons/mail/data/mail_data.xml
@@ -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>
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
index daf70f23ca4..a378f9e719d 100644
--- a/addons/mail/models/__init__.py
+++ b/addons/mail/models/__init__.py
@@ -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
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index 9da0862376f..edb0eb0e53e 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -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,6 +111,20 @@ 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')
@@ -97,6 +132,41 @@ class Channel(models.Model):
self.is_subscribed = self.env.user.partner_id in self.channel_partner_ids
@api.multi
+ @api.depends('moderator_ids')
+ def _compute_is_moderator(self):
+ for channel in self:
+ channel.is_moderator = self.env.user in channel.moderator_ids
+
+ @api.multi
+ @api.depends('moderation_ids')
+ def _compute_moderation_count(self):
+ read_group_res = self.env['mail.moderation'].read_group([('channel_id', 'in', self.ids)], ['channel_id'], 'channel_id')
+ data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res)
+ for channel in self:
+ channel.moderation_count = data.get(channel.id, 0)
+
+ @api.constrains('moderator_ids')
+ def _check_moderator_email(self):
+ if any(not moderator.email for channel in self for moderator in channel.moderator_ids):
+ raise ValidationError("Moderators must have an email address.")
+
+ @api.constrains('moderator_ids', 'channel_partner_ids', 'channel_last_seen_partner_ids')
+ def _check_moderator_is_member(self):
+ for channel in self:
+ if not (channel.mapped('moderator_ids.partner_id') <= channel.channel_partner_ids):
+ raise ValidationError("Moderators should be members of the channel they moderate.")
+
+ @api.constrains('moderation', 'email_send')
+ def _check_moderation_parameters(self):
+ if any(not channel.email_send and channel.moderation for channel in self):
+ raise ValidationError('Only mailing lists can be moderated.')
+
+ @api.constrains('moderator_ids')
+ def _check_moderator_existence(self):
+ if any(not channel.moderator_ids for channel in self if channel.moderation):
+ raise ValidationError('Moderated channels must have moderators.')
+
+ @api.multi
def _compute_is_member(self):
memberships = self.env['mail.channel.partner'].sudo().search([
('channel_id', 'in', self.ids),
@@ -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
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index b42de71abcf..d98c98fb1d9 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -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,
+ )
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index 5a7169ea9f1..4cde154e1cb 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -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):
diff --git a/addons/mail/models/res_company.py b/addons/mail/models/res_company.py
new file mode 100644
index 00000000000..45011902e83
--- /dev/null
+++ b/addons/mail/models/res_company.py
@@ -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 = ''
diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py
index f9ebd21eb1b..11d687cbca1 100644
--- a/addons/mail/models/res_users.py
+++ b/addons/mail/models/res_users.py
@@ -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
diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv
index c9bf2e909ec..85c2e6d4ded 100644
--- a/addons/mail/security/ir.model.access.csv
+++ b/addons/mail/security/ir.model.access.csv
@@ -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
diff --git a/addons/mail/security/mail_security.xml b/addons/mail/security/mail_security.xml
index b9b13d9de3b..d6d33578664 100644
--- a/addons/mail/security/mail_security.xml
+++ b/addons/mail/security/mail_security.xml
@@ -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>
diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js
index 3f37a620c80..def90b01595 100644
--- a/addons/mail/static/src/js/discuss.js
+++ b/addons/mail/static/src/js/discuss.js
@@ -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',
@@ -240,6 +303,49 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
//--------------------------------------------------------------------------
/**
+ * 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,12 +384,36 @@ 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
* loaded when scrolling to the top, so they can't be loaded if there is no
@@ -326,6 +456,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
});
},
/**
+ * 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.
*
@@ -394,6 +551,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
*/
_renderButtons: function () {
@@ -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
@@ -781,6 +1019,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
/**
* @private
*/
+ _onCloseNotificationBar: function () {
+ this.$(".o_mail_annoying_notification_bar").slideUp();
+ },
+ /**
+ * @private
+ */
_onComposerFocused: function () {
var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer;
var commands = this.call('chat_manager', 'getCommands', this.channel);
@@ -791,6 +1035,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
/**
* @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
+ */
_onMarkAllReadClicked: function () {
this.call('chat_manager', 'markAllAsRead', this.channel, this.domain);
},
@@ -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,12 +1075,36 @@ 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
*/
_onNewChannel: function (channel) {
@@ -872,7 +1157,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
}
})
.fail(function () {
- // todo: display notification
+ // todo: display notifications
});
},
/**
@@ -891,28 +1176,6 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
},
/**
* @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
*/
_onRequestNotificationPermission: function (event) {
@@ -951,6 +1214,17 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this._fetchAndRenderThread();
}
},
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSelectAllClicked: function (ev) {
+ var $button = $(ev.target);
+ if (!$button.hasClass('disabled')) {
+ this.thread.toggleModerationCheckboxes(true);
+ this._updateModerationButtons();
+ }
+ },
/**
* @private
* @param {MouseEvent} event
@@ -977,10 +1251,31 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
},
/**
* @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);
diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js
index 4bb6e9be6aa..eaedb9cd11e 100644
--- a/addons/mail/static/src/js/services/chat_manager.js
+++ b/addons/mail/static/src/js/services/chat_manager.js
@@ -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 = [];
@@ -522,6 +524,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.
*
@@ -557,6 +567,12 @@ var ChatManager = AbstractService.extend({
return this._getChannelCache(channel, domain).all_history_loaded;
},
/**
+ * @return {boolean}
+ */
+ isModerator: function () {
+ return this._isModerator;
+ },
+ /**
* @return {$.Promise}
*/
isReady: function () {
@@ -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) {
@@ -1469,6 +1531,13 @@ var ChatManager = AbstractService.extend({
},
/**
* @private
+ * @param {Object} data
+ */
+ _manageAuthorNotification: function (data) {
+ this._addMessage(data.message);
+ },
+ /**
+ * @private
* @param {Object} message
* @param {Array} message.channel_ids list of integers and strings,
* where strings for static channels, e.g. 'channel_inbox'.
@@ -1546,6 +1615,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.
*
* @private
@@ -1581,6 +1671,16 @@ var ChatManager = AbstractService.extend({
},
/**
* @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
* @param {integer[]} message.channel_ids
*/
@@ -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') {
diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js
index f34823ff9fc..39d179a715a 100644
--- a/addons/mail/static/src/js/thread.js
+++ b/addons/mail/static/src/js/thread.js
@@ -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;
diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml
index b0a3bfe1ba9..0bfec270eea 100644
--- a/addons/mail/static/src/xml/discuss.xml
+++ b/addons/mail/static/src/xml/discuss.xml
@@ -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">
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml
index 5ef23d46c43..20049e89cf6 100644
--- a/addons/mail/static/src/xml/thread.xml
+++ b/addons/mail/static/src/xml/thread.xml
@@ -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>
diff --git a/addons/mail/static/tests/discuss_moderation_tests.js b/addons/mail/static/tests/discuss_moderation_tests.js
new file mode 100644
index 00000000000..da8e8b1c68d
--- /dev/null
+++ b/addons/mail/static/tests/discuss_moderation_tests.js
@@ -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();
+ });
+});
+
+});
+});
diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js
index 8b7c72fd21e..a56a7c64855 100644
--- a/addons/mail/static/tests/discuss_tests.js
+++ b/addons/mail/static/tests/discuss_tests.js
@@ -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',
+ },
},
},
};
diff --git a/addons/mail/views/mail_channel_views.xml b/addons/mail/views/mail_channel_views.xml
index d5acaae761e..24115dde2fb 100644
--- a/addons/mail/views/mail_channel_views.xml
+++ b/addons/mail/views/mail_channel_views.xml
@@ -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>
diff --git a/addons/mail/views/mail_message_views.xml b/addons/mail/views/mail_message_views.xml
index 5e2fb5514a2..09bb34c86ac 100644
--- a/addons/mail/views/mail_message_views.xml
+++ b/addons/mail/views/mail_message_views.xml
@@ -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>
diff --git a/addons/mail/views/mail_moderation_views.xml b/addons/mail/views/mail_moderation_views.xml
new file mode 100644
index 00000000000..41b00dc1c3b
--- /dev/null
+++ b/addons/mail/views/mail_moderation_views.xml
@@ -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>
diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml
index af2bc8b3b75..5dd1b057bd9 100644
--- a/addons/mail/views/mail_templates.xml
+++ b/addons/mail/views/mail_templates.xml
@@ -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>
diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py
index dc9978773c4..411d73d5694 100644
--- a/addons/test_mail/tests/__init__.py
+++ b/addons/test_mail/tests/__init__.py
@@ -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 \ No newline at end of file
+from . import test_performance
+from . import test_res_users \ No newline at end of file
diff --git a/addons/test_mail/tests/common.py b/addons/test_mail/tests/common.py
index e536e15fa70..b526dce2906 100644
--- a/addons/test_mail/tests/common.py
+++ b/addons/test_mail/tests/common.py
@@ -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()
diff --git a/addons/test_mail/tests/test_mail_channel.py b/addons/test_mail/tests/test_mail_channel.py
index 73fa55ebbcc..395fbd7b318 100644
--- a/addons/test_mail/tests/test_mail_channel.py
+++ b/addons/test_mail/tests/test_mail_channel.py
@@ -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)
diff --git a/addons/test_mail/tests/test_mail_message.py b/addons/test_mail/tests/test_mail_message.py
index 5d747e0a207..94540272b4e 100644
--- a/addons/test_mail/tests/test_mail_message.py
+++ b/addons/test_mail/tests/test_mail_message.py
@@ -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") \ No newline at end of file
+ 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)
diff --git a/addons/test_mail/tests/test_res_users.py b/addons/test_mail/tests/test_res_users.py
new file mode 100644
index 00000000000..4ae3ca00265
--- /dev/null
+++ b/addons/test_mail/tests/test_res_users.py
@@ -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)