Purpose ======= This commit improves the UI of the mail group module (backend and portal view), fix some bugs and clean the code. Bug 1 ===== If a user click 2 times on a confirmation link, an error is raised. Bug 2 ===== A bug about the website snippets has been fixed; - Add the snippet to a group in which you are member - Change the group of the snippet to a group in which you are not member - The snippet still show "Unsubscribe" Bug 3 ===== In the portal view, when messages are filtered by month, messages which are created in the last day of the month appear also in the next month. Specification ============= UI -- Show the Allow / Ban buttons whatever is the state of the message, so the user can ban / whitelist a message even if there's no pending email. When we receive an email, remove the "Mailing List" footer. This was a big issue because when some users reply many times, the footer was duplicated on each answer. Improve the form view of the mail group: - On non-moderated group, show all messages instead of only pending message - Show the guidelines stuff on non-moderated groups Improve the portal view - show the attachments under every message (not only on opened message) - improve the template to confirm the subscription of the user - improve the portal view for mobile device - do not show rejected messages, even for admin - do not show the button "X replies" when the mode is not thread Technical --------- Split the route "/groups/subscription" into "/group/subscribe" and "/group/unsubscribe" Correctly quote the parent email for the emails sent by web version of Outlook (with the HTML id "divRplyFwdMsg"). ACLs ---- Now only administrators or responsible / moderators can write / unlink the group. Fix an ACL issue on the group member where the responsible of a non-moderated group could not access the members of the group. Members with same email ----------------------- Improve the behavior when multiple members have the same email address (this situation is possible because there's no unique constraint on the email field of the <res.partner> model.) We now return the most appropriate member for the given email address and partner. When unsubscribing by clicking on the confirmation link that we received by email, remove all members with the same email address. Links ===== Task-2599676 See odoo/odoo/pull/73640 closes odoo/odoo#73640 Related: odoo/upgrade#2774 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
362 lines
15 KiB
Python
362 lines
15 KiB
Python
# -*- 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
|
|
from odoo.osv import expression
|
|
|
|
|
|
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=True, website=True)
|
|
def group_view_message(self, group, message, mode='thread', date_begin=None, date_end=None, **post):
|
|
if group != message.mail_group_id:
|
|
raise werkzeug.exceptions.NotFound()
|
|
|
|
GroupMessage = request.env['mail.group.message']
|
|
base_domain = 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.ref('mail_group.messages_short')._render(values, engine='ir.qweb')
|
|
|
|
# ------------------------------------------------------------
|
|
# SUBSCRIPTION
|
|
# ------------------------------------------------------------
|
|
|
|
@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)
|