Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,49 @@
# Mail Group
Manage your mailing lists from Odoo.
## Installation
```bash
pip install odoo-bringout-oca-ocb-mail_group
```
## Dependencies
This addon depends on:
- mail
- portal
## Manifest Information
- **Name**: Mail Group
- **Version**: 1.0
- **Category**: N/A
- **License**: LGPL-3
- **Installable**: False
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `mail_group`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Mail_group Module - mail_group
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for mail_group. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,17 @@
# Controllers
HTTP routes provided by this module.
```mermaid
sequenceDiagram
participant U as User/Client
participant C as Module Controllers
participant O as ORM/Views
U->>C: HTTP GET/POST (routes)
C->>O: ORM operations, render templates
O-->>U: HTML/JSON/PDF
```
Notes
- See files in controllers/ for route definitions.

View file

@ -0,0 +1,6 @@
# Dependencies
This addon depends on:
- [mail](../../odoo-bringout-oca-ocb-mail)
- [portal](../../odoo-bringout-oca-ocb-portal)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon mail_group or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-ocb-mail_group"
# or
uv pip install odoo-bringout-oca-ocb-mail_group"
```

View file

@ -0,0 +1,15 @@
# Models
Detected core models and extensions in mail_group.
```mermaid
classDiagram
class mail_group
class mail_group_member
class mail_group_message
class mail_group_moderation
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: mail_group. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon mail_group
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,41 @@
# Security
Access control and security definitions in mail_group.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../mail_group/security/ir.model.access.csv)**
- 7 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[mail_group_security.xml](../mail_group/security/mail_group_security.xml)**
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../mail_group/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[mail_group_security.xml](../mail_group/security/mail_group_security.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon mail_group
```

View file

@ -0,0 +1,8 @@
# Wizards
Transient models exposed as UI wizards in mail_group.
```mermaid
classDiagram
class MailGroupMessageReject
```

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

View file

@ -0,0 +1,43 @@
# -*- 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',
],
'data': [
'data/ir_cron_data.xml',
'data/mail_templates.xml',
'data/mail_template_data.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/css/mail_group.scss',
'mail_group/static/src/js/*',
],
'web.assets_backend': [
'mail_group/static/src/css/mail_group_backend.scss',
],
},
'license': 'LGPL-3',
}

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import portal

View file

@ -0,0 +1,387 @@
# -*- 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.exceptions import AccessError
from odoo.http import request, Response
from odoo.osv import expression
from odoo.tools import consteq
class PortalMailGroup(http.Controller):
_thread_per_page = 20
_replies_per_page = 5
def _get_website_domain(self):
# Base group domain in addition to the security access rules
# Do not show rejected message on the portal view even for admin
return [('moderation_status', '!=', 'rejected')]
def _get_archives(self, group_id):
"""Return the different date range and message count for the group messages."""
domain = expression.AND([self._get_website_domain(), [('mail_group_id', '=', group_id)]])
results = request.env['mail.group.message']._read_group_raw(
domain,
['subject', 'create_date'],
groupby=['create_date'], orderby='create_date')
date_groups = []
for result in results:
(dates_range, label) = result['create_date']
start, end = dates_range.split('/')
date_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'],
})
thread_domain = expression.AND([domain, [('group_message_parent_id', '=', False)]])
threads_count = request.env['mail.group.message'].search_count(thread_domain)
return {
'threads_count': threads_count,
'threads_time_data': date_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,
'is_mail_group_manager': request.env.user.has_group('mail_group.group_mail_group_manager'),
})
# ------------------------------------------------------------
# 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 = expression.AND([self._get_website_domain(), [('mail_group_id', '=', group.id)]])
if mode == 'thread':
domain = expression.AND([domain, [('group_message_parent_id', '=', False)]])
if date_begin and date_end:
domain = expression.AND([domain, [('create_date', '>', date_begin), ('create_date', '<=', date_end)]])
# SUDO after the search to apply access rules but be able to read attachments
messages_sudo = GroupMessage.search(
domain, limit=self._thread_per_page,
offset=(page - 1) * self._thread_per_page).sudo()
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}
)
self._generate_attachments_access_token(messages_sudo)
return request.render('mail_group.group_messages', {
'page_name': 'groups',
'group': group,
'messages': messages_sudo,
'archives': self._get_archives(group.id),
'date_begin': date_begin,
'date_end': date_end,
'pager': pager,
'replies_per_page': self._replies_per_page,
'mode': mode,
})
@http.route('/groups/<model("mail.group"):group>/<model("mail.group.message"):message>',
type='http', auth='public', sitemap=False, 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 = expression.AND([
self._get_website_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)
message_sudo = message.sudo()
self._generate_attachments_access_token(message_sudo)
values = {
'page_name': 'groups',
'message': message_sudo,
'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 = expression.AND([
self._get_website_domain(),
[('id', '>', int(last_displayed_id)), ('group_message_parent_id', '=', message.id)],
])
# SUDO after the search to apply access rules but be able to read attachments
replies_sudo = request.env['mail.group.message'].search(replies_domain, limit=self._replies_per_page).sudo()
message_count = request.env['mail.group.message'].search_count(replies_domain)
if not replies_sudo:
return
message_sudo = message.sudo()
self._generate_attachments_access_token(message_sudo | replies_sudo)
values = {
'group': group,
'parent_message': message_sudo,
'messages': replies_sudo,
'msg_more_count': message_count - self._replies_per_page,
'replies_per_page': self._replies_per_page,
}
return request.env['ir.qweb']._render('mail_group.messages_short', values)
# ------------------------------------------------------------
# SUBSCRIPTION
# ------------------------------------------------------------
# csrf is disabled here because it will be called by the MUA with unpredictable session at that time
@http.route('/group/<int:group_id>/unsubscribe_oneclick', website=True, type='http', auth='public',
methods=['POST'], csrf=False)
def group_unsubscribe_oneclick(self, group_id, token, email):
""" Unsubscribe a given user from a given group. One-click unsubscribe
allow mail user agent to propose a one click button to the user to
unsubscribe as defined in rfc8058. Only POST method is allowed preventing
the risk that anti-spam trigger unwanted unsubscribe (scenario explained
in the same rfc).
:param int group_id: group ID from which user wants to unsubscribe;
:param str token: optional access token ensuring security;
:param email: email to unsubscribe;
"""
group_sudo = request.env['mail.group'].sudo().browse(group_id).exists()
# new route parameters
if group_sudo and token and email:
correct_token = group_sudo._generate_email_access_token(email)
if not consteq(correct_token, token):
raise werkzeug.exceptions.NotFound()
group_sudo._leave_group(email)
else:
raise werkzeug.exceptions.NotFound()
return Response(status=200)
@http.route('/group/subscribe', type='json', auth='public', website=True)
def group_subscribe(self, group_id=0, 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 email: Email to add in the member list
:param token: An access token to bypass the <mail.group> access rule
:return:
'added'
if the member was added in the mailing list
'email_sent'
if we send a confirmation email
'is_already_member'
if we try to subscribe but we are already member
"""
group_sudo, is_member, partner_id = self._group_subscription_get_group(group_id, email, token)
if is_member:
return 'is_already_member'
if not request.env.user._is_public():
# For logged user, automatically join / leave without sending a confirmation email
group_sudo._join_group(request.env.user.email, partner_id)
return 'added'
# For non-logged user, send an email with a token to confirm the action
group_sudo._send_subscribe_confirmation_email(email)
return 'email_sent'
@http.route('/group/unsubscribe', type='json', auth='public', website=True)
def group_unsubscribe(self, group_id=0, email=None, token=None, **kw):
"""Unsubscribe 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 email: Email to add in the member list
:param token: An access token to bypass the <mail.group> access rule
:return:
'removed'
if the member was removed from the mailing list
'email_sent'
if we send a confirmation email
'is_not_member'
if we try to unsubscribe but we are not member
"""
group_sudo, is_member, partner_id = self._group_subscription_get_group(group_id, email, token)
if 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
group_sudo._leave_group(request.env.user.email, partner_id)
return 'removed'
# For non-logged user, send an email with a token to confirm the action
group_sudo._send_unsubscribe_confirmation_email(email)
return 'email_sent'
def _group_subscription_get_group(self, group_id, email, token):
"""Check the given token and return,
:return:
- The group sudo-ed
- True if the email is member of the group
- The partner of the current user
:raise NotFound: if the given token is not valid
"""
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:
try:
# Check that the current user has access to the group
group.check_access_rights('read')
group.check_access_rule('read')
except AccessError:
raise werkzeug.exceptions.NotFound()
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))
return group_sudo, is_member, partner_id
@http.route('/group/subscribe-confirm', type='http', auth='public', website=True)
def group_subscribe_confirm(self, group_id, email, token, **kw):
"""Confirm the subscribe / unsubscribe action which was sent by email."""
group = self._group_subscription_confirm_get_group(group_id, email, token, 'subscribe')
if not group:
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
group._join_group(email, partner_id)
return request.render('mail_group.confirmation_subscription', {
'group': group,
'email': email,
'subscribing': True,
})
@http.route('/group/unsubscribe-confirm', type='http', auth='public', website=True)
def group_unsubscribe_confirm(self, group_id, email, token, **kw):
"""Confirm the subscribe / unsubscribe action which was sent by email."""
group = self._group_subscription_confirm_get_group(group_id, email, token, 'unsubscribe')
if not group:
return request.render('mail_group.invalid_token_subscription')
group._leave_group(email, all_members=True)
return request.render('mail_group.confirmation_subscription', {
'group': group,
'email': email,
'subscribing': False,
})
def _group_subscription_confirm_get_group(self, group_id, email, token, action):
"""Retrieve the group and check the token use to perform the given action."""
if not group_id or not email or not token:
return False
# 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)
return group if token == excepted_token else False
def _generate_attachments_access_token(self, messages):
for message in messages:
if message.attachment_ids:
message.attachment_ids.generate_access_token()
self._generate_attachments_access_token(message.group_message_child_ids)

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>

View file

@ -0,0 +1,164 @@
<odoo>
<data>
<!-- Group 1 -->
<record id="mail_group_1" model="mail.group">
<field name="name">My Company News</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>

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_template_guidelines" model="mail.template">
<field name="name">Mail Group: Send Guidelines</field>
<field name="model_id" ref="mail_group.model_mail_group_member"/>
<field name="subject">Guidelines of group {{ object.mail_group_id.name }}</field>
<field name="email_to">{{ object.email }}</field>
<field name="description">Sent to people who subscribed to a mailing group with group guidelines</field>
<field name="body_html" type="html">
<div>
<p>Hello <t t-out="object.partner_id.name or ''"></t>,</p>
<p>Please find below the guidelines of the <t t-out="object.mail_group_id.name"></t> mailing list.</p>
<p><t t-out="object.mail_group_id.moderation_guidelines_msg or ''"></t></p>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- 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="description">Subscription confirmation to a mailing group</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 t-out="object.name or ''"></strong>.
<br/><br/>
To confirm, please visit the following link: <strong t-if="ctx.get('token_url')"><a t-att-href="ctx['token_url']"><t t-out="ctx['token_url'] or ''"></t></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="description">Sent to people who unsubscribed from a mailing group</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 t-out="object.name or ''"></strong>.
<br/><br/>
To confirm, please visit the following link: <strong t-if="ctx.get('token_url')"><a t-att-href="ctx['token_url']"><t t-out="ctx['token_url'] or ''"></t></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>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="mail_group_footer" name="Mail Group: Footer">
<div id="o_mg_message_footer">
<p>_______________________________________________</p>
<p>Mailing-List: <t t-esc="group_url"/></p>
<p>Post to: <t t-esc="mailto"/></p>
<p>Unsubscribe: <a t-att-href="unsub_url" t-esc="unsub_label"/></p>
</div>
</template>
<template id="mail_group_notify_moderation">
<div style="max-width: 600px">
<p>Hello <t t-esc="moderator.partner_id.name"/>,</p>
<p>You have messages to moderate, please go for the proceedings.</p>
<p><a t-attf-href="/web#action=mail_group.mail_group_action&amp;id={{group.id}}&amp;view_type=form" class="o_default_snippet_text">Moderate Messages</a></p>
<p>Thank you!</p>
</div>
</template>
</data>
</odoo>

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>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

View file

@ -0,0 +1,739 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import lxml
from ast import literal_eval
from datetime import datetime
from dateutil import relativedelta
from werkzeug import urls
from odoo import _, api, fields, models, tools
from odoo.addons.http_routing.models.ir_http import slug
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__)
# TODO remove me master
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 = 'Mail Group'
# TDE CHECK: use blaclist mixin
_inherit = ['mail.alias.mixin']
_order = 'create_date DESC, id DESC'
@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
active = fields.Boolean('Active', default=True)
name = fields.Char('Name', required=True, translate=True)
alias_name = fields.Char('Alias Name', copy=False, related='alias_id.alias_name', readonly=False)
alias_fullname = fields.Char('Alias Full Name', compute='_compute_alias_fullname')
description = fields.Text('Description')
image_128 = fields.Image('Image', max_width=128, max_height=128)
# 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_count = fields.Integer('Messages Count', help='Number of message in this group', compute='_compute_mail_group_message_count')
mail_group_message_moderation_count = fields.Integer('Pending Messages Count', help='Messages that need an action', compute='_compute_mail_group_message_moderation_count')
# Members
is_member = fields.Boolean('Is Member', compute='_compute_is_member')
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.Html(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.Html(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_group = fields.Boolean('Can Manage', help='Can manage the members', compute='_compute_can_manage_group')
@api.depends('alias_name', 'alias_domain')
def _compute_alias_fullname(self):
for group in self:
if group.alias_name and group.alias_domain:
group.alias_fullname = f'{group.alias_name}@{group.alias_domain}'
else:
group.alias_fullname = group.alias_name
@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([
('mail_group_id', 'in', self.ids),
('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')
def _compute_mail_group_message_count(self):
if not self:
self.mail_group_message_count = 0
return
results = self.env['mail.group.message']._read_group(
[('mail_group_id', 'in', self.ids)],
['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_count = result_per_group.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(
[('mail_group_id', 'in', self.ids), ('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, 0)
@api.depends('member_ids')
def _compute_member_count(self):
for group in self:
group.member_count = len(group.member_ids)
@api.depends_context('uid')
def _compute_is_member(self):
if not self or self.env.user._is_public():
self.is_member = False
return
# SUDO to bypass the ACL rules
members = self.env['mail.group.member'].sudo().search([
('partner_id', '=', self.env.user.partner_id.id),
('mail_group_id', 'in', self.ids),
])
is_member = {member.mail_group_id.id: True for member in members}
for group in self:
group.is_member = is_member.get(group.id, False)
@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_group(self):
is_admin = self.env.user.has_group('mail_group.group_mail_group_manager') or self.env.su
for group in self:
group.can_manage_group = 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):
""" Checks for access errors related to sending email to the mailing list.
Returns None if the mailing list is public or if no error cases are detected. """
self.ensure_one()
# Error Case: Selected group of users, but no user found for that email
email = email_normalize(message_dict.get('email_from', ''))
email_has_access = self.search_count([('id', '=', self.id), ('access_group_id.users.email_normalized', '=', email)])
if self.access_mode == 'groups' and not email_has_access:
return _('Only selected groups of users can send email to the mailing list.')
# Error Case: Access for members, but no member found for that email
elif self.access_mode == 'members' and not self._find_member(message_dict.get('email_from')):
return _('Only members can send email to the mailing list.')
return None
@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 the mail gateway 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_on_email=True)
values.update({
'author_id': author_id,
'body': self._clean_email_body(body),
'email_from': email_from,
'model': self._name,
'partner_ids': [],
'res_id': self.id,
'subject': subject,
})
# Force the "reply-to" to make the mail group flow work
values['reply_to'] = self.env['mail.message']._get_reply_to(values)
# 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_template_guidelines', raise_if_not_found=False)
if not template:
raise UserError(_('Template "mail_group.mail_template_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)
for member in members:
company = member.partner_id.company_id or self.env.company
template.send_mail(
member.id,
email_values={
'author_id': self.env.user.partner_id.id,
'email_from': company.email_formatted or company.catchall_formatted,
'reply_to': company.email_formatted or company.catchall_formatted,
},
)
_logger.info('Send guidelines to %i members', len(members))
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)
# Email added in a dict to be sure to send only once the email to each address
member_emails = {
email_normalize(member.email): member.email
for member in self.member_ids
}
batch_size = int(self.env['ir.config_parameter'].sudo().get_param('mail.session.batch.size', GROUP_SEND_BATCH_SIZE))
for batch_email_member in tools.split_every(batch_size, member_emails.items()):
mail_values = []
for email_member_normalized, email_member in batch_email_member:
if email_member_normalized == message.email_from_normalized:
# Do not send the email to their author
continue
# SMTP headers related to the subscription
email_url_encoded = urls.url_quote(email_member)
unsubscribe_url = self._get_email_unsubscribe_url(email_member_normalized)
headers = {
** self._notify_by_email_get_headers(),
'List-Archive': f'<{base_url}/groups/{slug(self)}>',
'List-Subscribe': f'<{base_url}/groups?email={email_url_encoded}>',
'List-Unsubscribe': f'<{unsubscribe_url}>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'Precedence': 'list',
'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange
}
if self.alias_name and self.alias_domain:
headers.update({
'List-Id': f'<{self.alias_name}.{self.alias_domain}>',
'List-Post': f'<mailto:{self.alias_name}@{self.alias_domain}>',
'X-Forge-To': f'"{self.name}" <{self.alias_name}@{self.alias_domain}>',
})
if message.mail_message_id.parent_id:
headers['In-Reply-To'] = message.mail_message_id.parent_id.message_id
# 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_label': f'{base_url}/groups?unsubscribe',
'unsub_url': unsubscribe_url,
}
footer = self.env['ir.qweb']._render('mail_group.mail_group_footer', template_values, minimal_qcontext=True)
member_body = tools.append_content_to_html(body, footer, plaintext=False)
mail_values.append({
'auto_delete': True,
'attachment_ids': message.attachment_ids.ids,
'body_html': member_body,
'email_from': message.email_from,
'email_to': email_member,
'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(
[('mail_group_id', 'in', self.ids), ('moderation_status', '=', 'pending_moderation')],
['mail_group_id'],
['mail_group_id'],
)
groups = self.browse([result['mail_group_id'][0] for result in results])
for group in groups:
moderators_to_notify = group.moderator_ids
MailThread = self.env['mail.thread'].with_context(mail_notify_author=True)
for moderator in moderators_to_notify:
body = self.env['ir.qweb']._render('mail_group.mail_group_notify_moderation', {
'moderator': moderator,
'group': group,
}, 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,
model='mail.group',
res_id=group.id,
)
@api.model
def _clean_email_body(self, body_html):
"""When we receive an email, we want to clean it before storing it in the database."""
tree = lxml.html.fromstring(body_html or '')
# Remove the mailing footer
xpath_footer = ".//div[contains(@id, 'o_mg_message_footer')]"
for parent_footer in tree.xpath(xpath_footer + "/.."):
for footer in parent_footer.xpath(xpath_footer):
parent_footer.remove(footer)
return lxml.etree.tostring(tree, encoding='utf-8').decode()
# ------------------------------------------------------------
# MEMBERSHIP
# ------------------------------------------------------------
def action_join(self):
self.check_access_rights('read')
self.check_access_rule('read')
partner = self.env.user.partner_id
self.sudo()._join_group(partner.email, partner.id)
_logger.info('"%s" (#%s) joined mail.group "%s" (#%s)', partner.name, partner.id, self.name, self.id)
def action_leave(self):
self.check_access_rights('read')
self.check_access_rule('read')
partner = self.env.user.partner_id
self.sudo()._leave_group(partner.email, partner.id)
_logger.info('"%s" (#%s) leaved mail.group "%s" (#%s)', partner.name, partner.id, self.name, self.id)
def _join_group(self, email, partner_id=None):
self.ensure_one()
if partner_id:
partner = self.env['res.partner'].browse(partner_id).exists()
if not partner:
raise ValidationError(_('The partner can not be found.'))
email = partner.email
existing_member = self._find_member(email, partner_id)
if existing_member:
# Update the information of the partner to force the synchronization
# If one the value is not up to date (e.g. if our email is subscribed
# but our partner was not set)
existing_member.write({
'email': email,
'partner_id': partner_id,
})
return
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, all_members=False):
"""Remove the given email / partner from the group.
If the "all_members" parameter is set to True, remove all members with the given
email address (multiple members might have the same email address).
Otherwise, remove the most appropriate.
"""
self.ensure_one()
if all_members and not partner_id:
self.env['mail.group.member'].search([
('mail_group_id', '=', self.id),
('email_normalized', '=', email_normalize(email)),
]).unlink()
else:
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,
email_layout_xmlid='mail.mail_notification_light',
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',
},
force_send=True,
)
_logger.info('Subscription email sent to %s.', email)
def _send_unsubscribe_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, 'unsubscribe')
template = self.env.ref('mail_group.mail_template_list_unsubscribe')
template.with_context(token_url=confirm_action_url).send_mail(
self.id,
email_layout_xmlid='mail.mail_notification_light',
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',
},
force_send=True,
)
_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 = '/group/%s-confirm?%s' % (
action,
urls.url_encode({
'group_id': self.id,
'email': email,
'token': self._generate_action_token(email, 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_email_access_token(self, email):
"""Generate an action token to be able to unsubscribe from the mailing
list, while hashing the target email to avoid spoofind other emails.
:param str email: email included in hash, should be normalized
"""
return tools.hmac(self.env(su=True), 'mail_group-access-token-portal-email', (self.id, email))
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 _get_email_unsubscribe_url(self, email_to):
params = urls.url_encode({
'email': email_to,
'token': self._generate_email_access_token(email_to),
})
return urls.url_join(
self.get_base_url(),
f'group/{self.id}/unsubscribe_oneclick?{params}'
)
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>}
Multiple members might have the same email address, but with different partner
because there's no unique constraint on the email field of the <res.partner>
model.
When a partner is given for the search, return in priority
- The member whose partner match the given partner
- The member without partner but whose email match the given email
When no partner is given for the search, return in priority
- A member whose email match the given email and has no partner
- A member whose email match the given email and has partner
"""
order = 'partner_id ASC'
if not email_normalize(email):
# empty email should match nobody
return {}
domain = [('email_normalized', '=', email_normalize(email))]
if partner_id:
domain = expression.OR([
expression.AND([
[('partner_id', '=', False)],
domain,
]),
[('partner_id', '=', partner_id)],
])
order = 'partner_id DESC'
domain = expression.AND([domain, [('mail_group_id', 'in', self.ids)]])
members_data = self.env['mail.group.member'].sudo().search(domain, order=order)
return {
member.mail_group_id.id: member
for member in members_data
}

View file

@ -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)

View file

@ -0,0 +1,247 @@
# -*- 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.exceptions import AccessError, UserError
from odoo.osv import expression
from odoo.tools import email_normalize, append_content_to_html, ustr
_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'
_primary_email = 'email_from'
# <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)
group_message_child_ids = fields.One2many('mail.group.message', 'group_message_parent_id', string='Children')
# Moderation
author_moderation = fields.Selection([('ban', 'Banned'), ('allow', 'Whitelisted')], string='Author Moderation Status',
compute='_compute_author_moderation')
is_group_moderated = fields.Boolean('Is Group Moderated', related='mail_group_id.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')
create_date = fields.Datetime(string='Posted')
@api.depends('email_from')
def _compute_email_from_normalized(self):
for message in self:
message.email_from_normalized = email_normalize(message.email_from)
@api.depends('email_from_normalized', 'mail_group_id')
def _compute_author_moderation(self):
moderations = self.env['mail.group.moderation'].search([
('mail_group_id', 'in', self.mail_group_id.ids),
])
all_emails = set(self.mapped('email_from_normalized'))
moderations = {
(moderation.mail_group_id, moderation.email): moderation.status
for moderation in moderations
if moderation.email in all_emails
}
for message in self:
message.author_moderation = moderations.get((message.mail_group_id, message.email_from_normalized), False)
@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_moderate_allow(self):
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_moderate_ban(self):
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 action_moderate_ban_with_comment(self, ban_subject, ban_comment):
self._create_moderation_rule('ban')
if ban_subject or ban_comment:
self._moderate_send_reject_email(ban_subject, ban_comment)
# 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
body_html = append_content_to_html('<div>%s</div>' % 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',
})

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="mail_group_rule_read_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', user.groups_id.ids),
'&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="False"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="mail_group_rule_write_all" model="ir.rule">
<field name="name">Mail Group: Moderator have write access on their group</field>
<field name="model_id" ref="model_mail_group"/>
<field name="domain_force">[('moderator_ids', 'in', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_create" eval="False"/>
<field name="perm_read" eval="False"/>
<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', user.groups_id.ids),
'&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', user.groups_id.ids),
'&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)]</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>

View file

@ -0,0 +1,44 @@
.o_mg_subscribe_btn {
width: 120px;
}
.o_mg_page_description {
background-image: url(/mail_group/static/src/img/mail_group_portal.jpg);
}
.o_mg_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;
}
}
.o_mg_message *[data-o-mail-quote]:not(.visible) {
// Hide the quotation of the parent email in the body
// The "visible" class with be toggled in JS
display: none!important;
}
.o_mg_attachment {
width: 120px;
}
.o_mg_message {
min-width: 250px;
}

View file

@ -0,0 +1,9 @@
.o_mail_group_message_form.o_xxs_form_view {
.o_group tr td {
min-width: 100px;
}
}
.o_mg_description {
height: 20px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -0,0 +1,92 @@
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;
const searchParams = (new URL(document.location.href)).searchParams;
this.token = searchParams.get('token');
this.forceUnsubscribe = searchParams.has('unsubscribe');
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, .form-select').addClass('is-invalid');
return false;
}
this.$target.removeClass('o_has_error').find('.form-control, .form-select').removeClass('is-invalid');
const action = (this.isMember || this.forceUnsubscribe) ? 'unsubscribe' : 'subscribe';
const response = await this._rpc({
route: '/group/' + action,
params: {
'group_id': this.mailgroupId,
'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')).removeClass('btn-primary').addClass('btn-outline-primary');
} else if (response === 'removed') {
this.isMember = false;
this.$target.find('.o_mg_subscribe_btn').text(_t('Subscribe')).removeClass('btn-outline-primary').addClass('btn-primary');
} 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 instructions has been sent.'))
);
} else if (response === 'is_already_member') {
this.isMember = true;
this.$target.find('.o_mg_subscribe_btn').text(_t('Unsubscribe')).removeClass('btn-primary').addClass('btn-outline-primary');
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') {
if (!this.forceUnsubscribe) {
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.'))
);
}
},
});
return publicWidget.registry.MailGroup;
});

View file

@ -0,0 +1,89 @@
odoo.define('mail_group.mail_group_message', function (require) {
'use strict';
const publicWidget = require('web.public.widget');
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-light btn-sm ms-1"/>').text('. . .');
quoted.first().before(readMore);
readMore.on('click', () => {
quoted.toggleClass('visible');
});
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.closest('.o_mg_link_parent');
$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.closest('.o_mg_link_parent');
$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').first();
if ($threadContainer) {
const $data = $(data);
const $lastMsg = $threadContainer.children('li.media').last();
const $newMessages = $data.find('ul.list-unstyled').first().children('li.media');
$newMessages.insertAfter($lastMsg);
$data.find('.o_mg_read_more').parent().appendTo($threadContainer);
}
const $showMore = $link.parent();
$showMore.remove();
});
},
});
});

View file

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_mail_group
from . import test_mail_group_mailing
from . import test_mail_group_message
from . import test_mail_group_moderation

View file

@ -0,0 +1,80 @@
# -*- 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>',
})
cls.user_portal = cls._create_portal_user()

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--
"""

View file

@ -0,0 +1,254 @@
# -*- 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, AccessError
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger, append_content_to_html
@tagged("mail_group")
class TestMailGroup(TestMailListCommon):
def test_clean_email_body(self):
footer = self.env['ir.qweb']._render('mail_group.mail_group_footer', {'group_url': 'Test remove footer'}, minimal_qcontext=True)
body = append_content_to_html("<div>Test email body</div>", footer, plaintext=False)
result = self.env['mail.group']._clean_email_body(body)
self.assertIn('Test email body', result, 'Should have kept the original email body')
self.assertNotIn('Test remove footer', result, 'Should have removed the mailing list footer')
self.assertNotIn('o_mg_message_footer', result, 'Should have removed the entire HTML element')
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
def test_find_group_user_for_alias(self):
"""Check for mail incoming from an allowed group. Specifically for a situation where
the sender is a part of the allowed USER group, but is NOT a member of the mailing list."""
group_user_not_member = mail_new_test_user(self.env, login='group user not member', email="group_user_not_member@example.com")
self.assertIn(group_user_not_member, self.test_group.access_group_id.users,
"User, that sends e-mail, must be part of the access group (in this test scenario)")
self.assertNotIn(group_user_not_member.id, self.test_group.member_ids.ids,
"User, that sends e-mail, shan't be a member of the mail group (in this test scenario)")
self.test_group.alias_id.alias_contact = 'followers'
self.test_group.access_mode = 'groups'
err_msg = self.test_group._alias_get_error_message({}, {'email_from': group_user_not_member.email}, self.test_group.alias_id)
self.assertFalse(err_msg, "Mail with sender belonging to allowed user group (not a member of the mail group) was rejected")
def test_find_member(self):
"""Test the priority to retrieve a member of a mail group from a partner_id
and an email address.
When a partner is given for the search, return in priority
- The member whose partner match the given partner
- The member without partner but whose email match the given email
When no partner is given for the search, return in priority
- A member whose email match the given email and has no partner
- A member whose email match the given email and has partner
"""
member_1 = self.test_group_member_1
email = member_1.email_normalized
partner_2 = self.user_portal.partner_id
partner_2.email = '"Bob" <%s>' % email
member_2 = self.env['mail.group.member'].create({
'partner_id': partner_2.id,
'mail_group_id': self.test_group.id,
})
partner_3 = self.partner_root
partner_3.email = '"Bob" <%s>' % email
member_3 = self.env['mail.group.member'].create({
'partner_id': partner_3.id,
'mail_group_id': self.test_group.id,
})
self.env['mail.group.member'].create({
'email': "Alice",
'mail_group_id': self.test_group.id,
})
member = self.test_group._find_member(email)
self.assertEqual(member, member_1, 'When no partner is provided, return the member without partner in priority')
member = self.test_group._find_member(email, partner_2.id)
self.assertEqual(member, member_2, 'Should return the member with the right partner')
member = self.test_group._find_member(email, partner_3.id)
self.assertEqual(member, member_3, 'Should return the member with the right partner')
member_2.unlink()
member = self.test_group._find_member(email, partner_2.id)
self.assertEqual(member, member_1, 'Should return the member without partner')
member_1.unlink()
member = self.test_group._find_member(email, partner_2.id)
self.assertFalse(member, 'Should not return any member because the only one with the same email has a different partner')
member = self.test_group._find_member('', None)
self.assertEqual(member, None, 'When no email nor partner is provided, return nobody')
def test_find_member_for_alias(self):
"""Test the matching of a mail_group.members, when 2 users have the same partner email, and
that the first user was subscribed."""
user = self.user_portal
user2 = mail_new_test_user(self.env, login='login_2', email=user.email)
member = self.env['mail.group.member'].create({
# subscribe with the first user
'partner_id': user.partner_id.id,
'mail_group_id': self.test_group.id,
})
self.assertEqual(member.email, user.email)
# In case of matching, function return a falsy value.
# Should not return string (exception) if at least one members have the same email, whatever
# the partner (author_id) that could match this email.
msg_dict = {
# send mail with the second user
'author_id': user2.partner_id.id,
'email_from': user2.email,
}
self.test_group.alias_id.alias_contact = 'followers'
self.assertFalse(self.test_group._alias_get_error_message({}, msg_dict, self.test_group.alias_id))
@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)
self.assertTrue(mail_group._find_member('"Test" <jack@test.com>'))
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')
# Join a group with a different email than the partner
portal_partner = self.user_portal.partner_id
mail_group._join_group('"Bob" <email_different_than_partner@test.com>', portal_partner.id)
self.assertEqual(len(mail_group.member_ids), 6, 'Should have added the new member')
member = mail_group._find_member('email_different_than_partner@test.com', portal_partner.id)
self.assertTrue(member)
self.assertEqual(member.partner_id, portal_partner, 'Should have set the partner')
self.assertEqual(member.email, portal_partner.email, 'Should have force the email to the email of the partner')
self.assertEqual(member.email_normalized, portal_partner.email_normalized)
portal_partner.email = 'new_portal_email@example.com'
self.assertEqual(member.email, 'new_portal_email@example.com', 'Should have change the email of the partner')
self.assertEqual(member.email_normalized, 'new_portal_email@example.com')
@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')
with self.assertRaises(AccessError, msg='Only moderator / responsible and admin can write on the group'):
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')
with self.assertRaises(AccessError, msg='Only moderator / responsible and admin can write on the group'):
mail_group.with_user(self.user_employee_2).check_access_rule('write')
mail_group.moderator_ids |= self.user_employee_2
mail_group.with_user(self.user_employee_2).check_access_rule('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'
partner = self.user_employee_2.partner_id
self.assertNotIn(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_employee_2).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': partner.id,
})]})
self.assertIn(partner, mail_group.member_partner_ids)
# Now that portal is in the member list they should have access
mail_group.with_user(self.user_employee_2).check_access_rule('read')
with self.assertRaises(AccessError, msg='Only moderator / responsible and admin can write on the group'):
mail_group.with_user(self.user_employee_2).check_access_rule('write')
mail_group.moderator_ids |= self.user_employee_2
mail_group.with_user(self.user_employee_2).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'):
member.with_user(self.user_portal).check_access_rule('read')
member.with_user(self.user_portal).check_access_rights('read')
with self.assertRaises(AccessError, msg='Non moderators should not have access to member'):
member.with_user(self.user_portal).check_access_rule('read')
member.with_user(self.user_portal).check_access_rights('read')

View file

@ -0,0 +1,35 @@
from ast import literal_eval
from odoo.addons.mail_group.tests.common import TestMailListCommon
from odoo.tests.common import HttpCase, tagged, users
@tagged("mail_group", "mail_mail", "post_install", "-at_install")
class TestMailGroupMailing(TestMailListCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_group.moderation = False
@users("employee")
def test_mail_mail_headers(self):
""" Test headers notably unsubscribe headers """
test_group = self.test_group.with_env(self.env)
# don't contact yourself, banned people receive outgoing emails
expected_recipients = self.test_group_member_1 + self.test_group_member_2 + self.test_group_member_3_banned
with self.mock_mail_gateway(mail_unlink_sent=False):
test_group.message_post(
body="<p>Test Body</p>",
)
self.assertEqual(len(self._new_mails), len(expected_recipients))
for member in expected_recipients:
mail = self._find_mail_mail_wemail(member.email, "outgoing")
unsubscribe_url = literal_eval(mail.headers).get("List-Unsubscribe").strip('<>')
_response = self.opener.post(unsubscribe_url)
self.assertEqual(test_group.member_ids, self.test_group_member_4_emp,
"Mail Group: people should have been unsubscribed")

View file

@ -0,0 +1,160 @@
# -*- 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):
def test_batch_send(self):
"""Test that when someone sends an email to a large group that it is
delivered exactly to those people"""
self.test_group.write({
'access_mode': 'members',
'alias_contact': 'followers',
'moderation': False,
})
self.test_group.member_ids.unlink()
for num in range(42):
self.env['mail.group.member'].create({
'email': f'emu-{num}@example.com',
'mail_group_id': self.test_group.id,
})
self.assertEqual(len(self.test_group.member_ids), 42)
# force a batch split with a low limit
self.env['ir.config_parameter'].sudo().set_param('mail.session.batch.size', 10)
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, self.test_group.member_ids[0].email,
self.test_group.alias_id.display_name,
subject='Never Surrender', msg_id='<glory.to.the.hypnotoad@localhost>', target_model='mail.group')
message = self.env['mail.group.message'].search([('mail_message_id.message_id', '=', '<glory.to.the.hypnotoad@localhost>')])
self.assertEqual(message.subject, 'Never Surrender', 'Should have created a <mail.group.message>')
mails = self.env['mail.mail'].search([('mail_message_id', '=', message.mail_message_id.id)])
# 42 -1 as the sender doesn't get an email
self.assertEqual(len(mails), 41, 'Should have send one and only one email per recipient')
@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), len(self.test_group.member_ids) - 1)
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_recordset()
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_recordset()
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_recordset()
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_recordset()
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')
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail_group.models.mail_group_message')
def test_email_empty_from(self):
"""Test that when someone sends an email the group process does not send
it back to the original author."""
self.test_group.write({
'access_mode': 'members',
'alias_contact': 'followers',
'moderation': False,
})
# new member without email
self.env['mail.group.member'].create({
'email': '',
'mail_group_id': self.test_group.id,
})
with self.mock_mail_gateway():
self.format_and_process(
GROUP_TEMPLATE, "Foo",
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), 0, "Email should not be delivered when no email is specified")

View file

@ -0,0 +1,415 @@
# -*- 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()
cls.test_group_2 = cls.env['mail.group'].create({
'access_mode': 'members',
'alias_name': 'test.mail.group.2',
'moderation': True,
'moderator_ids': [Command.link(cls.user_employee.id)],
'name': 'Test group 2',
})
@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',
})
@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):
with self.assertRaises(AccessError, msg='Portal should not have access to moderation rules'):
self.env['mail.group.moderation'].with_user(self.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': 'members',
'alias_name': 'test.mail.group.2',
'moderation': True,
'moderator_ids': [Command.link(cls.user_employee.id)],
'name': 'Test group 2',
})
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.test_group
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)
group_2_message_count = len(mail_group_2_as2.mail_group_message_ids)
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_moderate_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), group_2_message_count,
'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_moderate_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.email_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.email_formatted,
'subject': 'Guidelines of group %s' % mail_group.name,
})

View file

@ -0,0 +1,37 @@
<?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" sample="1">
<field name="email"
required="1" force_save="1"
attrs="{'readonly': [('partner_id', '!=', False)]}"/>
<field name="email_normalized" invisible="1" force_save="1"/>
<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>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No Members in this list yet!</p>
<p>Let people subscribe to your list online or manually add them here.</p>
</field>
</record>
</odoo>

Some files were not shown because too many files have changed in this diff Show more