/** @odoo-module **/ // ensure bus mock server is loaded first. import '@bus/../tests/helpers/mock_server'; import { patch } from "@web/core/utils/patch"; import { MockServer } from "@web/../tests/helpers/mock_server"; import { date_to_str, datetime_to_str } from 'web.time'; patch(MockServer.prototype, 'mail', { init({ models }) { this._super(...arguments); if (this.currentPartnerId && models && 'res.partner' in models) { this.currentPartner = this.getRecords('res.partner', [['id', '=', this.currentPartnerId]])[0]; } // creation of the ir.model.fields records, required for tracked fields for (const modelName in models) { const fieldNamesToFields = models[modelName].fields; for (const fname in fieldNamesToFields) { if (fieldNamesToFields[fname].tracking) { this.mockCreate('ir.model.fields', { model: modelName, name: fname }); } } } }, /** * @override */ async performRPC(route, args) { if (route === '/mail/attachment/upload') { const ufile = args.body.get('ufile'); const is_pending = args.body.get('is_pending') === 'true'; const model = is_pending ? 'mail.compose.message' : args.body.get('thread_model'); const id = is_pending ? 0 : parseInt(args.body.get('thread_id')); const attachmentId = this.mockCreate('ir.attachment', { // datas, mimetype: ufile.type, name: ufile.name, res_id: id, res_model: model, }); const attachment = this.getRecords('ir.attachment', [['id', '=', attachmentId]])[0]; return { 'filename': attachment.name, 'id': attachment.id, 'mimetype': attachment.mimetype, 'size': attachment.file_size }; } return this._super(...arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ async _performRPC(route, args) { // routes if (route === '/mail/message/post') { const finalData = {}; for (const allowedField of ['attachment_ids', 'body', 'message_type', 'partner_ids', 'subtype_xmlid', 'parent_id']) { if (args.post_data[allowedField] !== undefined) { finalData[allowedField] = args.post_data[allowedField]; } } if (args.thread_model === 'mail.channel') { return this._mockMailChannelMessagePost(args.thread_id, finalData, args.context); } return this._mockMailThreadMessagePost(args.thread_model, [args.thread_id], finalData, args.context); } if (route === '/mail/attachment/delete') { const { attachment_id } = args; return this._mockRouteMailAttachmentRemove(attachment_id); } 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(); } if (route === '/mail/link_preview') { return this._mockRouteMailLinkPreview(args.message_id); } if (route === '/mail/load_message_failures') { return this._mockRouteMailLoadMessageFailures(); } if (route === '/mail/history/messages') { const { min_id, max_id, limit } = args; return this._mockRouteMailMessageHistory(min_id, max_id, limit); } if (route === '/mail/inbox/messages') { const { min_id, max_id, limit } = args; return this._mockRouteMailMessageInbox(min_id, max_id, limit); } if (route === '/mail/starred/messages') { const { min_id, max_id, limit } = args; return this._mockRouteMailMessageStarredMessages(min_id, max_id, limit); } if (route === '/mail/read_subscription_data') { const follower_id = args.follower_id; return this._mockRouteMailReadSubscriptionData(follower_id); } if (route === '/mail/thread/data') { return this._mockRouteMailThreadData(args.thread_model, args.thread_id, args.request_list); } if (route === '/mail/thread/messages') { const { min_id, max_id, limit, thread_model, thread_id } = args; return this._mockRouteMailThreadFetchMessages(thread_model, thread_id, max_id, min_id, limit); } if (route === '/mail/channel/messages') { const { channel_id, min_id, max_id, limit } = args; return this._mockRouteMailChannelMessages(channel_id, max_id, min_id, limit); } if (new RegExp('/mail/channel/\\d+/partner/\\d+/avatar_128').test(route)) { return; } if (args.model === 'ir.attachment' && args.method === 'register_as_main_attachment') { const ids = args.args[0]; return this._mockIrAttachmentRegisterAsMainAttachment(ids); } // mail.activity methods if (args.model === 'mail.activity' && args.method === 'action_feedback') { const ids = args.args[0]; return this._mockMailActivityActionFeedback(ids); } if (args.model === 'mail.activity' && args.method === 'action_feedback_schedule_next') { const ids = args.args[0]; return this._mockMailActivityActionFeedbackScheduleNext(ids); } if (args.model === 'mail.activity' && args.method === 'activity_format') { const ids = args.args[0]; return this._mockMailActivityActivityFormat(ids); } if (args.model === 'mail.activity' && args.method === 'get_activity_data') { const res_model = args.args[0] || args.kwargs.res_model; const domain = args.args[1] || args.kwargs.domain; return this._mockMailActivityGetActivityData(res_model, domain); } // mail.channel methods if (args.model === 'mail.channel' && args.method === 'action_unfollow') { const ids = args.args[0]; return this._mockMailChannelActionUnfollow(ids); } if (args.model === 'mail.channel' && args.method === 'channel_fetched') { const ids = args.args[0]; return this._mockMailChannelChannelFetched(ids); } if (args.model === 'mail.channel' && args.method === 'channel_fetch_preview') { const ids = args.args[0]; return this._mockMailChannelChannelFetchPreview(ids); } if (args.model === 'mail.channel' && args.method === 'channel_fold') { const ids = args.args[0]; const state = args.args[1] || args.kwargs.state; return this._mockMailChannelChannelFold(ids, state); } if (args.model === 'mail.channel' && args.method === 'channel_get') { const partners_to = args.args[0] || args.kwargs.partners_to; const pin = args.args[1] !== undefined ? args.args[1] : args.kwargs.pin !== undefined ? args.kwargs.pin : 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 === 'add_members') { const ids = args.args[0]; const partner_ids = args.args[1] || args.kwargs.partner_ids; return this._mockMailChannelAddMembers(ids, partner_ids); } if (args.model === 'mail.channel' && args.method === 'channel_pin') { const ids = args.args[0]; const pinned = args.args[1] || args.kwargs.pinned; return this._mockMailChannelChannelPin(ids, pinned); } if (args.model === 'mail.channel' && args.method === 'channel_rename') { const ids = args.args[0]; const name = args.args[1] || args.kwargs.name; return this._mockMailChannelChannelRename(ids, name); } if (route === '/mail/channel/set_last_seen_message') { const id = args.channel_id; const last_message_id = args.last_message_id; return this._mockMailChannel_ChannelSeen([id], last_message_id); } if (args.model === 'mail.channel' && args.method === 'channel_set_custom_name') { const ids = args.args[0]; const name = args.args[1] || args.kwargs.name; return this._mockMailChannelChannelSetCustomName(ids, name); } if (args.model === 'mail.channel' && args.method === 'create_group') { const partners_to = args.args[0] || args.kwargs.partners_to; return this._mockMailChannelCreateGroup(partners_to); } if (args.model === 'mail.channel' && args.method === 'execute_command_leave') { return this._mockMailChannelExecuteCommandLeave(args); } if (args.model === 'mail.channel' && args.method === 'execute_command_who') { return this._mockMailChannelExecuteCommandWho(args); } if (args.model === 'mail.channel' && args.method === 'write' && 'image_128' in args.args[1]) { const ids = args.args[0]; return this._mockMailChannelWriteImage128(ids[0]); } if (args.model === 'mail.channel' && args.method === 'load_more_members') { const [channel_ids] = args.args; const { known_member_ids } = args.kwargs; return this._mockMailChannelLoadMoreMembers(channel_ids, known_member_ids); } // mail.message methods if (args.model === 'mail.message' && args.method === 'mark_all_as_read') { const domain = args.args[0] || args.kwargs.domain; return this._mockMailMessageMarkAllAsRead(domain); } if (args.model === 'mail.message' && args.method === 'message_format') { const ids = args.args[0]; return this._mockMailMessageMessageFormat(ids); } if (args.model === 'mail.message' && args.method === 'set_message_done') { const ids = args.args[0]; return this._mockMailMessageSetMessageDone(ids); } if (args.model === 'mail.message' && args.method === 'toggle_message_starred') { const ids = args.args[0]; return this._mockMailMessageToggleMessageStarred(ids); } if (args.model === 'mail.message' && args.method === 'unstar_all') { return this._mockMailMessageUnstarAll(); } if (args.model === 'res.users.settings' && args.method === '_find_or_create_for_user') { const user_id = args.args[0][0]; return this._mockResUsersSettings_FindOrCreateForUser(user_id); } if (args.model === 'res.users.settings' && args.method === 'set_res_users_settings') { const id = args.args[0][0]; const newSettings = args.kwargs.new_settings; return this._mockResUsersSettingsSetResUsersSettings(id, newSettings); } // res.partner methods if (args.method === 'get_mention_suggestions') { if (args.model === 'mail.channel') { return this._mockMailChannelGetMentionSuggestions(args); } if (args.model === 'res.partner') { return this._mockResPartnerGetMentionSuggestions(args); } } if (args.model === 'res.partner' && args.method === 'im_search') { const name = args.args[0] || args.kwargs.search; const limit = args.args[1] || args.kwargs.limit; return this._mockResPartnerImSearch(name, limit); } if (args.model === 'res.partner' && args.method === 'search_for_channel_invite') { const search_term = args.args[0] || args.kwargs.search_term; const channel_id = args.args[1] || args.kwargs.channel_id; const limit = args.args[2] || args.kwargs.limit; return this._mockResPartnerSearchForChannelInvite(search_term, channel_id, limit); } // res.users method if (args.model === 'res.users' && args.method === 'systray_get_activities') { return this._mockResUsersSystrayGetActivities(); } // mail.thread methods (can work on any model) if (args.method === 'message_subscribe') { const ids = args.args[0]; const partner_ids = args.args[1] || args.kwargs.partner_ids; const subtype_ids = args.args[2] || args.kwargs.subtype_ids; return this._mockMailThreadMessageSubscribe(args.model, ids, partner_ids, subtype_ids); } if (args.method === 'message_unsubscribe') { const ids = args.args[0]; const partner_ids = args.args[1] || args.kwargs.partner_ids; return this._mockMailThreadMessageUnsubscribe(args.model, ids, partner_ids); } if (args.method === 'message_post') { const id = args.args[0]; const kwargs = args.kwargs; const context = kwargs.context; delete kwargs.context; return this._mockMailThreadMessagePost(args.model, [id], kwargs, context); } if (args.method === 'notify_cancel_by_type') { return this._mockMailThreadNotifyCancelByType(args.model, args.kwargs.notification_type); } return this._super(route, args); }, /** * @override */ mockWrite(model) { const initialTrackedFieldValuesByRecordId = this._mockMailThread_TrackPrepare(model); const mockWriteResult = this._super(...arguments); if (initialTrackedFieldValuesByRecordId) { this._mockMailThread_TrackFinalize(model, initialTrackedFieldValuesByRecordId); } return mockWriteResult; }, //-------------------------------------------------------------------------- // Private Mocked Routes //-------------------------------------------------------------------------- /** * Simulates `activity_format` on `mail.activity`. * * @private * @param {number[]} ids * @returns {Object[]} */ _mockMailActivityActivityFormat(ids) { let res = this.mockRead('mail.activity', [ids]); res = res.map(record => { if (record.mail_template_ids) { record.mail_template_ids = record.mail_template_ids.map(template_id => { const template = this.getRecords('mail.template', [['id', '=', template_id]])[0]; return { id: template.id, name: template.name, }; }); } return record; }); return res; }, /** * Simulates the `/mail/attachment/delete` route. * * @private * @param {integer} attachment_id */ async _mockRouteMailAttachmentRemove(attachment_id) { this.pyEnv['bus.bus']._sendone(this.currentPartnerId, 'ir.attachment/delete', { id: attachment_id }); return this.pyEnv['ir.attachment'].unlink([attachment_id]); }, /** * Simulates the `/mail/channel/messages` route. * * @private * @param {integer} channel_id * @param {integer} limit * @param {integer} max_id * @param {integer} min_id * @returns {Object} list of messages */ async _mockRouteMailChannelMessages(channel_id, max_id = false, min_id = false, limit = 30) { const domain = [ ['res_id', '=', channel_id], ['model', '=', 'mail.channel'], ['message_type', '!=', 'user_notification'], ]; const messages = this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); this._mockMailMessageSetMessageDone(messages.map(message => message.id)); return this._mockMailMessageMessageFormat(messages.map(message => message.id)); }, /** * 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.partner_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 * @returns {Object} */ _mockRouteMailInitMessaging() { return this._mockResUsers_InitMessaging([this.currentUserId]); }, /** * Simulates `/mail/link_preview` route. * * @private * @param {integer} message_id * @returns {Object} */ _mockRouteMailLinkPreview(message_id) { const linkPreviews = []; const [message] = this.pyEnv['mail.message'].searchRead([['id', '=', message_id]]); if (message.body === 'https://make-link-preview.com') { const linkPreviewId = this.pyEnv['mail.link.preview'].create({ og_description: "test description", og_title: 'Article title', og_type: 'article', source_url: 'https://make-link-preview.com', }); const [linkPreview] = this.pyEnv['mail.link.preview'].searchRead([['id', '=', linkPreviewId]]); linkPreviews.push(this._mockMailLinkPreviewFormat(linkPreview)); let target = this.currentPartner; if (message.model === 'mail.channel') { target = this.pyEnv['mail.channel'].search([['id', '=', message.res_id]]); } this.pyEnv['bus.bus']._sendmany([[target, 'mail.link.preview/insert', linkPreviews]]); } }, /** * Simulates the `/mail/load_message_failures` route. * * @private * @returns {Object[]} */ _mockRouteMailLoadMessageFailures() { return this._mockResPartner_MessageFetchFailed(this.currentPartnerId); }, /** * Simulates the `/mail/history/messages` route. * * @private * @returns {Object} */ _mockRouteMailMessageHistory(min_id = false, max_id = false, limit = 30) { const domain = [['needaction', '=', false]]; const messages = this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); return this._mockMailMessageMessageFormat(messages.map(message => message.id)); }, /** * Simulates the `/mail/inbox/messages` route. * * @private * @returns {Object} */ _mockRouteMailMessageInbox(min_id = false, max_id = false, limit = 30) { const domain = [['needaction', '=', true]]; const messages = this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); return this._mockMailMessageMessageFormat(messages.map(message => message.id)); }, /** * Simulates the `/mail/starred/messages` route. * * @private * @returns {Object} */ _mockRouteMailMessageStarredMessages(min_id = false, max_id = false, limit = 30) { const domain = [['starred_partner_ids', 'in', [this.currentPartnerId]]]; const messages = this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); return this._mockMailMessageMessageFormat(messages.map(message => message.id)); }, /** * Simulates the `/mail/read_subscription_data` route. * * @private * @param {integer} follower_id * @returns {Object[]} list of followed subtypes */ async _mockRouteMailReadSubscriptionData(follower_id) { const follower = this.getRecords('mail.followers', [['id', '=', follower_id]])[0]; const subtypes = this.getRecords('mail.message.subtype', [ '&', ['hidden', '=', false], '|', ['res_model', '=', follower.res_model], ['res_model', '=', false], ]); const subtypes_list = subtypes.map(subtype => { const parent = this.getRecords('mail.message.subtype', [ ['id', '=', subtype.parent_id], ])[0]; return { 'default': subtype.default, 'followed': follower.subtype_ids.includes(subtype.id), 'id': subtype.id, 'internal': subtype.internal, 'name': subtype.name, 'parent_model': parent ? parent.res_model : false, 'res_model': subtype.res_model, 'sequence': subtype.sequence, }; }); // NOTE: server is also doing a sort here, not reproduced for simplicity return subtypes_list; }, /** * Simulates the `/mail/thread/data` route. * * @param {string} thread_model * @param {integer} thread_id * @param {string[]} request_list * @returns {Object} */ async _mockRouteMailThreadData(thread_model, thread_id, request_list) { const res = { 'hasWriteAccess': true, // mimic user with write access by default 'hasReadAccess': true, }; const thread = this.pyEnv[thread_model].searchRead([['id', '=', thread_id]])[0]; if (!thread) { res['hasReadAccess'] = false; return res; } res['canPostOnReadonly'] = thread_model === 'mail.channel'; // model that have attr _mail_post_access='read' if (request_list.includes('activities')) { const activities = this.pyEnv['mail.activity'].searchRead([['id', 'in', thread.activity_ids || []]]); res['activities'] = this._mockMailActivityActivityFormat(activities.map(activity => activity.id)); } if (request_list.includes('attachments')) { const attachments = this.pyEnv['ir.attachment'].searchRead( [['res_id', '=', thread.id], ['res_model', '=', thread_model]], ); // order not done for simplicity res['attachments'] = this._mockIrAttachment_attachmentFormat(attachments.map(attachment => attachment.id)); res['mainAttachment'] = thread.message_main_attachment_id ? { 'id': thread.message_main_attachment_id[0] } : [['clear']]; } if (request_list.includes('followers')) { const followers = this.pyEnv['mail.followers'].searchRead([['id', 'in', thread.message_follower_ids || []]]); // search read returns many2one relations as an array [id, display_name]. // But the original route does not. Thus, we need to change it now. followers.forEach(follower => follower.partner_id = follower.partner_id[0]); res['followers'] = followers; } if (request_list.includes('suggestedRecipients')) { res['suggestedRecipients'] = this._mockMailThread_MessageGetSuggestedRecipients(thread_model, [thread.id])[thread_id]; } return res; }, /** * Simulates the `/mail/thread/messages` route. * * @private * @param {string} res_model * @param {integer} res_id * @param {integer} max_id * @param {integer} min_id * @param {integer} limit * @returns {Object[]} list of messages */ async _mockRouteMailThreadFetchMessages(res_model, res_id, max_id = false, min_id = false, limit = 30) { const domain = [ ['res_id', '=', res_id], ['model', '=', res_model], ['message_type', '!=', 'user_notification'], ]; const messages = this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); this._mockMailMessageSetMessageDone(messages.map(message => message.id)); return this._mockMailMessageMessageFormat(messages.map(message => message.id)); }, //-------------------------------------------------------------------------- // Private Mocked Methods //-------------------------------------------------------------------------- /** * Simulates `_attachment_format` on `ir.attachment`. * * @private * @param {integer} ids * @returns {Object} */ _mockIrAttachment_attachmentFormat(ids) { const attachments = this.mockRead('ir.attachment', [ids]); return attachments.map(attachment => { const res = { 'checksum': attachment.checksum, 'filename': attachment.name, 'id': attachment.id, 'mimetype': attachment.mimetype, 'name': attachment.name, 'type': attachment.type, 'url': attachment.url, }; res['originThread'] = [['insert', { 'id': attachment.res_id, 'model': attachment.res_model, }]]; return res; }); }, /** * Simulates `register_as_main_attachment` on `ir.attachment`. * * @private * @param {integer} ids * @param {boolean} [force=true] * @returns {boolean} dummy value for mock server */ _mockIrAttachmentRegisterAsMainAttachment(ids, force = true) { const [attachment] = this.getRecords('ir.attachment', [['id', 'in', ids]]); if (!attachment.res_model) { return true; // dummy value for mock server } if (!this.models[attachment.res_model].fields['message_main_attachment_id']) { return true; // dummy value for mock server } const [record] = this.pyEnv[attachment.res_model].searchRead([['id', '=', attachment.res_id]]); if (force || !record.message_main_attachment_id) { this.pyEnv[attachment.res_model].write( [record.id], { message_main_attachment_id: attachment.id }, ); } return true; // dummy value for mock server }, /** * Simulates `_action_done` on `mail.activity`. * * @private * @param {string} model * @param {integer[]} ids * @returns {Object} */ _mockMailActivityActionDone(ids) { const activities = this.getRecords('mail.activity', [['id', 'in', ids]]); this.mockUnlink('mail.activity', [activities.map(activity => activity.id)]); }, /** * Simulates `action_feedback` on `mail.activity`. * * @private * @param {string} model * @param {integer[]} ids * @returns {Object} */ _mockMailActivityActionFeedback(ids) { this._mockMailActivityActionDone(ids); }, /** * Simulates `action_feedback_schedule_next` on `mail.activity`. * * @private * @param {string} model * @param {integer[]} ids * @returns {Object} */ _mockMailActivityActionFeedbackScheduleNext(ids) { this._mockMailActivityActionDone(ids); return { name: 'Schedule an Activity', view_mode: 'form', res_model: 'mail.activity', views: [[false, 'form']], type: 'ir.actions.act_window', }; }, /** * Simulates `get_activity_data` on `mail.activity`. * * @private * @param {string} res_model * @param {string} domain * @returns {Object} */ _mockMailActivityGetActivityData(res_model, domain) { const self = this; const records = this.getRecords(res_model, domain); const activityTypes = this.getRecords('mail.activity.type', []); const activityIds = _.pluck(records, 'activity_ids').flat(); const groupedActivities = {}; const resIdToDeadline = {}; const groups = self.mockReadGroup('mail.activity', { domain: [['id', 'in', activityIds]], fields: ['res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'], groupby: ['res_id', 'activity_type_id'], lazy: false, }); groups.forEach(function (group) { // mockReadGroup doesn't correctly return all asked fields const activites = self.getRecords('mail.activity', group.__domain); group.activity_type_id = group.activity_type_id[0]; let minDate; activites.forEach(function (activity) { if (!minDate || moment(activity.date_deadline) < moment(minDate)) { minDate = activity.date_deadline; } }); group.date_deadline = minDate; resIdToDeadline[group.res_id] = minDate; let state; if (group.date_deadline === moment().format("YYYY-MM-DD")) { state = 'today'; } else if (moment(group.date_deadline) > moment()) { state = 'planned'; } else { state = 'overdue'; } if (!groupedActivities[group.res_id]) { groupedActivities[group.res_id] = {}; } groupedActivities[group.res_id][group.activity_type_id] = { count: group.__count, state: state, o_closest_deadline: group.date_deadline, ids: _.pluck(activites, 'id'), }; }); return { activity_types: activityTypes.map((type) => { let mailTemplates = []; if (type.mail_template_ids) { mailTemplates = type.mail_template_ids.map((template_id) => { const template = this.getRecords('mail.template', [['id', '=', template_id]])[0]; return { id: template.id, name: template.name, }; }); } return [type.id, type.display_name, mailTemplates]; }), activity_res_ids: _.sortBy(_.pluck(records, 'id'), function (id) { return moment(resIdToDeadline[id]); }), grouped_activities: groupedActivities, }; }, _mockMailBaseModel_MailTrack(model, trackedFieldNamesToField, initialTrackedFieldValues, record) { const trackingValueIds = []; const changedFieldNames = []; for (const fname in trackedFieldNamesToField) { const initialValue = initialTrackedFieldValues[fname]; const newValue = record[fname]; if (initialValue !== newValue) { const tracking = this._mockMailTrackingValue_CreateTrackingValues(initialValue, newValue, fname, trackedFieldNamesToField[fname], model); if (tracking) { trackingValueIds.push(tracking); } changedFieldNames.push(fname); } } return { changedFieldNames, trackingValueIds }; }, /** * Simulates `action_unfollow` on `mail.channel`. * * @private * @param {integer[]} ids */ _mockMailChannelActionUnfollow(ids) { const channel = this.getRecords('mail.channel', [['id', 'in', ids]])[0]; const [channelMember] = this.getRecords('mail.channel.member', [['channel_id', 'in', ids], ['partner_id', '=', this.currentPartnerId]]); if (!channelMember) { return true; } this.pyEnv['mail.channel'].write( [channel.id], { channel_member_ids: [[2, channelMember.id]], }, ); this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.channel/leave', { 'id': channel.id, }); /** * Leave message not posted here because it would send the new message * notification on a separate bus notification list from the unsubscribe * itself which would lead to the channel being pinned again (handler * for unsubscribe is weak and is relying on both of them to be sent * together on the bus). */ // this._mockMailChannelMessagePost(channel.id, { // author_id: this.currentPartnerId, // body: '
left the channel
', // subtype_xmlid: "mail.mt_comment", // }); return true; }, /** * Simulates `add_members` on `mail.channel`. * * @private * @param {integer[]} ids * @param {integer[]} partner_ids */ _mockMailChannelAddMembers(ids, partner_ids) { const [channel] = this.getRecords('mail.channel', [['id', 'in', ids]]); const partners = this.getRecords('res.partner', [['id', 'in', partner_ids]]); for (const partner of partners) { this.pyEnv['mail.channel.member'].create({ channel_id: channel.id, partner_id: partner.id, }); const body = `
invited ${partner.name} to the channel
`; const message_type = "notification"; const subtype_xmlid = "mail.mt_comment"; this._mockMailChannelMessagePost( channel.id, { body, message_type, subtype_xmlid }, ); } this.pyEnv['bus.bus']._sendone(channel, 'mail.channel/joined', { 'channel': this._mockMailChannelChannelInfo([channel.id])[0], 'invited_by_user_id': this.currentUserId, }); }, /** * 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.pyEnv['bus.bus']._sendmany(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. const channelInfos = this._mockMailChannelChannelInfo(ids); const [relatedPartner] = this.pyEnv['res.partner'].searchRead([['id', '=', partner_id]]); for (const channelInfo of channelInfos) { notifications.push([relatedPartner, 'mail.channel/legacy_insert', 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]]); for (const channel of channels) { const channelMessages = this.getRecords('mail.message', [ ['model', '=', 'mail.channel'], ['res_id', '=', channel.id], ]); const lastMessage = channelMessages.reduce((lastMessage, message) => { if (message.id > lastMessage.id) { return message; } return lastMessage; }, channelMessages[0]); if (!lastMessage) { continue; } const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel.id], ['partner_id', '=', this.currentPartnerId]]); this.pyEnv['mail.channel.member'].write( [memberOfCurrentUser.id], { fetched_message_id: lastMessage.id }, ); this.pyEnv['bus.bus']._sendone(channel, 'mail.channel.member/fetched', { 'channel_id': channel.id, 'id': memberOfCurrentUser.id, 'last_message_id': lastMessage.id, 'partner_id': this.currentPartnerId, }); } }, /** * Simulates `channel_fetch_preview` on `mail.channel`. * * @private * @param {integer[]} ids * @returns {Object[]} list of channels previews */ _mockMailChannelChannelFetchPreview(ids) { const channels = this.getRecords('mail.channel', [['id', 'in', ids]]); return channels.map(channel => { const channelMessages = this.getRecords('mail.message', [ ['model', '=', 'mail.channel'], ['res_id', '=', channel.id], ]); const lastMessage = channelMessages.reduce((lastMessage, message) => { if (message.id > lastMessage.id) { return message; } return lastMessage; }, channelMessages[0]); return { id: channel.id, last_message: lastMessage ? this._mockMailMessageMessageFormat([lastMessage.id])[0] : false, }; }); }, /** * Simulates the 'channel_fold' route on `mail.channel`. * In particular sends a notification on the bus. * * @private * @param {number} ids * @param {state} [state] */ _mockMailChannelChannelFold(ids, state) { const channels = this.getRecords('mail.channel', [['id', 'in', ids]]); for (const channel of channels) { const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel.id], ['partner_id', '=', this.currentPartnerId]]); const foldState = state ? state : memberOfCurrentUser.fold_state === 'open' ? 'folded' : 'open'; const vals = { fold_state: foldState, is_minimized: foldState !== 'closed', }; this.pyEnv['mail.channel.member'].write([memberOfCurrentUser.id], vals); this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.thread/insert', { 'id': channel.id, 'model': 'mail.channel', 'serverFoldState': memberOfCurrentUser.fold_state, }); } }, /** * Simulates 'channel_get' on 'mail.channel'. * * @private * @param {integer[]} [partners_to=[]] * @param {boolean} [pin=true] * @returns {Object} */ _mockMailChannelChannelGet(partners_to = [], pin = true) { if (partners_to.length === 0) { return false; } if (!partners_to.includes(this.currentPartnerId)) { partners_to.push(this.currentPartnerId); } const partners = this.getRecords('res.partner', [['id', 'in', partners_to]]); // NOTE: this mock is not complete, which is done for simplicity. // Indeed if a chat already exists for the given partners, the server // is supposed to return this existing chat. But the mock is currently // always creating a new chat, because no test is relying on receiving // an existing chat. const id = this.pyEnv['mail.channel'].create({ channel_member_ids: partners.map(partner => [0, 0, { partner_id: partner.id, }]), channel_type: 'chat', name: partners.map(partner => partner.name).join(", "), }); return this._mockMailChannelChannelInfo([id])[0]; }, /** * Simulates `channel_info` on `mail.channel`. * * @private * @param {integer[]} ids * @returns {Object[]} */ _mockMailChannelChannelInfo(ids) { const channels = this.getRecords('mail.channel', [['id', 'in', ids]]); return channels.map(channel => { const members = this.getRecords('mail.channel.member', [['id', 'in', channel.channel_member_ids]]); const messages = this.getRecords('mail.message', [ ['model', '=', 'mail.channel'], ['res_id', '=', channel.id], ]); const [group_public_id] = this.getRecords('res.groups', [ ['id', '=', channel.group_public_id], ]); const lastMessageId = messages.reduce((lastMessageId, message) => { if (!lastMessageId || message.id > lastMessageId) { return message.id; } return lastMessageId; }, undefined); const messageNeedactionCounter = this.getRecords('mail.notification', [ ['res_partner_id', '=', this.currentPartnerId], ['is_read', '=', false], ['mail_message_id', 'in', messages.map(message => message.id)], ]).length; const channelData = { avatarCacheKey: channel.avatarCacheKey, channel_type: channel.channel_type, id: channel.id, memberCount: channel.member_count, }; const res = Object.assign({}, channel, { last_message_id: lastMessageId, message_needaction_counter: messageNeedactionCounter, authorizedGroupFullName: group_public_id ? group_public_id.name : false, }); const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel.id], ['partner_id', '=', this.currentPartnerId]]); if (memberOfCurrentUser) { Object.assign(res, { is_minimized: memberOfCurrentUser.is_minimized, is_pinned: memberOfCurrentUser.is_pinned, last_interest_dt: memberOfCurrentUser.last_interest_dt, message_unread_counter: memberOfCurrentUser.message_unread_counter, state: memberOfCurrentUser.fold_state || 'open', }); Object.assign(channelData, { custom_channel_name: memberOfCurrentUser.custom_channel_name, serverMessageUnreadCounter: memberOfCurrentUser.message_unread_counter, }); if (memberOfCurrentUser.rtc_inviting_session_id) { res['rtc_inviting_session'] = { 'id': memberOfCurrentUser.rtc_inviting_session_id }; } channelData['channelMembers'] = [['insert', this._mockMailChannelMember_MailChannelMemberFormat([memberOfCurrentUser.id])]]; } if (channel.channel_type !== 'channel') { res['seen_partners_info'] = members.filter(member => member.partner_id).map(member => { return { partner_id: member.partner_id, seen_message_id: member.seen_message_id, fetched_message_id: member.fetched_message_id, }; }); channelData['channelMembers'] = [['insert', this._mockMailChannelMember_MailChannelMemberFormat(members.map(member => member.id))]]; } res.channel = channelData; return res; }); }, /** * Simulates the `channel_pin` method of `mail.channel`. * * @private * @param {number[]} ids * @param {boolean} [pinned=false] */ async _mockMailChannelChannelPin(ids, pinned = false) { const [channel] = this.getRecords('mail.channel', [['id', 'in', ids]]); const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel.id], ['partner_id', '=', this.currentPartnerId], ['is_pinned', '!=', pinned]]); if (memberOfCurrentUser) { this.pyEnv['mail.channel.member'].write( [memberOfCurrentUser.id], { is_pinned: pinned }, ); } if (!pinned) { this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.channel/unpin', { 'id': channel.id, }); } else { this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.channel/legacy_insert', this._mockMailChannelChannelInfo([channel.id])[0]); } }, /** * Simulates the `_channel_seen` method of `mail.channel`. * * @private * @param integer[] ids * @param {integer} last_message_id */ async _mockMailChannel_ChannelSeen(ids, last_message_id) { // Update record const channel_id = ids[0]; if (!channel_id) { throw new Error('Should only be one channel in channel_seen mock params'); } const channel = this.getRecords('mail.channel', [['id', '=', channel_id]])[0]; const messagesBeforeGivenLastMessage = this.getRecords('mail.message', [ ['id', '<=', last_message_id], ['model', '=', 'mail.channel'], ['res_id', '=', channel.id], ]); if (!messagesBeforeGivenLastMessage || messagesBeforeGivenLastMessage.length === 0) { return; } if (!channel) { return; } const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id], ['partner_id', '=', this.currentPartnerId]]); if (memberOfCurrentUser.seen_message_id && memberOfCurrentUser.seen_message_id >= last_message_id) { return; } this._mockMailChannel_SetLastSeenMessage([channel.id], last_message_id); this.pyEnv['bus.bus']._sendone(channel.channel_type === 'chat' ? channel : this.currentPartner, 'mail.channel.member/seen', { 'channel_id': channel.id, 'last_message_id': last_message_id, 'partner_id': this.currentPartnerId, }); }, /** * Simulates `channel_rename` on `mail.channel`. * * @private * @param {integer[]} ids */ _mockMailChannelChannelRename(ids, name) { const channel = this.getRecords('mail.channel', [['id', 'in', ids]])[0]; this.pyEnv['mail.channel'].write( [channel.id], { name }, ); this.pyEnv['bus.bus']._sendone(channel, 'mail.thread/insert', { 'id': channel.id, 'model': 'mail.channel', 'name': name, }); }, /** * Simulates `channel_set_custom_name` on `mail.channel`. * * @private * @param {integer[]} ids */ _mockMailChannelChannelSetCustomName(ids, name) { const channelId = ids[0]; // simulate ensure_one. const [memberIdOfCurrentUser] = this.pyEnv['mail.channel.member'].search([['partner_id', '=', this.currentPartnerId], ['channel_id', '=', channelId]]); this.pyEnv['mail.channel.member'].write( [memberIdOfCurrentUser], { custom_channel_name: name }, ); this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.channel/insert', { 'custom_channel_name': name, 'id': channelId, }); }, /** * Simulates the `create_group` on `mail.channel`. * * @private * @param {integer[]} partners_to * @returns {Object} */ async _mockMailChannelCreateGroup(partners_to) { const partners = this.getRecords('res.partner', [['id', 'in', partners_to]]); const id = this.pyEnv['mail.channel'].create({ channel_type: 'group', channel_member_ids: partners.map(partner => [0, 0, { partner_id: partner.id }]), name: '', }); this._mockMailChannel_broadcast(id, partners.map(partner => partner.id)); return this._mockMailChannelChannelInfo([id])[0]; }, /** * Simulates `execute_command_leave` on `mail.channel`. * * @private */ _mockMailChannelExecuteCommandLeave(args) { const channel = this.getRecords('mail.channel', [['id', 'in', args.args[0]]])[0]; if (channel.channel_type === 'channel') { this._mockMailChannelActionUnfollow([channel.id]); } else { this._mockMailChannelChannelPin(channel.uuid, false); } }, /** * Simulates `execute_command_who` on `mail.channel`. * * @private */ _mockMailChannelExecuteCommandWho(args) { const ids = args.args[0]; const channels = this.getRecords('mail.channel', [['id', 'in', ids]]); for (const channel of channels) { const members = this.getRecords('mail.channel.member', [['id', 'in', channel.channel_member_ids]]); const otherPartnerIds = members.filter(member => member.partner_id && member.partner_id !== this.currentPartnerId).map(member => member.partner_id); const otherPartners = this.getRecords('res.partner', [['id', 'in', otherPartnerIds]]); let message = "You are alone in this channel."; if (otherPartners.length > 0) { message = `Users in this channel: ${otherPartners.map(partner => partner.name).join(', ')} and you`; } this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.channel/transient_message', { 'body': `${message}`, 'model': 'mail.channel', 'res_id': channel.id, }); } }, /** * Simulates `get_mention_suggestions` on `mail.channel`. * * @private * @returns {Array[]} */ _mockMailChannelGetMentionSuggestions(args) { const search = args.kwargs.search || ''; const limit = args.kwargs.limit || 8; /** * Returns the given list of channels after filtering it according to * the logic of the Python method `get_mention_suggestions` for the * given search term. The result is truncated to the given limit and * formatted as expected by the original method. * * @param {Object[]} channels * @param {string} search * @param {integer} limit * @returns {Object[]} */ const mentionSuggestionsFilter = function (channels, search, limit) { const matchingChannels = channels .filter(channel => { // no search term is considered as return all if (!search) { return true; } // otherwise name or email must match search term if (channel.name && channel.name.includes(search)) { return true; } return false; }).map(channel => { // expected format return { authorizedGroupFullName: channel.group_public_id ? channel.group_public_id.name : false, channel: { channel_type: channel.channel_type, id: channel.id, }, id: channel.id, name: channel.name, }; }); // reduce results to max limit matchingChannels.length = Math.min(matchingChannels.length, limit); return matchingChannels; }; const mentionSuggestions = mentionSuggestionsFilter(this.models['mail.channel'].records, search, limit); return mentionSuggestions; }, /** * Simulates `write` on `mail.channel` when `image_128` changes. * * @param {integer} id */ _mockMailChannelWriteImage128(id) { this.pyEnv['mail.channel'].write( [id], { avatarCacheKey: moment.utc().format("YYYYMMDDHHmmss"), }, ); const channel = this.pyEnv['mail.channel'].searchRead([['id', '=', id]])[0]; this.pyEnv['bus.bus']._sendone(channel, 'mail.channel/insert', { 'avatarCacheKey': channel.avatarCacheKey, 'id': id, }); }, /** * Simulates `message_post` on `mail.channel`. * * @private * @param {integer} id * @param {Object} kwargs * @param {Object} [context] * @returns {integer|false} */ _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') { const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel.id], ['partner_id', '=', this.currentPartnerId]]); this.pyEnv['mail.channel.member'].write( [memberOfCurrentUser.id], { last_interest_dt: datetime_to_str(new Date()), is_pinned: true, }, ); } const messageData = this._mockMailThreadMessagePost( 'mail.channel', [id], Object.assign(kwargs, { message_type, }), context, ); if (kwargs.author_id === this.currentPartnerId) { this._mockMailChannel_SetLastSeenMessage([channel.id], messageData.id); } // simulate compute of message_unread_counter const otherMembers = this.getRecords('mail.channel.member', [['channel_id', '=', channel.id], ['partner_id', '!=', this.currentPartnerId]]); for (const member of otherMembers) { this.pyEnv['mail.channel.member'].write( [member.id], { message_unread_counter: member.message_unread_counter + 1 }, ); } return messageData; }, /** * Simulates `load_more_members` on `mail.channel`. * * @private * @param {integer[]} channel_ids * @param {integer[]} known_member_ids */ _mockMailChannelLoadMoreMembers(channel_ids, known_member_ids) { const members = this.pyEnv['mail.channel.member'].searchRead([ ['id', 'not in', known_member_ids], ['channel_id', 'in', channel_ids], ], { limit: 100 }); const memberCount = this.pyEnv['mail.channel.member'].searchCount([ ['channel_id', 'in', channel_ids], ]); const membersData = []; for (const member of members) { let persona; if (member.partner_id) { const [partner] = this.pyEnv['res.partner'].searchRead( [['id', '=', member.partner_id[0]]], { fields: ['id', 'name', 'im_status'] } ); persona = { 'partner': { 'id': partner.id, 'name': partner.name, 'im_status': partner.im_status, }, }; } if (member.guest_id) { const [guest] = this.pyEnv['mail.guest'].searchRead( [['id', '=', member.guest_id[0]]], { fields: ['id', 'name'] } ); persona = { 'guest': { 'id': guest.id, 'name': guest.name, }, }; } membersData.push({ 'id': member.id, 'persona': persona, }); } return { channelMembers: [['insert', membersData]], memberCount, }; }, /** * Simulates the `_set_last_seen_message` method of `mail.channel`. * * @private * @param {integer[]} ids * @param {integer} message_id */ _mockMailChannel_SetLastSeenMessage(ids, message_id) { const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', 'in', ids], ['partner_id', '=', this.currentPartnerId]]); this.pyEnv['mail.channel.member'].write([memberOfCurrentUser.id], { fetched_message_id: message_id, seen_message_id: message_id, }); }, /** * Simulates the `_link_preview_format` method of `mail.link.preview`. * * @private * @param {object} linkPreview */ _mockMailLinkPreviewFormat(linkPreview) { return { id: linkPreview.id, image_mimetype: linkPreview.image_mimetype, og_description: linkPreview.og_description, og_image: linkPreview.og_image, og_mimetype: linkPreview.og_mimetype, og_title: linkPreview.og_title, og_type: linkPreview.og_type, source_url: linkPreview.source_url }; }, /** * Simulates `mark_all_as_read` on `mail.message`. * * @private * @param {Array[]} [domain] * @returns {integer[]} */ _mockMailMessageMarkAllAsRead(domain) { const notifDomain = [ ['res_partner_id', '=', this.currentPartnerId], ['is_read', '=', false], ]; if (domain) { const messages = this.getRecords('mail.message', domain); const ids = messages.map(messages => messages.id); this._mockMailMessageSetMessageDone(ids); return ids; } const notifications = this.getRecords('mail.notification', notifDomain); this.pyEnv['mail.notification'].write( notifications.map(notification => notification.id), { is_read: true }, ); const messageIds = []; for (const notification of notifications) { if (!messageIds.includes(notification.mail_message_id)) { messageIds.push(notification.mail_message_id); } } const messages = this.getRecords('mail.message', [['id', 'in', messageIds]]); // simulate compute that should be done based on notifications for (const message of messages) { this.pyEnv['mail.message'].write( [message.id], { needaction: false, needaction_partner_ids: message.needaction_partner_ids.filter( partnerId => partnerId !== this.currentPartnerId ), }, ); } this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.message/mark_as_read', { 'message_ids': messageIds, 'needaction_inbox_counter': this._mockResPartner_GetNeedactionCount(this.currentPartnerId), }); return messageIds; }, /** * Simulates `_message_fetch` on `mail.message`. * * @private * @param {Array[]} domain * @param {integer} [max_id] * @param {integer} [min_id] * @param {integer} [limit=30] * @returns {Object[]} */ _mockMailMessage_MessageFetch(domain, max_id, min_id, limit = 30) { if (max_id) { domain.push(['id', '<', max_id]); } if (min_id) { domain.push(['id', '>', min_id]); } let messages = this.getRecords('mail.message', domain); // sorted from highest ID to lowest ID (i.e. from youngest to oldest) messages.sort(function (m1, m2) { return m1.id < m2.id ? 1 : -1; }); // pick at most 'limit' messages messages.length = Math.min(messages.length, limit); return messages; }, /** * Simulates `message_format` on `mail.message`. * * @private * @returns {integer[]} ids * @returns {Object[]} */ _mockMailMessageMessageFormat(ids) { const messages = this.getRecords('mail.message', [['id', 'in', ids]]); // sorted from highest ID to lowest ID (i.e. from most to least recent) messages.sort(function (m1, m2) { return m1.id < m2.id ? 1 : -1; }); return messages.map(message => { const thread = message.model && this.getRecords(message.model, [ ['id', '=', message.res_id], ])[0]; let formattedAuthor; if (message.author_id) { const [author] = this.getRecords('res.partner', [['id', '=', message.author_id]], { active_test: false }); formattedAuthor = { 'id': author.id, 'name': author.name, }; } else { formattedAuthor = [['clear']]; } const attachments = this.getRecords('ir.attachment', [ ['id', 'in', message.attachment_ids], ]); const formattedAttachments = this._mockIrAttachment_attachmentFormat(attachments.map(attachment => attachment.id)); const allNotifications = this.getRecords('mail.notification', [ ['mail_message_id', '=', message.id], ]); const historyPartnerIds = allNotifications .filter(notification => notification.is_read) .map(notification => notification.res_partner_id); const needactionPartnerIds = allNotifications .filter(notification => !notification.is_read) .map(notification => notification.res_partner_id); let notifications = this._mockMailNotification_FilteredForWebClient( allNotifications.map(notification => notification.id) ); notifications = this._mockMailNotification_NotificationFormat( notifications.map(notification => notification.id) ); const trackingValueIds = this.getRecords('mail.tracking.value', [ ['id', 'in', message.tracking_value_ids], ]); const formattedTrackingValues = this._mockMailTrackingValue_TrackingValueFormat(trackingValueIds); const partners = this.getRecords( 'res.partner', [['id', 'in', message.partner_ids]], ); const linkPreviews = this.getRecords('mail.link.preview', [ ['id', 'in', message.link_preview_ids], ]); const linkPreviewsFormatted = linkPreviews.map(linkPreview => this._mockMailLinkPreviewFormat(linkPreview)); const response = Object.assign({}, message, { attachment_ids: formattedAttachments, author: formattedAuthor, history_partner_ids: historyPartnerIds, linkPreviews: linkPreviewsFormatted, needaction_partner_ids: needactionPartnerIds, notifications, parentMessage: message.parent_id ? this._mockMailMessageMessageFormat([message.parent_id])[0] : false, recipients: partners.map(p => ({ id: p.id, name: p.name })), record_name: thread && (thread.name !== undefined ? thread.name : thread.display_name), trackingValues: formattedTrackingValues, }); delete response['author_id']; if (message.subtype_id) { const subtype = this.getRecords('mail.message.subtype', [ ['id', '=', message.subtype_id], ])[0]; response.subtype_description = subtype.description; } if (message.author_guest_id) { const [guest] = this.pyEnv['mail.guest'].searchRead([['id', '=', message.author_guest_id]]); response['guestAuthor'] = { id: guest.id, name: guest.name }; } return response; }); }, /** * Simulates `_message_notification_format` on `mail.message`. * * @private * @returns {integer[]} ids * @returns {Object[]} */ _mockMailMessage_MessageNotificationFormat(ids) { const messages = this.getRecords('mail.message', [['id', 'in', ids]]); return messages.map(message => { let notifications = this.getRecords('mail.notification', [ ['mail_message_id', '=', message.id], ]); notifications = this._mockMailNotification_FilteredForWebClient( notifications.map(notification => notification.id) ); notifications = this._mockMailNotification_NotificationFormat( notifications.map(notification => notification.id) ); return { 'date': message.date, 'id': message.id, 'message_type': message.message_type, 'model': message.model, 'notifications': notifications, 'res_id': message.res_id, 'res_model_name': message.res_model_name, }; }); }, /** * Simulates `set_message_done` on `mail.message`, which turns provided * needaction message to non-needaction (i.e. they are marked as read from * from the Inbox mailbox). Also notify on the longpoll bus that the * messages have been marked as read, so that UI is updated. * * @private * @param {integer[]} ids */ _mockMailMessageSetMessageDone(ids) { const messages = this.getRecords('mail.message', [['id', 'in', ids]]); const notifications = this.getRecords('mail.notification', [ ['res_partner_id', '=', this.currentPartnerId], ['is_read', '=', false], ['mail_message_id', 'in', messages.map(messages => messages.id)] ]); this.pyEnv['mail.notification'].write( notifications.map(notification => notification.id), { is_read: true }, ); // simulate compute that should be done based on notifications for (const message of messages) { this.pyEnv['mail.message'].write( [message.id], { needaction: false, needaction_partner_ids: message.needaction_partner_ids.filter( partnerId => partnerId !== this.currentPartnerId ), }, ); this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.message/mark_as_read', { 'message_ids': [message.id], 'needaction_inbox_counter': this._mockResPartner_GetNeedactionCount(this.currentPartnerId), }); } }, /** * Simulates `toggle_message_starred` on `mail.message`. * * @private * @returns {integer[]} ids */ _mockMailMessageToggleMessageStarred(ids) { const messages = this.getRecords('mail.message', [['id', 'in', ids]]); for (const message of messages) { const wasStared = message.starred_partner_ids.includes(this.currentPartnerId); this.pyEnv['mail.message'].write( [message.id], { starred_partner_ids: [[wasStared ? 3 : 4, this.currentPartnerId]] } ); this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.message/toggle_star', { 'message_ids': [message.id], 'starred': !wasStared, }); } }, /** * Simulates `unstar_all` on `mail.message`. * * @private */ _mockMailMessageUnstarAll() { const messages = this.getRecords('mail.message', [ ['starred_partner_ids', 'in', this.currentPartnerId], ]); this.pyEnv['mail.message'].write( messages.map(message => message.id), { starred_partner_ids: [[3, this.currentPartnerId]] } ); this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.message/toggle_star', { 'message_ids': messages.map(message => message.id), 'starred': false, }); }, /** * Simulates `_filtered_for_web_client` on `mail.notification`. * * @private * @returns {integer[]} ids * @returns {Object[]} */ _mockMailNotification_FilteredForWebClient(ids) { const notifications = this.getRecords('mail.notification', [ ['id', 'in', ids], ]); return notifications.filter(notification => { const partner = this.getRecords('res.partner', [['id', '=', notification.res_partner_id]])[0]; if (['bounce', 'exception', 'canceled'].includes(notification.notification_status) || (partner && partner.partner_share)) { return true; } const message = this.getRecords('mail.message', [['id', '=', notification.mail_message_id]])[0]; const subtypes = (message.subtype_id) ? this.getRecords('mail.message.subtype', [['id', '=', message.subtype_id]]) : []; return (subtypes.length == 0) || subtypes[0].track_recipients; }); }, /** * Simulates `_notification_format` on `mail.notification`. * * @private * @returns {integer[]} ids * @returns {Object[]} */ _mockMailNotification_NotificationFormat(ids) { const notifications = this.getRecords('mail.notification', [['id', 'in', ids]]); return notifications.map(notification => { const partner = this.getRecords('res.partner', [['id', '=', notification.res_partner_id]])[0]; return { 'id': notification.id, 'notification_type': notification.notification_type, 'notification_status': notification.notification_status, 'failure_type': notification.failure_type, 'res_partner_id': partner ? [partner && partner.id, partner && partner.display_name] : undefined, }; }); }, /** * 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_add_suggested_recipient` on `mail.thread`. * * @private * @param {string} model * @param {integer[]} ids * @param {Object} result * @param {Object} [param3={}] * @param {string} [param3.email] * @param {integer} [param3.partner] * @param {string} [param3.reason] * @returns {Object} */ _mockMailThread_MessageAddSuggestedRecipient(model, ids, result, { email, partner, reason = '' } = {}) { const record = this.getRecords(model, [['id', 'in', 'ids']])[0]; // for simplicity result[record.id].push([partner, email, reason]); return result; }, /** * Simulates `_message_get_suggested_recipients` on `mail.thread`. * * @private * @param {string} model * @param {integer[]} ids * @returns {Object} */ _mockMailThread_MessageGetSuggestedRecipients(model, ids) { if (model === 'res.fake') { return this._mockResFake_MessageGetSuggestedRecipients(model, ids); } const result = ids.reduce((result, id) => result[id] = [], {}); const records = this.getRecords(model, [['id', 'in', ids]]); for (const record in records) { if (record.user_id) { const user = this.getRecords('res.users', [['id', '=', record.user_id]]); if (user.partner_id) { const reason = this.models[model].fields['user_id'].string; this._mockMailThread_MessageAddSuggestedRecipient(result, user.partner_id, reason); } } } return result; }, /** * Simulates `_message_get_suggested_recipients` on `res.fake`. * * @private * @param {string} model * @param {integer[]} ids * @returns {Object} */ _mockResFake_MessageGetSuggestedRecipients(model, ids) { const result = {}; const records = this.getRecords(model, [['id', 'in', ids]]); for (const record of records) { result[record.id] = []; if (record.email_cc) { result[record.id].push([ false, record.email_cc, undefined, 'CC email', ]); } const partners = this.getRecords( 'res.partner', [['id', 'in', record.partner_ids]], ); if (partners.length) { for (const partner of partners) { result[record.id].push([ partner.id, partner.display_name, undefined, 'Email partner', ]); } } } return result; }, /** * Simulates `message_post` on `mail.thread`. * * @private * @param {string} model * @param {integer[]} ids * @param {Object} kwargs * @param {Object} [context] * @returns {Object} */ _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], ['res_model', '=', 'mail.compose.message'], ['res_id', '=', 0], ]); const attachmentIds = attachments.map(attachment => attachment.id); this.pyEnv['ir.attachment'].write( attachmentIds, { res_id: id, res_model: model, }, ); 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, res_id: id, }); delete values.subtype_xmlid; const messageId = this.pyEnv['mail.message'].create(values); this._mockMailThread_NotifyThread(model, ids, messageId); return this._mockMailMessageMessageFormat([messageId])[0]; }, /** * Simulates `message_subscribe` on `mail.thread`. * * @private * @param {string} model not in server method but necessary for thread mock * @param {integer[]} ids * @param {integer[]} partner_ids * @param {integer[]} subtype_ids * @returns {boolean} */ _mockMailThreadMessageSubscribe(model, ids, partner_ids, subtype_ids) { for (const id of ids) { for (const partner_id of partner_ids) { const followerId = this.pyEnv['mail.followers'].create({ is_active: true, partner_id, res_id: id, res_model: model, subtype_ids: subtype_ids, }); this.pyEnv['res.partner'].write([partner_id], { message_follower_ids: [followerId], }); } } }, /** * 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 = []; if (model === 'mail.channel') { // members const channels = this.getRecords('mail.channel', [['id', '=', message.res_id]]); for (const channel of channels) { // notify update of last_interest_dt const now = datetime_to_str(new Date()); const members = this.getRecords('mail.channel.member', [['id', 'in', channel.channel_member_ids]]); this.pyEnv['mail.channel.member'].write( members.map(member => member.id), { last_interest_dt: now }, ); for (const member of members) { // simplification, send everything on the current user "test" bus, but it should send to each member instead notifications.push([member, 'mail.channel/last_interest_dt_changed', { 'id': channel.id, 'isServerPinned': member.is_pinned, 'last_interest_dt': member.last_interest_dt, }]); } notifications.push([channel, 'mail.channel/new_message', { 'id': channel.id, 'message': messageFormat, }]); } } this.pyEnv['bus.bus']._sendmany(notifications); }, /** * Simulates `message_unsubscribe` on `mail.thread`. * * @private * @param {string} model not in server method but necessary for thread mock * @param {integer[]} ids * @param {integer[]} partner_ids * @returns {boolean|undefined} */ _mockMailThreadMessageUnsubscribe(model, ids, partner_ids) { if (!partner_ids) { return true; } const followers = this.getRecords('mail.followers', [ ['res_model', '=', model], ['res_id', 'in', ids], ['partner_id', 'in', partner_ids || []], ]); this.pyEnv['mail.followers'].unlink(followers.map(follower => follower.id)); }, /** * Simulates `_message_track` on `mail.thread` */ _mockMailThread_MessageTrack(modelName, trackedFieldNames, initialTrackedFieldValuesByRecordId) { const trackFieldNamesToField = this.mockFieldsGet(modelName, trackedFieldNames); const tracking = {}; const records = this.models[modelName].records; for (const record of records) { tracking[record.id] = this._mockMailBaseModel_MailTrack(modelName, trackFieldNamesToField, initialTrackedFieldValuesByRecordId[record.id], record); } for (const record of records) { const { trackingValueIds, changedFieldNames } = tracking[record.id] || {}; if (!changedFieldNames || !changedFieldNames.length) { continue; } const changedFieldsInitialValues = {}; const initialFieldValues = initialTrackedFieldValuesByRecordId[record.id]; for (const fname in changedFieldNames) { changedFieldsInitialValues[fname] = initialFieldValues[fname]; } const subtype = this._mockMailThread_TrackSubtype(changedFieldsInitialValues); this._mockMailThreadMessagePost(modelName, [record.id], { subtype_id: subtype.id, tracking_value_ids: trackingValueIds, }); } return tracking; }, /** * Simulates `_track_finalize` on `mail.thread` */ _mockMailThread_TrackFinalize(model, initialTrackedFieldValuesByRecordId) { this._mockMailThread_MessageTrack( model, this._mockMailThread_TrackGetFields(model), initialTrackedFieldValuesByRecordId, ); }, /** * Simulates `_track_get_fields` on `mail.thread` */ _mockMailThread_TrackGetFields(model) { return Object.entries(this.models[model].fields).reduce((prev, next) => { if (next[1].tracking) { prev.push(next[0]); } return prev; }, []); }, /** * Simulates `_track_prepare` on `mail.thread` */ _mockMailThread_TrackPrepare(model) { const trackedFieldNames = this._mockMailThread_TrackGetFields(model); if (!trackedFieldNames.length) { return; } const initialTrackedFieldValuesByRecordId = {}; for (const record of this.models[model].records) { const values = {}; initialTrackedFieldValuesByRecordId[record.id] = values; for (const fname of trackedFieldNames) { values[fname] = record[fname]; } } return initialTrackedFieldValuesByRecordId; }, /** * Simulates `_track_subtype` on `mail.thread` */ _mockMailThread_TrackSubtype(initialFieldValuesByRecordId) { return false; }, /** * Simulates `create_tracking_values` on `mail.tracking.value` */ _mockMailTrackingValue_CreateTrackingValues(initialValue, newValue, fieldName, field, modelName) { let isTracked = true; const irField = this.models['ir.model.fields'].records.find(field => field.model === modelName && field.name === fieldName); if (!irField) { return; } const values = { field: irField['id'], field_desc: field['string'], field_type: field['type'] }; switch (values.field_type) { case 'char': case 'datetime': case 'float': case 'integer': case 'monetary': case 'text': values[`old_value_${values.field_type}`] = initialValue; values[`new_value_${values.field_type}`] = newValue; break; case 'date': values['old_value_datetime'] = initialValue; values['new_value_datetime'] = newValue; break; case 'boolean': values['old_value_integer'] = initialValue ? 1 : 0; values['new_value_integer'] = newValue ? 1 : 0; break; case 'selection': values['old_value_char'] = initialValue; values['new_value_char'] = newValue; break; case 'many2one': initialValue = initialValue ? this.pyEnv[field.relation].searchRead([['id', '=', initialValue]])[0] : initialValue; newValue = newValue ? this.pyEnv[field.relation].searchRead([['id', '=', newValue]])[0] : newValue; values['old_value_integer'] = initialValue ? initialValue.id : 0; values['new_value_integer'] = newValue ? newValue.id : 0; values['old_value_char'] = initialValue ? initialValue.display_name : ''; values['new_value_char'] = newValue ? newValue.display_name : ''; break; default: isTracked = false; } if (isTracked) { return this.pyEnv['mail.tracking.value'].create(values); } return false; }, /** * Simulates `_tracking_value_format` on `mail.tracking.value` */ _mockMailTrackingValue_TrackingValueFormat(tracking_value_ids) { const trackingValues = tracking_value_ids.map(tracking => ({ changedField: tracking.field_desc, id: tracking.id, newValue: { fieldType: tracking.field_type, value: this._mockMailTrackingValue_GetDisplayValue(tracking, 'new') }, oldValue: { fieldType: tracking.field_type, value: this._mockMailTrackingValue_GetDisplayValue(tracking, 'old') }, })); return trackingValues; }, /** * Simulates `_get_display_value` on `mail.tracking.value` */ _mockMailTrackingValue_GetDisplayValue(record, type) { switch (record.field_type) { case 'float': case 'integer': case 'monetary': case 'text': return record[`${type}_value_${record.field_type}`]; case 'datetime': if (record[`${type}_value_datetime`]) { const datetime = record[`${type}_value_datetime`]; return `${datetime}Z`; } else { return record[`${type}_value_datetime`]; } case 'date': if (record[`${type}_value_datetime`]) { return record[`${type}_value_datetime`]; } else { return record[`${type}_value_datetime`]; } case 'boolean': return !!record[`${type}_value_integer`]; default: return record[`${type}_value_char`]; } }, /** * Simulates `_get_channels_as_member` on `res.partner`. * * @private * @param {integer[]} ids * @returns {Object} */ _mockResPartner_GetChannelsAsMember(ids) { const partner = this.getRecords('res.partner', [['id', 'in', ids]])[0]; const channelMembers = this.getRecords('mail.channel.member', [['partner_id', '=', partner.id]]); const channels = this.getRecords('mail.channel', [ ['channel_type', 'in', ['channel', 'group']], ['channel_member_ids', 'in', channelMembers.map(member => member.id)], ]); const directMessagesMembers = this.getRecords('mail.channel.member', [['partner_id', '=', partner.id], ['is_pinned', '=', true]]); const directMessages = this.getRecords('mail.channel', [ ['channel_type', '=', 'chat'], ['channel_member_ids', 'in', directMessagesMembers.map(member => member.id)], ]); return [ ...channels, ...directMessages, ]; }, /** * Simulates `systray_get_activities` on `res.users`. * * @private */ _mockResUsersSystrayGetActivities() { const activities = this.pyEnv['mail.activity'].searchRead([]); const userActivitiesByModelName = {}; for (const activity of activities) { const today = date_to_str(new Date()); if (today === activity['date_deadline']) { activity['states'] = 'today'; } else if (today > activity['date_deadline']) { activity['states'] = 'overdue'; } else { activity['states'] = 'planned'; } } for (const activity of activities) { const modelName = activity['res_model']; if (!userActivitiesByModelName[modelName]) { userActivitiesByModelName[modelName] = { id: modelName, // for simplicity model: modelName, name: modelName, overdue_count: 0, planned_count: 0, today_count: 0, total_count: 0, type: 'activity', }; } userActivitiesByModelName[modelName][`${activity['states']}_count`] += 1; userActivitiesByModelName[modelName]['total_count'] += 1; userActivitiesByModelName[modelName].actions = [{ icon: 'fa-clock-o', name: 'Summary', }]; } return Object.values(userActivitiesByModelName); }, /** * Simulates `_find_or_create_for_user` on `res.users.settings`. * * @param {Object} user * @returns {Object} */ _mockResUsersSettings_FindOrCreateForUser(user_id) { let settings = this.getRecords('res.users.settings', [['user_id', '=', user_id]])[0]; if (!settings) { const settingsId = this.pyEnv['res.users.settings'].create({ user_id: user_id }); settings = this.getRecords('res.users.settings', [['id', '=', settingsId]])[0]; } return settings; }, /** * @param {integer} id * @param {string[]} [fieldsToFormat] * @returns {Object} */ _mockResUsersSettings_ResUsersSettingsFormat(id, fieldsToFormat) { const [settings] = this.getRecords('res.users.settings', [['id', '=', id]]); const ormAutomaticFields = new Set(['create_date', 'create_uid', 'display_name', 'name', 'write_date', 'write_uid', '__last_update']); const filterPredicate = fieldsToFormat ? ([fieldName]) => fieldsToFormat.includes(fieldName) : ([fieldName]) => !ormAutomaticFields.has(fieldName); const res = Object.fromEntries(Object.entries(settings).filter(filterPredicate)); if (Object.prototype.hasOwnProperty.call(res, 'user_id')) { res.user_id = { id: settings.user_id }; } if (Object.prototype.hasOwnProperty.call(res, 'volume_settings_ids')) { const volumeSettings = this._mockResUsersSettingsVolumes_DiscussUsersSettingsVolumeFormat(settings.volume_settings_ids); res.volume_settings_ids = [['insert', volumeSettings]]; } return res; }, /** * Simulates `set_res_users_settings` on `res.users.settings`. * * @param {integer} id * @param {Object} newSettings */ _mockResUsersSettingsSetResUsersSettings(id, newSettings) { const oldSettings = this.getRecords('res.users.settings', [['id', '=', id]])[0]; const changedSettings = {}; for (const setting in newSettings) { if (setting in oldSettings && newSettings[setting] !== oldSettings[setting]) { changedSettings[setting] = newSettings[setting]; } } this.pyEnv['res.users.settings'].write( [id], changedSettings, ); const [relatedUser] = this.pyEnv['res.users'].searchRead([['id', '=', oldSettings.user_id]]); const [relatedPartner] = this.pyEnv['res.partner'].searchRead([['id', '=', relatedUser.partner_id]]); this.pyEnv['bus.bus']._sendone(relatedPartner, 'res.users.settings/insert', { ...changedSettings, id }); }, _mockResUsersSettingsVolumes_DiscussUsersSettingsVolumeFormat(ids) { const volumeSettingsRecords = this.getRecords('res.users.settings.volumes', [['id', 'in', ids]]); return volumeSettingsRecords.map(volumeSettingsRecord => { const [relatedGuest] = this.getRecords('mail.guest', [['id', '=', volumeSettingsRecord.guest_id]]); const [relatedPartner] = this.getRecords('res.partner', [['id', '=', volumeSettingsRecord.partner_id]]); return { guest_id: relatedGuest ? { id: relatedGuest.id, name: relatedGuest.name } : [['clear']], id: volumeSettingsRecord.id, partner_id: relatedPartner ? { id: relatedPartner.id, name: relatedPartner.name } : [['clear']], volume: volumeSettingsRecord.volume, }; }); }, /** * Simulates `get_mention_suggestions` on `res.partner`. * * @private * @returns {Array[]} */ _mockResPartnerGetMentionSuggestions(args) { const search = (args.args[0] || args.kwargs.search || '').toLowerCase(); const limit = args.args[1] || args.kwargs.limit || 8; const channel_id = args.args[2] || args.kwargs.channel_id; /** * Returns the given list of partners after filtering it according to * the logic of the Python method `get_mention_suggestions` for the * given search term. The result is truncated to the given limit and * formatted as expected by the original method. * * @param {Object[]} partners * @param {string} search * @param {integer} limit * @returns {Object[]} */ const mentionSuggestionsFilter = (partners, search, limit) => { const matchingPartners = [...this._mockResPartnerMailPartnerFormat( partners .filter(partner => { if (channel_id) { const [member] = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id], ['partner_id', '=', partner.id]]); if (!member) { return false; } } // no search term is considered as return all if (!search) { return true; } // otherwise name or email must match search term if (partner.name && partner.name.toLowerCase().includes(search)) { return true; } if (partner.email && partner.email.toLowerCase().includes(search)) { return true; } return false; }) .map(partner => partner.id) ).values()].map(partnerFormat => { if (channel_id) { const [member] = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id], ['partner_id', '=', partnerFormat.id]]); partnerFormat['persona'] = { 'channelMembers': [['insert', this._mockMailChannelMember_MailChannelMemberFormat([member.id])]], }; } return partnerFormat; }); // reduce results to max limit matchingPartners.length = Math.min(matchingPartners.length, limit); return matchingPartners; }; // add main suggestions based on users const partnersFromUsers = this.getRecords('res.users', []) .map(user => this.getRecords('res.partner', [['id', '=', user.partner_id]])[0]) .filter(partner => partner); const mainMatchingPartners = mentionSuggestionsFilter(partnersFromUsers, search, limit); let extraMatchingPartners = []; // if not enough results add extra suggestions based on partners const remainingLimit = limit - mainMatchingPartners.length; if (mainMatchingPartners.length < limit) { const partners = this.getRecords('res.partner', [['id', 'not in', mainMatchingPartners.map(partner => partner.id)]]); extraMatchingPartners = mentionSuggestionsFilter(partners, search, remainingLimit); } return mainMatchingPartners.concat(extraMatchingPartners); }, /** * Simulates `_get_needaction_count` on `res.partner`. * * @private * @param {integer} id * @returns {integer} */ _mockResPartner_GetNeedactionCount(id) { const partner = this.getRecords('res.partner', [['id', '=', id]])[0]; return this.getRecords('mail.notification', [ ['res_partner_id', '=', partner.id], ['is_read', '=', false], ]).length; }, /** * Simulates `im_search` on `res.partner`. * * @private * @param {string} [name=''] * @param {integer} [limit=20] * @returns {Object[]} */ _mockResPartnerImSearch(name = '', limit = 20) { name = name.toLowerCase(); // simulates ILIKE // simulates domain with relational parts (not supported by mock server) const matchingPartners = this.getRecords('res.users', []) .filter(user => { const partner = this.getRecords('res.partner', [['id', '=', user.partner_id]])[0]; // user must have a partner if (!partner) { return false; } // not current partner if (partner.id === this.currentPartnerId) { return false; } // no name is considered as return all if (!name) { return true; } if (partner.name && partner.name.toLowerCase().includes(name)) { return true; } return false; }).map(user => { const partner = this.getRecords('res.partner', [['id', '=', user.partner_id]])[0]; return { id: partner.id, name: partner.name, }; }).sort((a, b) => (a.name === b.name) ? (a.id - b.id) : (a.name > b.name) ? 1 : -1); matchingPartners.length = Math.min(matchingPartners.length, limit); return [...this._mockResPartnerMailPartnerFormat(matchingPartners.map(partner => partner.id)).values()]; }, /** * Simulates `mail_partner_format` on `res.partner`. * * @private * @returns {integer[]} ids * @returns {Map} */ _mockResPartnerMailPartnerFormat(ids) { const partners = this.getRecords( 'res.partner', [['id', 'in', ids]], { active_test: false } ); // Servers is also returning `is_internal_user` but not // done here for simplification. return new Map(partners.map(partner => { const users = this.getRecords('res.users', [['id', 'in', partner.user_ids]]); const internalUsers = users.filter(user => !user.share); let mainUser; if (internalUsers.length > 0) { mainUser = internalUsers[0]; } else if (users.length > 0) { mainUser = users[0]; } return [partner.id, { "active": partner.active, "email": partner.email, "id": partner.id, "im_status": partner.im_status, "name": partner.name, "user": mainUser ? { 'id': mainUser.id, } : [['clear']], }]; })); }, /** * Simulates `search_for_channel_invite` on `res.partner`. * * @private * @param {string} [search_term=''] * @param {integer} [channel_id] * @param {integer} [limit=30] * @returns {Object[]} */ _mockResPartnerSearchForChannelInvite(search_term, channel_id, limit = 30) { search_term = search_term.toLowerCase(); // simulates ILIKE // simulates domain with relational parts (not supported by mock server) const matchingPartners = [...this._mockResPartnerMailPartnerFormat( this.getRecords('res.users', []) .filter(user => { const partner = this.getRecords('res.partner', [['id', '=', user.partner_id]])[0]; // user must have a partner if (!partner) { return false; } // not current partner if (partner.id === this.currentPartnerId) { return false; } // no name is considered as return all if (!search_term) { return true; } if (partner.name && partner.name.toLowerCase().includes(search_term)) { return true; } return false; }) .map(user => user.partner_id) ).values()]; const count = matchingPartners.length; matchingPartners.length = Math.min(count, limit); return { count, partners: matchingPartners }; }, /** * Simulates `_message_fetch_failed` on `res.partner`. * * @private * @param {integer} id * @returns {Object[]} */ _mockResPartner_MessageFetchFailed(id) { const partner = this.getRecords('res.partner', [['id', '=', id]])[0]; const messages = this.getRecords('mail.message', [ ['author_id', '=', partner.id], ['res_id', '!=', 0], ['model', '!=', false], ['message_type', '!=', 'user_notification'], ]).filter(message => { // Purpose is to simulate the following domain on mail.message: // ['notification_ids.notification_status', 'in', ['bounce', 'exception']], // But it's not supported by getRecords domain to follow a relation. const notifications = this.getRecords('mail.notification', [ ['mail_message_id', '=', message.id], ['notification_status', 'in', ['bounce', 'exception']], ]); return notifications.length > 0; }); return this._mockMailMessage_MessageNotificationFormat(messages.map(message => message.id)); }, /** * Simulates `_init_messaging` on `res.users`. * * @private * @param {integer[]} ids * @returns {Object} */ _mockResUsers_InitMessaging(ids) { const user = this.getRecords('res.users', [['id', 'in', ids]])[0]; const userSettings = this._mockResUsersSettings_FindOrCreateForUser(user.id); return { channels: this._mockMailChannelChannelInfo(this._mockResPartner_GetChannelsAsMember(user.partner_id).map(channel => channel.id)), current_partner: this._mockResPartnerMailPartnerFormat(user.partner_id).get(user.partner_id), current_user_id: this.currentUserId, current_user_settings: this._mockResUsersSettings_ResUsersSettingsFormat(userSettings.id), menu_id: false, // not useful in QUnit tests needaction_inbox_counter: this._mockResPartner_GetNeedactionCount(user.partner_id), partner_root: this._mockResPartnerMailPartnerFormat(this.partnerRootId).get(this.partnerRootId), shortcodes: this.pyEnv['mail.shortcode'].searchRead([], { fields: ['source', 'substitution'] }), starred_counter: this.getRecords('mail.message', [['starred_partner_ids', 'in', user.partner_id]]).length, }; }, /** * Simulate the `notify_cancel_by_type` on `mail.thread` . * Note that this method is overridden by snailmail module but not simulated here. */ _mockMailThreadNotifyCancelByType(model, notificationType) { // Query matching notifications const notifications = this.getRecords('mail.notification', [ ['notification_type', '=', notificationType], ['notification_status', 'in', ['bounce', 'exception']], ]).filter(notification => { const message = this.getRecords('mail.message', [['id', '=', notification.mail_message_id]])[0]; return message.model === model && message.author_id === this.currentPartnerId; }); // Update notification status this.pyEnv['mail.notification'].write( notifications.map(notification => notification.id), { notification_status: 'canceled' }, ); // Send bus notifications to update status of notifications in the web client this.pyEnv['bus.bus']._sendone(this.currentPartner, 'mail.message/notification_update', { 'elements': this._mockMailMessage_MessageNotificationFormat( notifications.map(notification => notification.mail_message_id) ), }); }, });