From b3d9fbd3d5268e25ee61f21d5df72a0b3668188b Mon Sep 17 00:00:00 2001
From: Pierre-Yves Dufays
Date: Mon, 21 Nov 2022 15:09:43 +0000
Subject: [PATCH 1/4] [IMP] mail: allows unfollowing record from inbox
Allow users to unfollow easily document from the inbox for which he/she doesn't
want to be notified anymore.
When hovering on a follow up message with a related document followed by the
user, the interface displays an action to unfollow it that allows the user to
remove himself/herself as follower of the document. As a result of performing
that action a toast is displayed as a feedback.
Technical note:
The unfollow link is displayed on a message.canUnfollow is true which check
that the thread related to the message is followed by the user.
In order to know if the user follows the record related to the message, we now
transmit with each message the id of the follower table that link the user to
the related record if it follows it. This allows to insert the follower on
client side so that the unfollow action can use the standard mechaism already
developed to unfollow a record.
Unfortunately, that information coudn't be added in the already public method
message_format of mail message because that method is not always called on the
user retreiving the message but also by the one posting message. We didn't want
also to include all followers in the formated message so we rely on additional
method that add that information per partner.
To support unfollowing a document in the inbox no matter the current company,
we have modified message_unsubscribe in mail_thread to allow internal user to
unsubscribe themself without checking any rights. Indeed, some document have
record rule that prevents reading it if the user is not in the right company
(ex. crm.lead) and then was preventing the user to unfollow the document using
that method.
Task-3061864
Part-of: odoo/odoo#107978
---
addons/mail/controllers/mailbox.py | 5 +-
addons/mail/models/mail_message.py | 68 ++++++++++++++++++-
addons/mail/models/mail_thread.py | 23 +++++--
.../mail/static/src/core/message_service.js | 22 ++++++
addons/mail/static/src/core_ui/message.js | 4 ++
addons/mail/static/src/core_ui/message.xml | 1 +
.../static/tests/discuss_app/discuss_tests.js | 60 ++++++++++++++++
.../mock_server/controllers/discuss.js | 2 +-
.../mock_server/models/mail_message.js | 25 +++++++
9 files changed, 199 insertions(+), 11 deletions(-)
diff --git a/addons/mail/controllers/mailbox.py b/addons/mail/controllers/mailbox.py
index a5f335449a9..26397082de6 100644
--- a/addons/mail/controllers/mailbox.py
+++ b/addons/mail/controllers/mailbox.py
@@ -7,8 +7,11 @@ from odoo.http import request
class MailboxController(http.Controller):
@http.route("/mail/inbox/messages", methods=["POST"], type="json", auth="user")
def discuss_inbox_messages(self, before=None, after=None, limit=30, around=None):
+ partner_id = request.env.user.partner_id.id
domain = [("needaction", "=", True)]
- return request.env["mail.message"]._message_fetch(domain, before, after, around, limit).message_format()
+ return (request.env["mail.message"]
+ ._message_fetch(domain, before, after, around, limit)
+ ._message_format_personalize(partner_id))
@http.route("/mail/history/messages", methods=["POST"], type="json", auth="user")
def discuss_history_messages(self, before=None, after=None, limit=30):
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index d80dc68f2fe..156b7bf35e0 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -10,7 +10,7 @@ from collections import defaultdict
from odoo import _, api, Command, fields, models, modules, tools
from odoo.exceptions import AccessError
from odoo.osv import expression
-from odoo.tools.misc import clean_context
+from odoo.tools.misc import clean_context, groupby as tools_groupby
_logger = logging.getLogger(__name__)
_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])(?: data-filename="([^"]*)")?', re.I)
@@ -1005,7 +1005,6 @@ class Message(models.Model):
com_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
note_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')
-
# fetch scheduled notifications once, only if msg_vals is not given to
# avoid useless queries when notifying Inbox right after a message_post
scheduled_dt_by_msg_id = {}
@@ -1034,6 +1033,71 @@ class Message(models.Model):
vals['module_icon'] = modules.module.get_module_icon(self.env[vals['model']]._original_module)
return vals_list
+ @api.model
+ def _message_format_personalized_prepare(self, messages_formatted, partner_ids=None):
+ """ Prepare message to be personalized by partner.
+
+ This method add partner information in batch to the messages so that the
+ messages could be personalized for each partner by using
+ _message_format_personalize with no or a limited number of queries.
+
+ For example, it gathers all followers of the record related to the messages
+ in one go (or limit it to the partner given in parameter), so that the method
+ _message_format_personalize could then personalize each message for each partner.
+
+ Note that followers for message related to discuss.channel are not fetched.
+
+ :param list messages_formatted: list of message formatted using the method
+ message_format
+ :param list partner_ids: (optional) limit value computation to the partners
+ of the given list; if not set, all partners are considered (all partners
+ following each record will be included in follower_id_by_partner_id).
+
+ :return: list of messages_formatted with added value:
+ 'follower_id_by_partner_id': dict partner_id -> follower_id of the record
+ """
+ domain = expression.OR([
+ [('res_model', '=', model), ('res_id', 'in', list({value['res_id'] for value in values}))]
+ for model, values in tools_groupby(
+ (vals for vals in messages_formatted
+ if vals.get("res_id") and vals.get("model") not in {None, False, '', 'discuss.channel'}),
+ key=lambda r: r["model"]
+ )
+ ])
+ if partner_ids:
+ domain = expression.AND([domain, [('partner_id', 'in', partner_ids)]])
+ records_followed = self.env['mail.followers'].sudo().search(domain)
+ followers_by_record_ref = (
+ {(res_model, res_id): {value['partner_id'][0]: value['id'] for value in values}
+ for (res_model, res_id), values in tools_groupby(
+ records_followed.read(['res_id', 'res_model', 'partner_id']),
+ key=lambda r: (r["res_model"], r['res_id'])
+ )})
+ for vals in messages_formatted:
+ vals['follower_id_by_partner_id'] = followers_by_record_ref.get((vals['model'], vals['res_id']), dict())
+ return messages_formatted
+
+ def _message_format_personalize(self, partner_id, messages_formatted=None, format_reply=True, msg_vals=None):
+ """ Personalize the messages for the partner.
+
+ :param integer partner_id: id of the partner to personalize the messages for
+ :param list messages_formatted: (optional) list of message formatted using
+ the method _message_format_personalized_prepare.
+ If not provided message_format is called on self with the 2 next parameters
+ to format the messages and then the messages are personalized.
+ :param bool format_reply: (optional) see method message_format
+ :param dict msg_vals: (optional) see method message_format
+ :return: list of messages_formatted personalized for the partner
+ """
+ if not messages_formatted:
+ messages_formatted = self.message_format(format_reply=format_reply, msg_vals=msg_vals)
+ self._message_format_personalized_prepare(messages_formatted, [partner_id])
+ for vals in messages_formatted:
+ # set value for user being a follower, fallback to False if not prepared
+ follower_id_by_pid = vals.pop('follower_id_by_partner_id', {})
+ vals['user_follower_id'] = follower_id_by_pid.get(partner_id, False)
+ return messages_formatted
+
def _get_message_format_fields(self):
return [
'id', 'body', 'date', 'email_from', # base message fields
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index ac1d0023ab1..cb40936dae9 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -2995,9 +2995,15 @@ class MailThread(models.AbstractModel):
} for pid in inbox_pids]
self.env['mail.notification'].sudo().create(notif_create_values)
- message_format_values = message.message_format(msg_vals=msg_vals)[0]
+ MailMessage = self.env['mail.message']
+ messages_format_prepared = MailMessage._message_format_personalized_prepare(
+ message.message_format(msg_vals=msg_vals), partner_ids=inbox_pids)
for partner_id in inbox_pids:
- bus_notifications.append((self.env['res.partner'].browse(partner_id), 'mail.message/inbox', dict(message_format_values)))
+ bus_notifications.append(
+ (self.env['res.partner'].browse(partner_id),
+ 'mail.message/inbox',
+ MailMessage._message_format_personalize(partner_id, messages_format_prepared)[0])
+ )
self.env['bus.bus'].sudo()._sendmany(bus_notifications)
def _notify_thread_by_email(self, message, recipients_data, msg_vals=False,
@@ -3820,16 +3826,19 @@ class MailThread(models.AbstractModel):
# not necessary for computation, but saves an access right check
if not partner_ids:
return True
- if set(partner_ids) == set([self.env.user.partner_id.id]):
- self.check_access_rights('read')
- self.check_access_rule('read')
- else:
+ # To support unfollowing a document in the inbox no matter the current
+ # company, we allow internal users to unsubscribe themselves without
+ # checking any rights.
+ if set(partner_ids) != {self.env.user.partner_id.id}:
self.check_access_rights('write')
self.check_access_rule('write')
+ elif not self.env.user._is_internal():
+ self.check_access_rights('read')
+ self.check_access_rule('read')
self.env['mail.followers'].sudo().search([
('res_model', '=', self._name),
('res_id', 'in', self.ids),
- ('partner_id', 'in', partner_ids or []),
+ ('partner_id', 'in', partner_ids),
]).unlink()
def _message_auto_subscribe_followers(self, updated_values, default_subtype_ids):
diff --git a/addons/mail/static/src/core/message_service.js b/addons/mail/static/src/core/message_service.js
index c758c22aca0..a3346a695dc 100644
--- a/addons/mail/static/src/core/message_service.js
+++ b/addons/mail/static/src/core/message_service.js
@@ -3,7 +3,9 @@
import { Message } from "./message_model";
import { removeFromArrayWithPredicate } from "../utils/arrays";
import { convertBrToLineBreak, prettifyMessageContent } from "../utils/format";
+import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
+import { sprintf } from "@web/core/utils/strings";
import { MessageReactions } from "./message_reactions_model";
import { Notification } from "./notification_model";
import { LinkPreview } from "./link_preview_model";
@@ -141,6 +143,18 @@ export class MessageService {
});
}
+ async unfollow(message) {
+ if (message.isNeedaction) {
+ await this.setDone(message);
+ }
+ const thread = message.originThread;
+ await this.env.services["mail.thread"].removeFollower(thread.followerOfSelf);
+ this.env.services.notification.add(
+ sprintf(_t('You are no longer following "%(thread_name)s".'), { thread_name: thread.name }),
+ { type: "success" }
+ );
+ }
+
async unstarAll() {
// apply the change immediately for faster feedback
this.store.discuss.starred.counter = 0;
@@ -285,6 +299,14 @@ export class MessageService {
if (data.res_model_name) {
message.originThread.modelName = data.res_model_name;
}
+ if ("user_follower_id" in data && data.user_follower_id && this.store.self) {
+ this.env.services["mail.thread"].insertFollower({
+ followedThread: message.originThread,
+ id: data.user_follower_id,
+ isActive: true,
+ partner: this.store.self,
+ });
+ }
this._updateReactions(message, data.messageReactionGroups);
if (message.isNotification && !message.notificationType) {
const parser = new DOMParser();
diff --git a/addons/mail/static/src/core_ui/message.js b/addons/mail/static/src/core_ui/message.js
index 746f5e14276..9b1ab2a4971 100644
--- a/addons/mail/static/src/core_ui/message.js
+++ b/addons/mail/static/src/core_ui/message.js
@@ -216,6 +216,10 @@ export class Message extends Component {
return Boolean(!this.message.isTransient && this.message.resId);
}
+ get canUnfollow() {
+ return Boolean(this.message.originThread?.followerOfSelf);
+ }
+
/**
* Determines whether clicking on the author's avatar opens a chat with the
* author.
diff --git a/addons/mail/static/src/core_ui/message.xml b/addons/mail/static/src/core_ui/message.xml
index effc93503bd..1c5641109c3 100644
--- a/addons/mail/static/src/core_ui/message.xml
+++ b/addons/mail/static/src/core_ui/message.xml
@@ -145,6 +145,7 @@
View Reactions
Mark as Read
+ Unfollow
Mark as Unread
Edit
diff --git a/addons/mail/static/tests/discuss_app/discuss_tests.js b/addons/mail/static/tests/discuss_app/discuss_tests.js
index d2a70faadf5..76261d419d5 100644
--- a/addons/mail/static/tests/discuss_app/discuss_tests.js
+++ b/addons/mail/static/tests/discuss_app/discuss_tests.js
@@ -827,6 +827,66 @@ QUnit.test("rendering of inbox message", async (assert) => {
assert.containsOnce($message, "[title='Mark as Read']");
});
+QUnit.test('Unfollow message', async function (assert) {
+ assert.expect(12);
+
+ const pyEnv = await startServer();
+ const currentPartnerId = pyEnv.currentPartnerId;
+ const [threadFollowedId, threadNotFollowedId] = pyEnv["res.partner"].create([{
+ name: 'Thread followed',
+ }, {
+ name: 'Thread not followed',
+ }]);
+ pyEnv["mail.followers"].create({
+ partner_id: currentPartnerId,
+ res_id: threadFollowedId,
+ res_model: "res.partner",
+ });
+ for (let threadId of [threadFollowedId, threadFollowedId, threadNotFollowedId]) {
+ const messageId = pyEnv["mail.message"].create({
+ body: "not empty",
+ model: "res.partner",
+ needaction: true,
+ needaction_partner_ids: [currentPartnerId],
+ res_id: threadId,
+ });
+ pyEnv["mail.notification"].create({
+ mail_message_id: messageId,
+ notification_status: "sent",
+ notification_type: "inbox",
+ res_partner_id: currentPartnerId,
+ });
+ }
+ const { openDiscuss } = await start();
+ await openDiscuss();
+ // 2 messages about "Thread followed" with unfollow button and 1 message about "Thread not followed" without it
+ assert.containsN($, ".o-mail-Message", 3);
+ for (let messageN of [0, 1]) {
+ const $message = $(`.o-mail-Message:eq(${messageN})`);
+ assert.containsOnce($message, ".o-mail-Message-header:contains(on Thread followed)");
+ await afterNextRender(() => $message.find("[title='Expand']").click());
+ assert.containsOnce($message, "[title='Unfollow']");
+ }
+ const $messageNotFollowed = $(".o-mail-Message:eq(2)");
+ assert.containsOnce($messageNotFollowed, ".o-mail-Message-header:contains(on Thread not followed)");
+ await afterNextRender(() => $messageNotFollowed.find("[title='Expand']").click());
+ assert.containsNone($messageNotFollowed, "[title='Unfollow']");
+
+ const $message0Followed = $(".o-mail-Message:eq(0)");
+ await afterNextRender(() => $message0Followed.find("[title='Expand']").click());
+ await afterNextRender(() => $message0Followed.find("[title='Unfollow']").click());
+
+ assert.containsN($, ".o-mail-Message", 2, "Unfollowing message 0 marks it as read -> Message removed");
+ assert.containsOnce($, ".o-mail-Message-header:contains(on Thread followed)");
+ assert.containsOnce($, ".o-mail-Message-header:contains(on Thread not followed)");
+ for (let messageN of [0, 1]) {
+ const $message = $(`.o-mail-Message:eq(${messageN})`);
+ await afterNextRender(() => $message.find("[title='Expand']").click());
+ assert.containsNone($, "button[title='Unfollow']",
+ "Unfollowing message 0 -> unfollowing 'Thread followed' -> no more unfollow action on any message");
+ }
+});
+
QUnit.test('messages marked as read move to "History" mailbox', async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "other-disco" });
diff --git a/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js b/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js
index 96ec2cb3771..603447ccaf0 100644
--- a/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js
+++ b/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js
@@ -336,7 +336,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", {
_mockRouteMailMessageInbox(after = false, before = false, around = false, limit = 30) {
const domain = [["needaction", "=", true]];
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
- return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
+ return this._mockMailMessageFormatPersonalize(messages.map((message) => message.id));
},
/**
* Simulates the `/mail/starred/messages` route.
diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_message.js b/addons/mail/static/tests/helpers/mock_server/models/mail_message.js
index 3b6ba58ed24..07ebf4660fe 100644
--- a/addons/mail/static/tests/helpers/mock_server/models/mail_message.js
+++ b/addons/mail/static/tests/helpers/mock_server/models/mail_message.js
@@ -302,6 +302,31 @@ patch(MockServer.prototype, "mail/models/mail_message", {
return response;
});
},
+ /**
+ * Simulate `_message_format_personalize` on `mail.message` for the current partner.
+ *
+ * @private
+ * @returns {integer[]} ids
+ * @returns {Object[]}
+ */
+ _mockMailMessageFormatPersonalize(ids) {
+ const messages = this._mockMailMessageMessageFormat(ids);
+ messages.forEach(message => {
+ let user_follower_id = false;
+ if (message.model && message.res_id) {
+ const follower = this.getRecords("mail.followers", [
+ ["res_model", "=", message.model],
+ ["res_id", "=", message.res_id],
+ ["partner_id", "=", this.pyEnv.currentPartnerId],
+ ]);
+ if (follower.length !== 0) {
+ user_follower_id = follower[0].id;
+ }
+ }
+ message.user_follower_id = user_follower_id;
+ });
+ return messages;
+ },
/**
* Simulates `_message_notification_format` on `mail.message`.
*
From 3e57dc295c81cd03ca1a7238b0ed1a41c9e64b63 Mon Sep 17 00:00:00 2001
From: Pierre-Yves Dufays
Date: Thu, 24 Nov 2022 07:29:29 +0000
Subject: [PATCH 2/4] [IMP] mail, portal, website_slides, mass_mailing:
unfollow record from email
Allow partner to unfollow a document from a follow up email of that document
through an unsubscribe URL in the email even if not connected.
It works for internal user for follow up on any document and for any partner on
follow up of document tagged as authorizing being unfollowed by any partner (
slide.channel and slide.slide).
Technical note:
The unfollow block is rendered in mail_thread and updated for each recipient in
mail_mail. We don't render it in mail_mail because we don't have the language
of the message at that point.
Depending on the partner and the related document, the unfollow block is:
- either removed if the user cannot unfollow the document (for example if it
doesn't follow it)
- or updated with partner and document information + a security token
Task-3061864
Part-of: odoo/odoo#107978
---
addons/mail/__manifest__.py | 1 +
addons/mail/controllers/mail.py | 27 ++++++++-
.../data/mail_templates_email_layouts.xml | 10 +++-
addons/mail/models/mail_followers.py | 27 +++++++++
addons/mail/models/mail_mail.py | 56 +++++++++++++++----
addons/mail/views/mail_templates_public.xml | 33 +++++++++++
addons/mass_mailing/models/mail_mail.py | 4 +-
addons/portal/__manifest__.py | 1 +
addons/portal/controllers/mail.py | 5 ++
addons/portal/views/mail_templates_public.xml | 8 +++
addons/website_slides/models/slide_channel.py | 1 +
addons/website_slides/models/slide_slide.py | 1 +
12 files changed, 159 insertions(+), 15 deletions(-)
create mode 100644 addons/mail/views/mail_templates_public.xml
create mode 100644 addons/portal/views/mail_templates_public.xml
diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index a9b70d82eb1..57014d155b9 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -101,6 +101,7 @@ For more specific needs, you may also assign custom-defined actions
'views/mail_gateway_allowed_views.xml',
'views/mail_guest_views.xml',
'views/mail_message_reaction_views.xml',
+ 'views/mail_templates_public.xml',
'views/res_users_views.xml',
'views/res_users_settings_views.xml',
'views/mail_template_views.xml',
diff --git a/addons/mail/controllers/mail.py b/addons/mail/controllers/mail.py
index 62fcc30019e..a3abb1213ac 100644
--- a/addons/mail/controllers/mail.py
+++ b/addons/mail/controllers/mail.py
@@ -4,7 +4,7 @@ import logging
from werkzeug.urls import url_encode
-from odoo import http
+from odoo import _, http
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.tools import consteq
@@ -164,3 +164,28 @@ class MailController(http.Controller):
except ValueError:
res_id = False
return self._redirect_to_record(model, res_id, access_token, **kwargs)
+
+ # csrf is disabled here because it will be called by the MUA with unpredictable session at that time
+ @http.route('/mail/unfollow', type='http', auth='public', csrf=False)
+ def mail_action_unfollow(self, model, res_id, pid, token, **kwargs):
+ comparison, record, __ = MailController._check_token_and_record_or_redirect(model, int(res_id), token)
+ if not comparison or not record:
+ raise AccessError(_('Non existing record or wrong token.'))
+
+ pid = int(pid)
+ record_sudo = record.sudo()
+ record_sudo.message_unsubscribe([pid])
+
+ display_link = True
+ if request.session.uid:
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule('read')
+ except AccessError:
+ display_link = False
+
+ return request.render('mail.message_document_unfollowed', {
+ 'name': record_sudo.display_name,
+ 'model_name': request.env['ir.model'].sudo()._get(model).display_name,
+ 'access_url': record._notify_get_action_link('view', model=model, res_id=res_id) if display_link else False,
+ })
diff --git a/addons/mail/data/mail_templates_email_layouts.xml b/addons/mail/data/mail_templates_email_layouts.xml
index 91c06bccd2e..b631713e0bb 100644
--- a/addons/mail/data/mail_templates_email_layouts.xml
+++ b/addons/mail/data/mail_templates_email_layouts.xml
@@ -100,9 +100,12 @@
-
+