diff --git a/addons/mail_group/__init__.py b/addons/mail_group/__init__.py new file mode 100644 index 00000000000..88710707227 --- /dev/null +++ b/addons/mail_group/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import wizard diff --git a/addons/mail_group/__manifest__.py b/addons/mail_group/__manifest__.py new file mode 100644 index 00000000000..4386f4e2e64 --- /dev/null +++ b/addons/mail_group/__manifest__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Mail Group", + 'summary': "Manage your mailing lists", + 'description': """ + Manage your mailing lists from Odoo. + """, + 'version': '1.0', + 'depends': [ + 'mail', + 'portal', + ], + 'auto_install': False, + 'data': [ + 'data/ir_cron_data.xml', + 'data/mail_templates.xml', + 'data/res_groups.xml', + 'security/ir.model.access.csv', + 'security/mail_group_security.xml', + 'wizard/mail_group_message_reject_views.xml', + 'views/mail_group_member_views.xml', + 'views/mail_group_message_views.xml', + 'views/mail_group_moderation_views.xml', + 'views/mail_group_views.xml', + 'views/mail_group_menus.xml', + 'views/portal_templates.xml', + ], + 'demo': [ + 'data/mail_group_demo.xml', + ], + 'assets': { + 'web.assets_frontend': [ + 'mail_group/static/src/*/*', + ], + }, +} diff --git a/addons/mail_group/controllers/__init__.py b/addons/mail_group/controllers/__init__.py new file mode 100644 index 00000000000..903b755e71e --- /dev/null +++ b/addons/mail_group/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import portal diff --git a/addons/mail_group/controllers/portal.py b/addons/mail_group/controllers/portal.py new file mode 100644 index 00000000000..c6e5ea234a9 --- /dev/null +++ b/addons/mail_group/controllers/portal.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import werkzeug + +from odoo import http, fields, tools +from odoo.addons.http_routing.models.ir_http import slug +from odoo.addons.portal.controllers.portal import pager as portal_pager +from odoo.http import request +from odoo.osv import expression + + +class PortalMailGroup(http.Controller): + _thread_per_page = 20 + _replies_per_page = 5 + + def _get_archives(self, group_id): + """Return the different date range and message count for the group messages.""" + results = request.env['mail.group.message']._read_group_raw( + [('mail_group_id', '=', group_id)], + ['subject', 'create_date'], + groupby=['create_date'], orderby='create_date DESC') + + groups = [] + + for result in results: + (dates_range, label) = result['create_date'] + start, end = dates_range.split('/') + + groups.append({ + 'date': label, + 'date_begin': fields.Date.to_string(fields.Date.to_date(start)), + 'date_end': fields.Date.to_string(fields.Date.to_date(end)), + 'messages_count': result['create_date_count'], + }) + + return groups + + # ------------------------------------------------------------ + # MAIN PAGE + # ------------------------------------------------------------ + + @http.route('/groups', type='http', auth='public', sitemap=True, website=True) + def groups_index(self, email='', **kw): + """View of the group lists. Allow the users to subscribe and unsubscribe.""" + if kw.get('group_id') and kw.get('token'): + group_id = int(kw.get('group_id')) + token = kw.get('token') + group = request.env['mail.group'].browse(group_id).exists().sudo() + if not group: + raise werkzeug.exceptions.NotFound() + + if token != group._generate_group_access_token(): + raise werkzeug.exceptions.NotFound() + + mail_groups = group + + else: + mail_groups = request.env['mail.group'].search([]).sudo() + + if not request.env.user._is_public(): + # Force the email if the user is logged + email_normalized = request.env.user.email_normalized + partner_id = request.env.user.partner_id.id + else: + email_normalized = tools.email_normalize(email) + partner_id = None + + members_data = mail_groups._find_members(email_normalized, partner_id) + + return request.render('mail_group.mail_groups', { + 'mail_groups': [{ + 'group': group, + 'is_member': bool(members_data.get(group.id, False)), + } for group in mail_groups], + 'email': email_normalized, + }) + + # ------------------------------------------------------------ + # THREAD DISPLAY / MANAGEMENT + # ------------------------------------------------------------ + + @http.route([ + '/groups/', + '/groups//page/', + ], type='http', auth='public', sitemap=True, website=True) + def group_view_messages(self, group, page=1, mode='thread', date_begin=None, date_end=None, **post): + GroupMessage = request.env['mail.group.message'] + + domain = [('mail_group_id', '=', group.id)] + if mode == 'thread': + domain += [('group_message_parent_id', '=', False)] + + if date_begin and date_end: + domain += [('create_date', '>=', date_begin), ('create_date', '<=', date_end)] + + messages = GroupMessage.search( + domain, limit=self._thread_per_page, + offset=(page - 1) * self._thread_per_page, + order='create_date, id DESC') + + pager = portal_pager( + url=f'/groups/{slug(group)}', + total=GroupMessage.search_count(domain), + page=page, + step=self._thread_per_page, + scope=5, + url_args={'date_begin': date_begin, 'date_end': date_end, 'mode': mode} + ) + + return request.render('mail_group.group_messages', { + 'group': group, + 'messages': messages, + 'archives': self._get_archives(group.id), + 'date_begin': date_begin, + 'date_end': date_end, + 'pager': pager, + 'replies_per_page': self._replies_per_page, + }) + + @http.route('/groups//', + type='http', auth='public', sitemap=True, website=True) + def group_view_message(self, group, message, mode='thread', date_begin=None, date_end=None, **post): + if group != message.mail_group_id: + raise werkzeug.exceptions.NotFound() + + GroupMessage = request.env['mail.group.message'] + base_domain = [ + ('mail_group_id', '=', group.id), + ('group_message_parent_id', '=', message.group_message_parent_id.id), + ] + + next_message = GroupMessage.search( + expression.AND([base_domain, [('id', '>', message.id)]]), + order='id ASC', limit=1) + prev_message = GroupMessage.search( + expression.AND([base_domain, [('id', '<', message.id)]]), + order='id DESC', limit=1) + + # Handle access to attachments for public / portal + if request.env.user.has_group('base.group_user'): + attachments = message.attachment_ids + else: + attachments = message.sudo().attachment_ids + attachments.generate_access_token() + + values = { + 'message': message, + 'attachments': attachments, + 'group': group, + 'mode': mode, + 'archives': self._get_archives(group.id), + 'date_begin': date_begin, + 'date_end': date_end, + 'replies_per_page': self._replies_per_page, + 'next_message': next_message, + 'prev_message': prev_message, + } + return request.render('mail_group.group_message', values) + + @http.route('/groups///get_replies', + type='json', auth='public', methods=['POST'], website=True) + def group_message_get_replies(self, group, message, last_displayed_id, **post): + if group != message.mail_group_id: + raise werkzeug.exceptions.NotFound() + + replies_domain = [('id', '>', int(last_displayed_id)), ('group_message_parent_id', '=', message.id)] + messages = request.env['mail.group.message'].search(replies_domain, limit=self._replies_per_page) + message_count = request.env['mail.group.message'].search_count(replies_domain) + + if not messages: + return + + values = { + 'group': group, + 'parent_message': message, + 'messages': messages, + 'msg_more_count': message_count - self._replies_per_page, + 'replies_per_page': self._replies_per_page, + } + return request.env.ref('mail_group.messages_short')._render(values, engine='ir.qweb') + + # ------------------------------------------------------------ + # SUBSCRIPTION + # ------------------------------------------------------------ + + @http.route('/groups/subscription', type='json', auth='public', website=True) + def group_subscribe(self, group_id=0, action='subscribe', email=None, token=None, **kw): + """Subscribe the current logged user or the given email address to the mailing list. + + If the user is logged, the action is automatically done. + + But if the user is not logged (public user) an email will be send with a token + to confirm the action. + + :param group_id: Id of the group + :param action: Action to perform (subscribe or unsubscribe) + :param email: Email to add in the member list + :param token: An access token to bypass the access rule + :return: + 'added' + if the mmeber was added in the mailing list + 'removed' + if the member was removed from the mailing list + 'email_sent' + if we send a confirmation email + 'is_already_member' + if we try to subscribe but we are already member + 'is_not_member' + if we try to unsubscribe but we are not member + """ + if action not in ('subscribe', 'unsubscribe'): + raise werkzeug.exceptions.NotFound() + + group = request.env['mail.group'].browse(int(group_id)).exists() + if not group: + raise werkzeug.exceptions.NotFound() + + # SUDO to have access to field of the many2one + group_sudo = group.sudo() + + if token and token != group_sudo._generate_group_access_token(): + raise werkzeug.exceptions.NotFound() + + elif not token: + # Check that the current user has access to the group + group.check_access_rights('read') + group.check_access_rule('read') + + partner_id = None + if not request.env.user._is_public(): + partner_id = request.env.user.partner_id.id + + is_member = bool(group_sudo._find_member(email, partner_id)) + if action == 'subscribe' and is_member: + return 'is_already_member' + elif action == 'unsubscribe' and not is_member: + return 'is_not_member' + + if not request.env.user._is_public(): + # For logged user, automatically join / leave without sending a confirmation email + if action == 'subscribe': + group_sudo._join_group(request.env.user.email, partner_id) + return 'added' + else: + group_sudo._leave_group(request.env.user.email, partner_id) + return 'removed' + else: + # For non-logged user, send an email with a token to confirm the action + if action == 'subscribe': + group_sudo._send_subscribe_confirmation_email(email, action) + else: + group_sudo._send_unsubscribe_confirmation_email(email, action) + return 'email_sent' + + @http.route('/groups/subscribe', type='http', auth='public', website=True) + def groups_subscribe_confirm(self, group_id, email, token, action, **kw): + """Confirm the subscribe / unsubscribe action which was sent by email.""" + if action not in ('subscribe', 'unsubscribe'): + raise werkzeug.exceptions.NotFound() + + # Here we can SUDO because the token will be checked + group = request.env['mail.group'].browse(int(group_id)).exists().sudo() + if not group: + raise werkzeug.exceptions.NotFound() + + excepted_token = group._generate_action_token(email, action) + + if token != excepted_token: + return request.render('mail_group.invalid_token_subscription') + + partners = request.env['mail.thread'].sudo()._mail_find_partner_from_emails([email]) + partner_id = partners[0].id if partners else None + if action == 'subscribe': + group._join_group(email, partner_id) + else: + group._leave_group(email, partner_id) + + return request.render('mail_group.confirmation_subscription', {'subscribing': action == 'subscribe'}) diff --git a/addons/mail_group/data/ir_cron_data.xml b/addons/mail_group/data/ir_cron_data.xml new file mode 100644 index 00000000000..90b9cf69322 --- /dev/null +++ b/addons/mail_group/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + Mail List: Notify group moderators + + code + model._cron_notify_moderators() + + 1 + days + -1 + + 1000 + + + diff --git a/addons/mail_group/data/mail_group_demo.xml b/addons/mail_group/data/mail_group_demo.xml new file mode 100644 index 00000000000..d83ace5c832 --- /dev/null +++ b/addons/mail_group/data/mail_group_demo.xml @@ -0,0 +1,164 @@ + + + + + Newsletter + newsletter + Receive news about "My Company" + + + groups + + + + + + admin@yourcompany.example.com + + + + + mark.brown23@example.com + + + + + U3VwZXIgc2VjcmV0IGF0dGFjaG1lbnQ= + attachment.txt + + + QnV0IEphdmFzY3JpcHQgc3Vja3M= + attachment.txt + + + + Important Announce + This weekend, you should all come to our barbecue! + mark.brown23@example.com + + accepted + + + + + + Re: Important Announce + Will there be vegetarian food? + admin@yourcompany.example.com + + accepted + + + + + Re: Important Announce + Yes of course, and for those who are allergic bring your own food + mark.brown23@example.com + + accepted + + + + + Re: Important Announce + Can I come with my dog ? + joel.willis63@example.com + accepted + + + + + Re: Important Announce + No animals please. + admin@yourcompany.example.com + + accepted + + + + + Recruitment + We are pleased to announce that we have hired 10 new people this month. + admin@yourcompany.example.com + + accepted + + + + + Relocation + We are moving our office to Brussels. Take back all your remaining stuff. + admin@yourcompany.example.com + + accepted + + + + + Re: Relocation + Is there a swimming pool in this new office? + joel.willis63@example.com + accepted + + + + + Re: Relocation + Of course! + admin@yourcompany.example.com + + accepted + + + + + Re: Relocation + What is the deadline for taking back my stuff? + mark.brown23@example.com + + accepted + + + + + Re: Relocation + In 4 weeks. + admin@yourcompany.example.com + + pending_moderation + + + + + I really like CSS & HTML + + <p style="margin:0px; font-size:13px;">Hi,</p><br> <p style="margin:0px; font-size:13px;"> We <u style="font-size:14px">really</u> like <font style="color:rgb(57, 123, 33); font-weight:bolder">colors</font> and <span style="font-weight:bolder"><font style="color:rgb(255, 0, 0)">CSS</font></span>. </p> <p style="margin:0px; font-size:13px;">Do you know you can add </p> <a href="https://example.com" target="_blank" class="btn btn-primary flat btn-sm">link in email</a> ? <ol> <li>Also image</li> <li>List</li> <li>Any HTML code in the end...</li> </ol> + + + admin@yourcompany.example.com + + pending_moderation + + + + + + + Public Mailing List + public_group + Get the patch notes of our amazing product. + + + + + + Best patch ever + In this patch, we have cleaned the CSS! + mark.brown23@example.com + + accepted + + + + + diff --git a/addons/mail_group/data/mail_templates.xml b/addons/mail_group/data/mail_templates.xml new file mode 100644 index 00000000000..e7a066ee254 --- /dev/null +++ b/addons/mail_group/data/mail_templates.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + Mail Group: Mailing list subscription + + Confirm subscription to ${object.name} + +
+ Hello,

+ You have requested to be subscribed to the mailing list ${object.name}. +

+ To confirm, please visit the following link: ${ctx['token_url']} +

+ If this was a mistake or you did not requested this action, please ignore this message. +
+
+ +
+ + + + Mail Group: Mailing list unsubscription + + Confirm unsubscription to ${object.name} + +
+ Hello,

+ You have requested to be unsubscribed to the mailing list ${object.name}. +

+ To confirm, please visit the following link: ${ctx['token_url']}. +

+ If this was a mistake or you did not requested this action, please ignore this message. +
+
+ +
+
diff --git a/addons/mail_group/data/res_groups.xml b/addons/mail_group/data/res_groups.xml new file mode 100644 index 00000000000..7579f1ad319 --- /dev/null +++ b/addons/mail_group/data/res_groups.xml @@ -0,0 +1,10 @@ + + + + Mail Group Administrator + + + + + + diff --git a/addons/mail_group/models/__init__.py b/addons/mail_group/models/__init__.py new file mode 100644 index 00000000000..3671e351d89 --- /dev/null +++ b/addons/mail_group/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import mail_group +from . import mail_group_member +from . import mail_group_message +from . import mail_group_moderation diff --git a/addons/mail_group/models/mail_group.py b/addons/mail_group/models/mail_group.py new file mode 100644 index 00000000000..833c1ec395b --- /dev/null +++ b/addons/mail_group/models/mail_group.py @@ -0,0 +1,571 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import json +import logging + +from ast import literal_eval +from datetime import datetime +from dateutil import relativedelta +from werkzeug import urls +from odoo.addons.http_routing.models.ir_http import slug + +from odoo import _, api, fields, models, modules, tools +from odoo.exceptions import ValidationError, UserError +from odoo.osv import expression +from odoo.tools import email_normalize, hmac, generate_tracking_message_id + +_logger = logging.getLogger(__name__) + +GROUP_SEND_BATCH_SIZE = 500 + + +class MailGroup(models.Model): + """This model represents a mailing list. + + Users send emails to an alias to create new group messages or reply to existing + group messages. Moderation can be activated on groups. In that case email have to + be validated or rejected. + """ + _name = 'mail.group' + _description = 'Mailing List' + # TDE CHECK: use blaclist mixin + _inherit = ['mail.alias.mixin'] + + @api.model + def default_get(self, fields): + res = super(MailGroup, self).default_get(fields) + if not res.get('alias_contact') and (not fields or 'alias_contact' in fields): + res['alias_contact'] = 'everyone' if res.get('access_mode') == 'public' else 'followers' + return res + + def _get_default_image(self): + image_path = modules.get_resource_path('mail', 'static/src/img', 'groupdefault.png') + with tools.file_open(image_path, 'rb') as fd: + return base64.b64encode(fd.read()) + + name = fields.Char('Name', required=True) + alias_name = fields.Char('Alias Name', copy=False, related='alias_id.alias_name', readonly=False) + description = fields.Text('Description') + image_128 = fields.Image('Image', max_width=128, max_height=128, default=_get_default_image) + # Messages + mail_group_message_ids = fields.One2many('mail.group.message', 'mail_group_id', string='Pending Messages') + mail_group_message_last_month_count = fields.Integer('Messages Per Month', compute='_compute_mail_group_message_last_month_count') + mail_group_message_moderation_count = fields.Integer('Messages Count', help='Messages that need an action', compute='_compute_mail_group_message_moderation_count') + # Members + member_ids = fields.One2many('mail.group.member', 'mail_group_id', string='Members') + member_partner_ids = fields.Many2many('res.partner', string='Partners Member', compute='_compute_member_partner_ids', search='_search_member_partner_ids') + member_count = fields.Integer('Members Count', compute='_compute_member_count') + # Moderation + is_moderator = fields.Boolean(string='Moderator', help='Current user is a moderator of the group', compute='_compute_is_moderator') + moderation = fields.Boolean(string='Moderate this group') + moderation_rule_count = fields.Integer(string='Moderated emails count', compute='_compute_moderation_rule_count') + moderation_rule_ids = fields.One2many('mail.group.moderation', 'mail_group_id', string='Moderated Emails') + moderator_ids = fields.Many2many('res.users', 'mail_group_moderator_rel', string='Moderators', + domain=lambda self: [('groups_id', 'in', self.env.ref('base.group_user').id)]) + 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 group will automatically receive the guidelines.') + moderation_guidelines_msg = fields.Text(string='Guidelines') + # ACLs + access_mode = fields.Selection([ + ('public', 'Everyone'), + ('members', 'Members only'), + ('groups', 'Selected group of users'), + ], string='Privacy', required=True, default='public') + access_group_id = fields.Many2one('res.groups', string='Authorized Group', + default=lambda self: self.env.ref('base.group_user')) + # UI + can_manage_members = fields.Boolean('Can Manage', help='Can manage the members', compute='_compute_can_manage_members') + + @api.depends('mail_group_message_ids.create_date', 'mail_group_message_ids.moderation_status') + def _compute_mail_group_message_last_month_count(self): + month_date = datetime.today() - relativedelta.relativedelta(months=1) + messages_data = self.env['mail.group.message'].read_group([ + ('create_date', '>=', fields.Datetime.to_string(month_date)), + ('moderation_status', '=', 'accepted'), + ], ['mail_group_id'], ['mail_group_id']) + + # { mail_discusison_id: number_of_mail_group_message_last_month_count } + messages_data = { + message['mail_group_id'][0]: message['mail_group_id_count'] + for message in messages_data + } + + for group in self: + group.mail_group_message_last_month_count = messages_data.get(group.id, 0) + + @api.depends('mail_group_message_ids.moderation_status') + def _compute_mail_group_message_moderation_count(self): + results = self.env['mail.group.message'].read_group( + [('moderation_status', '=', 'pending_moderation')], + ['mail_group_id'], + ['mail_group_id'], + ) + result_per_group = { + result['mail_group_id'][0]: result['mail_group_id_count'] + for result in results + } + + for group in self: + group.mail_group_message_moderation_count = result_per_group.get(group.id) + + @api.depends('member_ids') + def _compute_member_count(self): + for group in self: + group.member_count = len(group.member_ids) + + @api.depends('member_ids') + def _compute_member_partner_ids(self): + for group in self: + group.member_partner_ids = group.member_ids.partner_id + + def _search_member_partner_ids(self, operator, operand): + return [( + 'member_ids', + 'in', + self.env['mail.group.member'].sudo()._search([ + ('partner_id', operator, operand) + ]) + )] + + @api.depends('moderator_ids') + @api.depends_context('uid') + def _compute_is_moderator(self): + for group in self: + group.is_moderator = self.env.user.id in group.moderator_ids.ids + + @api.depends('moderation_rule_ids') + def _compute_moderation_rule_count(self): + for group in self: + group.moderation_rule_count = len(group.moderation_rule_ids) + + @api.depends('is_moderator') + @api.depends_context('uid') + def _compute_can_manage_members(self): + is_admin = self.env.user.has_group('mail_group.group_mail_group_manager') + for group in self: + group.can_manage_members = is_admin or group.is_moderator + + @api.onchange('access_mode') + def _onchange_access_mode(self): + if self.access_mode == 'public': + self.alias_contact = 'everyone' + else: + self.alias_contact = 'followers' + + @api.onchange('moderation') + def _onchange_moderation(self): + if self.moderation and self.env.user not in self.moderator_ids: + self.moderator_ids |= self.env.user + + # CONSTRAINTS + + @api.constrains('moderator_ids') + def _check_moderator_email(self): + if any(not moderator.email for group in self for moderator in group.moderator_ids): + raise ValidationError(_('Moderators must have an email address.')) + + @api.constrains('moderation_notify', 'moderation_notify_msg') + def _check_moderation_notify(self): + if any(group.moderation_notify and not group.moderation_notify_msg for group in self): + raise ValidationError(_('The notification message is missing.')) + + @api.constrains('moderation_guidelines', 'moderation_guidelines_msg') + def _check_moderation_guidelines(self): + if any(group.moderation_guidelines and not group.moderation_guidelines_msg for group in self): + raise ValidationError(_('The guidelines description is missing.')) + + @api.constrains('moderator_ids', 'moderation') + def _check_moderator_existence(self): + if any(not group.moderator_ids for group in self if group.moderation): + raise ValidationError(_('Moderated group must have moderators.')) + + @api.constrains('access_mode', 'access_group_id') + def _check_access_mode(self): + if any(group.access_mode == 'groups' and not group.access_group_id for group in self): + raise ValidationError(_('The "Authorized Group" is missing.')) + + def _alias_get_creation_values(self): + """Return the default values for the automatically created alias.""" + values = super(MailGroup, self)._alias_get_creation_values() + values['alias_model_id'] = self.env['ir.model']._get('mail.group').id + values['alias_force_thread_id'] = self.id + values['alias_defaults'] = literal_eval(self.alias_defaults or '{}') + return values + + # ------------------------------------------------------------ + # MAILING + # ------------------------------------------------------------ + + def _alias_get_error_message(self, message, message_dict, alias): + self.ensure_one() + + if alias.alias_contact == 'followers': + # Members only + author_id = message_dict.get('author_id', None) + email_from = message_dict.get('email_from') + if not self._find_member(email_from, author_id): + return _('Only members can send email to the mailing list.') + # Skip the verification because the partner is in the member list + return + + return super(MailGroup, self)._alias_get_error_message(message, message_dict, alias) + + @api.model + def message_new(self, msg_dict, custom_values=None): + """Add the method to make the mail gateway flow work with this model.""" + return + + @api.model + def message_update(self, msg_dict, update_vals=None): + """Add the method to make the mail gateway flow work with this model.""" + return + + @api.returns('mail.message', lambda value: value.id) + def message_post(self, body='', subject=None, email_from=None, author_id=None, **kwargs): + """ Custom posting process. This model does not inherit from ``mail.thread`` + but uses mailgateway so few methods should be defined. + + This custom posting process works as follow + + * create a ``mail.message`` based on incoming email; + * create linked ``mail.group.message`` that encapsulates message in a + format used in mail groups; + * apply moderation rules; + + :return message: newly-created mail.message + """ + self.ensure_one() + # First create the + Mailthread = self.env['mail.thread'] + values = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields) + author_id, email_from = Mailthread._message_compute_author(author_id, email_from, raise_exception=True) + values.update({ + 'author_id': author_id, + 'body': body or '', + 'email_from': email_from, + 'model': self._name, + 'partner_ids': [], + 'reply_to': self.env['mail.message']._get_reply_to(values), + 'res_id': self.id, + 'subject': subject, + }) + # ensure message ID so that replies go to the right thread + if not values.get('message_id'): + values['message_id'] = generate_tracking_message_id('%s-mail.group' % self.id) + + attachments = kwargs.get('attachments') or [] + attachment_ids = kwargs.get('attachment_ids') or [] + attachement_values = Mailthread._message_post_process_attachments(attachments, attachment_ids, values) + values.update(attachement_values) + + mail_message = Mailthread._message_create(values) + + # Find the parent + group_message_parent_id = False + if mail_message.parent_id: + group_message_parent = self.env['mail.group.message'].search( + [('mail_message_id', '=', mail_message.parent_id.id)]) + group_message_parent_id = group_message_parent.id if group_message_parent else False + + moderation_status = 'pending_moderation' if self.moderation else 'accepted' + + # Create the group message associated + group_message = self.env['mail.group.message'].create({ + 'mail_group_id': self.id, + 'mail_message_id': mail_message.id, + 'moderation_status': moderation_status, + 'group_message_parent_id': group_message_parent_id, + }) + + # Check the moderation rule to determine if we should accept or reject the email + email_normalized = email_normalize(email_from) + moderation_rule = self.env['mail.group.moderation'].search([ + ('mail_group_id', '=', self.id), + ('email', '=', email_normalized), + ], limit=1) + + if not self.moderation: + self._notify_members(group_message) + + elif moderation_rule and moderation_rule.status == 'allow': + group_message.action_moderate_accept() + + elif moderation_rule and moderation_rule.status == 'ban': + group_message.action_moderate_reject() + + elif self.moderation_notify: + self.env['mail.mail'].sudo().create({ + 'author_id': self.env.user.partner_id.id, + 'auto_delete': True, + 'body_html': group_message.mail_group_id.moderation_notify_msg, + 'email_from': self.env.user.company_id.catchall_formatted or self.env.user.company_id.email_formatted, + 'email_to': email_from, + 'subject': 'Re: %s' % (subject or ''), + 'state': 'outgoing' + }) + + return mail_message + + def action_send_guidelines(self, members=None): + """ Send guidelines to given members. """ + self.ensure_one() + + if not self.env.is_admin() and not self.is_moderator: + raise UserError(_('Only an administrator or a moderator can send guidelines to group members.')) + + if not self.moderation_guidelines_msg: + raise UserError(_('The guidelines description is empty.')) + + template = self.env.ref('mail_group.mail_group_send_guidelines', raise_if_not_found=False) + if not template: + raise UserError(_('View "mail_group.mail_group_send_guidelines" was not found. No email has been sent. Please contact an administrator to fix this issue.')) + + banned_emails = self.env['mail.group.moderation'].sudo().search([ + ('status', '=', 'ban'), + ('mail_group_id', '=', self.id), + ]).mapped('email') + + if members is None: + members = self.member_ids + members = members.filtered(lambda member: member.email_normalized not in banned_emails) + + mails_values = [] + for member in members: + company = member.partner_id.company_id or self.env.company + mails_values.append({ + 'author_id': self.env.user.partner_id.id, + 'auto_delete': True, + 'body_html': template._render({'group': self, 'member': member}, engine='ir.qweb', minimal_qcontext=True), + 'email_from': company.catchall_formatted or company.email_formatted, + 'email_to': member.email, + 'model': 'mail.group', + 'res_id': self.id, + 'subject': _('Guidelines of group %s', self.name), + }) + + mails = self.env['mail.mail'].sudo().create(mails_values) + + _logger.info('Send guidelines to %i members', len(mails)) + + def _notify_members(self, message): + """Send the given message to all members of the mail group (except the author).""" + self.ensure_one() + + if message.mail_group_id != self: + raise UserError(_('The group of the message do not match.')) + + if not message.mail_message_id.reply_to: + _logger.error('The alias or the catchall domain is missing, group might not work properly.') + + base_url = self.get_base_url() + body = self.env['mail.render.mixin']._replace_local_links(message.body) + access_token = self._generate_group_access_token() + mail_values = [] + for batch_members in tools.split_every(GROUP_SEND_BATCH_SIZE, self.member_ids): + for member in batch_members: + if member.email_normalized == message.email_from_normalized: + # Do not send the email to his author + continue + + # SMTP headers related to the subscription + email_url_encoded = urls.url_quote(member.email_normalized) + headers = { + 'List-Archive': f'<{base_url}/groups/{slug(self)}>', + 'List-Subscribe': f'<{base_url}/groups?email={email_url_encoded}>', + 'List-Unsubscribe': f'<{base_url}/groups?unsubscribe&email={email_url_encoded}>', + } + + # Add the footer (member specific) in the body + template_values = { + 'mailto': f'{self.alias_name}@{self.alias_domain}', + 'group_url': f'{base_url}/groups/{slug(self)}', + 'unsub_url': f'{base_url}/groups?unsubscribe&group_id={self.id}&token={access_token}' + } + template = self.env.ref('mail_group.mail_group_footer') + footer = template._render(template_values, engine='ir.qweb', minimal_qcontext=True) + member_body = tools.append_content_to_html(body, footer) + + mail_values.append({ + 'auto_delete': True, + 'attachment_ids': message.attachment_ids.ids, + 'body_html': member_body, + 'email_from': message.email_from, + 'email_to': member.email, + 'headers': json.dumps(headers), + 'mail_message_id': message.mail_message_id.id, + 'message_id': message.mail_message_id.message_id, + 'model': 'mail.group', + 'reply_to': message.mail_message_id.reply_to, + 'res_id': self.id, + 'subject': message.subject, + }) + + if mail_values: + self.env['mail.mail'].sudo().create(mail_values) + + @api.model + def _cron_notify_moderators(self): + moderated_groups = self.env['mail.group'].search([('moderation', '=', True)]) + return moderated_groups._notify_moderators() + + def _notify_moderators(self): + """Push a notification (Inbox / Email) to the moderators whose an action is waiting.""" + template = self.env.ref('mail_group.mail_group_notify_moderation', raise_if_not_found=False) + if not template: + _logger.warning('Template "mail_group.mail_group_notify_moderation" was not found. Cannot send reminder notifications.') + return + + results = self.env['mail.group.message'].read_group( + [('moderation_status', '=', 'pending_moderation'), + ('mail_group_id', 'in', self.ids) + ], + ['mail_group_id'], + ['mail_group_id'], + ) + groups = self.browse([result['mail_group_id'][0] for result in results]) + moderators_to_notify = groups.mapped('moderator_ids') + + MailThread = self.env['mail.thread'].with_context(mail_notify_author=True) + for moderator in moderators_to_notify: + body = template._render({'record': moderator.partner_id}, engine='ir.qweb', minimal_qcontext=True) + email_from = moderator.company_id.catchall_formatted or moderator.company_id.email_formatted + MailThread.message_notify( + partner_ids=moderator.partner_id.ids, + subject=_('Messages are pending moderation'), body=body, + email_from=email_from, + ) + + # ------------------------------------------------------------ + # MEMBERSHIP + # ------------------------------------------------------------ + + def _join_group(self, email, partner_id=None): + self.ensure_one() + + if partner_id: + partner = self.env['res.partner'].browse(partner_id).exists() + email = partner.email + + existing_member = self._find_member(email, partner_id) + if existing_member: + raise UserError(_('The email "%s" is already registered in this group.', email)) + + member = self.env['mail.group.member'].create({ + 'partner_id': partner_id, + 'email': email, + 'mail_group_id': self.id, + }) + + if self.moderation_guidelines: + # Automatically send the guidelines to the new member + self.action_send_guidelines(member) + + def _leave_group(self, email, partner_id=None): + self.ensure_one() + member = self._find_member(email, partner_id) + if member: + member.unlink() + + def _send_subscribe_confirmation_email(self, email): + """Send an email to the given address to subscribe / unsubscribe to the mailing list.""" + self.ensure_one() + confirm_action_url = self._generate_action_url(email, 'subscribe') + + template = self.env.ref('mail_group.mail_template_list_subscribe') + template.with_context(token_url=confirm_action_url).send_mail( + self.id, + force_send=True, + email_values={ + 'author_id': self.create_uid.partner_id.id, + 'auto_delete': True, + 'email_from': self.env.company.email_formatted, + 'email_to': email, + 'message_type': 'user_notification', + }, + notif_layout='mail.mail_notification_light', + ) + _logger.info('Subscription email sent to %s.', email) + + def _send_unsubscribe_confirmation_email(self, email, action): + """Send an email to the given address to subscribe / unsubscribe to the mailing list.""" + self.ensure_one() + confirm_action_url = self._generate_action_url(email, 'unsubscribe') + + template = self.env.ref('mail_group.mail_template_list_unsubscribe') + template.with_context(token_url=confirm_action_url).send_mail( + self.id, + force_send=True, + email_values={ + 'author_id': self.create_uid.partner_id.id, + 'auto_delete': True, + 'email_from': self.env.company.email_formatted, + 'email_to': email, + 'message_type': 'user_notification', + }, + notif_layout='mail.mail_notification_light', + ) + _logger.info('Unsubscription email sent to %s.', email) + + def _generate_action_url(self, email, action): + """Generate the confirmation URL to subscribe / unsubscribe from the mailing list.""" + if action not in ['subscribe', 'unsubscribe']: + raise ValueError(_('Invalid action for URL generation (%s)', action)) + self.ensure_one() + + confirm_action_url = '/groups/subscribe?%s' % urls.url_encode({ + 'group_id': self.id, + 'email': email, + 'token': self._generate_action_token(email, action), + 'action': action, + }) + base_url = self.get_base_url() + confirm_action_url = urls.url_join(base_url, confirm_action_url) + return confirm_action_url + + def _generate_action_token(self, email, action): + """Generate an action token to be able to subscribe / unsubscribe from the mailing list.""" + if action not in ['subscribe', 'unsubscribe']: + raise ValueError(_('Invalid action for URL generation (%s)', action)) + self.ensure_one() + + email_normalized = email_normalize(email) + if not email_normalized: + raise UserError(_('Email %s is invalid', email)) + + data = (self.id, email_normalized, action) + return hmac(self.env(su=True), 'mail_group-email-subscription', data) + + def _generate_group_access_token(self): + """Generate an action token to be able to subscribe / unsubscribe from the mailing list.""" + self.ensure_one() + return hmac(self.env(su=True), 'mail_group-access-token-portal', self.id) + + def _find_member(self, email, partner_id=None): + """Return the corresponding to the given email address.""" + self.ensure_one() + + result = self._find_members(email, partner_id) + return result.get(self.id) + + def _find_members(self, email, partner_id): + """Get all the members record corresponding to the email / partner_id. + + Can be called in batch and return a dictionary + {'group_id': } + """ + domain = [('email_normalized', '=', email_normalize(email))] + if partner_id: + domain = expression.OR([domain, [('partner_id', '=', partner_id)]]) + domain = expression.AND([domain, [('mail_group_id', 'in', self.ids)]]) + members_data = self.env['mail.group.member'].sudo().search(domain) + return { + member.mail_group_id.id: member + for member in members_data + } diff --git a/addons/mail_group/models/mail_group_member.py b/addons/mail_group/models/mail_group_member.py new file mode 100644 index 00000000000..3bb07d0a3e6 --- /dev/null +++ b/addons/mail_group/models/mail_group_member.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import api, fields, models +from odoo.tools import email_normalize + +_logger = logging.getLogger(__name__) + + +class MailGroupMember(models.Model): + """Models a group member that can be either an email address either a full partner.""" + _name = 'mail.group.member' + _description = 'Mailing List Member' + _rec_name = 'email' + + email = fields.Char(string='Email', compute='_compute_email', readonly=False, store=True) + email_normalized = fields.Char( + string='Normalized Email', compute='_compute_email_normalized', + index=True, store=True) + mail_group_id = fields.Many2one('mail.group', string='Group', required=True, ondelete='cascade') + partner_id = fields.Many2one('res.partner', 'Partner', ondelete='cascade') + + _sql_constraints = [( + 'unique_partner', + 'UNIQUE(partner_id, mail_group_id)', + 'This partner is already subscribed to the group', + )] + + @api.depends('partner_id.email') + def _compute_email(self): + for member in self: + if member.partner_id: + member.email = member.partner_id.email + elif not member.email: + member.email = False + + @api.depends('email') + def _compute_email_normalized(self): + for moderation in self: + moderation.email_normalized = email_normalize(moderation.email) diff --git a/addons/mail_group/models/mail_group_message.py b/addons/mail_group/models/mail_group_message.py new file mode 100644 index 00000000000..1b705031eec --- /dev/null +++ b/addons/mail_group/models/mail_group_message.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, api, fields, models, tools +from odoo.exceptions import AccessError, UserError +from odoo.osv import expression +from odoo.tools import email_normalize + +_logger = logging.getLogger(__name__) + + +class MailGroupMessage(models.Model): + """Emails belonging to a discussion group. + + Those are build on with additional information related to specific + features of like better parent / children management and moderation. + """ + _name = 'mail.group.message' + _description = 'Mailing List Message' + _rec_name = 'subject' + _order = 'create_date DESC' + + # fields, can not be done with inherits because it will impact + # the performance of the model (different cache, so the ORM will need + # to do one more SQL query to be able to update the cache) + attachment_ids = fields.Many2many(related='mail_message_id.attachment_ids', readonly=False) + author_id = fields.Many2one(related='mail_message_id.author_id', readonly=False) + email_from = fields.Char(related='mail_message_id.email_from', readonly=False) + email_from_normalized = fields.Char('Normalized From', compute='_compute_email_from_normalized', store=True) + body = fields.Html(related='mail_message_id.body', readonly=False) + subject = fields.Char(related='mail_message_id.subject', readonly=False) + # thread + mail_group_id = fields.Many2one( + 'mail.group', string='Group', + required=True, ondelete='cascade') + mail_message_id = fields.Many2one('mail.message', 'Mail Message', required=True, ondelete='cascade', index=True, copy=False) + # Parent and children + group_message_parent_id = fields.Many2one( + 'mail.group.message', string='Parent', store=True, + inverse='_inverse_group_message_parent_id') + group_message_child_ids = fields.One2many('mail.group.message', 'group_message_parent_id', string='Childs') + # Moderation + moderation_status = fields.Selection( + [('pending_moderation', 'Pending Moderation'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected')], + string='Status', index=True, copy=False, + required=True, default='pending_moderation') + moderator_id = fields.Many2one('res.users', string='Moderated By') + + @api.depends('email_from') + def _compute_email_from_normalized(self): + for message in self: + message.email_from_normalized = email_normalize(message.email_from) + + def _inverse_group_message_parent_id(self): + # TDE CHECK: check to remove + for message in self: + message.mail_message_id.parent_id = message.group_message_parent_id.mail_message_id + + @api.constrains('mail_message_id') + def _constrains_mail_message_id(self): + for message in self: + if message.mail_message_id.model != 'mail.group': + raise AccessError(_( + 'Group message can only be linked to mail group. Current model is %s.', + message.mail_message_id.model, + )) + if message.mail_message_id.res_id != message.mail_group_id.id: + raise AccessError(_('The record of the message should be the group.')) + + @api.model_create_multi + def create(self, values_list): + for vals in values_list: + if not vals.get('mail_message_id'): + vals.update({ + 'res_id': vals.get('mail_group_id'), + 'model': 'mail.group', + }) + vals['mail_message_id'] = self.env['mail.message'].sudo().create({ + field: vals.pop(field) + for field in self.env['mail.message']._fields + if field in vals + }).id + return super(MailGroupMessage, self).create(values_list) + + def copy(self, default=None): + default = dict(default or {}) + default['mail_message_id'] = self.mail_message_id.copy().id + return super(MailGroupMessage, self).copy(default) + + # -------------------------------------------------- + # MODERATION API + # -------------------------------------------------- + + def action_moderate_accept(self): + """Accept the incoming email. + + Will send the incoming email to all members of the group. + """ + self._assert_moderable() + self.write({ + 'moderation_status': 'accepted', + 'moderator_id': self.env.uid, + }) + + # Send the email to the members of the group + for message in self: + message.mail_group_id._notify_members(message) + + def action_moderate_reject_with_comment(self, reject_subject, reject_comment): + self._assert_moderable() + if reject_subject or reject_comment: + self._moderate_send_reject_email(reject_subject, reject_comment) + self.action_moderate_reject() + + def action_moderate_reject(self): + self._assert_moderable() + self.write({ + 'moderation_status': 'rejected', + 'moderator_id': self.env.uid, + }) + + def action_allow(self): + self._assert_moderable() + self._create_moderation_rule('allow') + + # Accept all emails of the same authors + same_author = self._get_pending_same_author_same_group() + same_author.action_moderate_accept() + + def action_ban(self): + self._assert_moderable() + self._create_moderation_rule('ban') + + # Reject all emails of the same author + same_author = self._get_pending_same_author_same_group() + same_author.action_moderate_reject() + + def _get_pending_same_author_same_group(self): + """Return the pending messages of the same authors in the same groups.""" + return self.search( + expression.AND([ + expression.OR([ + [ + ('mail_group_id', '=', message.mail_group_id.id), + ('email_from_normalized', '=', message.email_from_normalized), + ] for message in self + ]), + [('moderation_status', '=', 'pending_moderation')], + ]) + ) + + def _create_moderation_rule(self, status): + """Create a moderation rule with the given status. + + Update existing moderation rule for the same email address if found, + otherwise create a new rule. + """ + if status not in ('ban', 'allow'): + raise ValueError(_('Wrong status (%s)', status)) + + for message in self: + if not email_normalize(message.email_from): + raise UserError(_('The email "%s" is not valid.', message.email_from)) + + existing_moderation = self.env['mail.group.moderation'].search( + expression.OR([ + [ + ('email', '=', email_normalize(message.email_from)), + ('mail_group_id', '=', message.mail_group_id.id) + ] for message in self + ]) + ) + existing_moderation.status = status + + # Add the value in a set to create only 1 moderation rule per (email_normalized, group) + moderation_to_create = { + (email_normalize(message.email_from), message.mail_group_id.id) + for message in self + if email_normalize(message.email_from) not in existing_moderation.mapped('email') + } + + self.env['mail.group.moderation'].create([ + { + 'email': email, + 'mail_group_id': mail_group_id, + 'status': status, + } for email, mail_group_id in moderation_to_create]) + + def _assert_moderable(self): + """Raise an error if one of the current message can not be moderated. + + A can only be moderated + if it's moderation status is "pending_moderation". + """ + non_moderable_messages = self.filtered_domain([ + ('moderation_status', '!=', 'pending_moderation'), + ]) + if non_moderable_messages: + if len(self) == 1: + raise UserError(_('This message can not be moderated')) + raise UserError(_( + 'Those messages can not be moderated: %s.', + ', '.join(non_moderable_messages.mapped('subject')), + )) + + def _moderate_send_reject_email(self, subject, comment): + for message in self: + if not message.email_from: + continue + + # TDE CHECK: markup + body_html = tools.append_content_to_html('
%s
' % tools.ustr(comment), message.body, plaintext=False) + body_html = self.env['mail.render.mixin']._replace_local_links(body_html) + self.env['mail.mail'].sudo().create({ + 'author_id': self.env.user.partner_id.id, + 'auto_delete': True, + 'body_html': body_html, + 'email_from': self.env.user.email_formatted or self.env.company.catchall_formatted, + 'email_to': message.email_from, + 'references': message.mail_message_id.message_id, + 'subject': subject, + 'state': 'outgoing', + }) diff --git a/addons/mail_group/models/mail_group_moderation.py b/addons/mail_group/models/mail_group_moderation.py new file mode 100644 index 00000000000..c19b1ba3ec9 --- /dev/null +++ b/addons/mail_group/models/mail_group_moderation.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api, _ +from odoo.exceptions import UserError +from odoo.tools import email_normalize + + +class MailGroupModeration(models.Model): + """Represent the moderation rules for an email address in a group.""" + _name = 'mail.group.moderation' + _description = 'Mailing List black/white list' + + email = fields.Char(string='Email', required=True) + status = fields.Selection( + [('allow', 'Always Allow'), ('ban', 'Permanent Ban')], + string='Status', required=True, default='ban') + mail_group_id = fields.Many2one('mail.group', string='Group', required=True, ondelete='cascade') + + _sql_constraints = [( + 'mail_group_email_uniq', + 'UNIQUE(mail_group_id, email)', + 'You can create only one rule for a given email address in a group.', + )] + + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + email_normalized = email_normalize(values.get('email')) + if not email_normalized: + raise UserError(_('Invalid email address %r', values.get('email'))) + values['email'] = email_normalized + return super(MailGroupModeration, self).create(vals_list) + + def write(self, values): + if 'email' in values: + email_normalized = email_normalize(values['email']) + if not email_normalized: + raise UserError(_('Invalid email address %r', values.get('email'))) + values['email'] = email_normalized + return super(MailGroupModeration, self).write(values) diff --git a/addons/mail_group/security/ir.model.access.csv b/addons/mail_group/security/ir.model.access.csv new file mode 100644 index 00000000000..1b6906449bb --- /dev/null +++ b/addons/mail_group/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_group_all,access_mail_group_all,model_mail_group,,1,0,0,0 +access_mail_group_user,access_mail_group_user,model_mail_group,base.group_user,1,1,1,1 +access_mail_group_member,access_mail_group_member,model_mail_group_member,base.group_user,1,1,1,1 +access_mail_group_message_all,access_mail_group_message_all,model_mail_group_message,,1,0,0,0 +access_mail_group_message_user,access_mail_group_message_user,model_mail_group_message,base.group_user,1,1,1,1 +access_mail_group_moderation,access_mail_group_moderation,model_mail_group_moderation,base.group_user,1,1,1,1 +access_mail_group_message_reject,access_mail_group_message_reject,model_mail_group_message_reject,base.group_user,1,1,1,1 diff --git a/addons/mail_group/security/mail_group_security.xml b/addons/mail_group/security/mail_group_security.xml new file mode 100644 index 00000000000..d42376c8b39 --- /dev/null +++ b/addons/mail_group/security/mail_group_security.xml @@ -0,0 +1,138 @@ + + + + Mail Group: Access only public and joined groups + + [ + '|', + '|', + '|', + ('moderator_ids', 'in', user.id), + ('access_mode', '=', 'public'), + '&', + ('access_mode', '=', 'groups'), + ('access_group_id', 'in', [g.id for g in user.groups_id]), + '&', + ('access_mode', '=', 'members'), + ('member_partner_ids', 'in', [user.partner_id.id]), + ] + + + + + + + + + Mail Group: Administrator have access to all mail group + + [(1, '=', 1)] + + + + + + + + Mail Group Message: Only accepted message are accessible + + [ + '&', + ('moderation_status', '=', 'accepted'), + '|', + '|', + '|', + ('mail_group_id.moderator_ids', 'in', user.id), + ('mail_group_id.access_mode', '=', 'public'), + '&', + ('mail_group_id.access_mode', '=', 'groups'), + ('mail_group_id.access_group_id', 'in', [g.id for g in user.groups_id]), + '&', + ('mail_group_id.access_mode', '=', 'members'), + ('mail_group_id.member_partner_ids', 'in', [user.partner_id.id]), + ] + + + + + + + + + Mail Group Message: Non-accepted messages are accessible only by moderators + + [ + '&', + '|', + ('moderation_status', '=', 'accepted'), + ('mail_group_id.moderator_ids', 'in', user.id), + '|', + '|', + '|', + ('mail_group_id.moderator_ids', 'in', user.id), + ('mail_group_id.access_mode', '=', 'public'), + '&', + ('mail_group_id.access_mode', '=', 'groups'), + ('mail_group_id.access_group_id', 'in', [g.id for g in user.groups_id]), + '&', + ('mail_group_id.access_mode', '=', 'members'), + ('mail_group_id.member_partner_ids', 'in', [user.partner_id.id]), + ] + + + + + + + + + Mail Group Message: Administrator have access to all messages + + [(1, '=', 1)] + + + + + + + + Mail Group Member: Members are accessible only by moderators + + [('mail_group_id.moderator_ids', 'in', user.id), ('mail_group_id.moderation', '=', True)] + + + + + + + + Mail Group Member: Administrator have access to all members + + [(1, '=', 1)] + + + + + + + + Mail Group Moderation: Moderation rules are accessible only by moderators + + [('mail_group_id.moderator_ids', 'in', user.id)] + + + + + + + + Mail Group Moderation: Administrator have access to all moderation rules + + [(1, '=', 1)] + + + + + + + diff --git a/addons/mail_group/static/src/css/mail_group.scss b/addons/mail_group/static/src/css/mail_group.scss new file mode 100644 index 00000000000..c0f026e88b3 --- /dev/null +++ b/addons/mail_group/static/src/css/mail_group.scss @@ -0,0 +1,26 @@ +.o_mg_subscribe_btn { + width: 120px; +} + +.ribbon { + right: 0; + width: 120px; + height: 120px; + overflow: hidden; + position: absolute; + + span { + left: -20px; + top: 20px; + transform: rotate(45deg); + z-index: 1; + position: absolute; + width: 220px; + height: 28px; + line-height: 28px; + padding: 0 60px; + text-align: center; + overflow: hidden; + user-select: none; + } +} diff --git a/addons/mail_group/static/src/js/mail_group.js b/addons/mail_group/static/src/js/mail_group.js new file mode 100644 index 00000000000..0437cd549b2 --- /dev/null +++ b/addons/mail_group/static/src/js/mail_group.js @@ -0,0 +1,87 @@ +odoo.define('mail_group.mail_group', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); +const core = require('web.core'); +const _t = core._t; + +publicWidget.registry.MailGroup = publicWidget.Widget.extend({ + selector: '.o_mail_group', + events: { + 'click .o_mg_subscribe_btn': '_onSubscribeBtnClick', + }, + + /** + * @override + */ + start: function () { + this.mailgroupId = this.$target.data('id'); + this.isMember = this.$target.data('isMember') || false; + this.token = (new URL(document.location.href)).searchParams.get('token'); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onSubscribeBtnClick: async function (ev) { + ev.preventDefault(); + const $email = this.$target.find(".o_mg_subscribe_email"); + const email = $email.val(); + + if (!email.match(/.+@.+/)) { + this.$target.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); + return false; + } + + this.$target.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); + + let action = this.isMember ? 'unsubscribe' : 'subscribe'; + + const response = await this._rpc({ + route: '/groups/subscription', + params: { + 'group_id': this.mailgroupId, + 'action': action, + 'email': email, + 'token': this.token, + }, + }); + + this.$el.find('.o_mg_alert').remove(); + + if (response === 'added') { + this.isMember = true; + this.$target.find('.o_mg_subscribe_btn').text(_t('Unsubscribe')); + } else if (response === 'removed') { + this.isMember = false; + this.$target.find('.o_mg_subscribe_btn').text(_t('Subscribe')); + } else if (response === 'email_sent') { + // The confirmation email has been sent + this.$target.html( + $('