From 299ebb2cdf63de2e3967da0d2f3e7effd239812b Mon Sep 17 00:00:00 2001 From: Mathieu Duckaerts-Antoine <dam@odoo.com> Date: Thu, 24 May 2018 15:27:28 +0200 Subject: [PATCH] [IMP] mail: add moderation on channels Purpose of this commit is to allow moderation on incoming messages in discussion channels. On some channels on which moderation is required messages should be in a pending moderation stage. Moderators can accept or refuse messages as well as always allow or ban messages coming from a given set of emails. Channels now have an option to be moderated. Moderators can be added on channels. They have access to a specific UI in Discuss to see and take action on messages waiting for moderation. Concerning mail.thread message that are pending moderation are not notified. It means nobody receives a notification about them. Moderation process calls the notification once the message is validated. Various features included in this commit : * a model is added to store the decision about emails, allow or ban; * access rights are updated so that only moderators can modify moderation fields on message; * specific bus notifications are send to moderated people as well as to moderators on incoming emails as well as when a decision is taken; * options are added on channels to send explanations to moderated emails; * options are added on channels to write and send guidelines explaining why and how moderation is performed; * a reminder is send daily to moderators with remaining messages to moderate; * discuss UI is adapted and a new channel is added below Inbox and Starred giving access to moderation tools; * chanenl UI is adapted allowing to moderate directly inside channels; This commit is linked to task ID 29521. Closes #21921. --- addons/mail/__manifest__.py | 1 + addons/mail/controllers/main.py | 3 + addons/mail/data/ir_cron_data.xml | 15 +- addons/mail/data/mail_data.xml | 32 + addons/mail/models/__init__.py | 1 + addons/mail/models/mail_channel.py | 220 ++++- addons/mail/models/mail_message.py | 295 ++++++- addons/mail/models/mail_thread.py | 24 +- addons/mail/models/res_company.py | 23 + addons/mail/models/res_users.py | 35 +- addons/mail/security/ir.model.access.csv | 1 + addons/mail/security/mail_security.xml | 5 + addons/mail/static/src/js/discuss.js | 359 +++++++- .../static/src/js/services/chat_manager.js | 118 ++- addons/mail/static/src/js/thread.js | 29 +- addons/mail/static/src/xml/discuss.xml | 15 + addons/mail/static/src/xml/thread.xml | 36 +- .../static/tests/discuss_moderation_tests.js | 822 ++++++++++++++++++ addons/mail/static/tests/discuss_tests.js | 22 +- addons/mail/views/mail_channel_views.xml | 23 +- addons/mail/views/mail_message_views.xml | 2 + addons/mail/views/mail_moderation_views.xml | 52 ++ addons/mail/views/mail_templates.xml | 1 + addons/test_mail/tests/__init__.py | 3 +- addons/test_mail/tests/common.py | 58 +- addons/test_mail/tests/test_mail_channel.py | 104 ++- addons/test_mail/tests/test_mail_message.py | 105 ++- addons/test_mail/tests/test_res_users.py | 34 + 28 files changed, 2347 insertions(+), 91 deletions(-) create mode 100644 addons/mail/models/res_company.py create mode 100644 addons/mail/static/tests/discuss_moderation_tests.js create mode 100644 addons/mail/views/mail_moderation_views.xml create mode 100644 addons/test_mail/tests/test_res_users.py diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 5bdfbc3ae618..1638baae47ef 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 9a57808f057f..f4b6f6b8d5d7 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 8b742a7c02d7..d1012c9cae64 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 11f0117066f1..fbbc41f5202f 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&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 daf70f23ca42..a378f9e719d8 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 9da0862376f6..edb0eb0e53e2 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,12 +111,61 @@ class Channel(models.Model): "Use this field anywhere a small image is required.") is_subscribed = fields.Boolean( 'Is Subscribed', compute='_compute_is_subscribed') + # moderation + moderation = fields.Boolean(string='Moderate this channel') + moderator_ids = fields.Many2many('res.users', 'mail_channel_moderator_rel', string='Moderators') + is_moderator = fields.Boolean(help="Current user is a moderator of the channel", string='Moderator', compute="_compute_is_moderator") + moderation_ids = fields.One2many( + 'mail.moderation', 'channel_id', string='Moderated Emails', + groups="base.group_user") + moderation_count = fields.Integer( + string='Moderated emails count', compute='_compute_moderation_count', + groups="base.group_user") + moderation_notify = fields.Boolean(string="Automatic notification", help="People receive an automatic notification about their message being waiting for moderation.") + moderation_notify_msg = fields.Text(string="Notification message") + moderation_guidelines = fields.Boolean(string="Send guidelines to new subscribers", help="Newcomers on this moderated channel will automatically receive the guidelines.") + moderation_guidelines_msg = fields.Text(string="Guidelines") @api.one @api.depends('channel_partner_ids') def _compute_is_subscribed(self): self.is_subscribed = self.env.user.partner_id in self.channel_partner_ids + @api.multi + @api.depends('moderator_ids') + def _compute_is_moderator(self): + for channel in self: + channel.is_moderator = self.env.user in channel.moderator_ids + + @api.multi + @api.depends('moderation_ids') + def _compute_moderation_count(self): + read_group_res = self.env['mail.moderation'].read_group([('channel_id', 'in', self.ids)], ['channel_id'], 'channel_id') + data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res) + for channel in self: + channel.moderation_count = data.get(channel.id, 0) + + @api.constrains('moderator_ids') + def _check_moderator_email(self): + if any(not moderator.email for channel in self for moderator in channel.moderator_ids): + raise ValidationError("Moderators must have an email address.") + + @api.constrains('moderator_ids', 'channel_partner_ids', 'channel_last_seen_partner_ids') + def _check_moderator_is_member(self): + for channel in self: + if not (channel.mapped('moderator_ids.partner_id') <= channel.channel_partner_ids): + raise ValidationError("Moderators should be members of the channel they moderate.") + + @api.constrains('moderation', 'email_send') + def _check_moderation_parameters(self): + if any(not channel.email_send and channel.moderation for channel in self): + raise ValidationError('Only mailing lists can be moderated.') + + @api.constrains('moderator_ids') + def _check_moderator_existence(self): + if any(not channel.moderator_ids for channel in self if channel.moderation): + raise ValidationError('Moderated channels must have moderators.') + @api.multi def _compute_is_member(self): memberships = self.env['mail.channel.partner'].sudo().search([ @@ -113,6 +183,26 @@ class Channel(models.Model): else: self.alias_contact = 'followers' + @api.onchange('moderator_ids') + def _onchange_moderator_ids(self): + missing_partners = self.mapped('moderator_ids.partner_id') - self.channel_partner_ids + if missing_partners: + self.channel_partner_ids |= missing_partners + + @api.onchange('email_send') + def _onchange_email_send(self): + if not self.email_send: + self.moderation = False + + @api.onchange('moderation') + def _onchange_moderation(self): + if not self.moderation: + self.moderation_notify = False + self.moderation_guidelines = False + self.moderator_ids = False + else: + self.moderator_ids |= self.env.user + @api.model def create(self, vals): # ensure image at quick create @@ -154,10 +244,25 @@ class Channel(models.Model): @api.multi def write(self, vals): + # First checks if user tries to modify moderation fields and has not the right to do it. + if any(key for key in MODERATION_FIELDS if vals.get(key)) and any(self.env.user not in channel.moderator_ids for channel in self if channel.moderation): + if not self.env.user.has_group('base.group_system'): + raise UserError("You do not possess the rights to modify fields related to moderation on one of the channels you are modifying.") + tools.image_resize_images(vals) result = super(Channel, self).write(vals) + if vals.get('group_ids'): self._subscribe_users() + + # avoid keeping messages to moderate and accept them + if vals.get('moderation') is False: + self.env['mail.message'].search([ + ('moderation_status', '=', 'pending_moderation'), + ('model', '=', 'mail.channel'), + ('res_id', 'in', self.ids) + ])._moderate_accept() + return result def get_alias_model_name(self, vals): @@ -240,12 +345,54 @@ class Channel(models.Model): } return super(Channel, self)._notify_email_recipients(message, recipient_ids) + def _extract_moderation_values(self, message_type, **kwargs): + """ This method is used to compute moderation status before the creation + of a message. For this operation the message's author email address is required. + This address is returned with status for other computations. """ + moderation_status = 'accepted' + email = '' + if self.moderation and message_type in ['email', 'comment']: + author_id = kwargs.get('author_id') + if author_id and isinstance(author_id, pycompat.integer_types): + email = self.env['res.partner'].browse([author_id]).email + elif author_id: + email = author_id.email + elif kwargs.get('email_from'): + email = tools.email_split(kwargs['email_from'])[0] + else: + email = self.env.user.email + if email in self.mapped('moderator_ids.email'): + return moderation_status, email + status = self.env['mail.moderation'].sudo().search([('email', '=', email), ('channel_id', 'in', self.ids)]).mapped('status') + if status and status[0] == 'allow': + moderation_status = 'accepted' + elif status and status[0] == 'ban': + moderation_status = 'rejected' + else: + moderation_status = 'pending_moderation' + return moderation_status, email + @api.multi - @api.returns('self', lambda value: value.id) - def message_post(self, **kwargs): - # auto pin 'direct_message' channel partner + @api.returns('mail.message', lambda value: value.id) + def message_post(self, message_type='notification', **kwargs): + moderation_status, email = self._extract_moderation_values(message_type, **kwargs) + if moderation_status == 'rejected': + return self.env['mail.message'] + self.filtered(lambda channel: channel.channel_type == 'chat').mapped('channel_last_seen_partner_ids').write({'is_pinned': True}) - message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(**kwargs) + + message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs) + + # Notifies the message author when his message is pending moderation if required on channel. + # The fields "email_from" and "reply_to" are filled in automatically by method create in model mail.message. + if self.moderation_notify and self.moderation_notify_msg and message_type == 'email' and moderation_status == 'pending_moderation': + self.env['mail.mail'].create({ + 'body_html': self.moderation_notify_msg, + 'subject': 'Re: %s' % (kwargs.get('subject', '')), + 'email_to': email, + 'auto_delete': True, + 'state': 'outgoing' + }) return message def _alias_check_contact(self, message, message_dict, alias): @@ -264,6 +411,58 @@ class Channel(models.Model): if not self._cr.fetchone(): self._cr.execute('CREATE INDEX mail_channel_partner_seen_message_id_idx ON mail_channel_partner (channel_id,partner_id,seen_message_id)') + # -------------------------------------------------- + # Moderation + # -------------------------------------------------- + + @api.multi + def send_guidelines(self): + """ Send guidelines to all channel members. """ + if self.env.user in self.moderator_ids or self.env.user.has_group('base.group_system'): + success = self._send_guidelines(self.channel_partner_ids) + if not success: + raise UserError('Template "mail.mail_template_channel_send_guidelines" was not found. No email has been sent. Please contact an administrator to fix this issue.') + else: + raise UserError("Only an administrator or a moderator can send guidelines to channel members!") + + @api.multi + def _send_guidelines(self, partners): + """ Send guidelines of a given channel. Returns False if template used for guidelines + not found. Caller may have to handle this return value. """ + self.ensure_one() + template = self.env.ref('mail.mail_template_channel_send_guidelines', raise_if_not_found=False) + if not template: + _logger.warning('Template "mail.mail_template_channel_send_guidelines" was not found.') + return False + banned_emails = self.env['mail.moderation'].sudo().search([ + ('status', '=', 'ban'), + ('channel_id', 'in', self.ids) + ]).mapped('email') + for partner in partners.filtered(lambda p: p.email and not (p.email in banned_emails)): + # the sudo is needed because because send_mail will create a message (a mail). As the template is + # linked to res.partner model the current user could not have the rights to modify this model. It + # means it could prevent users to create the mail.message necessary for the mail.mail. + template.with_context(lang=partner.lang, channel=self).sudo().send_mail(partner.id) + return True + + @api.multi + def _update_moderation_email(self, emails, status): + """ This method adds emails into either white or black of the channel list of emails + according to status. If an email in emails is already moderated, the method updates the email status. + :param emails: list of email addresses to put in white or black list of channel. + :param status: value is 'allow' or 'ban'. Emails are put in white list if 'allow', in black list if 'ban'. + """ + self.ensure_one() + splitted_emails = [tools.email_split(email)[0] for email in emails if tools.email_split(email)] + moderated = self.env['mail.moderation'].sudo().search([ + ('email', 'in', splitted_emails), + ('channel_id', 'in', self.ids) + ]) + cmds = [(1, record.id, {'status': status}) for record in moderated] + not_moderated = [email for email in splitted_emails if email not in moderated.mapped('email')] + cmds += [(0, 0, {'email': email, 'status': status}) for email in not_moderated] + return self.write({'moderation_ids': cmds}) + #------------------------------------------------------ # Instant Messaging API #------------------------------------------------------ @@ -347,6 +546,8 @@ class Channel(models.Model): 'channel_type': channel.channel_type, 'public': channel.public, 'mass_mailing': channel.email_send, + 'moderation': channel.moderation, + 'is_moderator': self.env.uid in channel.moderator_ids.ids, 'group_based_subscription': bool(channel.group_ids), } if extra_info: @@ -572,6 +773,9 @@ class Channel(models.Model): self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") self.action_follow() + if self.moderation_guidelines: + self._send_guidelines(self.env.user.partner_id) + channel_info = self.channel_info()[0] self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info) return channel_info diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index b42de71abcfd..d98c98fb1d9b 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 5a7169ea9f14..4cde154e1cbf 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 000000000000..45011902e83c --- /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 f9ebd21eb1bc..11d687cbca18 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 c9bf2e909ec6..85c2e6d4dedf 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 b9b13d9de3bc..d6d335786646 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 3f37a620c80b..def90b01595c 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', @@ -239,6 +302,49 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { // Private //-------------------------------------------------------------------------- + /** + * Ban the authors of the messages with ID in `messageIDs` + * Show a confirmation dialog to the moderator. + * + * @private + * @param {integer[]} messageIDs IDs of messages for which we should ban authors + */ + _banAuthorsFromMessageIDs: function (messageIDs) { + var self = this; + var emailList = _.map(messageIDs, function (messageID) { + return self.call('chat_manager', 'getMessage', messageID).email_from; + }).join(", "); + var text = _.str.sprintf(_t("You are going to ban: %s. Do you confirm the action?"), emailList); + var options = { + confirm_callback: function () { + self._moderateMessages(messageIDs, 'ban'); + } + }; + Dialog.confirm(this, text, options); + }, + /** + * Discard the messages with ID in `messageIDs` + * Show a confirmation dialog to the moderator. + * + * @private + * @param {integer[]]} messageIDs list of message IDs to discard + */ + _discardMessages: function (messageIDs) { + var self = this; + var num = messageIDs.length; + var text; + if (num > 1) { + text = _.str.sprintf(_t("You are going to discard %s messages. Do you confirm the action?"), num); + } else if (num === 1) { + text = _t("You are going to discard 1 message. Do you confirm the action?"); + } + var options = { + confirm_callback: function () { + self._moderateMessages(messageIDs, 'discard'); + } + }; + Dialog.confirm(this, text, options); + }, /** * @private * @returns {$.Promise} @@ -278,11 +384,35 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { squash_close_messages: this.channel.type !== 'static' && !this.channel.mass_mailing, display_empty_channel: !messages.length && !this.domain.length, display_no_match: !messages.length && this.domain.length, - display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox", + display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox" || this.channel.id === "channel_moderation", display_email_icon: false, display_reply_icon: true, }; }, + /** + * Determine the action to apply on messages with ID in `messageIDs` + * based on the moderation decision `decision`. + * + * @private + * @param {number[]} messageIDs list of message ids that are moderated + * @param {string} decision of the moderator, could be either 'reject', + * 'discard', 'ban', 'accept', 'allow'. + */ + _handleModerationDecision: function (messageIDs, decision) { + if (messageIDs) { + if (decision === 'reject') { + this._rejectMessages(messageIDs); + } else if (decision === 'discard') { + this._discardMessages(messageIDs); + } else if (decision === 'ban') { + this._banAuthorsFromMessageIDs(messageIDs); + } else { + // other decisions do not need more information, + // confirmation dialog, etc. + this._moderateMessages(messageIDs, decision); + } + } + }, /** * Ensures that enough messages have been loaded to fill the entire screen * (this is particularily important because remaining messages can only be @@ -325,6 +455,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { self.thread.scroll_to({offset: offset}); }); }, + /** + * Apply the moderation decision `decision` on the messages with ID in + * `messageIDs`. + * + * @private + * @param {integer[]} messageIDs list of message IDs to apply the + * moderation decision. + * @param {string} decision the moderation decision to apply on the + * messages. Could be either 'reject', 'discard', 'ban', 'accept', + * or 'allow'. + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + * @return {undefined|$.Promise} + */ + _moderateMessages: function (messageIDs, decision, kwargs) { + if (messageIDs.length && decision) { + return this._rpc({ + model: 'mail.message', + method: 'moderate', + args: [messageIDs, decision], + kwargs: kwargs + }); + } + }, /** * Binds handlers on the given $input to make them autocomplete and/or * create channels. @@ -393,6 +550,24 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }); } }, + /** + * Reject the messages + * + * The moderator must provide a reason for reject, and may also + * cancel his action. + * + * @private + * @param {number[]]} messageIDs list of message IDs to reject + */ + _rejectMessages: function (messageIDs) { + var self = this; + new ModeratorRejectMessageDialog(this, { + messageIDs: messageIDs, + proceedReject: function (reason) { + self._moderateMessages(messageIDs, 'reject', reason); + } + }).open(); + }, /** * @private */ @@ -402,6 +577,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { this.$buttons.on('click', '.o_mail_chat_button_invite', this._onInviteButtonClicked.bind(this)); this.$buttons.on('click', '.o_mail_chat_button_mark_read', this._onMarkAllReadClicked.bind(this)); this.$buttons.on('click', '.o_mail_chat_button_unstar_all', this._onUnstarAllClicked.bind(this)); + this.$buttons.on('click', '.o_mail_chat_button_moderate_all', this._onModerateAllClicked.bind(this)); + this.$buttons.on('click', '.o_mail_chat_button_select_all', this._onSelectAllClicked.bind(this)); + this.$buttons.on('click', '.o_mail_chat_button_unselect_all', this._onUnselectAllClicked.bind(this)); }, /** * @private @@ -454,25 +632,23 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @returns {Deferred} */ _renderThread: function () { - var self = this; this.thread = new ChatThread(this, {display_help: true, loadMoreOnScroll: true}); this.thread.on('redirect', this, function (resModel, resID) { - self.call('chat_manager', 'redirect', resModel, resID, self._setChannel.bind(self)); + this.call('chat_manager', 'redirect', resModel, resID, this._setChannel.bind(this)); }); this.thread.on('redirect_to_channel', this, function (channelID) { - self.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this)); + this.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this)); }); this.thread.on('load_more_messages', this, this._loadMoreMessages); this.thread.on('mark_as_read', this, function (messageID) { - self.call('chat_manager', 'markAsRead', [messageID]); + this.call('chat_manager', 'markAsRead', [messageID]); }); this.thread.on('toggle_star_status', this, function (messageID) { - self.call('chat_manager', 'toggleStarStatus', messageID); + this.call('chat_manager', 'toggleStarStatus', messageID); }); this.thread.on('select_message', this, this._selectMessage); this.thread.on('unselect_message', this, this._unselectMessage); - return this.thread.appendTo(this.$('.o_mail_chat_content')); }, /** @@ -612,6 +788,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { chatBus.on('update_channel_unread_counter', this, this.throttledUpdateChannels); chatBus.on('update_dm_presence', this, this.throttledUpdateChannels); chatBus.on('activity_updated', this, this.throttledUpdateChannels); + chatBus.on('update_moderation_counter', this, this.throttledUpdateChannels); }, /** * Stores the scroll position and composer state of the current channel @@ -663,6 +840,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { channels: this.call('chat_manager', 'getChannels'), needaction_counter: this.call('chat_manager', 'getNeedactionCounter'), starred_counter: this.call('chat_manager', 'getStarredCounter'), + moderationCounter: this.call('chat_manager', 'getModerationCounter'), + isModerator: this.call('chat_manager', 'isModerator'), }); this.$(".o_mail_chat_sidebar").html($sidebar.contents()); _.each(['dm', 'public', 'private'], function (type) { @@ -694,6 +873,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { .find('.o_mail_chat_button_unstar_all') .toggleClass('disabled', disabled); } + if ((this.channel.isModerated && this.channel.isModerator) || this.channel.id === "channel_moderation") { + this._updateModerationButtons(); + } }, /** * @private @@ -727,13 +909,69 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { .find('.o_mail_chat_button_unstar_all') .toggle(channel.id === "channel_starred") .removeClass("o_hidden"); - + this.$buttons + .find('.o_mail_chat_button_select_all') + .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation") + .removeClass("o_hidden"); + this.$buttons + .find('.o_mail_chat_button_unselect_all') + .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation") + .removeClass("o_hidden"); + this.$buttons.find('.o_mail_chat_button_moderate_all').hide(); this.$('.o_mail_chat_channel_item') .removeClass('o_active') .filter('[data-channel-id=' + channel.id + ']') .removeClass('o_unread_message') .addClass('o_active'); }, + /** + * Update the moderation buttons. + * + * @private + */ + _updateModerationButtons: function () { + this._updateSelectUnselectAllButtons(); + this._updateModerationDecisionButton(); + }, + /** + * Display/hide the "moderate all" button based on whether + * some moderation checkboxes are checked or not. + * If some checkboxes are checked, display this button, + * otherwise hide it. + * + * @private + */ + _updateModerationDecisionButton: function () { + if (this.thread.$('.moderation_checkbox:checked').length) { + this.$buttons.find('.o_mail_chat_button_moderate_all').show(); + } else { + this.$buttons.find('.o_mail_chat_button_moderate_all').hide(); + } + }, + /** + * @private + */ + _updateSelectUnselectAllButtons: function () { + var buttonSelect = this.$buttons.find('.o_mail_chat_button_select_all'); + var buttonUnselect = this.$buttons.find('.o_mail_chat_button_unselect_all'); + var numCheckboxes = this.thread.$('.moderation_checkbox').length; + var numCheckboxesChecked = this.thread.$('.moderation_checkbox:checked').length; + if (numCheckboxes) { + if (numCheckboxesChecked === numCheckboxes) { + buttonSelect.toggleClass('disabled', true); + buttonUnselect.toggleClass('disabled', false); + } else if (numCheckboxesChecked === 0) { + buttonSelect.toggleClass('disabled', false); + buttonUnselect.toggleClass('disabled', true); + } else { + buttonSelect.toggleClass('disabled', false); + buttonUnselect.toggleClass('disabled', false); + } + } else { + buttonSelect.toggleClass('disabled', true); + buttonUnselect.toggleClass('disabled', true); + } + }, //-------------------------------------------------------------------------- // Handlers @@ -778,6 +1016,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { this._updateChannels(); delete this.channelsScrolltop[channelID]; }, + /** + * @private + */ + _onCloseNotificationBar: function () { + this.$(".o_mail_annoying_notification_bar").slideUp(); + }, /** * @private */ @@ -788,6 +1032,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { composer.mention_set_enabled_commands(commands); composer.mention_set_prefetched_partners(partners); }, + /** + * @private + */ + _onInviteButtonClicked: function () { + var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name); + new PartnerInviteDialog(this, title, this.channel.id).open(); + }, + /** + * @private + * @param {KeyEvent} event + */ + _onKeydown: function (event) { + if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) { + this._unselectMessage(); + } + }, /** * @private */ @@ -803,6 +1063,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { var self = this; var currentChannelID = this.channel.id; if ((currentChannelID === "channel_starred" && !message.is_starred) || + (currentChannelID === "channel_moderation" && !message.needsModeration) || (currentChannelID === "channel_inbox" && !message.is_needaction)) { this.call('chat_manager', 'getMessages', { channelID: this.channel.id, @@ -814,10 +1075,34 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { self._updateButtonStatus(messages.length === 0, type); }); }); - } else if (_.contains(message.channel_ids, currentChannelID)) { + } else if (_.contains(message.channel_ids, currentChannelID) || (message.res_id === currentChannelID)) { this._fetchAndRenderThread(); } }, + /** + * @private + * @param {MouseEvent} ev + */ + _onModerateAllClicked: function (ev) { + var decision = $(ev.target).data('decision'); + var messageIDs = this.thread.$('.moderation_checkbox:checked') + .map(function () { + return $(this).data('message-id'); + }) + .get(); + this._handleModerationDecision(messageIDs, decision); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {integer} ev.data.messageID ID of the moderated message + * @param {string} ev.data.decision can be 'reject', 'discard', 'ban', 'accept', 'allow'. + */ + _onMessageModeration: function (ev) { + var messageIDs = [ev.data.messageID]; + var decision = ev.data.decision; + this._handleModerationDecision(messageIDs, decision); + }, /** * @private * @param {Object} channel @@ -872,7 +1157,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { } }) .fail(function () { - // todo: display notification + // todo: display notifications }); }, /** @@ -889,28 +1174,6 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { on_reverse_breadcrumb: this.on_reverse_breadcrumb, }); }, - /** - * @private - */ - _onCloseNotificationBar: function () { - this.$(".o_mail_annoying_notification_bar").slideUp(); - }, - /** - * @private - */ - _onInviteButtonClicked: function () { - var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name); - new PartnerInviteDialog(this, title, this.channel.id).open(); - }, - /** - * @private - * @param {KeyEvent} event - */ - _onKeydown: function (event) { - if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) { - this._unselectMessage(); - } - }, /** * @private * @param {MouseEvent} event @@ -950,6 +1213,17 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { // this will be done as soon as the default channel is set this._fetchAndRenderThread(); } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onSelectAllClicked: function (ev) { + var $button = $(ev.target); + if (!$button.hasClass('disabled')) { + this.thread.toggleModerationCheckboxes(true); + this._updateModerationButtons(); + } }, /** * @private @@ -975,12 +1249,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { var channel = this.call('chat_manager', 'getChannel', channelID); this.call('chat_manager', 'unsubscribe', channel); }, + /** + * @private + * @param {MouseEvent} ev + */ + _onUnselectAllClicked: function (ev) { + var $button = $(ev.target); + if (!$button.hasClass('disabled')) { + this.thread.toggleModerationCheckboxes(false); + this._updateModerationButtons(); + } + }, /** * @private */ _onUnstarAllClicked: function () { this.call('chat_manager', 'unstarAll'); }, + /** + * Update the moderation buttons. + * This is triggered when a moderation checkbox + * has its checked property changed. + * + * @private + */ + _onUpdateModerationButtons: function () { + this._updateModerationButtons(); + }, }); core.action_registry.add('mail.chat.instant_messaging', Discuss); diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js index 4bb6e9be6aab..eaedb9cd11e8 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 = []; @@ -521,6 +523,14 @@ var ChatManager = AbstractService.extend({ } return $.when([]); }, + /** + * Returns the number of messages that the user needs to moderate + * + * @return {integer} + */ + getModerationCounter: function () { + return this.moderationCounter; + }, /** * Returns the number of messages received from followed channels * + all messages where the current user is notified. @@ -556,6 +566,12 @@ var ChatManager = AbstractService.extend({ isAllHistoryLoaded: function (channel, domain) { return this._getChannelCache(channel, domain).all_history_loaded; }, + /** + * @return {boolean} + */ + isModerator: function () { + return this._isModerator; + }, /** * @return {$.Promise} */ @@ -733,7 +749,7 @@ var ChatManager = AbstractService.extend({ subtype: 'mail.mt_comment', command: data.command, }), - }); + }); } if ('model' in options && 'res_id' in options) { // post a message in a chatter @@ -1034,6 +1050,15 @@ var ChatManager = AbstractService.extend({ } } else if (options.domain && options.domain !== []) { this._addToCache(msg, options.domain); + } else if (data.moderation_status === 'accepted') { + msg.channel_ids = data.channel_ids; + msg.needsModeration = false; + if (msg.isModerator) { + this.moderationCounter--; + this._removeMessageFromChannel("channel_moderation", msg); + this.chatBus.trigger('update_moderation_counter'); + } + this.chatBus.trigger('update_message', msg); } return msg; }, @@ -1110,9 +1135,16 @@ var ChatManager = AbstractService.extend({ var self = this; options = options || {}; var domain = - (channel.id === "channel_inbox") ? [['needaction', '=', true]] : - (channel.id === "channel_starred") ? [['starred', '=', true]] : - [['channel_ids', 'in', channel.id]]; + (channel.id === 'channel_inbox') ? [['needaction', '=', true]] : + (channel.id === 'channel_starred') ? [['starred', '=', true]] : + (channel.id === 'channel_moderation') ? [['need_moderation', '=', true]] : + ['|', + '&', '&', + ['model', '=', 'mail.channel'], + ['res_id', 'in', [channel.id]], + ['need_moderation', '=', true], + ['channel_ids', 'in', [channel.id]] + ]; var cache = this._getChannelCache(channel, options.domain); if (options.domain) { @@ -1185,6 +1217,18 @@ var ChatManager = AbstractService.extend({ }); self.needactionCounter = result.needaction_inbox_counter || 0; self.starredCounter = result.starred_counter || 0; + self.moderationCounter = result.moderation_counter; + self.moderatedChannelIDs = result.moderation_channel_ids; + self._isModerator = result.is_moderator; + + //if user is moderator then add moderation channel + if (self._isModerator) { + self._addChannel({ + id: "channel_moderation", + name: _lt("Moderate Messages"), + type: "static" + }); + } self.commands = _.map(result.commands, function (command) { return _.extend({ id: command.name }, command); }); @@ -1233,17 +1277,19 @@ var ChatManager = AbstractService.extend({ * @param {Object[]} [data.direct_partner] * @param {boolean} [data.group_based_subscription] * @param {integer|string} data.id + * @param {boolean} data.is_moderator * @param {boolean} data.is_minimized * @param {string} [data.last_message_date] * @param {boolean} data.mass_mailing * @param {integer} [data.message_needaction_counter] * @param {integer} [data.message_unread_counter] + * @param {boolean} data.moderation whether this channel is moderated or not * @param {string} data.name * @param {string} [data.public] * @param {integer} data.seen_message_id * @param {string} [data.state] * @param {string} [data.type] - * @param {string} channel.uuid + * @param {string} data.uuid * @param {Object} options * @param {boolean} [options.autoswitch] * @param {boolean} options.displayNeedactions @@ -1264,6 +1310,8 @@ var ChatManager = AbstractService.extend({ hidden: options.hidden, display_needactions: options.displayNeedactions, mass_mailing: data.mass_mailing, + isModerated: data.moderation, + isModerator: data.is_moderator, group_based_subscription: data.group_based_subscription, needaction_counter: data.message_needaction_counter || 0, unread_counter: 0, @@ -1314,6 +1362,7 @@ var ChatManager = AbstractService.extend({ * @param {boolean} data.is_note * @param {boolean} data.is_notification * @param {string} data.message_type + * @param {string} [data.moderation_status] * @param {string} [data.model] * @param {boolean} data.module_icon src url of the module icon * @param {string} data.record_name @@ -1333,6 +1382,9 @@ var ChatManager = AbstractService.extend({ message_type: data.message_type, subtype_description: data.subtype_description, is_author: data.author_id && data.author_id[0] === session.partner_id, + isModerator: data.model === 'mail.channel' && + _.contains(this.moderatedChannelIDs, data.res_id) && + data.moderation_status === 'pending_moderation', is_note: data.is_note, is_discussion: data.is_discussion, is_notification: data.is_notification, @@ -1384,6 +1436,7 @@ var ChatManager = AbstractService.extend({ Object.defineProperties(msg, { is_starred: propertyDescr("channel_starred"), is_needaction: propertyDescr("channel_inbox"), + needsModerationByUser: propertyDescr("channel_moderation"), }); if (_.contains(data.needaction_partner_ids, session.partner_id)) { @@ -1392,8 +1445,17 @@ var ChatManager = AbstractService.extend({ if (_.contains(data.starred_partner_ids, session.partner_id)) { msg.is_starred = true; } + if (data.moderation_status === 'pending_moderation') { + msg.needsModeration = true; + msg.needsModerationByUser = msg.isModerator; + // the message is not linked to the moderated channel on the + // server, therefore this message has not this channel in + // channel_ids. Here, just to show this message in the channel + //visually, it links this message to the channel + msg.channel_ids.push(msg.res_id); + } if (msg.model === 'mail.channel') { - var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred'); + var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred', 'channel_moderation'); var origin = realChannels.length === 1 ? realChannels[0] : undefined; var channel = origin && this.getChannel(origin); if (channel) { @@ -1467,6 +1529,13 @@ var ChatManager = AbstractService.extend({ _manageActivityUpdateNotification: function (data) { this.chatBus.trigger('activity_updated', data); }, + /** + * @private + * @param {Object} data + */ + _manageAuthorNotification: function (data) { + this._addMessage(data.message); + }, /** * @private * @param {Object} message @@ -1545,6 +1614,27 @@ var ChatManager = AbstractService.extend({ } } }, + /** + * @private + * @param {Object} data + * @param {Object[]} [data.message_ids] + */ + _manageDeletionNotification: function (data) { + var self = this; + _.each(data.message_ids, function (msgID) { + var message = _.findWhere(self.messages, { id: msgID }); + if (message) { + if (message.isModerator) { + self._removeMessageFromChannel("channel_moderation", message); + self.moderationCounter--; + } + message.needsModeration = false; + self._removeMessageFromChannel(message.res_id, message); + self.chatBus.trigger('update_message', message); + } + }); + this.chatBus.trigger('update_moderation_counter'); + }, /** * Updates channel_inbox when a message has marked as read. * @@ -1579,6 +1669,16 @@ var ChatManager = AbstractService.extend({ this.needactionCounter = Math.max(this.needactionCounter - data.message_ids.length, 0); this.chatBus.trigger('update_needaction', this.needactionCounter); }, + /** + * @private + * @param {Object} data notification data + * @param {Object} data.message + */ + _manageModeratorNotification: function (data) { + this.moderationCounter++; + this._addMessage(data.message); + this.chatBus.trigger('update_moderation_counter'); + }, /** * @private * @param {Object} message @@ -1626,6 +1726,12 @@ var ChatManager = AbstractService.extend({ this._manageToggleStarNotification(data); } else if (data.type === 'mark_as_read') { this._manageMarkAsReadNotification(data); + } else if (data.type === 'moderator') { + this._manageModeratorNotification(data); + } else if (data.type === 'author') { + this._manageAuthorNotification(data); + } else if (data.type === 'deletion') { + this._manageDeletionNotification(data); } else if (data.info === 'channel_seen') { this._manageChannelSeenNotification(data); } else if (data.info === 'transient_message') { diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index f34823ff9fc9..39d179a715a3 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 b0a3bfe1ba95..0bfec270eea7 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 5ef23d46c43f..20049e89cf6a 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 && !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 && message.model != 'mail.channel' && options.display_reply_icon" @@ -194,6 +199,15 @@ <i t-if="message.is_needaction && 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 000000000000..da8e8b1c68db --- /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 8b7c72fd21e8..a56a7c64855e 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 d5acaae761ea..24115dde2fb7 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 5e2fb5514a2e..09bb34c86ac3 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 000000000000..41b00dc1c3b3 --- /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 af2bc8b3b75b..5dd1b057bd9c 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 dc9978773c42..411d73d5694a 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 e536e15fa708..b526dce2906f 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 73fa55ebbcc4..395fbd7b3185 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 5d747e0a207f..94540272b4e2 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 000000000000..4ae3ca002656 --- /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) -- GitLab