summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2020-08-24 10:37:47 +0000
committerSébastien Theys <seb@odoo.com>2020-08-24 13:21:45 +0000
commit72b9335334c076d8deaa36e404feaa3f4d30c351 (patch)
treeaea8c816b33d89b409fedbc75bcca3d2d6597717
parent0834d241624330ab3fd3925366a3217fe8145dfc (diff)
[FIX] mail, im_livechat: make livechat work properly
- fix traceback on receiving first livechat message due to bad computation of correspondent - fix missing [0] in return of country name_get in channel_info - fix visitor/correspondent computation to work when chatting with oneself - reintroduce livechat ordered by last message in discuss sidebar - improve mock server to make these fixes possible to test - introduce corresponding tests PR: #56243 task-2323670 closes odoo/odoo#56414 X-original-commit: f623bfd2c8819604c709bc723153c071dca6e3cc Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
-rw-r--r--addons/im_livechat/models/mail_channel.py25
-rw-r--r--addons/im_livechat/static/src/components/discuss/discuss_tests.js356
-rw-r--r--addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js27
-rw-r--r--addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js11
-rw-r--r--addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js11
-rw-r--r--addons/im_livechat/static/src/models/thread/thread.js28
-rw-r--r--addons/im_livechat/static/tests/helpers/mock_models.js18
-rw-r--r--addons/im_livechat/static/tests/helpers/mock_server.js236
-rw-r--r--addons/im_livechat/tests/test_get_mail_channel.py28
-rw-r--r--addons/mail/static/src/models/message/message.js9
-rw-r--r--addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js15
-rw-r--r--addons/mail/static/src/models/thread/thread.js12
-rw-r--r--addons/mail/static/src/utils/test_utils.js24
-rw-r--r--addons/mail/static/tests/chatter_tests.js91
-rw-r--r--addons/mail/static/tests/helpers/mock_models.js10
-rw-r--r--addons/mail/static/tests/helpers/mock_server.js364
16 files changed, 1129 insertions, 136 deletions
diff --git a/addons/im_livechat/models/mail_channel.py b/addons/im_livechat/models/mail_channel.py
index 783e576e71f..f15c5d54155 100644
--- a/addons/im_livechat/models/mail_channel.py
+++ b/addons/im_livechat/models/mail_channel.py
@@ -89,7 +89,7 @@ class MailChannel(models.Model):
channel_infos_dict[channel.id]['livechat_visitor'] = channel._channel_get_livechat_visitor_info()
last_msg = self.env['mail.message'].search([("channel_ids", "in", [channel.id])], limit=1)
if last_msg:
- channel_infos_dict[channel.id]['last_message_date'] = last_msg.date
+ channel_infos_dict[channel.id]['last_message_id'] = last_msg.id
return list(channel_infos_dict.values())
@api.model
@@ -100,23 +100,24 @@ class MailChannel(models.Model):
return values
def _channel_get_livechat_visitor_info(self):
- partners = self.channel_partner_ids - self.livechat_operator_id
- if partners:
+ self.ensure_one()
+ # remove active test to ensure public partner is taken into account
+ channel_partner_ids = self.with_context(active_test=False).channel_partner_ids
+ partners = channel_partner_ids - self.livechat_operator_id
+ if not partners:
+ # operator probably testing the livechat with his own user
+ partners = channel_partner_ids
+ if partners and partners[0] != self.env.ref('base.public_partner'):
+ # legit non-public partner
return {
- 'country': partners[0].country_id.name_get() if partners[0].country_id else False,
+ 'country': partners[0].country_id.name_get()[0] if partners[0].country_id else False,
'id': partners[0].id,
'name': partners[0].name,
}
- if self.anonymous_name:
- return {
- 'country': False,
- 'id': False,
- 'name': self.anonymous_name,
- }
return {
- 'country': False,
+ 'country': self.country_id.name_get()[0] if self.country_id else False,
'id': False,
- 'name': _("Visitor"),
+ 'name': self.anonymous_name or _("Visitor"),
}
def _channel_get_livechat_partner_name(self):
diff --git a/addons/im_livechat/static/src/components/discuss/discuss_tests.js b/addons/im_livechat/static/src/components/discuss/discuss_tests.js
index 22f4c9a235c..166e4fd679f 100644
--- a/addons/im_livechat/static/src/components/discuss/discuss_tests.js
+++ b/addons/im_livechat/static/src/components/discuss/discuss_tests.js
@@ -1,7 +1,13 @@
odoo.define('im_livechat/static/src/components/discuss/discuss_tests.js', function (require) {
'use strict';
-const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
QUnit.module('im_livechat', {}, function () {
QUnit.module('components', {}, function () {
@@ -28,13 +34,12 @@ QUnit.module('discuss_tests.js', {
QUnit.test('livechat in the sidebar: basic rendering', async function (assert) {
assert.expect(5);
- // channel that is expected to be found in the sidebar
this.data['mail.channel'].records.push({
- channel_type: 'livechat', // channel is expected to be livechat
- id: 11, // random unique id, will be referenced in the test
- livechat_visitor: {
- name: "Visitor 11", // random name, will be asserted during the test
- },
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
assert.containsOnce(document.body, '.o_Discuss_sidebar',
@@ -69,6 +74,343 @@ QUnit.test('livechat in the sidebar: basic rendering', async function (assert) {
);
});
+QUnit.test('livechat in the sidebar: existing user with country', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.country'].records.push({
+ code: 'be',
+ id: 10,
+ name: "Belgium",
+ });
+ this.data['res.partner'].records.push({
+ country_id: 10,
+ id: 10,
+ name: "Jean",
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, 10],
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat in the side bar"
+ );
+ const livechat = document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.ok(
+ livechat,
+ "should have a livechat in sidebar"
+ );
+ assert.strictEqual(
+ livechat.textContent,
+ "Jean (Belgium)",
+ "should have user name and country as livechat name"
+ );
+});
+
+QUnit.test('do not add livechat in the sidebar on visitor opening his chat', async function (assert) {
+ assert.expect(2);
+
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.im_status = 'online';
+ this.data['im_livechat.channel'].records.push({
+ id: 10,
+ user_ids: [this.data.currentUserId],
+ });
+ await this.start();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should not have any livechat in the sidebar initially"
+ );
+
+ // simulate livechat visitor opening his chat
+ await this.env.services.rpc({
+ route: '/im_livechat/get_session',
+ params: {
+ context: {
+ mockedUserId: false,
+ },
+ channel_id: 10,
+ },
+ });
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should still not have any livechat in the sidebar after visitor opened his chat"
+ );
+});
+
+QUnit.test('do not add livechat in the sidebar on visitor typing', async function (assert) {
+ assert.expect(2);
+
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.im_status = 'online';
+ this.data['im_livechat.channel'].records.push({
+ id: 10,
+ user_ids: [this.data.currentUserId],
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'livechat',
+ id: 10,
+ is_pinned: false,
+ livechat_channel_id: 10,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.publicPartnerId, this.data.currentPartnerId],
+ });
+ await this.start();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should not have any livechat in the sidebar initially"
+ );
+
+ // simulate livechat visitor typing
+ const channel = this.data['mail.channel'].records.find(channel => channel.id === 10);
+ await this.env.services.rpc({
+ route: '/im_livechat/notify_typing',
+ params: {
+ context: {
+ mockedPartnerId: this.publicPartnerId,
+ },
+ is_typing: true,
+ uuid: channel.uuid,
+ },
+ });
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should still not have any livechat in the sidebar after visitor started typing"
+ );
+});
+
+QUnit.test('add livechat in the sidebar on visitor sending first message', async function (assert) {
+ assert.expect(4);
+
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.im_status = 'online';
+ this.data['res.country'].records.push({
+ code: 'be',
+ id: 10,
+ name: "Belgium",
+ });
+ this.data['im_livechat.channel'].records.push({
+ id: 10,
+ user_ids: [this.data.currentUserId],
+ });
+ this.data['mail.channel'].records.push({
+ anonymous_name: "Visitor (Belgium)",
+ channel_type: 'livechat',
+ country_id: 10,
+ id: 10,
+ is_pinned: false,
+ livechat_channel_id: 10,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.publicPartnerId, this.data.currentPartnerId],
+ });
+ await this.start();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should not have any livechat in the sidebar initially"
+ );
+
+ // simulate livechat visitor sending a message
+ const channel = this.data['mail.channel'].records.find(channel => channel.id === 10);
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: false,
+ },
+ uuid: channel.uuid,
+ message_content: "new message",
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat in the side bar after receiving first message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item',
+ "should have a livechat in the sidebar after receiving first message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item').textContent,
+ "Visitor (Belgium)",
+ "should have visitor name and country as livechat name"
+ );
+});
+
+QUnit.test('livechats are sorted by last message date in the sidebar: most recent at the top', async function (assert) {
+ /**
+ * For simplicity the code that is covered in this test is considering
+ * messages to be more/less recent than others based on their ids instead of
+ * their actual creation date.
+ */
+ assert.expect(7);
+
+ this.data['mail.message'].records.push(
+ { id: 11, channel_ids: [11] }, // least recent message due to smaller id
+ { id: 12, channel_ids: [12] }, // most recent message due to higher id
+ );
+ this.data['mail.channel'].records.push(
+ {
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ {
+ anonymous_name: "Visitor 12",
+ channel_type: 'livechat',
+ id: 12,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ );
+ await this.start();
+ const livechat11 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const livechat12 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat"
+ );
+ const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.strictEqual(
+ initialLivechats.length,
+ 2,
+ "should have 2 livechats in the sidebar"
+ );
+ assert.strictEqual(
+ initialLivechats[0].dataset.threadLocalId,
+ livechat12.localId,
+ "first livechat should be the one with the most recent message"
+ );
+ assert.strictEqual(
+ initialLivechats[1].dataset.threadLocalId,
+ livechat11.localId,
+ "second livechat should be the one with the least recent message"
+ );
+
+ // post a new message on the last channel
+ await afterNextRender(() => initialLivechats[1].click());
+ await afterNextRender(() => document.execCommand('insertText', false, "Blabla"));
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ const livechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.strictEqual(
+ livechats.length,
+ 2,
+ "should still have 2 livechats in the sidebar after posting a new message"
+ );
+ assert.strictEqual(
+ livechats[0].dataset.threadLocalId,
+ livechat11.localId,
+ "first livechat should now be the one on which the new message was posted"
+ );
+ assert.strictEqual(
+ livechats[1].dataset.threadLocalId,
+ livechat12.localId,
+ "second livechat should now be the one on which the message was not posted"
+ );
+});
+
+QUnit.test('livechats with no messages are sorted by creation date in the sidebar: most recent at the top', async function (assert) {
+ /**
+ * For simplicity the code that is covered in this test is considering
+ * channels to be more/less recent than others based on their ids instead of
+ * their actual creation date.
+ */
+ assert.expect(5);
+
+ this.data['mail.message'].records.push(
+ { id: 13, channel_ids: [13] },
+ );
+ this.data['mail.channel'].records.push(
+ {
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11, // least recent channel due to smallest id
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ {
+ anonymous_name: "Visitor 12",
+ channel_type: 'livechat',
+ id: 12, // most recent channel that does not have a message
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ {
+ anonymous_name: "Visitor 13",
+ channel_type: 'livechat',
+ id: 13, // most recent channel (but it has a message)
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ );
+ await this.start();
+ const livechat11 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const livechat12 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ });
+ const livechat13 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 13,
+ model: 'mail.channel',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat"
+ );
+ const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.strictEqual(
+ initialLivechats.length,
+ 3,
+ "should have 3 livechats in the sidebar"
+ );
+ assert.strictEqual(
+ initialLivechats[0].dataset.threadLocalId,
+ livechat12.localId,
+ "first livechat should be the most recent channel without message"
+ );
+ assert.strictEqual(
+ initialLivechats[1].dataset.threadLocalId,
+ livechat11.localId,
+ "second livechat should be the second most recent channel without message"
+ );
+ assert.strictEqual(
+ initialLivechats[2].dataset.threadLocalId,
+ livechat13.localId,
+ "third livechat should be the channel with a message"
+ );
+});
+
});
});
});
diff --git a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js
index 36656c877c7..2dafeed4c79 100644
--- a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js
+++ b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js
@@ -19,11 +19,27 @@ patch(components.DiscussSidebar, 'im_livechat/static/src/components/discuss_side
* @returns {mail.thread[]}
*/
quickSearchOrderedAndPinnedLivechatList() {
- const allOrderedAndPinnedLivechats = this.env.models['mail.thread'].all(thread =>
- thread.channel_type === 'livechat' &&
- thread.isPinned &&
- thread.model === 'mail.channel'
- );
+ const allOrderedAndPinnedLivechats = this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'livechat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ ).sort((c1, c2) => {
+ // sort by: last message id (desc), id (desc)
+ if (c1.lastMessage && c2.lastMessage) {
+ return c2.lastMessage.id - c1.lastMessage.id;
+ }
+ // a channel without a last message is assumed to be a new
+ // channel just created with the intent of posting a new
+ // message on it, in which case it should be moved up.
+ if (!c1.lastMessage) {
+ return -1;
+ }
+ if (!c2.lastMessage) {
+ return 1;
+ }
+ return c2.id - c1.id;
+ });
if (!this.discuss.sidebarQuickSearchValue) {
return allOrderedAndPinnedLivechats;
}
@@ -59,7 +75,6 @@ patch(components.DiscussSidebar, 'im_livechat/static/src/components/discuss_side
thread.isPinned &&
thread.model === 'mail.channel'
)
- .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1)
.map(livechat => livechat.__state),
}
);
diff --git a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js
index 32fb4583108..6ff7767af25 100644
--- a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js
+++ b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js
@@ -42,13 +42,12 @@ QUnit.module('thread_icon_tests.js', {
QUnit.test('livechat: public website visitor is typing', async function (assert) {
assert.expect(4);
- // channel that will be used for testing the typing feature
this.data['mail.channel'].records.push({
- channel_type: 'livechat', // channel is expected to be livechat
- id: 20, // random unique id, will be referenced in the test
- livechat_visitor: {
- name: "Visitor 20", // random name, will be asserted during the test
- },
+ anonymous_name: "Visitor 20",
+ channel_type: 'livechat',
+ id: 20,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
const thread = this.env.models['mail.thread'].find(thread =>
diff --git a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
index e085c03b8d7..b3cbe96e277 100644
--- a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
+++ b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
@@ -42,15 +42,12 @@ QUnit.module('thread_textual_typing_status_tests.js', {
QUnit.test('receive visitor typing status "is typing"', async function (assert) {
assert.expect(2);
- // channel that will be used for testing the typing feature
this.data['mail.channel'].records.push({
- // channel is expected to be livechat, but channel_type set only for
- // consistency, not actually useful in the scope of this test
+ anonymous_name: "Visitor 20",
channel_type: 'livechat',
- id: 20, // random unique id, will be referenced in the test
- livechat_visitor: {
- name: "Visitor 20", // random name, will be asserted during the test
- },
+ id: 20,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
const thread = this.env.models['mail.thread'].find(thread =>
diff --git a/addons/im_livechat/static/src/models/thread/thread.js b/addons/im_livechat/static/src/models/thread/thread.js
index 148e7b7c5ee..190a2088066 100644
--- a/addons/im_livechat/static/src/models/thread/thread.js
+++ b/addons/im_livechat/static/src/models/thread/thread.js
@@ -17,12 +17,35 @@ registerClassPatchModel('mail.thread', 'im_livechat/static/src/models/thread/thr
*/
convertData(data) {
const data2 = this._super(data);
+ if ('last_message_id' in data) {
+ if (!data2.messagesAsServerChannel) {
+ data2.messagesAsServerChannel = [];
+ }
+ data2.messagesAsServerChannel.push(['insert', { id: data.last_message_id }]);
+ }
if ('livechat_visitor' in data && data.livechat_visitor) {
if (!data2.members) {
data2.members = [];
}
+ // `livechat_visitor` without `id` is the anonymous visitor.
if (!data.livechat_visitor.id) {
- // Create partner derived from public partner.
+ /**
+ * Create partner derived from public partner and replace the
+ * public partner.
+ *
+ * Indeed the anonymous visitor is registered as a member of the
+ * channel as the public partner in the database to avoid
+ * polluting the contact list with many temporary partners.
+ *
+ * But the issue with public partner is that it is the same
+ * record for every livechat, whereas every correspondent should
+ * actually have its own visitor name, typing status, etc.
+ *
+ * Due to JS being temporary by nature there is no such notion
+ * of polluting the database, it is therefore acceptable and
+ * easier to handle one temporary partner per channel.
+ */
+ data2.members.push(['unlink', this.env.messaging.publicPartner]);
const partner = this.env.models['mail.partner'].create(
Object.assign(
this.env.models['mail.partner'].convertData(data.livechat_visitor),
@@ -50,6 +73,9 @@ registerInstancePatchModel('mail.thread', 'im_livechat/static/src/models/thread/
*/
_computeDisplayName() {
if (this.channel_type === 'livechat' && this.correspondent) {
+ if (this.correspondent.country) {
+ return `${this.correspondent.nameOrDisplayName} (${this.correspondent.country.name})`;
+ }
return this.correspondent.nameOrDisplayName;
}
return this._super();
diff --git a/addons/im_livechat/static/tests/helpers/mock_models.js b/addons/im_livechat/static/tests/helpers/mock_models.js
index c618e7f9b32..db18e8f776b 100644
--- a/addons/im_livechat/static/tests/helpers/mock_models.js
+++ b/addons/im_livechat/static/tests/helpers/mock_models.js
@@ -15,9 +15,23 @@ MockModels.patch('im_livechat/static/tests/helpers/mock_models.js', T =>
*/
static generateData() {
const data = super.generateData(...arguments);
+ Object.assign(data, {
+ 'im_livechat.channel': {
+ fields: {
+ user_ids: { string: "Operators", type: 'many2many', relation: 'res.users' }
+ },
+ records: [],
+ }
+ });
Object.assign(data['mail.channel'].fields, {
- // fake type, not on Python models but is the result of a formatter
- livechat_visitor: { string: 'livechat_visitor', type: 'Object', default: false },
+ anonymous_name: { string: "Anonymous Name", type: 'char' },
+ country_id: { string: "Country", type: 'many2one', relation: 'res.country' },
+ livechat_active: { string: "Is livechat ongoing?", type: 'boolean', default: false },
+ livechat_channel_id: { string: "Channel", type: 'many2one', relation: 'im_livechat.channel' },
+ livechat_operator_id: { string: "Operator", type: 'many2one', relation: 'res.partner' },
+ });
+ Object.assign(data['res.users'].fields, {
+ livechat_username: { string: 'Livechat Username', type: 'string' },
});
return data;
}
diff --git a/addons/im_livechat/static/tests/helpers/mock_server.js b/addons/im_livechat/static/tests/helpers/mock_server.js
index d3f02f81fa2..b8f597dd418 100644
--- a/addons/im_livechat/static/tests/helpers/mock_server.js
+++ b/addons/im_livechat/static/tests/helpers/mock_server.js
@@ -13,6 +13,76 @@ MockServer.include({
/**
* @override
*/
+ async _performRpc(route, args) {
+ if (route === '/im_livechat/get_session') {
+ const channel_id = args.channel_id;
+ const anonymous_name = args.anonymous_name;
+ const previous_operator_id = args.previous_operator_id;
+ const context = args.context;
+ return this._mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context);
+ }
+ if (route === '/im_livechat/notify_typing') {
+ const uuid = args.uuid;
+ const is_typing = args.is_typing;
+ const context = args.context;
+ return this._mockRouteImLivechatNotifyTyping(uuid, is_typing, context);
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private Mocked Routes
+ //--------------------------------------------------------------------------
+
+ /**
+ * Simulates the `/im_livechat/get_session` route.
+ *
+ * @private
+ * @param {integer} channel_id
+ * @param {string} anonymous_name
+ * @param {integer} [previous_operator_id]
+ * @param {Object} [context={}]
+ * @returns {Object}
+ */
+ _mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context = {}) {
+ let user_id;
+ let country_id;
+ if ('mockedUserId' in context) {
+ // can be falsy to simulate not being logged in
+ user_id = context.mockedUserId;
+ } else {
+ user_id = this.currentUserId;
+ }
+ // don't use the anonymous name if the user is logged in
+ if (user_id) {
+ const user = this._getRecords('res.users', [['id', '=', user_id]])[0];
+ country_id = user.country_id;
+ } else {
+ // simulate geoip
+ const countryCode = context.mockedCountryCode;
+ const country = this._getRecords('res.country', [['code', '=', countryCode]])[0];
+ if (country) {
+ country_id = country.id;
+ anonymous_name = anonymous_name + ' (' + country.name + ')';
+ }
+ }
+ return this._mockImLivechatChannel_openLivechatMailChannel(channel_id, anonymous_name, previous_operator_id, user_id, country_id);
+ },
+ /**
+ * Simulates the `/im_livechat/notify_typing` route.
+ *
+ * @private
+ * @param {string} uuid
+ * @param {boolean} is_typing
+ * @param {Object} [context]
+ */
+ _mockRouteImLivechatNotifyTyping(uuid, is_typing, context) {
+ const mailChannel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0];
+ this._mockMailChannelNotifyTyping([mailChannel.id], is_typing, context);
+ },
+ /**
+ * @override
+ */
_mockRouteMailInitMessaging() {
const initMessaging = this._super(...arguments);
@@ -27,6 +97,172 @@ MockServer.include({
return initMessaging;
},
+
+ //--------------------------------------------------------------------------
+ // Private Mocked Methods
+ //--------------------------------------------------------------------------
+
+ /**
+ * Simulates `_channel_get_livechat_visitor_info` on `mail.channel`.
+ *
+ * @private
+ * @param {integer[]} ids
+ * @returns {Object}
+ */
+ _mockMailChannel_ChannelGetLivechatVisitorInfo(ids) {
+ const id = ids[0]; // ensure_one
+ const mailChannel = this._getRecords('mail.channel', [['id', '=', id]])[0];
+ // remove active test to ensure public partner is taken into account
+ let members = this._getRecords(
+ 'res.partner',
+ [['id', 'in', mailChannel.members]],
+ { active_test: false },
+ );
+ members = members.filter(member => member.id !== mailChannel.livechat_operator_id);
+ if (members.length === 0 && mailChannel.livechat_operator_id) {
+ // operator probably testing the livechat with his own user
+ members = [mailChannel.livechat_operator_id];
+ }
+ if (members.length > 0 && members[0].id !== this.publicPartnerId) {
+ // legit non-public partner
+ const country = this._getRecords('res.country', [['id', '=', members[0].country_id]])[0];
+ return {
+ 'country': country ? [country.id, country.name] : false,
+ 'id': members[0].id,
+ 'name': members[0].name,
+ };
+ }
+ return {
+ 'country': false,
+ 'id': false,
+ 'name': mailChannel.anonymous_name || "Visitor",
+ };
+ },
+ /**
+ * @override
+ */
+ _mockMailChannelChannelInfo(ids, extra_info) {
+ const channelInfos = this._super(...arguments);
+ for (const channelInfo of channelInfos) {
+ const channel = this._getRecords('mail.channel', [['id', '=', channelInfo.id]])[0];
+ // add the last message date
+ if (channel.channel_type === 'livechat') {
+ // add the operator id
+ if (channel.livechat_operator_id) {
+ const operator = this._getRecords('res.partner', [['id', '=', channel.livechat_operator_id]])[0];
+ // livechat_username ignored for simplicity
+ channelInfo.operator_pid = [operator.id, operator.display_name.replace(',', '')];
+ }
+ // add the anonymous or partner name
+ channelInfo.livechat_visitor = this._mockMailChannel_ChannelGetLivechatVisitorInfo([channel.id]);
+ const messages = this._getRecords('mail.message', [['channel_ids', 'in', channel.id]]);
+ const last_msg = messages.sort((m1, m2) => m1.id > m2.id ? -1 : 1)[0];
+ if (last_msg) {
+ channelInfo.last_message_id = last_msg.id;
+ }
+ }
+ }
+ return channelInfos;
+ },
+ /**
+ * Simulates `_get_available_users` on `im_livechat.channel`.
+ *
+ * @private
+ * @param {integer} id
+ * @returns {Object}
+ */
+ _mockImLivechatChannel_getAvailableUsers(id) {
+ const livechatChannel = this._getRecords('im_livechat.channel', [['id', '=', id]])[0];
+ const users = this._getRecords('res.users', [['id', 'in', livechatChannel.user_ids]]);
+ return users.filter(user => user.im_status === 'online');
+ },
+ /**
+ * Simulates `_get_livechat_mail_channel_vals` on `im_livechat.channel`.
+ *
+ * @private
+ * @param {integer} id
+ * @returns {Object}
+ */
+ _mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id) {
+ // partner to add to the mail.channel
+ const operator_partner_id = operator.partner_id;
+ const members = [[4, operator_partner_id]];
+ let visitor_user;
+ if (user_id) {
+ const visitor_user = this._getRecords('res.users', [['id', '=', user_id]])[0];
+ if (visitor_user && visitor_user.active) {
+ // valid session user (not public)
+ members.push([4, visitor_user.partner_id.id]);
+ }
+ } else {
+ // for simplicity of not having mocked channel.partner, add public partner here
+ members.push([4, this.publicPartnerId]);
+ }
+ const membersName = [
+ visitor_user ? visitor_user.display_name : anonymous_name,
+ operator.livechat_username ? operator.livechat_username : operator.name,
+ ];
+ return {
+ // Limitation of mocked models not having channel.partner: is_pinned
+ // is not supposed to be false for the visitor but must be false for
+ // the operator (writing on channel_partner_ids does not trigger the
+ // defaults that would set it to true) and here operator and visitor
+ // can't be differentiated in that regard.
+ 'is_pinned': false,
+ 'members': members, // channel_partner_ids
+ 'livechat_active': true,
+ 'livechat_operator_id': operator_partner_id,
+ 'livechat_channel_id': id,
+ 'anonymous_name': user_id ? false : anonymous_name,
+ 'country_id': country_id,
+ 'channel_type': 'livechat',
+ 'name': membersName.join(' '),
+ 'public': 'private',
+ 'mass_mailing': false, // email_send
+ };
+ },
+ /**
+ * Simulates `_get_random_operator` on `im_livechat.channel`.
+ * Simplified mock implementation: returns the first available operator.
+ *
+ * @private
+ * @param {integer} id
+ * @returns {Object}
+ */
+ _mockImLivechatChannel_getRandomOperator(id) {
+ const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id);
+ return availableUsers[0];
+ },
+ /**
+ * Simulates `_open_livechat_mail_channel` on `im_livechat.channel`.
+ *
+ * @private
+ * @param {integer} id
+ * @param {string} anonymous_name
+ * @param {integer} [previous_operator_id]
+ * @param {integer} [user_id]
+ * @param {integer} [country_id]
+ * @returns {Object}
+ */
+ _mockImLivechatChannel_openLivechatMailChannel(id, anonymous_name, previous_operator_id, user_id, country_id) {
+ let operator;
+ if (previous_operator_id) {
+ const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id);
+ operator = availableUsers.find(user => user.partner_id === previous_operator_id);
+ }
+ if (!operator) {
+ operator = this._mockImLivechatChannel_getRandomOperator(id);
+ }
+ if (!operator) {
+ // no one available
+ return false;
+ }
+ // create the session, and add the link with the given channel
+ const mailChannelVals = this._mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id);
+ const mailChannelId = this._mockCreate('mail.channel', mailChannelVals);
+ this._mockMailChannel_broadcast([mailChannelId], [operator.partner_id]);
+ return this._mockMailChannelChannelInfo([mailChannelId])[0];
+ },
});
});
diff --git a/addons/im_livechat/tests/test_get_mail_channel.py b/addons/im_livechat/tests/test_get_mail_channel.py
index f6b769d8d75..30deee55d4a 100644
--- a/addons/im_livechat/tests/test_get_mail_channel.py
+++ b/addons/im_livechat/tests/test_get_mail_channel.py
@@ -52,6 +52,34 @@ class TestGetMailChannel(TransactionCase):
channel_operator_ids = [channel_operator[0] for channel_operator in channel_operators]
self.assertTrue(all(partner_id in channel_operator_ids for partner_id in self.operators.mapped('partner_id').ids))
+ def test_channel_get_livechat_visitor_info(self):
+ belgium = self.env.ref('base.be')
+ public_user = self.env.ref('base.public_user')
+ test_user = self.env['res.users'].create({'name': 'Roger', 'login': 'roger', 'country_id': belgium.id})
+
+ # ensure visitor info are correct with anonymous
+ channel_info = self.livechat_channel.with_user(public_user)._open_livechat_mail_channel(anonymous_name='Visitor 22', country_id=belgium.id)
+ visitor_info = channel_info['livechat_visitor']
+ self.assertFalse(visitor_info['id'])
+ self.assertEqual(visitor_info['name'], "Visitor 22")
+ self.assertEqual(visitor_info['country'], (20, "Belgium"))
+
+ # ensure visitor info are correct with real user
+ channel_info = self.livechat_channel.with_user(test_user)._open_livechat_mail_channel(anonymous_name='whatever', user_id=test_user.id)
+ visitor_info = channel_info['livechat_visitor']
+ self.assertEqual(visitor_info['id'], test_user.partner_id.id)
+ self.assertEqual(visitor_info['name'], "Roger")
+ self.assertEqual(visitor_info['country'], (20, "Belgium"))
+
+ # ensure visitor info are correct when operator is testing himself
+ operator = self.operators[0]
+ channel_info = self.livechat_channel.with_user(operator)._open_livechat_mail_channel(anonymous_name='whatever', previous_operator_id=operator.partner_id.id, user_id=operator.id)
+ self.assertEqual(channel_info['operator_pid'], (operator.partner_id.id, "Michel"))
+ visitor_info = channel_info['livechat_visitor']
+ self.assertEqual(visitor_info['id'], operator.partner_id.id)
+ self.assertEqual(visitor_info['name'], "Michel")
+ self.assertFalse(visitor_info['country'])
+
def _open_livechat_mail_channel(self):
mail_channels = []
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
index 582745e28f2..e982df36667 100644
--- a/addons/mail/static/src/models/message/message.js
+++ b/addons/mail/static/src/models/message/message.js
@@ -607,12 +607,13 @@ function factory(dependencies) {
tracking_value_ids: attr({
default: [],
}),
-
/**
- * All channels that this message is linked to (from server message
- * format).
+ * All channels containing this message on the server.
+ * Equivalent of python field `channel_ids`.
*/
- serverChannels: many2many('mail.thread'),
+ serverChannels: many2many('mail.thread', {
+ inverse: 'messagesAsServerChannel',
+ }),
};
Message.modelName = 'mail.message';
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
index 49f07bc5423..d89992d8a4b 100644
--- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
+++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
@@ -56,7 +56,7 @@ function factory(dependencies) {
/**
* @private
* @param {Object[]} notifications
- * @param {Array} notifications[i][0] meta-data of the notification.
+ * @param {Array|string} notifications[i][0] meta-data of the notification.
* @param {string} notifications[i][0][0] name of database this
* notification comes from.
* @param {string} notifications[i][0][1] type of notification.
@@ -68,20 +68,25 @@ function factory(dependencies) {
async _handleNotifications(notifications) {
const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications);
const proms = filteredNotifications.map(notification => {
- const [[, model, id], data] = notification;
+ const [channel, message] = notification;
+ if (typeof channel === 'string') {
+ // uuid notification, only for (livechat) public handler
+ return;
+ }
+ const [, model, id] = channel;
switch (model) {
case 'ir.needaction':
- return this._handleNotificationNeedaction(data);
+ return this._handleNotificationNeedaction(message);
case 'mail.channel':
return this._handleNotificationChannel(Object.assign({
channelId: id,
- }, data));
+ }, message));
case 'res.partner':
if (id !== this.env.messaging.currentPartner.id) {
// ignore broadcast to other partners
return;
}
- return this._handleNotificationPartner(Object.assign({}, data));
+ return this._handleNotificationPartner(Object.assign({}, message));
default:
console.warn(`mail.messaging_notification_handler: Unhandled notification "${model}"`);
return;
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
index 74cae5d25ab..e3d48d54e35 100644
--- a/addons/mail/static/src/models/thread/thread.js
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -779,8 +779,13 @@ function factory(dependencies) {
partner !== this.env.messaging.currentPartner
);
if (correspondents.length === 1) {
+ // 2 members chat
return [['link', correspondents[0]]];
}
+ if (this.members.length === 1) {
+ // chat with oneself
+ return [['link', this.members[0]]];
+ }
return [['unlink']];
}
@@ -1360,6 +1365,13 @@ function factory(dependencies) {
messages: many2many('mail.message', {
inverse: 'threads',
}),
+ /**
+ * All messages that are contained on this channel on the server.
+ * Equivalent to the inverse of python field `channel_ids`.
+ */
+ messagesAsServerChannel: many2many('mail.message', {
+ inverse: 'serverChannels',
+ }),
messageSeenIndicators: one2many('mail.message_seen_indicator', {
inverse: 'thread',
isCausal: true,
diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js
index dfad584dfeb..4ceb05d7736 100644
--- a/addons/mail/static/src/utils/test_utils.js
+++ b/addons/mail/static/src/utils/test_utils.js
@@ -260,33 +260,41 @@ const afterNextRender = (function () {
function beforeEach(self) {
const data = MockModels.generateData();
+ data.partnerRootId = 2;
data['res.partner'].records.push({
active: false,
display_name: "OdooBot",
- id: 2,
+ id: data.partnerRootId,
});
- data.partnerRootId = 2;
+ data.currentPartnerId = 3;
data['res.partner'].records.push({
display_name: "Your Company, Mitchell Admin",
- id: 3,
+ id: data.currentPartnerId,
name: "Mitchell Admin",
});
- data.currentPartnerId = 3;
+ data.currentUserId = 2;
data['res.users'].records.push({
display_name: "Your Company, Mitchell Admin",
- id: 2,
+ id: data.currentUserId,
name: "Mitchell Admin",
partner_id: data.currentPartnerId,
});
- data.currentUserId = 2;
+ data.publicPartnerId = 4;
data['res.partner'].records.push({
active: false,
display_name: "Public user",
- id: 4,
+ id: data.publicPartnerId,
+ });
+ data.publicUserId = 3;
+ data['res.users'].records.push({
+ active: false,
+ display_name: "Public user",
+ id: data.publicUserId,
+ name: "Public user",
+ partner_id: data.publicPartnerId,
});
- data.publicPartnerId = 4;
const originals = {
'_.debounce': _.debounce,
diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js
index 5dac33ba543..006b9ff5fe5 100644
--- a/addons/mail/static/tests/chatter_tests.js
+++ b/addons/mail/static/tests/chatter_tests.js
@@ -133,12 +133,16 @@ QUnit.test('list activity widget with no activity', async function (assert) {
QUnit.test('list activity widget with activities', async function (assert) {
assert.expect(7);
- const lastUserIndex = this.data['res.users'].records.length - 1;
- this.data['res.users'].records[lastUserIndex].activity_ids = [1, 4];
- this.data['res.users'].records[lastUserIndex].activity_state = 'today';
- this.data['res.users'].records[lastUserIndex].activity_summary = 'Call with Al';
- this.data['res.users'].records[lastUserIndex].activity_type_id = 3;
- this.data['res.users'].records[lastUserIndex].activity_type_icon = 'fa-phone';
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ Object.assign(currentUser, {
+ activity_ids: [1, 4],
+ activity_state: 'today',
+ activity_summary: 'Call with Al',
+ activity_type_id: 3,
+ activity_type_icon: 'fa-phone',
+ });
this.data['res.users'].records.push({
id: 44,
@@ -179,13 +183,17 @@ QUnit.test('list activity widget with activities', async function (assert) {
QUnit.test('list activity widget with exception', async function (assert) {
assert.expect(5);
- const lastUserIndex = this.data['res.users'].records.length - 1;
- this.data['res.users'].records[lastUserIndex].activity_ids = [1];
- this.data['res.users'].records[lastUserIndex].activity_state = 'today';
- this.data['res.users'].records[lastUserIndex].activity_summary = 'Call with Al';
- this.data['res.users'].records[lastUserIndex].activity_type_id = 3;
- this.data['res.users'].records[lastUserIndex].activity_exception_decoration = 'warning';
- this.data['res.users'].records[lastUserIndex].activity_exception_icon = 'fa-warning';
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ Object.assign(currentUser, {
+ activity_ids: [1],
+ activity_state: 'today',
+ activity_summary: 'Call with Al',
+ activity_type_id: 3,
+ activity_exception_decoration: 'warning',
+ activity_exception_icon: 'fa-warning',
+ });
const { widget: list } = await start({
hasView: true,
@@ -213,11 +221,15 @@ QUnit.test('list activity widget with exception', async function (assert) {
QUnit.test('list activity widget: open dropdown', async function (assert) {
assert.expect(10);
- const lastUserIndex = this.data['res.users'].records.length - 1;
- this.data['res.users'].records[lastUserIndex].activity_ids = [1, 4];
- this.data['res.users'].records[lastUserIndex].activity_state = 'today';
- this.data['res.users'].records[lastUserIndex].activity_summary = 'Call with Al';
- this.data['res.users'].records[lastUserIndex].activity_type_id = 3;
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ Object.assign(currentUser, {
+ activity_ids: [1, 4],
+ activity_state: 'today',
+ activity_summary: 'Call with Al',
+ activity_type_id: 3,
+ });
this.data['mail.activity'].records.push(
{
id: 1,
@@ -225,8 +237,8 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
date_deadline: moment().format("YYYY-MM-DD"), // now
can_write: true,
state: "today",
- user_id: 2,
- create_uid: 2,
+ user_id: this.data.currentUserId,
+ create_uid: this.data.currentUserId,
activity_type_id: 3,
},
{
@@ -235,13 +247,13 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
date_deadline: moment().add(1, 'day').format("YYYY-MM-DD"), // tomorrow
can_write: true,
state: "planned",
- user_id: 2,
- create_uid: 2,
+ user_id: this.data.currentUserId,
+ create_uid: this.data.currentUserId,
activity_type_id: 1,
}
);
- const { widget: list } = await start({
+ const { env, widget: list } = await start({
hasView: true,
View: ListView,
model: 'res.users',
@@ -254,10 +266,15 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
mockRPC: function (route, args) {
assert.step(args.method || route);
if (args.method === 'action_feedback') {
- this.data['res.users'].records[lastUserIndex].activity_ids = [4];
- this.data['res.users'].records[lastUserIndex].activity_state = 'planned';
- this.data['res.users'].records[lastUserIndex].activity_summary = 'Meet FP';
- this.data['res.users'].records[lastUserIndex].activity_type_id = 1;
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === env.messaging.currentUser.id
+ );
+ Object.assign(currentUser, {
+ activity_ids: [4],
+ activity_state: 'planned',
+ activity_summary: 'Meet FP',
+ activity_type_id: 1,
+ });
return Promise.resolve();
}
return this._super(route, args);
@@ -299,8 +316,10 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
QUnit.test('list activity exception widget with activity', async function (assert) {
assert.expect(3);
- const lastUserIndex = this.data['res.users'].records.length - 1;
- this.data['res.users'].records[lastUserIndex].activity_ids = [1];
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.activity_ids = [1];
this.data['res.users'].records.push({
id: 13,
message_attachment_count: 3,
@@ -390,8 +409,8 @@ QUnit.test('fieldmany2many tags email', function (assert) {
assert.expect(13);
var done = assert.async();
- const lastUserIndex = this.data['res.users'].records.length - 1;
- this.data['res.users'].records[lastUserIndex].timmy = [12, 14];
+ const user11 = this.data['res.users'].records.find(user => user.id === 11);
+ user11.timmy = [12, 14];
// the modals need to be closed before the form view rendering
start({
@@ -451,8 +470,8 @@ QUnit.test('fieldmany2many tags email', function (assert) {
QUnit.test('fieldmany2many tags email (edition)', async function (assert) {
assert.expect(15);
- const lastUserIndex = this.data['res.users'].records.length - 1;
- this.data['res.users'].records[lastUserIndex].timmy = [12];
+ const user11 = this.data['res.users'].records.find(user => user.id === 11);
+ user11.timmy = [12];
var { widget: form } = await start({
hasView: true,
@@ -512,12 +531,12 @@ QUnit.test('fieldmany2many tags email (edition)', async function (assert) {
QUnit.test('many2many_tags_email widget can load more than 40 records', async function (assert) {
assert.expect(3);
- const lastUserIndex = this.data['res.users'].records.length - 1;
+ const user11 = this.data['res.users'].records.find(user => user.id === 11);
this.data['res.users'].fields.partner_ids = { string: "Partner", type: "many2many", relation: 'res.users' };
- this.data['res.users'].records[lastUserIndex].partner_ids = [];
+ user11.partner_ids = [];
for (let i = 100; i < 200; i++) {
this.data['res.users'].records.push({ id: i, display_name: `partner${i}` });
- this.data['res.users'].records[lastUserIndex].partner_ids.push(i);
+ user11.partner_ids.push(i);
}
const { widget: form } = await start({
diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js
index 085eb3e0234..d46a205d558 100644
--- a/addons/mail/static/tests/helpers/mock_models.js
+++ b/addons/mail/static/tests/helpers/mock_models.js
@@ -117,6 +117,7 @@ class MockModels {
body: { string: "Contents", type: 'html', default: "<p></p>" },
channel_ids: { string: "Channels", type: 'many2many', relation: 'mail.channel' },
date: { string: "Date", type: 'datetime' },
+ email_from: { string: "From", type: 'char' },
history_partner_ids: { string: "Partners with History", type: 'many2many', relation: 'res.partner' },
id: { string: "Id", type: 'integer' },
is_discussion: { string: "Discussion", type: 'boolean' },
@@ -169,11 +170,19 @@ class MockModels {
},
records: [],
},
+ 'res.country': {
+ fields: {
+ code: { string: "Code", type: 'char' },
+ name: { string: "Name", type: 'char' },
+ },
+ records: [],
+ },
'res.partner': {
fields: {
active: { string: "Active", type: 'boolean', default: true },
activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' },
contact_address_complete: { string: "Address", type: 'char' },
+ country_id: { string: "Country", type: 'many2one', relation: 'res.country' },
description: { string: 'description', type: 'text' },
display_name: { string: "Displayed name", type: "char" },
email: { type: 'char' },
@@ -192,6 +201,7 @@ class MockModels {
fields: {
active: { string: "Active", type: 'boolean', default: true },
display_name: { string: "Display name", type: "char" },
+ im_status: { string: "IM Status", type: 'char' },
name: { string: "Name", type: 'char' },
partner_id: { string: "Related partners", type: 'many2one', relation: 'res.partner' },
},
diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js
index f57ca20a508..0cc23dabc79 100644
--- a/addons/mail/static/tests/helpers/mock_server.js
+++ b/addons/mail/static/tests/helpers/mock_server.js
@@ -15,6 +15,7 @@ MockServer.include({
* @param {Object} [data.currentUserId]
* @param {Object} [data.partnerRootId]
* @param {Object} [data.publicPartnerId]
+ * @param {Object} [data.publicUserId]
* @param {Widget} [options.widget] mocked widget (use to call services)
*/
init(data, options) {
@@ -34,6 +35,10 @@ MockServer.include({
this.publicPartnerId = data.publicPartnerId;
delete data.publicPartnerId;
}
+ if (data && data.publicUserId) {
+ this.publicUserId = data.publicUserId;
+ delete data.publicUserId;
+ }
this._widget = options.widget;
this._super(...arguments);
@@ -92,6 +97,12 @@ MockServer.include({
*/
async _performRpc(route, args) {
// routes
+ if (route === '/mail/chat_post') {
+ const uuid = args.uuid;
+ const message_content = args.message_content;
+ const context = args.context;
+ return this._mockRouteMailChatPost(uuid, message_content, context);
+ }
if (route === '/mail/init_messaging') {
return this._mockRouteMailInitMessaging();
}
@@ -146,6 +157,14 @@ MockServer.include({
: undefined;
return this._mockMailChannelChannelGet(partners_to, pin);
}
+ if (args.model === 'mail.channel' && args.method === 'channel_info') {
+ const ids = args.args[0];
+ return this._mockMailChannelChannelInfo(ids);
+ }
+ if (args.model === 'mail.channel' && args.method === 'channel_join_and_get_info') {
+ const ids = args.args[0];
+ return this._mockMailChannelChannelJoinAndGetInfo(ids);
+ }
if (args.model === 'mail.channel' && args.method === 'channel_minimize') {
return;
}
@@ -163,10 +182,14 @@ MockServer.include({
if (args.model === 'mail.channel' && args.method === 'message_post') {
const id = args.args[0];
const kwargs = args.kwargs;
- return this._mockMailChannelMessagePost(id, kwargs);
+ const context = args.context;
+ return this._mockMailChannelMessagePost(id, kwargs, context);
}
if (args.model === 'mail.channel' && args.method === 'notify_typing') {
- return;
+ const ids = args.args[0];
+ const is_typing = args.args[1] || args.kwargs.is_typing;
+ const context = args.kwargs.context;
+ return this._mockMailChannelNotifyTyping(ids, is_typing, context);
}
// mail.message methods
if (args.model === 'mail.message' && args.method === 'mark_all_as_read') {
@@ -222,7 +245,8 @@ MockServer.include({
if (args.method === 'message_post') {
const id = args.args[0];
const kwargs = args.kwargs;
- return this._mockMailThreadMessagePost(id, args.model, kwargs);
+ const context = args.kwargs.context;
+ return this._mockMailThreadMessagePost(args.model, [id], kwargs, context);
}
return this._super(route, args);
},
@@ -232,10 +256,59 @@ MockServer.include({
//--------------------------------------------------------------------------
/**
+ * Simulates the `/mail/chat_post` route.
+ *
+ * @private
+ * @param {string} uuid
+ * @param {string} message_content
+ * @param {Object} [context={}]
+ * @returns {Object} one key for list of followers and one for subtypes
+ */
+ async _mockRouteMailChatPost(uuid, message_content, context = {}) {
+ const mailChannel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0];
+ if (!mailChannel) {
+ return false;
+ }
+
+ let user_id;
+ // find the author from the user session
+ if ('mockedUserId' in context) {
+ // can be falsy to simulate not being logged in
+ user_id = context.mockedUserId;
+ } else {
+ user_id = this.currentUserId;
+ }
+ let author_id;
+ let email_from;
+ if (user_id) {
+ const author = this._getRecords('res.users', [['id', '=', user_id]])[0];
+ author_id = author.id;
+ email_from = `${author.display_name} <${author.email}>`;
+ } else {
+ author_id = false;
+ // simpler fallback than catchall_formatted
+ email_from = mailChannel.anonymous_name || "catchall@example.com";
+ }
+ // supposedly should convert plain text to html
+ const body = message_content;
+ // ideally should be posted with mail_create_nosubscribe=True
+ return this._mockMailChannelMessagePost(
+ mailChannel.id,
+ {
+ author_id,
+ email_from,
+ body,
+ message_type: 'comment',
+ subtype_xmlid: 'mail.mt_comment',
+ },
+ context
+ );
+ },
+ /**
* Simulates the `/mail/init_messaging` route.
*
* @private
- * @return {Object}
+ * @returns {Object}
*/
_mockRouteMailInitMessaging() {
const channels = this._getRecords('mail.channel', [
@@ -302,7 +375,7 @@ MockServer.include({
},
commands: [],
current_partner: currentPartnerFormat,
- curret_user_id: this.currentUserId,
+ current_user_id: this.currentUserId,
mail_failures: mailFailures,
mention_partner_suggestions: [],
menu_id: false,
@@ -320,7 +393,7 @@ MockServer.include({
*
* @private
* @param {integer[]} follower_ids
- * @return {Object} one key for list of followers and one for subtypes
+ * @returns {Object} one key for list of followers and one for subtypes
*/
async _mockRouteMailReadFollowers(args) {
const res_id = args.res_id; // id of record to read the followers
@@ -337,7 +410,7 @@ MockServer.include({
*
* @private
* @param {integer} follower_id
- * @return {Object[]} list of followed subtypes
+ * @returns {Object[]} list of followed subtypes
*/
async _mockRouteMailReadSubscriptionData(follower_id) {
const follower = this._getRecords('mail.followers', [['id', '=', follower_id]])[0];
@@ -377,7 +450,7 @@ MockServer.include({
* @private
* @param {string} res_model
* @param {string} domain
- * @return {Object}
+ * @returns {Object}
*/
_mockMailActivityGetActivityData(res_model, domain) {
const self = this;
@@ -446,10 +519,48 @@ MockServer.include({
};
},
/**
+ * Simulates `_broadcast` on `mail.channel`.
+ *
+ * @private
+ * @param {integer} id
+ * @param {integer[]} partner_ids
+ * @returns {Object}
+ */
+ _mockMailChannel_broadcast(ids, partner_ids) {
+ const notifications = this._mockMailChannel_channelChannelNotifications(ids, partner_ids);
+ this._widget.call('bus_service', 'trigger', 'notification', notifications);
+ },
+ /**
+ * Simulates `_channel_channel_notifications` on `mail.channel`.
+ *
+ * @private
+ * @param {integer} id
+ * @param {integer[]} partner_ids
+ * @returns {Object}
+ */
+ _mockMailChannel_channelChannelNotifications(ids, partner_ids) {
+ const notifications = [];
+ for (const partner_id of partner_ids) {
+ const user = this._getRecords('res.users', [['partner_id', 'in', partner_id]])[0];
+ if (!user) {
+ continue;
+ }
+ // Note: `channel_info` on the server is supposed to be called with
+ // the proper user context, but this is not done here for simplicity
+ // of not having `channel.partner`.
+ const channelInfos = this._mockMailChannelChannelInfo(ids);
+ for (const channelInfo of channelInfos) {
+ notifications.push([[false, 'res.partner', partner_id], channelInfo]);
+ }
+ }
+ return notifications;
+ },
+ /**
* Simulates `channel_fetched` on `mail.channel`.
*
* @private
* @param {integer[]} ids
+ * @param {string} extra_info
*/
_mockMailChannelChannelFetched(ids) {
const channels = this._getRecords('mail.channel', [['id', 'in', ids]]);
@@ -485,7 +596,7 @@ MockServer.include({
*
* @private
* @param {integer[]} ids
- * @return {Object[]} list of channels previews
+ * @returns {Object[]} list of channels previews
*/
_mockMailChannelChannelFetchPreview(ids) {
const channels = this._getRecords('mail.channel', [['id', 'in', ids]]);
@@ -562,10 +673,11 @@ MockServer.include({
* Simulates `channel_info` on `mail.channel`.
*
* @private
- * @param {integer[]} [ids=[]]
+ * @param {integer[]} ids
+ * @param {string} [extra_info]
* @returns {Object[]}
*/
- _mockMailChannelChannelInfo(ids = []) {
+ _mockMailChannelChannelInfo(ids, extra_info) {
const channels = this._getRecords('mail.channel', [['id', 'in', ids]]);
const all_partners = [...new Set(channels.reduce((all_partners, channel) => {
return [...all_partners, ...channel.members];
@@ -590,17 +702,49 @@ MockServer.include({
['mail_message_id', 'in', messages.map(message => message.id)],
]).length;
return Object.assign({}, channel, {
+ info: extra_info,
members,
message_needaction_counter: messageNeedactionCounter,
});
});
},
/**
+ * Simulates `channel_join_and_get_info` on `mail.channel`.
+ *
+ * @private
+ * @param {integer[]} ids
+ * @returns {Object[]}
+ */
+ _mockMailChannelChannelJoinAndGetInfo(ids) {
+ const id = ids[0]; // ensure one
+ const channel = this._getRecords('mail.channel', [['id', '=', id]])[0];
+ // channel.partner not handled here for simplicity
+ if (!channel.is_pinned) {
+ this._mockWrite('mail.channel', [
+ [channel.id],
+ { is_pinned: true },
+ ]);
+ const body = `<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="${channel.id}">#${channel.name}</a></div>`;
+ const message_type = "notification";
+ const subtype_xmlid = "mail.mt_comment";
+ this._mockMailChannelMessagePost(
+ 'mail.channel',
+ [channel.id],
+ { body, message_type, subtype_xmlid },
+ );
+ }
+ // moderation_guidelines not handled here for simplicity
+ const channelInfo = this._mockMailChannelChannelInfo([channel.id], 'join')[0];
+ const notification = [[false, 'res.partner', this.currentPartnerId], channelInfo];
+ this._widget.call('bus_service', 'trigger', 'notification', [notification]);
+ return channelInfo;
+ },
+ /**
* Simulates `channel_set_custom_name` on `mail.channel`.
*
* @private
* @param {integer} channel_id
- * @return {string} [name]
+ * @returns {string} [name]
*/
_mockMailChannelChannelSetCustomName(channel_id, name) {
this._mockWrite('mail.channel', [
@@ -642,28 +786,70 @@ MockServer.include({
* @private
* @param {integer} id
* @param {Object} kwargs
- * @return {integer|false}
+ * @param {Object} [context]
+ * @returns {integer|false}
*/
- _mockMailChannelMessagePost(id, kwargs) {
+ _mockMailChannelMessagePost(id, kwargs, context) {
const message_type = kwargs.message_type || 'notification';
const channel = this._getRecords('mail.channel', [['id', '=', id]])[0];
+ if (channel.channel_type !== 'channel' && !channel.is_pinned) {
+ // channel.partner not handled here for simplicity
+ this._mockWrite('mail.channel', [
+ [channel.id],
+ { is_pinned: true },
+ ]);
+ }
let moderation_status = 'accepted';
if (channel.moderation && ['email', 'comment'].includes(message_type)) {
if (!channel.is_moderator) {
moderation_status = 'pending_moderation';
}
}
+ let channel_ids = [];
+ if (moderation_status === 'accepted') {
+ channel_ids = [[4, channel.id]];
+ }
const messageId = this._mockMailThreadMessagePost(
- id,
'mail.channel',
+ [id],
Object.assign(kwargs, {
+ channel_ids,
message_type,
moderation_status,
- })
+ }),
+ context,
);
return messageId;
},
/**
+ * Simulates `notify_typing` on `mail.channel`.
+ *
+ * @private
+ * @param {integer[]} ids
+ * @param {boolean} is_typing
+ * @param {Object} [context={}]
+ */
+ _mockMailChannelNotifyTyping(ids, is_typing, context = {}) {
+ const channels = this._getRecords('mail.channel', [['id', 'in', ids]]);
+ let partner_id;
+ if ('mockedPartnerId' in context) {
+ partner_id = context.mockedPartnerId;
+ } else {
+ partner_id = this.currentPartnerId;
+ }
+ const data = {
+ 'info': 'typing_status',
+ 'is_typing': is_typing,
+ 'partner_id': partner_id,
+ };
+ const notifications = [];
+ for (const channel of channels) {
+ notifications.push([[false, 'mail.channel', channel.id], data]);
+ notifications.push([channel.uuid, data]); // notify livechat users
+ }
+ this._widget.call('bus_service', 'trigger', 'notification', notifications);
+ },
+ /**
* Simulates `partner_info` on `mail.channel`.
*
* @private
@@ -672,7 +858,11 @@ MockServer.include({
* @returns {Object[]}
*/
_mockMailChannelPartnerInfo(all_partners, direct_partners) {
- const partners = this._getRecords('res.partner', [['id', 'in', all_partners]]);
+ const partners = this._getRecords(
+ 'res.partner',
+ [['id', 'in', all_partners]],
+ { active_test: false },
+ );
const partnerInfos = {};
for (const partner of partners) {
const partnerInfo = {
@@ -692,7 +882,7 @@ MockServer.include({
*
* @private
* @param {Array[]} [domain]
- * @return {integer[]}
+ * @returns {integer[]}
*/
_mockMailMessageMarkAllAsRead(domain) {
const notifDomain = [
@@ -741,7 +931,7 @@ MockServer.include({
* @param {Array[]} domain
* @param {string} [limit=20]
* @param {Object} [moderated_channel_ids]
- * @return {Object[]}
+ * @returns {Object[]}
*/
_mockMailMessageMessageFetch(domain, limit = 20, moderated_channel_ids) {
let messages = this._getRecords('mail.message', domain);
@@ -767,7 +957,7 @@ MockServer.include({
* Simulates `message_fetch_failed` on `mail.message`.
*
* @private
- * @return {Object[]}
+ * @returns {Object[]}
*/
_mockMailMessageMessageFetchFailed() {
const messages = this._getRecords('mail.message', [
@@ -791,8 +981,8 @@ MockServer.include({
* Simulates `message_format` on `mail.message`.
*
* @private
- * @return {integer[]} ids
- * @return {Object[]}
+ * @returns {integer[]} ids
+ * @returns {Object[]}
*/
_mockMailMessageMessageFormat(ids) {
const messages = this._getRecords('mail.message', [['id', 'in', ids]]);
@@ -806,7 +996,11 @@ MockServer.include({
])[0];
let formattedAuthor;
if (message.author_id) {
- const author = this._getRecords('res.partner', [['id', '=', message.author_id]])[0];
+ const author = this._getRecords(
+ 'res.partner',
+ [['id', '=', message.author_id]],
+ { active_test: false }
+ )[0];
formattedAuthor = [author.id, author.display_name];
} else {
formattedAuthor = [0, message.email_from];
@@ -888,8 +1082,8 @@ MockServer.include({
* Simulates `_message_notification_format` on `mail.message`.
*
* @private
- * @return {integer[]} ids
- * @return {Object[]}
+ * @returns {integer[]} ids
+ * @returns {Object[]}
*/
_mockMailMessage_MessageNotificationFormat(ids) {
const messages = this._getRecords('mail.message', [['id', 'in', ids]]);
@@ -957,7 +1151,7 @@ MockServer.include({
* Simulates `toggle_message_starred` on `mail.message`.
*
* @private
- * @return {integer[]} ids
+ * @returns {integer[]} ids
*/
_mockMailMessageToggleMessageStarred(ids) {
const messages = this._getRecords('mail.message', [['id', 'in', ids]]);
@@ -1001,8 +1195,8 @@ MockServer.include({
* Simulates `_filtered_for_web_client` on `mail.notification`.
*
* @private
- * @return {integer[]} ids
- * @return {Object[]}
+ * @returns {integer[]} ids
+ * @returns {Object[]}
*/
_mockMailNotification_FilteredForWebClient(ids) {
return this._getRecords('mail.notification', [
@@ -1016,8 +1210,8 @@ MockServer.include({
* Simulates `_notification_format` on `mail.notification`.
*
* @private
- * @return {integer[]} ids
- * @return {Object[]}
+ * @returns {integer[]} ids
+ * @returns {Object[]}
*/
_mockMailNotification_NotificationFormat(ids) {
const notifications = this._getRecords('mail.notification', [['id', 'in', ids]]);
@@ -1033,15 +1227,70 @@ MockServer.include({
});
},
/**
+ * Simulates `_message_compute_author` on `mail.thread`.
+ *
+ * @private
+ * @param {string} model
+ * @param {integer[]} ids
+ * @param {Object} [context={}]
+ * @returns {Array}
+ */
+ _MockMailThread_MessageComputeAuthor(model, ids, author_id, email_from, context = {}) {
+ if (author_id === undefined) {
+ // For simplicity partner is not guessed from email_from here, but
+ // that would be the first step on the server.
+ let user_id;
+ if ('mockedUserId' in context) {
+ // can be falsy to simulate not being logged in
+ user_id = context.mockedUserId
+ ? context.mockedUserId
+ : this.publicUserId;
+ } else {
+ user_id = this.currentUserId;
+ }
+ const user = this._getRecords(
+ 'res.users',
+ [['id', '=', user_id]],
+ { active_test: false },
+ )[0];
+ const author = this._getRecords(
+ 'res.partner',
+ [['id', '=', user.partner_id]],
+ { active_test: false },
+ )[0];
+ author_id = author.id;
+ email_from = `${author.display_name} <${author.email}>`;
+ }
+
+ if (email_from === undefined) {
+ if (author_id) {
+ const author = this._getRecords(
+ 'res.partner',
+ [['id', '=', author_id]],
+ { active_test: false },
+ )[0];
+ email_from = `${author.display_name} <${author.email}>`;
+ }
+ }
+
+ if (!email_from) {
+ throw Error("Unable to log message due to missing author email.");
+ }
+
+ return [author_id, email_from];
+ },
+ /**
* Simulates `message_post` on `mail.thread`.
*
* @private
- * @param {integer} id
* @param {string} model
+ * @param {integer[]} ids
* @param {Object} kwargs
- * @return {integer}
+ * @param {Object} [context]
+ * @returns {integer}
*/
- _mockMailThreadMessagePost(id, model, kwargs) {
+ _mockMailThreadMessagePost(model, ids, kwargs, context) {
+ const id = ids[0]; // ensure_one
if (kwargs.attachment_ids) {
const attachments = this._getRecords('ir.attachment', [
['id', 'in', kwargs.attachment_ids],
@@ -1059,7 +1308,15 @@ MockServer.include({
kwargs.attachment_ids = attachmentIds.map(attachmentId => [4, attachmentId]);
}
const subtype_xmlid = kwargs.subtype_xmlid || 'mail.mt_note';
+ const [author_id, email_from] = this._MockMailThread_MessageComputeAuthor(
+ model,
+ ids,
+ kwargs.author_id,
+ kwargs.email_from, context,
+ );
const values = Object.assign({}, kwargs, {
+ author_id,
+ email_from,
is_discussion: subtype_xmlid === 'mail.mt_comment',
is_note: subtype_xmlid === 'mail.mt_note',
model,
@@ -1067,13 +1324,7 @@ MockServer.include({
});
delete values.subtype_xmlid;
const messageId = this._mockCreate('mail.message', values);
- const message = this._getRecords('mail.message', [['id', '=', messageId]])[0];
- const notificationData = {
- type: 'author',
- message: this._mockMailMessageMessageFormat([messageId])[0],
- };
- const notification = [[false, 'res.partner', message.author_id], notificationData];
- this._widget.call('bus_service', 'trigger', 'notification', [notification]);
+ this._mockMailThread_NotifyThread(model, ids, messageId, values);
return messageId;
},
/**
@@ -1092,6 +1343,35 @@ MockServer.include({
// mockRPC should be considered for a specific result.
},
/**
+ * Simulates `_notify_thread` on `mail.thread`.
+ * Simplified version that sends notification to author and channel.
+ *
+ * @private
+ * @param {string} model not in server method but necessary for thread mock
+ * @param {integer[]} ids
+ * @param {integer} messageId
+ * @returns {boolean}
+ */
+ _mockMailThread_NotifyThread(model, ids, messageId) {
+ const message = this._getRecords('mail.message', [['id', '=', messageId]])[0];
+ const messageFormat = this._mockMailMessageMessageFormat([messageId])[0];
+ const notifications = [];
+ // author
+ const notificationData = {
+ type: 'author',
+ message: messageFormat,
+ };
+ if (message.author_id) {
+ notifications.push([[false, 'res.partner', message.author_id], notificationData]);
+ }
+ // members
+ const channels = this._getRecords('mail.channel', [['id', 'in', message.channel_ids]]);
+ for (const channel of channels) {
+ notifications.push([[false, 'mail.channel', channel.id], messageFormat]);
+ }
+ this._widget.call('bus_service', 'trigger', 'notification', notifications);
+ },
+ /**
* Simulates `message_unsubscribe` on `mail.thread`.
*
* @private
@@ -1223,8 +1503,8 @@ MockServer.include({
* Simulates `mail_partner_format` on `res.partner`.
*
* @private
- * @return {integer} id
- * @return {Object}
+ * @returns {integer} id
+ * @returns {Object}
*/
_mockResPartnerMailPartnerFormat(id) {
const partner = this._getRecords(