[IMP] mail, *: display rating in systray

* = im_livechat, test_mail_full

Display the rating changes that generate a notification under the systray.
Using the rating faces to represent the result.

task-2794182

closes odoo/odoo#89537

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Zelong Lin
2022-06-01 11:02:22 +02:00
parent 759df7a654
commit 947770ce7e
24 changed files with 466 additions and 32 deletions
+1
View File
@@ -6,3 +6,4 @@ from . import test_chatbot_form_ui
from . import test_chatbot_internals
from . import test_get_mail_channel
from . import test_im_livechat_support_page
from . import test_message
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests.common import users, tagged, TransactionCase
@tagged('post_install', '-at_install')
class TestImLivechatMessage(TransactionCase):
def setUp(self):
super().setUp()
self.users = self.env['res.users'].create([
{
'email': 'e.e@example.com',
'groups_id': [Command.link(self.env.ref('base.group_user').id)],
'login': 'emp',
'name': 'Ernest Employee',
'notification_type': 'inbox',
'odoobot_state': 'disabled',
'signature': '--\nErnest',
},
{'name': 'test1', 'login': 'test1', 'email': 'test1@example.com'},
])
@users('emp')
def test_message_format(self):
im_livechat_channel = self.env['im_livechat.channel'].sudo().create({'name': 'support', 'user_ids': [Command.link(self.users[0].id)]})
self.users[0].im_status = 'online' # make available for livechat (ignore leave)
channel_livechat_1 = self.env['mail.channel'].browse(im_livechat_channel._open_livechat_mail_channel(anonymous_name='anon 1', previous_operator_id=self.users[0].partner_id.id, user_id=self.users[1].id, country_id=self.env.ref('base.in').id)['id'])
record_rating = self.env['rating.rating'].create({
'res_model_id': self.env['ir.model']._get('mail.channel').id,
'res_id': channel_livechat_1.id,
'parent_res_model_id': self.env['ir.model']._get('im_livechat.channel').id,
'parent_res_id': im_livechat_channel.id,
'rated_partner_id': self.users[0].partner_id.id,
'partner_id': self.users[1].partner_id.id,
'rating': 5,
'consumed': True,
})
message = channel_livechat_1.message_post(
author_id=record_rating.partner_id.id,
body="<img src='%s' alt=':%s/5' style='width:18px;height:18px;float:left;margin-right: 5px;'/>%s"
% (record_rating.rating_image_url, record_rating.rating, record_rating.feedback),
rating_id=record_rating.id,
)
self.assertEqual(message.message_format(), [{
'attachment_ids': [],
'author_id': (self.users[1].partner_id.id, "test1"),
'body': message.body,
'date': message.date,
'history_partner_ids': [],
'id': message.id,
'is_discussion': False,
'is_note': True,
'is_notification': False,
'message_type': 'notification',
'messageReactionGroups': [('insert-and-replace', [])],
'model': 'mail.channel',
'module_icon': '/mail/static/description/icon.png',
'needaction_partner_ids': [],
'notifications': [],
'rating': [('insert-and-replace', {
'id': record_rating.id,
'ratingImageUrl': record_rating.rating_image_url,
'ratingText': record_rating.rating_text,
})],
'recipients': [],
'record_name': "test1 Ernest Employee",
'res_id': channel_livechat_1.id,
'sms_ids': [],
'starred_partner_ids': [],
'subject': False,
'subtype_description': False,
'subtype_id': (self.env.ref('mail.mt_note').id, 'Note'),
'trackingValues': [('insert-and-replace', [])],
}])
@@ -1,7 +1,5 @@
/** @odoo-module **/
import * as mailUtils from '@mail/js/utils';
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
@@ -39,18 +37,6 @@ export class ThreadNeedactionPreview extends Component {
return '/mail/static/src/img/smiley/avatar.jpg';
}
/**
* Get inline content of the last message of this conversation.
*
* @returns {string}
*/
get inlineLastNeedactionMessageAsOriginThreadBody() {
if (!this.threadNeedactionPreviewView.thread.lastNeedactionMessageAsOriginThread) {
return '';
}
return mailUtils.htmlToTextContentInline(this.threadNeedactionPreviewView.thread.lastNeedactionMessageAsOriginThread.prettyBody);
}
/**
* @returns {ThreadNeedactionPreviewView}
*/
@@ -41,11 +41,11 @@
</t>
</div>
<div class="o_ThreadNeedactionPreview_core d-flex align-items-baseline">
<span class="o_NotificationListItem_coreItem o_NotificationListItem_inlineText o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText me-2 text-truncate" t-att-class="{ 'o-empty': inlineLastNeedactionMessageAsOriginThreadBody.length === 0 }">
<span class="o_NotificationListItem_coreItem o_NotificationListItem_inlineText o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText me-2 text-truncate" t-att-class="{ 'o-empty': threadNeedactionPreviewView.isEmpty }">
<t t-if="threadNeedactionPreviewView.messageAuthorPrefixView">
<MessageAuthorPrefix record="threadNeedactionPreviewView.messageAuthorPrefixView"/>
</t>
<t t-esc="inlineLastNeedactionMessageAsOriginThreadBody"/>
<span class="o_ThreadNeedactionPreview_messageBody" t-esc="threadNeedactionPreviewView.inlineLastNeedactionMessageAsOriginThreadBody"/>
</span>
<span class="flex-grow-1"/>
<span class="o_NotificationListItem_coreItem o_NotificationListItem_markAsRead o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check d-flex flex-shrink-0 ms-2 text-600 opacity-50 opacity-100-hover" title="Mark as Read" t-on-click="threadNeedactionPreviewView.onClickMarkAsRead" t-ref="markAsRead"/>
@@ -1,7 +1,5 @@
/** @odoo-module **/
import * as mailUtils from '@mail/js/utils';
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
@@ -33,18 +31,6 @@ export class ThreadPreview extends Component {
return `/web/image/mail.channel/${this.threadPreviewView.thread.id}/avatar_128?unique=${this.threadPreviewView.thread.avatarCacheKey}`;
}
/**
* Get inline content of the last message of this conversation.
*
* @returns {string}
*/
get inlineLastMessageBody() {
if (!this.threadPreviewView.thread.lastMessage) {
return '';
}
return mailUtils.htmlToTextContentInline(this.threadPreviewView.thread.lastMessage.prettyBody);
}
/**
* @returns {ThreadPreviewView}
*/
@@ -45,11 +45,11 @@
</t>
</div>
<div class="o_ThreadPreview_core d-flex align-items-baseline">
<span class="o_NotificationListItem_coreItem o_NotificationListItem_inlineText o_ThreadPreview_coreItem o_ThreadPreview_inlineText me-2 text-truncate" t-att-class="{ 'o-empty': inlineLastMessageBody.length === 0 }">
<span class="o_NotificationListItem_coreItem o_NotificationListItem_inlineText o_ThreadPreview_coreItem o_ThreadPreview_inlineText me-2 text-truncate" t-att-class="{ 'o-empty': threadPreviewView.isEmpty }">
<t t-if="threadPreviewView.messageAuthorPrefixView">
<MessageAuthorPrefix record="threadPreviewView.messageAuthorPrefixView"/>
</t>
<t t-esc="inlineLastMessageBody"/>
<span class="o_ThreadPreview_messageBody" t-esc="threadPreviewView.inlineLastMessageBody"/>
</span>
<span class="flex-grow-1"/>
<t t-if="threadPreviewView.thread.localMessageUnreadCounter > 0">
@@ -3,6 +3,7 @@
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
import { clear, insertAndReplace } from '@mail/model/model_field_command';
import { htmlToTextContentInline } from '@mail/js/utils';
registerModel({
name: 'ThreadNeedactionPreviewView',
@@ -34,6 +35,23 @@ registerModel({
['res_id', '=', this.thread.id],
]);
},
/**
* @private
* @returns {string|FieldCommand}
*/
_computeInlineLastNeedactionMessageAsOriginThreadBody() {
if (!this.thread.lastNeedactionMessageAsOriginThread) {
return clear();
}
return htmlToTextContentInline(this.thread.lastNeedactionMessageAsOriginThread.prettyBody);
},
/**
* @private
* @returns {boolean}
*/
_computeIsEmpty() {
return !this.inlineLastNeedactionMessageAsOriginThreadBody;
},
/**
* @private
* @returns {FieldCommand}
@@ -49,6 +67,15 @@ registerModel({
},
},
fields: {
inlineLastNeedactionMessageAsOriginThreadBody: attr({
compute: '_computeInlineLastNeedactionMessageAsOriginThreadBody',
default: "",
readonly: true,
}),
isEmpty: attr({
compute: '_computeIsEmpty',
readonly: true,
}),
/**
* Reference of the "mark as read" button. Useful to disable the
* top-level click handler when clicking on this specific button.
@@ -3,6 +3,7 @@
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
import { clear, insertAndReplace } from '@mail/model/model_field_command';
import { htmlToTextContentInline } from '@mail/js/utils';
registerModel({
name: 'ThreadPreviewView',
@@ -33,6 +34,23 @@ registerModel({
this.thread.markAsSeen(this.thread.lastNonTransientMessage);
}
},
/**
* @private
* @returns {string|FieldCommand}
*/
_computeInlineLastMessageBody() {
if (!this.thread.lastMessage) {
return clear();
}
return htmlToTextContentInline(this.thread.lastMessage.prettyBody);
},
/**
* @private
* @returns {boolean}
*/
_computeIsEmpty() {
return !this.inlineLastMessageBody;
},
/**
* @private
* @returns {FieldCommand}
@@ -48,6 +66,15 @@ registerModel({
},
},
fields: {
inlineLastMessageBody: attr({
compute: '_computeInlineLastMessageBody',
default: "",
readonly: true,
}),
isEmpty: attr({
compute: '_computeIsEmpty',
readonly: true,
}),
/**
* Reference of the "mark as read" button. Useful to disable the
* top-level click handler when clicking on this specific button.
+8
View File
@@ -21,10 +21,18 @@ This module allows a customer to give rating.
'assets': {
'web.assets_backend': [
'rating/static/src/scss/rating_rating_views.scss',
'rating/static/src/components/*/*.scss',
'rating/static/src/models/*.js',
],
'web.assets_frontend': [
'rating/static/src/scss/rating_templates.scss',
],
'web.assets_qweb': [
'rating/static/src/components/*/*.xml',
],
'web.tests_assets': [
'rating/static/tests/helpers/*.js',
],
},
'license': 'LGPL-3',
}
+16
View File
@@ -25,3 +25,19 @@ class MailMessage(models.Model):
('message_id', '!=', False)
])
return [('id', 'in', ratings.mapped('message_id').ids)]
def message_format(self, format_reply=True):
message_values = super().message_format(format_reply=format_reply)
rating_mixin_messages = self.filtered(lambda message: issubclass(self.pool[message.model], self.pool['rating.mixin']))
if rating_mixin_messages:
ratings = self.env['rating.rating'].sudo().search([('message_id', 'in', rating_mixin_messages.ids), ('consumed', '=', True)])
rating_by_message_id = dict((r.message_id.id, r) for r in ratings)
for vals in message_values:
if vals['id'] in rating_by_message_id:
rating = rating_by_message_id[vals['id']]
vals['rating'] = [('insert-and-replace', {
'id': rating.id,
'ratingImageUrl': rating.rating_image_url,
'ratingText': rating.rating_text,
})]
return message_values
@@ -0,0 +1,4 @@
.o_ThreadNeedactionPreview_ratingImage {
height: $font-size-base;
width: $font-size-base;
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ThreadNeedactionPreview" t-inherit-mode="extension">
<xpath expr="//*[hasclass('o_ThreadNeedactionPreview_messageBody')]" position="replace">
<t t-if="threadNeedactionPreviewView.isRating">
<span class="o_ThreadNeedactionPreview_ratingText">Rating:</span>
<img class="o_ThreadNeedactionPreview_ratingImage ml-2" t-att-src="threadNeedactionPreviewView.thread.lastNeedactionMessageAsOriginThread.rating.ratingImageUrl" t-att-alt="threadNeedactionPreviewView.thread.lastNeedactionMessageAsOriginThread.rating.ratingText"/>
</t>
<t t-else="">$0</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,4 @@
.o_ThreadPreview_ratingImage {
height: $font-size-base;
width: $font-size-base;
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ThreadPreview" t-inherit-mode="extension">
<xpath expr="//*[hasclass('o_ThreadPreview_messageBody')]" position="replace">
<t t-if="threadPreviewView.isRating">
<span class="o_ThreadPreview_ratingText">Rating:</span>
<img class="o_ThreadPreview_ratingImage ml-2" t-att-src="threadPreviewView.thread.lastMessage.rating.ratingImageUrl" t-att-alt="threadPreviewView.thread.lastMessage.rating.ratingText"/>
</t>
<t t-else="">$0</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,26 @@
/** @odoo-module **/
import { addFields, patchModelMethods } from '@mail/model/model_core';
import { one } from '@mail/model/model_field';
// ensure the model definition is loaded before the patch
import '@mail/models/message';
patchModelMethods('Message', {
/**
* @override
*/
convertData(data) {
const data2 = this._super(data);
if ('rating' in data) {
data2.rating = data.rating;
}
return data2;
},
});
addFields('Message', {
rating: one('Rating', {
isCausal: true,
}),
});
+21
View File
@@ -0,0 +1,21 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
registerModel({
name: 'Rating',
identifyingFields: ['id'],
fields: {
id: attr({
readonly: true,
required: true,
}),
ratingImageUrl: attr({
readonly: true,
}),
ratingText: attr({
readonly: true,
}),
},
});
@@ -0,0 +1,31 @@
/** @odoo-module **/
import { addFields, addRecordMethods, patchRecordMethods } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
// ensure the model definition is loaded before the patch
import '@mail/models/thread_needaction_preview_view';
patchRecordMethods('ThreadNeedactionPreviewView', {
/**
* @override
*/
_computeIsEmpty() {
return this.isRating || this._super();
},
});
addRecordMethods('ThreadNeedactionPreviewView', {
/**
* @private
*/
_computeIsRating() {
return Boolean(this.thread.lastNeedactionMessageAsOriginThread && this.thread.lastNeedactionMessageAsOriginThread.rating);
},
});
addFields('ThreadNeedactionPreviewView', {
isRating: attr({
compute: '_computeIsRating',
readonly: true,
}),
});
@@ -0,0 +1,31 @@
/** @odoo-module **/
import { addFields, addRecordMethods, patchRecordMethods } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
// ensure the model definition is loaded before the patch
import '@mail/models/thread_preview_view';
patchRecordMethods('ThreadPreviewView', {
/**
* @override
*/
_computeIsEmpty() {
return this.isRating || this._super();
},
});
addRecordMethods('ThreadPreviewView', {
/**
* @private
*/
_computeIsRating() {
return Boolean(this.thread.lastMessage && this.thread.lastMessage.rating);
},
});
addFields('ThreadPreviewView', {
isRating: attr({
compute: '_computeIsRating',
readonly: true,
}),
});
@@ -0,0 +1,28 @@
/** @odoo-module **/
import '@mail/../tests/helpers/mock_server'; // ensure mail overrides are applied first
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'rating', {
/**
* @override
*/
_mockMailMessageMessageFormat(ids) {
const formattedMessages = this._super(...arguments);
for (const formattedMessage of formattedMessages) {
const [rating] = this.getRecords('rating.rating', [
['message_id', '=', formattedMessage.id],
]);
if (rating) {
formattedMessage['rating'] = [['insert-and-replace', {
'id': rating.id,
'ratingImageUrl': rating.rating_image_url,
'ratingText': rating.rating_text,
}]];
}
}
return formattedMessages;
},
});
@@ -0,0 +1,5 @@
/** @odoo-module **/
import { addModelNamesToFetch } from '@mail/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['rating.rating']);
+8
View File
@@ -28,6 +28,14 @@ real applications. """,
'security/ir.model.access.csv',
'security/ir_rule_data.xml',
],
'assets': {
'web.qunit_suite_tests': [
'test_mail_full/static/tests/qunit_suite_tests/*.js',
],
'web.tests_assets': [
'test_mail_full/static/tests/helpers/*.js',
],
},
'installable': True,
'license': 'LGPL-3',
}
@@ -0,0 +1,5 @@
/** @odoo-module **/
import { addModelNamesToFetch } from '@mail/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['mail.test.rating']);
@@ -0,0 +1,65 @@
/** @odoo-module **/
import { afterNextRender, start, startServer } from '@mail/../tests/helpers/test_utils';
QUnit.module('test_mail_full', {}, function () {
QUnit.module('thread_needaction_preview_tests.js');
QUnit.test('rating value displayed on the thread needaction preview', async function (assert) {
assert.expect(4);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create();
const mailTestRating1 = pyEnv['mail.test.rating'].create();
const mailMessageId1 = pyEnv['mail.message'].create({
model: 'mail.test.rating',
needaction: true,
needaction_partner_ids: [pyEnv.currentPartnerId],
res_id: mailTestRating1,
});
pyEnv['mail.notification'].create({
mail_message_id: mailMessageId1,
notification_status: 'sent',
notification_type: 'inbox',
res_partner_id: pyEnv.currentPartnerId,
});
pyEnv['rating.rating'].create([{
consumed: true,
message_id: mailMessageId1,
partner_id: resPartnerId1,
rating_image_url: "/rating/static/src/img/rating_5.png",
rating_text: "top",
}]);
const { afterEvent, createMessagingMenuComponent } = await start();
await createMessagingMenuComponent();
await afterNextRender(() => afterEvent({
eventName: 'o-thread-cache-loaded-messages',
func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
message: "should wait until inbox loaded initial needaction messages",
predicate: ({ threadCache }) => {
return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
},
}));
assert.strictEqual(
document.querySelector('.o_ThreadNeedactionPreview_ratingText').textContent,
"Rating:",
"should display the correct content (Rating:)"
);
assert.containsOnce(
document.body,
'.o_ThreadNeedactionPreview_ratingImage',
"should have a rating image in the body"
);
assert.strictEqual(
$('.o_ThreadNeedactionPreview_ratingImage').attr('data-src'),
"/rating/static/src/img/rating_5.png",
"should contain the correct rating image"
);
assert.strictEqual(
$('.o_ThreadNeedactionPreview_ratingImage').attr('alt'),
"top",
"should contain the correct rating text"
);
});
});
@@ -0,0 +1,56 @@
/** @odoo-module **/
import { afterNextRender, start, startServer } from '@mail/../tests/helpers/test_utils';
QUnit.module('test_mail_full', {}, function () {
QUnit.module('thread_preview_tests.js');
QUnit.test('rating value displayed on the thread preview', async function (assert) {
assert.expect(4);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create();
const mailChannelId1 = pyEnv['mail.channel'].create();
const mailMessageId1 = pyEnv['mail.message'].create([
{ author_id: resPartnerId1, model: 'mail.channel', res_id: mailChannelId1 },
]);
pyEnv['rating.rating'].create({
consumed: true,
message_id: mailMessageId1,
partner_id: resPartnerId1,
rating_image_url: "/rating/static/src/img/rating_5.png",
rating_text: "top",
});
const { afterEvent, createMessagingMenuComponent } = await start();
await createMessagingMenuComponent();
await afterNextRender(() => afterEvent({
eventName: 'o-thread-cache-loaded-messages',
func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
message: "should wait until inbox loaded initial needaction messages",
predicate: ({ threadCache }) => {
return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
},
}));
assert.strictEqual(
document.querySelector('.o_ThreadPreview_ratingText').textContent,
"Rating:",
"should display the correct content (Rating:)"
);
assert.containsOnce(
document.body,
'.o_ThreadPreview_ratingImage',
"should have a rating image in the body"
);
assert.strictEqual(
$('.o_ThreadPreview_ratingImage').attr('data-src'),
"/rating/static/src/img/rating_5.png",
"should contain the correct rating image"
);
assert.strictEqual(
$('.o_ThreadPreview_ratingImage').attr('alt'),
"top",
"should contain the correct rating text"
);
});
});