[ADD] mail_group: add a new module to manage the mailing lists

Purpose
=======

The purpose of this new module is to manage the mailing lists. Now they
are no more <mail.channel> (will email_send set to True) but they have
their own model.

Specifications
==============

The mailing list are basically a public discussion that users can have
by email. They can respond to email, send new messages, etc... All the
members of the mailing list will receive the message by email.

Users can moderate the emails of the mailing list in the same way as
they did with the "email" mail channel.
- *accept*, will accept the emails and send it to the members of the
  mailing list
- *discard*, will drop the email without warning the author
- *reject*, will drop the email and send a notification email to the
  author
- *allow*, will accept the email and all other pending emails of the
  same author and create a whitelist for him
- *ban* will drop the email and all other pending emails of the same
  author and create a blacklist for him

Now a portal view is available (in /groups) and replace the old module
"Website Mail Channel" that was removed in the previous commit. In this
view, users can subscribe / unsubscribe to the mailing lists and some
links to this portal view are added in the footer of the emails of the
mailing list.

As before with mail channels, you can send guidelines to the members,
notify the moderators whose an action is required (in a CRON), send back
an email to the author of an email to say "Your message is waiting
moderation"...

Links
=====

Task-2510267
See odoo/odoo/pull/71599
See odoo/enterprise/pull/19296
See odoo/upgrade/pull/2600
This commit is contained in:
std-odoo
2021-07-09 12:32:28 +00:00
committed by Thibault Delavallée
parent 583cc5d439
commit 2d359b909b
34 changed files with 3303 additions and 2 deletions
+6
View File
@@ -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
+38
View File
@@ -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/*/*',
],
},
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import portal
+279
View File
@@ -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/<model("mail.group"):group>',
'/groups/<model("mail.group"):group>/page/<int: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/<model("mail.group"):group>/<model("mail.group.message"):message>',
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/<model("mail.group"):group>/<model("mail.group.message"):message>/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 <mail.group> 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'})
+16
View File
@@ -0,0 +1,16 @@
<odoo>
<data>
<record id="ir_cron_mail_notify_group_moderators" model="ir.cron">
<field name="name">Mail List: Notify group moderators</field>
<field name="model_id" ref="model_mail_group"/>
<field name="state">code</field>
<field name="code">model._cron_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 name="doall" eval="False"/>
<field name="priority">1000</field>
</record>
</data>
</odoo>
+164
View File
@@ -0,0 +1,164 @@
<odoo>
<data>
<!-- Group 1 -->
<record id="mail_group_1" model="mail.group">
<field name="name">Newsletter</field>
<field name="alias_name">newsletter</field>
<field name="description">Receive news about "My Company"</field>
<field name="moderation" eval="True"/>
<field name="moderator_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="access_mode">groups</field>
<field name="access_group_id" ref="base.group_user"/>
</record>
<!-- Members of group 1 -->
<record id="mail_group_member_1" model="mail.group.member">
<field name="partner_id" ref="base.partner_admin"/>
<field name="email">admin@yourcompany.example.com</field>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_member_2" model="mail.group.member">
<field name="partner_id" ref="base.partner_demo"/>
<field name="email">mark.brown23@example.com</field>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<!-- Attachment of messages -->
<record id="ir_attachment_mail_group_message_1" model="ir.attachment">
<field name="datas">U3VwZXIgc2VjcmV0IGF0dGFjaG1lbnQ=</field>
<field name="name">attachment.txt</field>
</record>
<record id="ir_attachment_mail_group_message_2" model="ir.attachment">
<field name="datas">QnV0IEphdmFzY3JpcHQgc3Vja3M=</field>
<field name="name">attachment.txt</field>
</record>
<!-- Messages of group 1 -->
<record id="mail_group_message_1" model="mail.group.message">
<field name="subject">Important Announce</field>
<field name="body">This weekend, you should all come to our barbecue!</field>
<field name="email_from">mark.brown23@example.com</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" eval="False"/>
<field name="mail_group_id" ref="mail_group_1"/>
<field name="attachment_ids" eval="[(4, ref('ir_attachment_mail_group_message_1'))]"/>
</record>
<record id="mail_group_message_1_1" model="mail.group.message">
<field name="subject">Re: Important Announce</field>
<field name="body">Will there be vegetarian food?</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_1"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_1_1_1" model="mail.group.message">
<field name="subject">Re: Important Announce</field>
<field name="body">Yes of course, and for those who are allergic bring your own food</field>
<field name="email_from">mark.brown23@example.com</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_1_1"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_1_2" model="mail.group.message">
<field name="subject">Re: Important Announce</field>
<field name="body">Can I come with my dog ?</field>
<field name="email_from">joel.willis63@example.com</field>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_1"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_1_2_1" model="mail.group.message">
<field name="subject">Re: Important Announce</field>
<field name="body">No animals please.</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_1_2"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_2" model="mail.group.message">
<field name="subject">Recruitment</field>
<field name="body">We are pleased to announce that we have hired 10 new people this month.</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" eval="False"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_3" model="mail.group.message">
<field name="subject">Relocation</field>
<field name="body">We are moving our office to Brussels. Take back all your remaining stuff.</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" eval="False"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_3_1" model="mail.group.message">
<field name="subject">Re: Relocation</field>
<field name="body">Is there a swimming pool in this new office?</field>
<field name="email_from">joel.willis63@example.com</field>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_3"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_3_1_1" model="mail.group.message">
<field name="subject">Re: Relocation</field>
<field name="body">Of course!</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_3_1"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_3_2" model="mail.group.message">
<field name="subject">Re: Relocation</field>
<field name="body">What is the deadline for taking back my stuff?</field>
<field name="email_from">mark.brown23@example.com</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" ref="mail_group_message_3"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_3_2_1" model="mail.group.message">
<field name="subject">Re: Relocation</field>
<field name="body">In 4 weeks.</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">pending_moderation</field>
<field name="group_message_parent_id" ref="mail_group_message_3_2"/>
<field name="mail_group_id" ref="mail_group_1"/>
</record>
<record id="mail_group_message_4" model="mail.group.message">
<field name="subject">I really like CSS &amp; HTML</field>
<field name="body">
&lt;p style=&quot;margin:0px; font-size:13px;&quot;&gt;Hi,&lt;/p&gt;&lt;br&gt; &lt;p style=&quot;margin:0px; font-size:13px;&quot;&gt; We &lt;u style=&quot;font-size:14px&quot;&gt;really&lt;/u&gt; like &lt;font style=&quot;color:rgb(57, 123, 33); font-weight:bolder&quot;&gt;colors&lt;/font&gt; and &lt;span style=&quot;font-weight:bolder&quot;&gt;&lt;font style=&quot;color:rgb(255, 0, 0)&quot;&gt;CSS&lt;/font&gt;&lt;/span&gt;. &lt;/p&gt; &lt;p style=&quot;margin:0px; font-size:13px;&quot;&gt;Do you know you can add &lt;/p&gt; &lt;a href=&quot;https://example.com&quot; target=&quot;_blank&quot; class=&quot;btn btn-primary flat btn-sm&quot;&gt;link in email&lt;/a&gt; ? &lt;ol&gt; &lt;li&gt;Also image&lt;/li&gt; &lt;li&gt;List&lt;/li&gt; &lt;li&gt;Any HTML code in the end...&lt;/li&gt; &lt;/ol&gt;
</field>
<field name="email_from">admin@yourcompany.example.com</field>
<field name="author_id" ref="base.partner_admin"/>
<field name="moderation_status">pending_moderation</field>
<field name="group_message_parent_id" eval="False"/>
<field name="mail_group_id" ref="mail_group_1"/>
<field name="attachment_ids" eval="[(4, ref('ir_attachment_mail_group_message_2'))]"/>
</record>
<!-- Group 2 -->
<record id="mail_group_2" model="mail.group">
<field name="name">Public Mailing List</field>
<field name="alias_name">public_group</field>
<field name="description">Get the patch notes of our amazing product.</field>
<field name="moderation" eval="True"/>
<field name="moderator_ids" eval="[(4, ref('base.user_admin'))]"/>
</record>
<!-- Message of Group 2 -->
<record id="mail_group_message_5" model="mail.group.message">
<field name="subject">Best patch ever</field>
<field name="body">In this patch, we have cleaned the CSS!</field>
<field name="email_from">mark.brown23@example.com</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="moderation_status">accepted</field>
<field name="group_message_parent_id" eval="False"/>
<field name="mail_group_id" ref="mail_group_2"/>
</record>
</data>
</odoo>
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="mail_group_send_guidelines">
<div>
<p>Hello <t t-if="member.partner_id" t-esc="member.partner_id.name"/>,</p>
<p>Please find below the guidelines of the <t t-esc="group.name"/> mailing list.</p>
<p t-esc="group.moderation_guidelines_msg"/>
</div>
</template>
<template id="mail_group_footer" name="Mail Group: Footer">
<div>
<span>_______________________________________________</span>
<span>Mailing-List: <t t-esc="group_url"/></span>
<span>Post to: <t t-esc="mailto"/></span>
<span>Unsubscribe: <t t-esc="unsub_url"/></span>
</div>
</template>
<template id="mail_group_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_group.mail_group_message_action" class="o_default_snippet_text">Moderate Messages</a>
</div>
<p>Thank you!</p>
</div>
</template>
</data>
<!-- Confirm subscription email -->
<record id="mail_template_list_subscribe" model="mail.template">
<field name="name">Mail Group: Mailing list subscription</field>
<field name="model_id" ref="mail_group.model_mail_group"/>
<field name="subject">Confirm subscription to ${object.name}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
Hello,<br/><br/>
You have requested to be subscribed to the mailing list <strong>${object.name}</strong>.
<br/><br/>
To confirm, please visit the following link: <strong t-if="ctx.get('token_url')"><a href="${ctx['token_url']}">${ctx['token_url']}</a></strong>
<br/><br/>
If this was a mistake or you did not requested this action, please ignore this message.
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- Confirm unsubscription email -->
<record id="mail_template_list_unsubscribe" model="mail.template">
<field name="name">Mail Group: Mailing list unsubscription</field>
<field name="model_id" ref="mail_group.model_mail_group"/>
<field name="subject">Confirm unsubscription to ${object.name}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
Hello,<br/><br/>
You have requested to be unsubscribed to the mailing list <strong>${object.name}</strong>.
<br/><br/>
To confirm, please visit the following link: <strong t-if="ctx.get('token_url')"><a href="${ctx['token_url']}">${ctx['token_url']}</a></strong>.
<br/><br/>
If this was a mistake or you did not requested this action, please ignore this message.
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
</odoo>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="group_mail_group_manager" model="res.groups">
<field name="name">Mail Group Administrator</field>
<field name="category_id" ref="base.module_category_usability"/>
</record>
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('mail_group.group_mail_group_manager'))]"/>
</record>
</odoo>
+7
View File
@@ -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
+571
View File
@@ -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 <mail.message>
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 <mail.group.message> 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 <mail.group.member> 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': <mail.group.member>}
"""
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
}
@@ -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)
@@ -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 <mail.message> with additional information related to specific
features of <mail.group> like better parent / children management and moderation.
"""
_name = 'mail.group.message'
_description = 'Mailing List Message'
_rec_name = 'subject'
_order = 'create_date DESC'
# <mail.message> fields, can not be done with inherits because it will impact
# the performance of the <mail.message> model (different cache, so the ORM will need
# to do one more SQL query to be able to update the <mail.group.message> 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 <mail.group.moderation> 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 <mail.group.message> 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('<div>%s</div>' % 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',
})
@@ -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)
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mail_group_all access_mail_group_all model_mail_group 1 0 0 0
3 access_mail_group_user access_mail_group_user model_mail_group base.group_user 1 1 1 1
4 access_mail_group_member access_mail_group_member model_mail_group_member base.group_user 1 1 1 1
5 access_mail_group_message_all access_mail_group_message_all model_mail_group_message 1 0 0 0
6 access_mail_group_message_user access_mail_group_message_user model_mail_group_message base.group_user 1 1 1 1
7 access_mail_group_moderation access_mail_group_moderation model_mail_group_moderation base.group_user 1 1 1 1
8 access_mail_group_message_reject access_mail_group_message_reject model_mail_group_message_reject base.group_user 1 1 1 1
@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="mail_group_rule_all" model="ir.rule">
<field name="name">Mail Group: Access only public and joined groups</field>
<field name="model_id" ref="model_mail_group"/>
<field name="domain_force">[
'|',
'|',
'|',
('moderator_ids', 'in', user.id),
('access_mode', '=', 'public'),
'&amp;',
('access_mode', '=', 'groups'),
('access_group_id', 'in', [g.id for g in user.groups_id]),
'&amp;',
('access_mode', '=', 'members'),
('member_partner_ids', 'in', [user.partner_id.id]),
]
</field>
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal')), (4, ref('base.group_public'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_rule_administrator" model="ir.rule">
<field name="name">Mail Group: Administrator have access to all mail group</field>
<field name="model_id" ref="model_mail_group"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('mail_group.group_mail_group_manager'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_message_rule_public" model="ir.rule">
<field name="name">Mail Group Message: Only accepted message are accessible</field>
<field name="model_id" ref="model_mail_group_message"/>
<field name="domain_force">[
'&amp;',
('moderation_status', '=', 'accepted'),
'|',
'|',
'|',
('mail_group_id.moderator_ids', 'in', user.id),
('mail_group_id.access_mode', '=', 'public'),
'&amp;',
('mail_group_id.access_mode', '=', 'groups'),
('mail_group_id.access_group_id', 'in', [g.id for g in user.groups_id]),
'&amp;',
('mail_group_id.access_mode', '=', 'members'),
('mail_group_id.member_partner_ids', 'in', [user.partner_id.id]),
]
</field>
<field name="groups" eval="[(4, ref('base.group_public')), (4, ref('base.group_portal'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_message_rule_user" model="ir.rule">
<field name="name">Mail Group Message: Non-accepted messages are accessible only by moderators</field>
<field name="model_id" ref="model_mail_group_message"/>
<field name="domain_force">[
'&amp;',
'|',
('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'),
'&amp;',
('mail_group_id.access_mode', '=', 'groups'),
('mail_group_id.access_group_id', 'in', [g.id for g in user.groups_id]),
'&amp;',
('mail_group_id.access_mode', '=', 'members'),
('mail_group_id.member_partner_ids', 'in', [user.partner_id.id]),
]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_message_rule_administrator" model="ir.rule">
<field name="name">Mail Group Message: Administrator have access to all messages</field>
<field name="model_id" ref="model_mail_group_message"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('mail_group.group_mail_group_manager'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_member_rule_user" model="ir.rule">
<field name="name">Mail Group Member: Members are accessible only by moderators</field>
<field name="model_id" ref="model_mail_group_member"/>
<field name="domain_force">[('mail_group_id.moderator_ids', 'in', user.id), ('mail_group_id.moderation', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_member_rule_administrator" model="ir.rule">
<field name="name">Mail Group Member: Administrator have access to all members</field>
<field name="model_id" ref="model_mail_group_member"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('mail_group.group_mail_group_manager'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_moderation_rule_user" model="ir.rule">
<field name="name">Mail Group Moderation: Moderation rules are accessible only by moderators</field>
<field name="model_id" ref="model_mail_group_moderation"/>
<field name="domain_force">[('mail_group_id.moderator_ids', 'in', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_group_moderation_rule_administrator" model="ir.rule">
<field name="name">Mail Group Moderation: Administrator have access to all moderation rules</field>
<field name="model_id" ref="model_mail_group_moderation"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('mail_group.group_mail_group_manager'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</odoo>
@@ -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;
}
}
@@ -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(
$('<div class="o_mg_alert alert alert-success" role="alert"/>')
.text(_t('An email with the instructions has been sent.'))
);
} else if (response === 'is_already_member') {
this.isMember = true;
this.$target.find('.o_mg_subscribe_btn').text(_t('Unsubscribe'));
this.$target.find('.o_mg_subscribe_form').before(
$('<div class="o_mg_alert alert alert-warning" role="alert"/>')
.text(_t('This email is already subscribed.'))
);
} else if (response === 'is_not_member') {
this.isMember = false;
this.$target.find('.o_mg_subscribe_btn').text(_t('Subscribe'));
this.$target.find('.o_mg_subscribe_form').before(
$('<div class="o_mg_alert alert alert-warning" role="alert"/>')
.text(_t('This email is not subscribed.'))
);
}
},
});
});
@@ -0,0 +1,90 @@
odoo.define('mail_group.mail_group_message', function (require) {
'use strict';
const publicWidget = require('web.public.widget');
const core = require('web.core');
const _t = core._t;
publicWidget.registry.MailGroupMessage = publicWidget.Widget.extend({
selector: '.o_mg_message',
events: {
'click .o_mg_link_hide': '_onHideLinkClick',
'click .o_mg_link_show': '_onShowLinkClick',
'click button.o_mg_read_more': '_onReadMoreClick',
},
/**
* @override
*/
start: function () {
// By default hide the mention of the previous email for which we reply
// And add a button "Read more" to show the mention of the parent email
const body = this.$el.find('.card-body').first();
const quoted = body.find('*[data-o-mail-quote]');
const readMore = $('<button class="btn btn-link"/>').text(_t('Read more'));
quoted.first().before(readMore);
quoted.addClass('d-none');
readMore.on('click', () => {
quoted.toggleClass('d-none');
});
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} ev
*/
_onHideLinkClick: function (ev) {
ev.preventDefault();
ev.stopPropagation();
const $link = $(ev.currentTarget);
const $container = $link.parents('div').first();
$container.find('.o_mg_link_hide').first().addClass('d-none');
$container.find('.o_mg_link_show').first().removeClass('d-none');
$container.find('.o_mg_link_content').first().removeClass('d-none');
},
/**
* @private
* @param {Event} ev
*/
_onShowLinkClick: function (ev) {
ev.preventDefault();
ev.stopPropagation();
const $link = $(ev.currentTarget);
const $container = $link.parents('div').first();
$container.find('.o_mg_link_hide').first().removeClass('d-none');
$container.find('.o_mg_link_show').first().addClass('d-none');
$container.find('.o_mg_link_content').first().addClass('d-none');
},
/**
* @private
* @param {Event} ev
*/
_onReadMoreClick: function (ev) {
const $link = $(ev.target);
this._rpc({
route: $link.data('href'),
params: {
last_displayed_id: $link.data('last-displayed-id'),
},
}).then(function (data) {
if (!data) {
return;
}
const $threadContainer = $link.parents('.o_mg_replies').first().find('ul.list-unstyled');
if ($threadContainer) {
const $lastMsg = $threadContainer.find('li.media').last();
$(data).find('li.media').insertAfter($lastMsg);
$(data).find('.o_mg_read_more').parent().appendTo($threadContainer);
}
const $showMore = $link.parent();
$showMore.remove();
});
},
});
});
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import data
from . import test_mail_group
from . import test_mail_group_message
from . import test_mail_group_moderation
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command, tools
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mail.tests.common import TestMailCommon
class TestMailListCommon(TestMailCommon):
@classmethod
def setUpClass(cls):
super(TestMailListCommon, cls).setUpClass()
# Test credentials / from
cls.email_from_unknown = tools.formataddr(("Bob Lafrite", "bob.email@test.example.com"))
cls.user_employee_2 = mail_new_test_user(
cls.env, login='employee_2',
company_id=cls.company_admin.id,
email='employee_2@test.com',
groups='base.group_user',
name='Albertine Another Employee',
)
# Test group: members, moderation
cls.test_group = cls.env['mail.group'].create({
'access_mode': 'public',
'alias_name': 'test.mail.group',
'moderation': True,
'moderator_ids': [Command.link(cls.user_employee.id)],
'name': 'Test group',
})
cls.moderation = cls.env['mail.group.moderation'].create({
'mail_group_id': cls.test_group.id,
'email': 'banned_member@test.com',
'status': 'ban',
})
cls.test_group_member_1 = cls.env['mail.group.member'].create({
'email': '"Member 1" <member_1@test.com>',
'mail_group_id': cls.test_group.id,
})
cls.test_group_member_2 = cls.env['mail.group.member'].create({
'email': 'member_2@test.com',
'mail_group_id': cls.test_group.id,
})
cls.test_group_member_3_banned = cls.env['mail.group.member'].create({
'email': '"Banned Member" <banned_member@test.com>',
'mail_group_id': cls.test_group.id,
})
cls.test_group_member_4_emp = cls.env['mail.group.member'].create({
'partner_id': cls.partner_employee.id,
'mail_group_id': cls.test_group.id,
})
cls.test_group_valid_members = cls.test_group_member_1 + cls.test_group_member_2 + cls.test_group_member_4_emp
cls._init_mail_gateway()
# Create some messages
cls.test_group_msg_1_pending = cls.env['mail.group.message'].create({
'subject': 'Test message pending',
'mail_group_id': cls.test_group.id,
'moderation_status': 'pending_moderation',
'email_from': '"Bob" <bob@test.com>',
})
cls.test_group_msg_2_accepted = cls.env['mail.group.message'].create({
'subject': 'Test message accepted',
'mail_group_id': cls.test_group.id,
'moderation_status': 'accepted',
'email_from': '"Alice" <alice@test.com>',
})
cls.test_group_msg_3_rejected = cls.env['mail.group.message'].create({
'subject': 'Test message rejected',
'mail_group_id': cls.test_group.id,
'moderation_status': 'rejected',
'email_from': '"Alice" <alice@test.com>',
})
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
GROUP_TEMPLATE = """Return-Path: <whatever-2a840@postmaster.twitter.com>
To: {to}
cc: {cc}
Received: by mail1.openerp.com (Postfix, from userid 10002)
id 5DF9ABFB2A; Fri, 10 Aug 2012 16:16:39 +0200 (CEST)
From: {email_from}
Subject: {subject}
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary="----=_Part_4200734_24778174.1344608186754"
Date: Fri, 10 Aug 2012 14:16:26 +0000
Message-ID: {msg_id}
{extra}
------=_Part_4200734_24778174.1344608186754
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
This should be posted on a mail.group. Or not.
--
Sylvie
------=_Part_4200734_24778174.1344608186754
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>=20
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8" />
</head>=20
<body style=3D"margin: 0; padding: 0; background: #ffffff;-webkit-text-size-adjust: 100%;">=20
<p>This should be posted on a mail.group. Or not.</p>
<p>--<br/>
Sylvie
<p>
</body>
</html>
------=_Part_4200734_24778174.1344608186754--
"""
+131
View File
@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail_group.tests.common import TestMailListCommon
from odoo.exceptions import ValidationError, UserError, AccessError
from odoo.tests.common import users
from odoo.tools import mute_logger
class TestMailGroup(TestMailListCommon):
@classmethod
def setUpClass(cls):
super(TestMailGroup, cls).setUpClass()
cls.user_portal = cls._create_portal_user()
def test_constraint_valid_email(self):
mail_group = self.env['mail.group'].with_user(self.user_employee).browse(self.test_group.ids)
user_without_email = mail_new_test_user(
self.env, login='user_employee_nomail',
company_id=self.company_admin.id,
email=False,
groups='base.group_user',
name='User without email',
)
with self.assertRaises(ValidationError, msg="Moderators must have an email"):
mail_group.moderator_ids |= user_without_email
@users('employee')
def test_join_group(self):
mail_group = self.env['mail.group'].browse(self.test_group.ids)
self.assertEqual(len(mail_group.member_ids), 4)
mail_group._join_group('"Jack" <jack@test.com>')
self.assertEqual(len(mail_group.member_ids), 5)
with self.assertRaises(UserError):
mail_group._join_group('"Jack the developer" <jack@test.com>')
self.assertEqual(len(mail_group.member_ids), 5, 'Should not have added the duplicated email')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model')
@users('employee')
def test_mail_group_access_mode_groups(self):
test_group = self.env.ref('base.group_partner_manager')
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group.write({
'access_group_id': test_group.id,
'access_mode': 'groups',
})
with self.assertRaises(AccessError):
mail_group.with_user(self.user_portal).check_access_rule('read')
public_user = self.env.ref('base.public_user')
with self.assertRaises(AccessError):
mail_group.with_user(public_user).check_access_rule('read')
with self.assertRaises(AccessError):
mail_group.with_user(self.user_employee_2).check_access_rule('read')
# Add the group to the user
self.user_employee_2.groups_id |= test_group
mail_group.with_user(self.user_employee_2).check_access_rule('read')
mail_group.with_user(self.user_employee_2).check_access_rule('write')
# Remove the group of the user BUT add it in the moderators list
self.user_employee_2.groups_id -= test_group
mail_group.moderator_ids |= self.user_employee_2
mail_group.with_user(self.user_employee_2).check_access_rule('read')
mail_group.with_user(self.user_employee_2).check_access_rule('write')
# Test with public user
mail_group.access_group_id = self.env.ref('base.group_public')
mail_group.with_user(public_user).check_access_rule('read')
mail_group.with_user(public_user).check_access_rights('read')
with self.assertRaises(AccessError):
mail_group.with_user(public_user).check_access_rule('write')
mail_group.with_user(public_user).check_access_rights('write')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model')
@users('employee')
def test_mail_group_access_mode_public(self):
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group.access_mode = 'public'
public_user = self.env.ref('base.public_user')
mail_group.with_user(public_user).check_access_rule('read')
with self.assertRaises(AccessError):
mail_group.with_user(public_user).check_access_rights('write')
mail_group.with_user(self.user_employee_2).check_access_rule('read')
mail_group.with_user(self.user_employee_2).check_access_rights('write')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model')
@users('employee')
def test_mail_group_access_mode_members(self):
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group.access_mode = 'members'
portal_partner = self.user_portal.partner_id
self.assertNotIn(portal_partner, mail_group.member_partner_ids)
with self.assertRaises(AccessError, msg='Non-member should not have access to the group'):
mail_group.with_user(self.user_portal).check_access_rule('read')
public_user = self.env.ref('base.public_user')
with self.assertRaises(AccessError, msg='Non-member should not have access to the group'):
mail_group.with_user(public_user).check_access_rule('read')
mail_group.write({'member_ids': [(0, 0, {
'partner_id': portal_partner.id,
})]})
self.assertIn(portal_partner, mail_group.member_partner_ids)
# Now that portal is in the member list he should have access
mail_group.with_user(self.user_portal).check_access_rule('read')
mail_group.with_user(self.user_portal).check_access_rule('write')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model')
@users('employee')
def test_mail_group_member_security(self):
member = self.env['mail.group.member'].browse(self.test_group_member_1.ids)
self.assertEqual(member.email, '"Member 1" <member_1@test.com>', msg='Moderators should have access to members')
# with self.assertRaises(AccessError, msg='Portal should not have access to members'):
# self.env['mail.group.member'].with_user(self.user_portal).browse(member.ids).email
# with self.assertRaises(AccessError, msg='Non moderators should not have access to member'):
# self.env['mail.group.member'].with_user(self.user_employee_2).browse(member.ids).email
@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail_group.tests.common import TestMailListCommon
from odoo.addons.mail_group.tests.data import GROUP_TEMPLATE
from odoo.exceptions import AccessError
from odoo.tools import mute_logger
class TestMailGroupMessage(TestMailListCommon):
@classmethod
def setUpClass(cls):
super(TestMailGroupMessage, cls).setUpClass()
cls.test_group_member_5_emp_2 = cls.env['mail.group.member'].create({
'partner_id': cls.user_employee_2.partner_id.id,
'mail_group_id': cls.test_group.id,
})
cls.user_portal = cls._create_portal_user()
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message')
def test_email_duplicated(self):
""" Test gateway does not accept two times same incoming email """
self.test_group.write({'moderation': False})
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='Test subject', msg_id='<test.message.id@localhost>', target_model='mail.group')
message = self.env['mail.group.message'].search([('mail_message_id.message_id', '=', '<test.message.id@localhost>')])
self.assertEqual(message.subject, 'Test subject', 'Should have created a <mail.group.message>')
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='Another subject', msg_id='<test.message.id@localhost>', target_model='mail.group')
new_message = self.env['mail.group.message'].search([('mail_message_id.message_id', '=', '<test.message.id@localhost>')])
self.assertEqual(new_message, message)
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message')
def test_email_not_sent_to_author(self):
"""Test that when someone sends an email the group process does not send
it back to the original author."""
self.test_group.write({'moderation': False})
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.test_group_member_1.email,
self.test_group.alias_id.display_name,
subject='Test subject', target_model='mail.group')
mails = self.env['mail.mail'].search([('subject', '=', 'Test subject')])
self.assertEqual(len(mails), 4)
self.assertNotIn(self.test_group_member_1.email, mails.mapped('email_to'), 'Should not have send the email to the original author')
@mute_logger('odoo.addons.base.models.ir_rule')
def test_mail_group_message_security_groups(self):
user_group = self.env.ref('base.group_partner_manager')
self.test_group.access_group_id = user_group
self.test_group.access_mode = 'groups'
# Message pending
with self.assertRaises(AccessError, msg='Portal should not have access to pending messages'):
self.test_group_msg_1_pending.with_user(self.user_portal).check_access_rule('read')
self.user_portal.groups_id |= user_group
with self.assertRaises(AccessError, msg='Non moderator should have access to only accepted message'):
self.test_group_msg_1_pending.with_user(self.user_portal).check_access_rule('read')
self.test_group_msg_1_pending.invalidate_cache()
self.assertEqual(self.test_group_msg_1_pending.with_user(self.user_employee).moderation_status, 'pending_moderation',
msg='Moderators should have access to pending message')
# Message accepted
self.test_group_msg_2_accepted.invalidate_cache()
self.assertEqual(self.test_group_msg_2_accepted.with_user(self.user_portal).moderation_status, 'accepted',
msg='Portal should have access to accepted messages')
self.user_portal.groups_id -= user_group
with self.assertRaises(AccessError, msg='User not in the group should not have access to accepted message'):
self.test_group_msg_2_accepted.with_user(self.user_portal).check_access_rule('read')
@mute_logger('odoo.addons.base.models.ir_rule')
def test_mail_group_message_security_public(self):
self.test_group.access_mode = 'public'
# Message pending
with self.assertRaises(AccessError, msg='Portal should not have access to pending messages'):
self.test_group_msg_1_pending.with_user(self.user_portal).check_access_rule('read')
with self.assertRaises(AccessError, msg='Non moderator should have access to only accepted message'):
self.test_group_msg_1_pending.with_user(self.user_employee_2).check_access_rule('read')
self.test_group_msg_1_pending.invalidate_cache()
self.assertEqual(self.test_group_msg_1_pending.with_user(self.user_employee).moderation_status, 'pending_moderation',
msg='Moderators should have access to pending message')
# Message rejected
with self.assertRaises(AccessError, msg='Portal should not have access to pending messages'):
self.test_group_msg_1_pending.with_user(self.user_portal).check_access_rule('read')
# Message accepted
self.assertEqual(self.test_group_msg_2_accepted.with_user(self.user_portal).moderation_status, 'accepted',
msg='Portal should have access to accepted messages')
self.test_group_msg_3_rejected.invalidate_cache()
self.assertEqual(self.test_group_msg_1_pending.with_user(self.user_admin).moderation_status, 'pending_moderation',
msg='Mail Group Administrator should have access to all messages')
@@ -0,0 +1,420 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2 import IntegrityError
from odoo import Command, tools
from odoo.addons.mail_group.tests.data import GROUP_TEMPLATE
from odoo.addons.mail_group.tests.common import TestMailListCommon
from odoo.exceptions import AccessError
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('mail_group_moderation')
class TestMailGroupModeration(TestMailListCommon):
@classmethod
def setUpClass(cls):
super(TestMailGroupModeration, cls).setUpClass()
# Test group: members, moderation
cls.test_group_2 = cls.env['mail.group'].create({
'access_mode': 'public',
'alias_name': 'test.mail.group.2',
'moderation': True,
'moderator_ids': [Command.link(cls.user_employee.id)],
'name': 'Another test group',
})
@mute_logger('odoo.sql_db')
@users('employee')
def test_constraints(self):
mail_group = self.env['mail.group'].browse(self.test_group.ids)
with self.assertRaises(IntegrityError):
moderation = self.env['mail.group.moderation'].create({
'mail_group_id': mail_group.id,
'email': 'banned_member@test.com',
'status': 'ban',
})
moderation.flush()
@mute_logger('odoo.models.unlink', 'odoo.addons.mail_group.models.mail_group_message')
@users('employee')
def test_moderation_rule_api(self):
""" Test moderation rule creation / update through API """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group_2 = self.env['mail.group'].browse(self.test_group_2.ids)
self.assertEqual(
set(mail_group.moderation_rule_ids.mapped('email')),
set(['banned_member@test.com'])
)
moderation_1, moderation_2, moderation_3 = self.env['mail.group.moderation'].create([{
'email': 'std@test.com',
'status': 'allow',
'mail_group_id': mail_group.id,
}, {
'email': 'xss@test.com',
'status': 'ban',
'mail_group_id': mail_group.id,
}, {
'email': 'xss@test.com',
'status': 'ban',
'mail_group_id': mail_group_2.id,
}])
self.assertEqual(
set(mail_group.moderation_rule_ids.mapped('email')),
set(['banned_member@test.com', 'std@test.com', 'xss@test.com'])
)
message_1, message_2, message_3 = self.env['mail.group.message'].create([{
'email_from': '"Boum" <sTd@teST.com>',
'mail_group_id': mail_group.id,
}, {
'email_from': '"xSs" <xss@teST.com>',
'mail_group_id': mail_group.id,
}, {
'email_from': '"Bob" <bob@teST.com>',
'mail_group_id': mail_group.id,
}])
# status 'bouh' does not exist
with self.assertRaises(ValueError):
(message_1 | message_2 | message_3)._create_moderation_rule('bouh')
(message_1 | message_2 | message_3)._create_moderation_rule('allow')
self.assertEqual(len(mail_group.moderation_rule_ids), 4, "Should have created only one moderation rule")
self.assertEqual(
set(mail_group.moderation_rule_ids.mapped('email')),
set(['banned_member@test.com', 'std@test.com', 'xss@test.com', 'bob@test.com'])
)
self.assertEqual(moderation_1.status, 'allow')
self.assertEqual(moderation_2.status, 'allow', 'Should have write on the existing moderation rule')
self.assertEqual(moderation_3.status, 'ban', 'Should not have changed moderation of the other group')
new_moderation = mail_group.moderation_rule_ids.filtered(lambda rule: rule.email == 'bob@test.com')
self.assertEqual(new_moderation.status, 'allow', 'Should have created the moderation with the right status')
@users('employee')
def test_moderation_rule_email_normalize(self):
""" Test emails are automatically normalized """
rule = self.env['mail.group.moderation'].create({
'mail_group_id': self.test_group.id,
'email': '"Bob" <bob@test.com>',
'status': 'ban',
})
self.assertEqual(rule.email, 'bob@test.com')
rule.email = '"Alice" <alice@test.com>'
self.assertEqual(rule.email, 'alice@test.com')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model')
def test_moderation_rule_security(self):
user_portal = self._create_portal_user()
with self.assertRaises(AccessError, msg='Portal should not have access to moderation rules'):
self.env['mail.group.moderation'].with_user(user_portal).browse(self.moderation.ids).email
self.test_group.write({
'moderator_ids': [(4, self.user_admin.id), (3, self.user_employee.id)]
})
with self.assertRaises(AccessError, msg='Non moderators should not have access to moderation rules'):
self.env['mail.group.moderation'].with_user(self.user_employee).browse(self.moderation.ids).email
self.assertEqual(
self.env['mail.group.moderation'].with_user(self.user_admin).browse(self.moderation.ids).email,
'banned_member@test.com',
msg='Moderators should have access to moderation rules')
@tagged('mail_group_moderation')
class TestModeration(TestMailListCommon):
@classmethod
def setUpClass(cls):
super(TestModeration, cls).setUpClass()
# Test group: members, moderation
cls.test_group_2 = cls.env['mail.group'].create({
'access_mode': 'public',
'alias_name': 'test.mail.group.2',
'moderation': True,
'moderator_ids': [Command.link(cls.user_employee_2.id)],
'name': 'Another test group',
})
cls.test_group_2_member_emp = cls.env['mail.group.member'].create({
'partner_id': cls.user_employee_2.partner_id.id,
'email': cls.user_employee_2.email,
'mail_group_id': cls.test_group_2.id,
})
# Existing messages on group 2
cls.test_group_2_msg_1_pending = cls.env['mail.group.message'].create({
'email_from': cls.email_from_unknown,
'subject': 'Group 2 Pending',
'mail_group_id': cls.test_group_2.id,
'moderation_status': 'pending_moderation',
})
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message')
@users('employee')
def test_moderation_flow_accept(self):
""" Unknown email sends email on moderated group, test accept """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
self.assertEqual(len(mail_group.mail_group_message_ids), 3)
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='Old email', target_model='mail.group')
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='New email', target_model='mail.group')
# find messages
self.assertEqual(len(mail_group.mail_group_message_ids), 5)
old_email_message = mail_group.mail_group_message_ids[-2]
new_email_message = mail_group.mail_group_message_ids[-1]
# check message content
self.assertEqual(old_email_message.moderation_status, 'pending_moderation')
self.assertEqual(old_email_message.subject, 'Old email')
self.assertEqual(new_email_message.moderation_status, 'pending_moderation')
self.assertEqual(new_email_message.subject, 'New email')
# accept email without any moderation rule
with self.mock_mail_gateway():
new_email_message.action_moderate_accept()
self.assertEqual(len(self._new_mails), 4)
for email in self.test_group_valid_members.mapped('email'):
self.assertMailMailWEmails([email], 'outgoing',
content="This should be posted on a mail.group. Or not.",
fields_values={
'email_from': self.email_from_unknown,
'subject': 'New email',
},
mail_message=new_email_message.mail_message_id)
self.assertEqual(new_email_message.moderation_status, 'accepted', 'Should have accepted the message')
self.assertEqual(old_email_message.moderation_status, 'pending_moderation', 'Should not have touched other message of the same author')
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message', 'odoo.models.unlink')
@users('employee')
def test_moderation_flow_allow(self):
""" Unknown email sends email on moderated group, test allow """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group_2_as2 = self.env['mail.group'].with_user(self.user_employee_2).browse(self.test_group_2.ids)
self.assertEqual(len(mail_group.mail_group_message_ids), 3)
self.assertEqual(len(mail_group_2_as2.mail_group_message_ids), 1)
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='Old email', target_model='mail.group')
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='New email', target_model='mail.group')
# find messages
self.assertEqual(len(mail_group.mail_group_message_ids), 5)
old_email_message = mail_group.mail_group_message_ids[-2]
new_email_message = mail_group.mail_group_message_ids[-1]
# check message content
self.assertEqual(old_email_message.email_from, self.email_from_unknown)
self.assertEqual(old_email_message.moderation_status, 'pending_moderation')
self.assertEqual(old_email_message.subject, 'Old email')
self.assertEqual(new_email_message.email_from, self.email_from_unknown)
self.assertEqual(new_email_message.moderation_status, 'pending_moderation')
self.assertEqual(new_email_message.subject, 'New email')
# Create a moderation rule to always accept this email address
with self.mock_mail_gateway():
new_email_message.action_allow()
self.assertEqual(new_email_message.moderation_status, 'accepted', 'Should have accepted the message')
self.assertEqual(old_email_message.moderation_status, 'accepted', 'Should have accepted the old message of the same author')
# Test that the moderation rule has been created
new_rule = self.env['mail.group.moderation'].search([
('status', '=', 'allow'),
('email', '=', tools.email_normalize(self.email_from_unknown))
])
self.assertEqual(len(new_rule), 1, 'Should have created a moderation rule')
# Check emails have been sent
self.assertEqual(len(self._new_mails), 8)
for email in self.test_group_valid_members.mapped('email'):
self.assertMailMailWEmails([email], 'outgoing',
content="This should be posted on a mail.group. Or not.",
fields_values={
'email_from': self.email_from_unknown,
'subject': 'New email',
},
mail_message=new_email_message.mail_message_id)
self.assertMailMailWEmails([email], 'outgoing',
content="This should be posted on a mail.group. Or not.",
fields_values={
'email_from': self.email_from_unknown,
'subject': 'Old email',
},
mail_message=old_email_message.mail_message_id)
# Send a second email with the same FROM, but with a different name
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE,
tools.formataddr(("Another Name", "bob.email@test.example.com")),
self.test_group.alias_id.display_name,
subject='Another email', target_model='mail.group')
# find messages
self.assertEqual(len(mail_group.mail_group_message_ids), 6)
new_email_message = mail_group.mail_group_message_ids[-1]
self.assertEqual(new_email_message.email_from, tools.formataddr(("Another Name", "bob.email@test.example.com")))
self.assertEqual(new_email_message.moderation_status, 'accepted', msg='Should have automatically accepted the email')
self.assertEqual(new_email_message.subject, 'Another email')
self.assertEqual(
self.test_group_2_msg_1_pending.moderation_status, 'pending_moderation',
'Should not have accepted message in the other group')
self.assertEqual(
len(mail_group_2_as2.mail_group_message_ids), 1,
'Should never have created message in the other group')
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message', 'odoo.models.unlink')
@users('employee')
def test_moderation_flow_ban(self):
""" Unknown email sends email on moderated group, test ban """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
self.assertEqual(len(mail_group.mail_group_message_ids), 3)
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='Old email', target_model='mail.group')
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='New email', target_model='mail.group')
# find messages
self.assertEqual(len(mail_group.mail_group_message_ids), 5)
old_email_message = mail_group.mail_group_message_ids[-2]
new_email_message = mail_group.mail_group_message_ids[-1]
# ban and check moderation rule has been
with self.mock_mail_gateway():
new_email_message.action_ban()
self.assertEqual(old_email_message.moderation_status, 'rejected')
self.assertEqual(new_email_message.moderation_status, 'rejected')
# Test that the moderation rule has been created
new_rule = self.env['mail.group.moderation'].search([
('status', '=', 'ban'),
('email', '=', tools.email_normalize(self.email_from_unknown))
])
self.assertEqual(len(new_rule), 1, 'Should have created a moderation rule')
# Check no mail.mail has been sent
self.assertEqual(len(self._new_mails), 0, 'Should not have send emails')
# Send a second email with the same FROM, but with a different name
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE,
tools.formataddr(("Another Name", "bob.email@test.example.com")),
self.test_group.alias_id.display_name,
subject='Another email', target_model='mail.group')
# find messages
self.assertEqual(len(mail_group.mail_group_message_ids), 6)
new_email_message = mail_group.mail_group_message_ids[-1]
self.assertEqual(new_email_message.moderation_status, 'rejected', 'Should have automatically rejected the email')
# Check no mail.mail has been sent
self.assertEqual(len(self._new_mails), 0, 'Should not have send emails')
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message', 'odoo.models.unlink')
@users('employee')
def test_moderation_flow_reject(self):
""" Unknown email sends email on moderated group, test reject """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
self.assertEqual(len(mail_group.mail_group_message_ids), 3)
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='Old email', target_model='mail.group')
self.format_and_process(
GROUP_TEMPLATE, self.email_from_unknown, self.test_group.alias_id.display_name,
subject='New email', target_model='mail.group')
# find messages
self.assertEqual(len(mail_group.mail_group_message_ids), 5)
old_email_message = mail_group.mail_group_message_ids[-2]
new_email_message = mail_group.mail_group_message_ids[-1]
# reject without moderation rule
with self.mock_mail_gateway():
new_email_message.action_moderate_reject_with_comment('Test Rejected', 'Bad email')
self.assertEqual(new_email_message.moderation_status, 'rejected', 'Should have rejected the message')
self.assertEqual(old_email_message.moderation_status, 'pending_moderation', 'Should not have rejected old message')
self.assertEqual(len(self._new_mails), 1, 'Should have sent the reject email')
self.assertMailMailWEmails([self.email_from_unknown], 'outgoing',
content="This should be posted on a mail.group. Or not.",
fields_values={
'email_from': self.user_employee.email_formatted,
'subject': 'Test Rejected',
})
@mute_logger('odoo.addons.mail_group.models.mail_group')
@users('employee')
def test_moderation_send_guidelines(self):
""" Test sending guidelines """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group.write({
'moderation_guidelines': True,
'moderation_guidelines_msg': 'Test guidelines group',
})
with self.mock_mail_gateway():
mail_group.action_send_guidelines()
self.assertEqual(len(self._new_mails), 3)
for email in self.test_group_valid_members.mapped('email'):
self.assertMailMailWEmails([email], 'outgoing',
content="Test guidelines group",
fields_values={
'email_from': self.env.company.catchall_formatted,
'subject': 'Guidelines of group %s' % mail_group.name,
})
@mute_logger('odoo.addons.mail_group.models.mail_group')
@users('employee')
def test_moderation_send_guidelines_on_new_member(self):
""" Test sending guidelines when having a new members """
mail_group = self.env['mail.group'].browse(self.test_group.ids)
mail_group.write({
'moderation_guidelines': True,
'moderation_guidelines_msg': 'Test guidelines group',
})
with self.mock_mail_gateway():
mail_group._join_group('"New Member" <new.member@test.com>')
self.assertEqual(len(self._new_mails), 1)
self.assertMailMailWEmails(['"New Member" <new.member@test.com>'], 'outgoing',
content="Test guidelines group",
fields_values={
'email_from': self.env.company.catchall_formatted,
'subject': 'Guidelines of group %s' % mail_group.name,
})
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_group_member_view_tree" model="ir.ui.view">
<field name="name">mail.group.member.view.tree</field>
<field name="model">mail.group.member</field>
<field name="arch" type="xml">
<tree editable="top">
<field name="email"
required="1" force_save="1"
attrs="{'readonly': [('partner_id', '!=', False)]}"/>
<field name="partner_id"/>
<field name="mail_group_id"/>
</tree>
</field>
</record>
<record id="mail_group_member_view_search" model="ir.ui.view">
<field name="name">mail.group.member.view.search</field>
<field name="model">mail.group.member</field>
<field name="arch" type="xml">
<search string="Search Mail Group Member">
<field name="email" required="1" />
<field name="partner_id"/>
<field name="mail_group_id"/>
</search>
</field>
</record>
<record id="mail_group_member_action" model="ir.actions.act_window">
<field name="name">Members</field>
<field name="res_model">mail.group.member</field>
<field name="view_mode">tree</field>
</record>
</odoo>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="mail_group_menu"
name="Mail Groups"
action="mail_group_action"
parent="mail.mail_menu_technical"
sequence="50"/>
<menuitem id="mail_group_moderation_menu"
name="Moderation Rules"
action="mail_group_moderation_action"
parent="mail.mail_menu_technical"
sequence="51"/>
</odoo>
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_group_message_view_list" model="ir.ui.view">
<field name="name">mail.group.message.view.list</field>
<field name="model">mail.group.message</field>
<field name="arch" type="xml">
<tree>
<field name="create_date" string="Date"/>
<field name="author_id"/>
<field name="email_from"/>
<field name="subject"/>
<field name="mail_group_id"/>
<field name="moderation_status"/>
<button name="action_moderate_accept" string="Accept" title="Accept"
type="object" class="btn btn-primary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
<button name="action_moderate_reject" string="Discard" title="Remove message without explanation"
type="object" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
<button name="%(mail_group_message_reject_action)d"
string="Reject" title="Remove message with explanation"
type="action" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"
context="{'default_mail_group_message_id': id}" />
<button name="action_allow" string="Always Allow"
title="Add this email address to white list of people and accept all pending messages from the same author."
type="object" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
<button name="action_ban" type="object" string="Ban"
title="Ban this email address and reject all pending messages from the same author"
class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
</tree>
</field>
</record>
<record id="mail_group_message_view_form" model="ir.ui.view">
<field name="name">mail.group.message.view.form</field>
<field name="model">mail.group.message</field>
<field name="arch" type="xml">
<form string="Group Message">
<header>
<button name="action_moderate_accept" string="Accept" title="Accept"
type="object" class="btn btn-primary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
<button name="action_moderate_reject" string="Discard" title="Remove message without explanation"
type="object" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
<button name="%(mail_group_message_reject_action)d"
string="Reject" title="Remove message with explanation"
type="action" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"
context="{'default_mail_group_message_id': id}" />
<button name="action_allow" string="Always Allow"
title="Add this email address to white list of people and accept all pending messages from the same author."
type="object" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
<button name="action_ban" string="Ban"
title="Ban this email address and reject all pending messages from the same author"
type="object" class="btn btn-secondary"
attrs="{'invisible': [('moderation_status', '!=', 'pending_moderation')]}"/>
</header>
<sheet>
<widget name="web_ribbon" title="Rejected" bg_color="bg-danger"
attrs="{'invisible': [('moderation_status', '!=', 'rejected')]}"/>
<widget name="web_ribbon" title="Accepted" bg_color="bg-success"
attrs="{'invisible': [('moderation_status', '!=', 'accepted')]}"/>
<group>
<field name="mail_message_id" invisible="1"/>
<field name="moderation_status" invisible="1"/>
<field name="subject"/>
<field name="author_id"/>
<field name="email_from"/>
<field name="mail_group_id"/>
<field name="attachment_ids" widget="many2many_binary"/>
<field name="body" options="{'style-inline': true}"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="mail_group_message_view_search" model="ir.ui.view">
<field name="name">mail.group.message.view.search</field>
<field name="model">mail.group.message</field>
<field name="arch" type="xml">
<search string="Search Group Message">
<field name="mail_group_id"/>
<field name="email_from"/>
<field name="author_id"/>
<field name="moderation_status"/>
<separator/>
<group expand="0" string="Group By">
<filter string="group" name="group_by_group" context="{'group_by': 'mail_group_id'}"/>
</group>
</search>
</field>
</record>
<record id="mail_group_message_action" model="ir.actions.act_window">
<field name="name">Messages</field>
<field name="res_model">mail.group.message</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_moderation_status': 'pending_moderation'}</field>
</record>
</odoo>
@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<odoo>
<record id="mail_group_moderation_view_tree" model="ir.ui.view">
<field name="name">mail.group.moderation.view.tree</field>
<field name="model">mail.group.moderation</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<tree string="Moderation Lists" editable="bottom">
<field name="mail_group_id"/>
<field name="email"/>
<field name="status"/>
</tree>
</field>
</record>
<record id="mail_group_moderation_view_search" model="ir.ui.view">
<field name="name">mail.group.moderation.view.search</field>
<field name="model">mail.group.moderation</field>
<field name="priority">25</field>
<field name="arch" type="xml">
<search string="Search Moderation List">
<field name="mail_group_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')]"/>
<filter name="group_by_status" string="Status" domain="[]" context="{'group_by':'status'}"/>
</search>
</field>
</record>
<record id="mail_group_moderation_action" model="ir.actions.act_window">
<field name="name">Moderation</field>
<field name="res_model">mail.group.moderation</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="mail_group_moderation_view_search"/>
</record>
</odoo>
@@ -0,0 +1,126 @@
<?xml version="1.0"?>
<odoo>
<record id="mail_group_view_list" model="ir.ui.view">
<field name="name">mail.group.view.list</field>
<field name="model">mail.group</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
</tree>
</field>
</record>
<record id="mail_group_view_form" model="ir.ui.view">
<field name="name">mail.group.view.form</field>
<field name="model">mail.group</field>
<field name="arch" type="xml">
<form string="Mail Group">
<header>
<button name="action_send_guidelines" type="object" class="btn btn-secondary" string="Send Guidelines" attrs="{'invisible': ['|', ('is_moderator', '=', False), ('moderation', '=', False)]}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box" groups="base.group_user" attrs="{'invisible': [('can_manage_members', '=', False)]}">
<button name="%(mail_group.mail_group_member_action)d"
type="action"
context="{'search_default_mail_group_id': active_id}"
class="oe_stat_button"
icon="fa-users"
help="Members of this group">
<field name="member_count" widget="statinfo" string="Members"/>
</button>
<button name="%(mail_group.mail_group_moderation_action)d"
type="action"
context="{'search_default_mail_group_id': active_id}"
attrs="{'invisible': [('moderation', '=', False)]}"
class="oe_stat_button"
icon="fa-gavel"
help="Moderated emails in this group">
<field name="moderation_rule_count" widget="statinfo" string="Moderations"/>
</button>
<button name="%(mail_group.mail_group_message_action)d"
type="action"
context="{'search_default_mail_group_id': active_id, 'search_default_moderation_status': 'pending_moderation'}"
class="oe_stat_button"
icon="fa-envelope"
help="Emails waiting an action for this group">
<field name="mail_group_message_moderation_count" widget="statinfo" string="Messages"/>
</button>
</div>
<field name="image_128" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
<div class="oe_title">
<label for="name" string="Group Name"/>
<h1>
<field name="name" class="oe_inline" default_focus="1" placeholder="e.g. newsletter"/>
</h1>
</div>
<group>
<label for="alias_name" string="Email Alias"/>
<div class="oe_inline" name="alias_def">
<field name="alias_id" class="oe_read_only oe_inline"
string="Email Alias" required="0"
attrs="{'invisible': [('alias_domain', '=', False)]}"/>
<div class="oe_inline" name="edit_alias" style="display: inline;" >
<div class="oe_edit_only" attrs="{'invisible': [('alias_domain', '=', False)]}">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
</div>
<button icon="fa-arrow-right" type="action" name="%(base_setup.action_general_configuration)d" string="Configure a custom domain" class="p-0 btn-link" attrs="{'invisible': [('alias_domain', '!=', False)]}"/>
</div>
</div>
<field name="description"/>
<field name="access_mode" widget="radio"/>
<field name="access_group_id" attrs="{
'invisible': [('access_mode', '!=', 'groups')],
'required': [('access_mode', '=', 'groups')],
}"/>
<field name="alias_contact" invisible="1"/>
<td class="o_td_label">
<label for="moderator_ids" string="Moderators" attrs="{'invisible': [('moderation', '=', False)]}"/>
<label for="moderator_ids" string="Responsibles" attrs="{'invisible': [('moderation', '=', True)]}"/>
</td>
<field name="moderator_ids" widget="many2many_tags" class="oe_inline" nolabel="1"/>
<field name="moderation"/>
<field name="is_moderator" invisible="1"/>
<field name="can_manage_members" invisible="1"/>
</group>
<notebook>
<page name="members" string="Members" attrs="{'invisible': [('can_manage_members', '=', False)]}">
<field name="member_ids">
<tree editable="top">
<field name="email" attrs="{'readonly': [('partner_id', '!=', False)]}" force_save="1"/>
<field name="partner_id"/>
</tree>
</field>
</page>
<page name="moderation" string="Moderation" attrs="{'invisible': [('moderation', '=', False)]}">
<group>
<field name="moderation_notify"/>
<field attrs="{'invisible': [('moderation_notify', '=', False)], 'required': [('moderation_notify', '=', True)]}" name="moderation_notify_msg"/>
<field name="moderation_guidelines"/>
<field attrs="{'required':[('moderation_guidelines', '=', True)]}" name="moderation_guidelines_msg"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="mail_group_view_search" model="ir.ui.view">
<field name="name">mail.group.view.search</field>
<field name="model">mail.group</field>
<field name="arch" type="xml">
<search string="Search Mailing group">
<field name="name"/>
<field name="alias_name"/>
<separator/>
<filter string="Moderated" name="moderation" domain="[('moderation', '=', True)]"/>
<group expand="0" string="Group By">
<filter string="Moderation" name="Moderation" context="{'group_by':'moderation'}"/>
</group>
</search>
</field>
</record>
<record id="mail_group_action" model="ir.actions.act_window">
<field name="name">Mailing group</field>
<field name="res_model">mail.group</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>
@@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Templates used for the portal view.
-->
<template id="mail_groups" name="Mailing Lists">
<t t-call="portal.portal_layout">
<div class="oe_structure oe_empty">
<section class="bg-primary jumbotron mt0 mb0">
<div class="container">
<h1>Stay in touch with our Community</h1>
<p>Alone we can do so little, together we can do so much</p>
</div>
</section>
</div>
<div class="container mt32">
<div t-if="'unsubscribe' in request.params" class="offset-lg-9 col-lg-3 alert alert-info" role="status">
<h3>Need to unsubscribe? It's right here! <span class="fa fa-2x fa-arrow-down float-right" role="img" aria-label="" title="Read this !"></span></h3>
</div>
<div class="row mt8" t-foreach="mail_groups" t-as="group_data">
<t t-set="group" t-value="group_data['group']"/>
<t t-set="is_member" t-value="group_data['is_member']"/>
<div class="col-lg-3">
<img t-attf-src="/web/image/mail.group/#{group.id}/image_128" class="o_image_64_cover float-left" alt="Group"/>
<strong><a t-attf-href="/groups/#{ slug(group) }" t-esc="group.name"/></strong><br />
<t t-if="group.alias_id and group.alias_id.alias_name and group.alias_id.alias_domain">
<i class='fa fa-envelope-o' role="img" aria-label="Alias" title="Alias"/>
<a t-attf-href="mailto:#{group.alias_id.alias_name}@#{group.alias_id.alias_domain}"><span t-field="group.alias_id"/></a>
</t>
</div>
<div class="col-lg-4">
<div t-esc="group.description" class="text-muted"/>
</div>
<div class="col-lg-2">
<i class='fa fa-fw fa-user' role="img" aria-label="Recipients" title="Recipients"/> <t t-esc="group.member_count"/> members<br />
<i class='fa fa-fw fa-envelope-o' role="img" aria-label="Traffic" title="Traffic"/> <t t-esc="group.mail_group_message_last_month_count"/> messages / month
</div>
<div class="col-lg-3">
<t t-set="force_unsubscribe" t-value="'unsubscribe' in request.params and not email"/>
<div class="o_mail_group" t-att-data-id="group.id" t-att-data-is-member="force_unsubscribe or is_member">
<div class="input-group o_mg_subscribe_form">
<input type="email" name="email" class="o_mg_subscribe_email form-control" t-att-value="email" placeholder="your email..." t-att-readonly="int(bool(email))"/>
<div t-if="force_unsubscribe or is_member" class="input-group-append">
<button href="#" class="btn btn-primary o_mg_subscribe_btn">Unsubscribe</button>
</div>
<div t-else="" class="input-group-append">
<button href="#" class="btn btn-primary o_mg_subscribe_btn">Subscribe</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="group_messages" name="Message Threads">
<t t-call="portal.portal_layout">
<section class="container">
<div class="mt8">
<ol class="breadcrumb float-left">
<li class="breadcrumb-item"><a href="/groups">Mailing Lists</a></li>
<li class="breadcrumb-item">
<a t-attf-href="/groups/#{slug(group)}?#{mode and 'mode=%s' % mode or ''}#{date_begin and '&amp;date_begin=%s' % date_begin or ''}#{date_end and '&amp;date_end=%s' % date_end or ''}"><t t-esc="group.name"/></a>
</li>
</ol>
</div>
<div class="row">
<div class="col-lg-12">
<h1 class="text-center" t-esc="group.name"/>
<h4 class="text-center text-muted" t-if="group.alias_id and group.alias_id.alias_name and group.alias_id.alias_domain">
<i class='fa fa-envelope-o' role="img" aria-label="Alias" title="Alias"/>
<a t-attf-href="mailto:#{group.alias_id.alias_name}@#{group.alias_id.alias_domain}"><span t-field="group.alias_id"/></a>
</h4>
</div>
<div class="col-lg-3">
<h2>Archives</h2>
<ul class="nav nav-pills flex-column" id="group_mode">
<li class="nav-item">
<a t-attf-href="/groups/#{ slug(group) }?mode=thread" t-attf-class="nav-link#{mode=='thread' and ' active' or ''}">By thread</a>
</li>
<li class="nav-item">
<a t-attf-href="/groups/#{ slug(group) }?mode=date" t-attf-class="nav-link#{mode=='date' and not date_begin and ' active' or ''}">By date</a>
<ul class="nav nav-pills flex-column" style="margin-left: 8px;">
<t t-foreach="archives" t-as="month_archive">
<li class="nav-item">
<a t-ignore="True" t-attf-href="/groups/#{ slug(group) }?mode=date&amp;date_begin=#{ month_archive['date_begin'] }&amp;date_end=#{month_archive['date_end']}"
t-attf-class="nav-link#{month_archive['date_begin'] == date_begin and ' active' or ''}">
<t t-esc="month_archive['date']"/>
<span class="float-right badge badge-pill" t-esc="month_archive['messages_count']"/>
</a>
</li>
</t>
</ul>
</li>
</ul>
</div>
<div class="col-lg-9">
<div>
<t t-call="portal.pager"/>
</div>
<t t-call="mail_group.messages_short">
<t t-set="messages" t-value="messages"/>
<t t-set="msg_more_count" t-value="0"/>
<t t-set="parent_message" t-value="None"/>
</t>
<div>
<t t-call="portal.pager"/>
</div>
</div>
</div>
</section>
</t>
</template>
<template id="group_message">
<t t-call="portal.portal_layout">
<t t-set="additional_title"><t t-esc="message.subject"/></t>
<section class="container">
<div class="row mt8">
<ol class="breadcrumb float-left">
<li class="breadcrumb-item"><a href="/groups">Mailing Lists</a></li>
<li class="breadcrumb-item">
<a t-attf-href="/groups/#{slug(group)}?#{mode and 'mode=%s' % mode or ''}#{date_begin and '&amp;date_begin=%s' % date_begin or ''}#{date_end and '&amp;date_end=%s' % date_end or ''}"><t t-esc="group.name"/></a>
</li>
<li t-if="message" class="breadcrumb-item active"><t t-esc="message.subject"/></li>
</ol>
</div>
<div class="row">
<h1 class="text-center">
<t t-esc="group.name"/> archives
</h1>
<h4 class="text-center text-muted" t-if="group.alias_id and group.alias_id.alias_name and group.alias_id.alias_domain">
<i class='fa fa-envelope-o' role="img" aria-label="Alias" title="Alias"/>
<a t-attf-href="mailto:#{group.alias_id.alias_name}@#{group.alias_id.alias_domain}"><span t-field="group.alias_id"/></a>
</h4>
</div>
<div class="row">
<div class="col-lg-3">
<h4>Browse archives</h4>
<ul class="nav nav-pills flex-column" id="group_mode">
<li class="nav-item">
<a t-attf-href="/groups/#{ slug(group) }?mode=thread" t-attf-class="nav-link#{mode=='thread' and ' active' or ''}">By thread</a>
</li>
<li class="nav-item">
<a t-attf-href="/groups/#{ slug(group) }?mode=date" t-attf-class="nav-link#{mode=='date' and not date_begin and ' active' or ''}">By date</a>
<ul class="nav nav-pills flex-column" style="margin-left: 8px;">
<t t-foreach="archives" t-as="month_archive">
<li class="nav-item">
<a t-ignore="True" t-attf-href="/groups/#{ slug(group) }?mode=date&amp;date_begin=#{ month_archive['date_begin'] }&amp;date_end=#{month_archive['date_end']}"
t-attf-class="nav-link#{month_archive['date_begin'] == date_begin and ' active' or ''}">
<t t-esc="month_archive['date']"/>
<span class="float-right badge badge-pill" t-esc="month_archive['messages_count']"/>
</a>
</li>
</t>
</ul>
</li>
</ul>
</div>
<div class="col-lg-9">
<div class="row">
<h4 class="col-lg-6">
<t t-if="prev_message"><a t-attf-href='/groups/#{slug(group)}/#{slug(prev_message)}?#{mode and "mode=%s" % mode or ""}'>
<i class="fa fa-arrow-left" role="img" aria-label="Previous message" title="Previous message"/> <t t-esc="prev_message.subject"/>
</a></t>
</h4>
<h4 class="col-lg-6">
<t t-if="next_message"><a class="float-right" t-attf-href='/groups/#{slug(group)}/#{slug(next_message)}?#{mode and "mode=%s" % mode or ""}'>
<t t-esc="next_message.subject"/> <i class="fa fa-arrow-right" role="img" aria-label="Next message" title="Next message"/>
</a></t>
</h4>
</div>
<div class="media">
<div class="media-body o_mg_message">
<div class="card">
<div class="card-body">
<h4>
<img t-if="message.sudo().author_id.active and message.sudo().author_id.image_128" t-att-src="image_data_uri(message.sudo().author_id.image_128)" class="rounded o_image_40_cover"/>
<span t-esc="message.subject"/>
</h4>
<p class="card-text" t-field="message.body"/>
<t t-call="mail_group.message_footer"/>
</div>
</div>
<div>
<p t-if="attachments" class="mt8">
<a href="#" class="o_mg_link_hide">
<i class="fa fa-chevron-right" role="img" aria-label="Hide attachments" title="Hide attachments"/> <t t-esc="len(attachments)"/> attachments
</a>
<a href="#" class="o_mg_link_show d-none">
<i class="fa fa-chevron-down" role="img" aria-label="Show attachments" title="Show attachments"/> <t t-esc="len(attachments)"/> attachments
</a>
</p>
<div class="o_mg_link_content d-none row">
<div class="mx-4 my-3 text-center" t-foreach='attachments' t-as='attachment'>
<t t-if="attachment.access_token">
<a t-attf-href="/web/content/#{attachment.id}?download=true&amp;access_token=#{attachment.access_token}" target="_blank">
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-data-src="/web/image/#{attachment.id}/100x80?access_token=#{attachment.access_token}"/>
<div class='oe_attachment_name'><t t-esc='attachment.name' /></div>
</a>
</t>
<t t-else="">
<a t-attf-href="/web/content/#{attachment.id}?download=true" target="_blank">
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-data-src="/web/image/#{attachment.id}/100x80"/>
<div class='oe_attachment_name'><t t-esc='attachment.name' /></div>
</a>
</t>
</div>
</div>
</div>
</div>
</div>
<div t-if="message.group_message_child_ids" class="o_mg_replies">
<h4 class="o_page_header">Follow-Ups</h4>
<t t-call="mail_group.messages_short">
<t t-set="messages" t-value="message.group_message_child_ids[:replies_per_page]"/>
<t t-set="msg_more_count" t-value="len(message.group_message_child_ids) - replies_per_page"/>
<t t-set="parent_message" t-value="message"/>
</t>
</div>
<div t-if="message.group_message_parent_id">
<h4 class="o_page_header">Reference</h4>
<t t-call="mail_group.messages_short">
<t t-set="messages" t-value="[message.group_message_parent_id]"/>
</t>
</div>
</div>
</div>
</section>
</t>
</template>
<template id="messages_short">
<div class="o_mg_message">
<ul class="list-unstyled">
<li t-foreach="messages" t-as="message" class="media mt-3">
<div class="media-body">
<div class="card">
<div class="ribbon ribbon-top-right" t-if="message.moderation_status == 'pending_moderation'"><span class="bg-warning">Pending</span></div>
<div class="card-body">
<h5 class="card-title">
<img t-if="message.sudo().author_id.active and message.sudo().author_id.image_128" t-att-src="image_data_uri(message.sudo().author_id.image_128)" class="rounded o_image_40_cover"/>
<a t-attf-href="/groups/#{slug(group)}/#{slug(message)}?mode=#{mode}&amp;date_begin=#{date_begin}&amp;date_end=#{date_end}" t-esc="message.subject"/>
</h5>
<p class="card-text" t-field="message.body"/>
<t t-call="mail_group.message_footer"/>
</div>
</div>
<p t-if="message.group_message_child_ids" class="mt8">
<a href="#" class="o_mg_link_hide">
<i class="fa fa-chevron-right" role="img" aria-label="Hide replies" title="Hide replies"/> <t t-esc="len(message.group_message_child_ids)"/> replies
</a>
<a href="#" class="o_mg_link_show d-none">
<i class="fa fa-chevron-down" role="img" aria-label="Show replies" title="Show replies"/> <t t-esc="len(message.group_message_child_ids)"/> replies
</a>
</p>
<div class="o_mg_link_content o_mg_replies ml-5 d-none">
<t t-call="mail_group.messages_short">
<t t-set="messages" t-value="message.group_message_child_ids[:replies_per_page]"/>
<t t-set="msg_more_count" t-value="len(message.group_message_child_ids) - replies_per_page"/>
<t t-set="parent_message" t-value="message"/>
</t>
</div>
</div>
</li>
</ul>
<p t-if="messages and (msg_more_count or 0) > 0 and parent_message">
<button class="btn btn-link o_mg_read_more"
t-attf-data-href="/groups/#{slug(group)}/#{slug(parent_message)}/get_replies"
t-attf-data-last-displayed-id="#{messages[-1].id}">
show <t t-esc="msg_more_count"/> more replies
</button>
</p>
</div>
</template>
<template id="message_footer" name="Message Footer">
<small>
<span>
by <t t-esc="message.email_from"/>
</span>
<span class="mx-2">-</span>
<span>
<i class="fa fa-calendar" role="img" aria-label="Date" title="Date"/>
<span t-field="message.create_date"/>
</span>
<span class="mx-2">-</span>
<span>
<i class="fa fa-paperclip" role="img" aria-label="Attachments" title="Attachments"/>
<t t-esc="len(message.sudo().attachment_ids)"/>
</span>
</small>
</template>
<template id="confirmation_subscription" name="Mailing List Confirmation">
<div id="wrap" class="oe_structure oe_empty">
<div class="container">
<p>
You have been correctly
<t t-if="subscribing">subscribed</t>
<t t-if="not subscribing">unsubscribed</t>
to the mailing list.
</p>
</div>
</div>
</template>
<template id="invalid_token_subscription" name="Invalid Token Submitted">
<div id="wrap" class="oe_structure oe_empty">
<div class="container">
<p>
Invalid or expired confirmation link.
</p>
</div>
</div>
</template>
</odoo>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mail_group_message_reject
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailGroupMessageReject(models.TransientModel):
_name = 'mail.group.message.reject'
_description = 'Reject Group Message'
subject = fields.Char('Subject')
body = fields.Html('Contents', default='', sanitize_style=True)
mail_group_message_id = fields.Many2one('mail.group.message', string="Message", required=True, readonly=True)
def action_send_mail(self):
self.ensure_one()
self.mail_group_message_id.action_moderate_reject_with_comment(self.subject, self.body)
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_group_message_reject_form" model="ir.ui.view">
<field name="name">mail.group.message.reject.form</field>
<field name="model">mail.group.message.reject</field>
<field name="arch" type="xml">
<form string="Reject">
<group>
<field name="mail_group_message_id"/>
<field name="subject"/>
<field name="body"/>
</group>
<footer>
<button string="Send" name="action_send_mail" type="object" class="btn-primary"/>
<button string="Discard" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="mail_group_message_reject_action" model="ir.actions.act_window">
<field name="name">Reject Group Message</field>
<field name="res_model">mail.group.message.reject</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
@@ -51,7 +51,7 @@ class TestMassMailPerformance(TestMassMailPerformanceBase):
})
# runbot needs +50 compared to local
with self.assertQueryCount(__system__=1665, marketing=1665):
with self.assertQueryCount(__system__=1715, marketing=1715):
mailing.action_send_mail()
self.assertEqual(mailing.sent, 50)
@@ -91,7 +91,7 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase):
})
# runbot needs +62 compared to local
with self.assertQueryCount(__system__=1943, marketing=1943):
with self.assertQueryCount(__system__=2005, marketing=2005):
mailing.action_send_mail()
self.assertEqual(mailing.sent, 50)