[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#56423

X-original-commit: f623bfd2c8819604c709bc723153c071dca6e3cc
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2020-08-24 13:47:01 +00:00
parent 7bec33f578
commit efb66e8e7f
16 changed files with 1129 additions and 136 deletions
+13 -12
View File
@@ -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):
@@ -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"
);
});
});
});
});
@@ -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),
}
);
@@ -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 =>
@@ -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 =>
@@ -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();
@@ -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;
}
@@ -10,6 +10,76 @@ MockServer.include({
// Private
//--------------------------------------------------------------------------
/**
* @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
*/
@@ -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];
},
});
});
@@ -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 = []
@@ -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';
@@ -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;
@@ -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,
+16 -8
View File
@@ -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,
+55 -36
View File
@@ -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({
@@ -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' },
},
+322 -42
View File
@@ -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);
},
@@ -231,11 +255,60 @@ MockServer.include({
// Private Mocked Routes
//--------------------------------------------------------------------------
/**
* 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;
@@ -445,11 +518,49 @@ MockServer.include({
grouped_activities: groupedActivities,
};
},
/**
* 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,27 +786,69 @@ 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`.
*
@@ -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]]);
@@ -1032,16 +1226,71 @@ 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;
},
/**
@@ -1091,6 +1342,35 @@ MockServer.include({
// message_subscribe is too complex for a generic mock.
// 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`.
*
@@ -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(