[IMP] im_livechat: improve livechat channel kanban

This PR improves livechat channel kanban:
- Remove image field (always the same and does not bring value)
- Replace the count of operators by the many2many_avatars widget with connected users
- Join button as secondary button, Leave button color should be primary
- Add options from channel card: Ratings + configure
- Compute ratings based on last 2 weeks on channel's card
- Highlight active channels (at least one operator)

part of task-3332872

closes odoo/odoo#125769

Related: odoo/enterprise#42852
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
tsm-odoo
2023-07-03 21:55:56 +02:00
committed by Alexandre Kühn
parent 3c6c6e6bb3
commit 7ec8d66ce4
13 changed files with 121 additions and 57 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ class LivechatController(http.Controller):
@http.route('/im_livechat/init', type='json', auth="public", cors="*")
def livechat_init(self, channel_id):
operator_available = len(request.env['im_livechat.channel'].sudo().browse(channel_id)._get_available_users())
operator_available = len(request.env['im_livechat.channel'].sudo().browse(channel_id).available_operator_ids)
rule = {}
# find the country from the request
country_id = False
@@ -17,11 +17,7 @@ class ImLivechatChannel(models.Model):
_name = 'im_livechat.channel'
_inherit = ['rating.parent.mixin']
_description = 'Livechat Channel'
_rating_satisfaction_days = 7 # include only last 7 days to compute satisfaction
def _default_image(self):
image_path = modules.get_module_resource('im_livechat', 'static/src/img', 'default.png')
return base64.b64encode(open(image_path, 'rb').read())
_rating_satisfaction_days = 14 # include only last 14 days to compute satisfaction
def _default_user_ids(self):
return [(6, 0, [self._uid])]
@@ -49,10 +45,11 @@ class ImLivechatChannel(models.Model):
help="URL to a static page where you client can discuss with the operator of the channel.")
are_you_inside = fields.Boolean(string='Are you inside the matrix?',
compute='_are_you_inside', store=False, readonly=True)
available_operator_ids = fields.Many2many('res.users', compute='_compute_available_operator_ids')
script_external = fields.Html('Script (external)', compute='_compute_script_external', store=False, readonly=True, sanitize=False)
nbr_channel = fields.Integer('Number of conversation', compute='_compute_nbr_channel', store=False, readonly=True)
image_128 = fields.Image("Image", max_width=128, max_height=128, default=_default_image)
image_128 = fields.Image("Image", max_width=128, max_height=128)
# relationnal fields
user_ids = fields.Many2many('res.users', 'im_livechat_channel_im_user', 'channel_id', 'user_id', string='Operators', default=_default_user_ids)
@@ -64,6 +61,11 @@ class ImLivechatChannel(models.Model):
for channel in self:
channel.are_you_inside = bool(self.env.uid in [u.id for u in channel.user_ids])
@api.depends('user_ids.im_status')
def _compute_available_operator_ids(self):
for record in self:
record.available_operator_ids = record.user_ids.filtered(lambda user: user.im_status == 'online')
@api.depends('rule_ids.chatbot_script_id')
def _compute_chatbot_script_count(self):
data = self.env['im_livechat.channel.rule']._read_group(
@@ -130,13 +132,6 @@ class ImLivechatChannel(models.Model):
# --------------------------
# Channel Methods
# --------------------------
def _get_available_users(self):
""" get available user of a given channel
:retuns : return the res.users having their im_status online
"""
self.ensure_one()
return self.user_ids.filtered(lambda user: user.im_status == 'online')
def _get_livechat_discuss_channel_vals(self, anonymous_name, operator=None, chatbot_script=None, user_id=None, country_id=None):
# partner to add to the discuss.channel
operator_partner_id = operator.partner_id.id if operator else chatbot_script.operator_partner_id.id
@@ -190,10 +185,9 @@ class ImLivechatChannel(models.Model):
[('channel_id', 'in', self.ids)]).mapped('chatbot_script_id').ids:
return False
elif previous_operator_id:
available_users = self._get_available_users()
# previous_operator_id is the partner_id of the previous operator, need to convert to user
if previous_operator_id in available_users.mapped('partner_id').ids:
user_operator = next(available_user for available_user in available_users if available_user.partner_id.id == previous_operator_id)
if previous_operator_id in self.available_operator_ids.mapped('partner_id').ids:
user_operator = next(available_user for available_user in self.available_operator_ids if available_user.partner_id.id == previous_operator_id)
if not user_operator and not chatbot_script:
user_operator = self._get_random_operator()
if not user_operator and not chatbot_script:
@@ -227,8 +221,7 @@ class ImLivechatChannel(models.Model):
:return : user
:rtype : res.users
"""
operators = self._get_available_users()
if len(operators) == 0:
if not self.available_operator_ids:
return False
self.env.cr.execute("""SELECT COUNT(DISTINCT c.id), c.livechat_operator_id
@@ -238,12 +231,14 @@ class ImLivechatChannel(models.Model):
AND c.livechat_operator_id in %s
AND m.create_date > ((now() at time zone 'UTC') - interval '30 minutes')
GROUP BY c.livechat_operator_id
ORDER BY COUNT(DISTINCT c.id) asc""", (tuple(operators.mapped('partner_id').ids),))
ORDER BY COUNT(DISTINCT c.id) asc""", (tuple(self.available_operator_ids.mapped('partner_id').ids),))
active_channels = self.env.cr.dictfetchall()
# If inactive operator(s), return one of them
active_channel_operator_ids = [active_channel['livechat_operator_id'] for active_channel in active_channels]
inactive_operators = [operator for operator in operators if operator.partner_id.id not in active_channel_operator_ids]
inactive_operators = [
operator for operator in self.available_operator_ids if operator.partner_id.id not in active_channel_operator_ids
]
if inactive_operators:
return random.choice(inactive_operators)
@@ -255,7 +250,7 @@ class ImLivechatChannel(models.Model):
if active_channel['count'] == lowest_number_of_conversations])
# convert the selected 'partner_id' to its corresponding res.users
return next(operator for operator in operators if operator.partner_id.id == less_active_operator)
return next(operator for operator in self.available_operator_ids if operator.partner_id.id == less_active_operator)
def _get_channel_infos(self):
self.ensure_one()
@@ -278,7 +273,7 @@ class ImLivechatChannel(models.Model):
if username is None:
username = _('Visitor')
info = {}
info['available'] = self.chatbot_script_count or len(self._get_available_users()) > 0
info['available'] = self.chatbot_script_count or len(self.available_operator_ids) > 0
info['server_url'] = self.get_base_url()
if info['available']:
info['options'] = self._get_channel_infos()
@@ -0,0 +1,15 @@
.o_kanban_record:has(.o-livechat-ChannelKanban-highlighted) {
& .oe_kanban_global_click {
background-color: $o-component-active-bg !important;
color: $o-component-active-color;
}
}
.o-livechat-ChannelKanban-highlighted {
background-color: $o-component-active-bg !important;
color: $o-component-active-color;
&::before {
background-color: $o-component-active-bg !important;
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="im_livechat.KanbanRecordMenu" t-inherit="web.KanbanRecordMenu">
<xpath expr="//Dropdown" position="attributes">
<attribute name="position">'bottom-start'</attribute>
<attribute name="menuClass" add="` rounded-0 ${record.available_operator_ids.raw_value.length > 0 ? 'o-livechat-ChannelKanban-highlighted': ''}`" separator="+"/>
<attribute name="togglerClass" add="' bg-transparent px-2'" separator="+"/>
</xpath>
</t>
</templates>
@@ -0,0 +1,6 @@
/* @odoo-module */
import { KanbanRecord } from "@web/views/kanban/kanban_record";
export class LivechatChannelKanbanRecord extends KanbanRecord {}
LivechatChannelKanbanRecord.menuTemplate = "im_livechat.KanbanRecordMenu";
@@ -0,0 +1,11 @@
/* @odoo-module */
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { LivechatChannelKanbanRecord } from "./livechat_channel_kanban_record";
export class LivechatChannelKanbanRenderer extends KanbanRenderer {}
LivechatChannelKanbanRenderer.components = {
...KanbanRenderer.components,
KanbanRecord: LivechatChannelKanbanRecord,
};
@@ -0,0 +1,13 @@
/* @odoo-module */
import { LivechatChannelKanbanRenderer } from "@im_livechat/views/livechat_channel_kanban/livechat_channel_kanban_renderer";
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
const livechatChannelKanbanView = {
...kanbanView,
Renderer: LivechatChannelKanbanRenderer,
};
registry.category("views").add("im_livechat.livechat_channel_kanban", livechatChannelKanbanView);
@@ -5,13 +5,13 @@ import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, "im_livechat/models/im_livechat_channel", {
/**
* Simulates `_get_available_users` on `im_livechat.channel`.
* Simulates `_compute_available_operator_ids` on `im_livechat.channel`.
*
* @private
* @param {integer} id
* @returns {Object}
*/
_mockImLivechatChannel_getAvailableUsers(id) {
_mockImLivechatChannel__computeAvailableOperatorIds(id) {
const livechatChannel = this.getRecords("im_livechat.channel", [["id", "=", id]])[0];
const users = this.getRecords("res.users", [["id", "in", livechatChannel.user_ids]]);
return users.filter((user) => user.im_status === "online");
@@ -77,7 +77,7 @@ patch(MockServer.prototype, "im_livechat/models/im_livechat_channel", {
* @returns {Object}
*/
_mockImLivechatChannel_getRandomOperator(id) {
const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id);
const availableUsers = this._mockImLivechatChannel__computeAvailableOperatorIds(id);
return availableUsers[0];
},
/**
@@ -101,7 +101,7 @@ patch(MockServer.prototype, "im_livechat/models/im_livechat_channel", {
) {
let operator;
if (previous_operator_id) {
const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id);
const availableUsers = this._mockImLivechatChannel__computeAvailableOperatorIds(id);
operator = availableUsers.find((user) => user.partner_id === previous_operator_id);
}
if (!operator) {
+4 -3
View File
@@ -42,7 +42,8 @@ class TestImLivechatCommon(TransactionCase):
def setUp(self):
super().setUp()
def get_available_users(_):
return self.operators
def _compute_available_operator_ids(channel_self):
for record in channel_self:
record.available_operator_ids = type(self).operators
self.patch(type(self.env['im_livechat.channel']), '_get_available_users', get_available_users)
self.patch(type(self.env['im_livechat.channel']), '_compute_available_operator_ids', _compute_available_operator_ids)
@@ -26,7 +26,7 @@ class TestGetDiscussChannel(TestImLivechatCommon):
# ensure visitor info are correct with anonymous
operator = self.operators[0]
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_discuss_channel(anonymous_name='Visitor 22', previous_operator_id=operator.partner_id.id, country_id=belgium.id)
channel_info = self.livechat_channel.with_user(public_user).sudo()._open_livechat_discuss_channel(anonymous_name='Visitor 22', previous_operator_id=operator.partner_id.id, country_id=belgium.id)
self.assertEqual(channel_info['channel']['anonymous_name'], "Visitor 22")
self.assertEqual(channel_info['channel']['anonymous_country'], {'code': 'BE', 'id': belgium.id, 'name': 'Belgium'})
@@ -46,7 +46,7 @@ class TestGetDiscussChannel(TestImLivechatCommon):
}], key=lambda m: m['id']))
# ensure visitor info are correct with real user
channel_info = self.livechat_channel.with_user(test_user)._open_livechat_discuss_channel(anonymous_name='whatever', previous_operator_id=operator.partner_id.id, user_id=test_user.id)
channel_info = self.livechat_channel.with_user(test_user).sudo()._open_livechat_discuss_channel(anonymous_name='whatever', previous_operator_id=operator.partner_id.id, user_id=test_user.id)
self.assertFalse(channel_info['channel']['anonymous_name'])
self.assertEqual(channel_info['channel']['anonymous_country'], [('clear',)])
self.assertEqual(channel_info['channel']['channelMembers'], [('insert', [
@@ -117,7 +117,7 @@ class TestGetDiscussChannel(TestImLivechatCommon):
def test_channel_not_pinned_for_operator_before_first_message(self):
public_user = self.env.ref('base.public_user')
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_discuss_channel(anonymous_name='whatever')
channel_info = self.livechat_channel.with_user(public_user).sudo()._open_livechat_discuss_channel(anonymous_name='whatever')
operator_channel_member = self.env['discuss.channel.member'].search([('channel_id', '=', channel_info['id']), ('partner_id', 'in', self.operators.partner_id.ids)])
self.assertEqual(len(operator_channel_member), 1, "operator should be member of channel")
self.assertFalse(operator_channel_member.is_pinned, "channel should not be pinned for operator initially")
@@ -145,7 +145,7 @@ class TestGetDiscussChannel(TestImLivechatCommon):
def test_read_channel_unpined_for_operator_after_one_day(self):
public_user = self.env.ref('base.public_user')
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_discuss_channel(anonymous_name='visitor')
channel_info = self.livechat_channel.with_user(public_user).sudo()._open_livechat_discuss_channel(anonymous_name='visitor')
member_of_operator = self.env['discuss.channel.member'].search([('channel_id', '=', channel_info['id']), ('partner_id', 'in', self.operators.partner_id.ids)])
message = self.env['discuss.channel'].browse(channel_info['id']).message_post(body='cc')
member_of_operator.channel_id.with_user(self.operators.filtered(
@@ -157,7 +157,7 @@ class TestGetDiscussChannel(TestImLivechatCommon):
def test_unread_channel_not_unpined_for_operator_after_autovacuum(self):
public_user = self.env.ref('base.public_user')
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_discuss_channel(anonymous_name='visitor')
channel_info = self.livechat_channel.with_user(public_user).sudo()._open_livechat_discuss_channel(anonymous_name='visitor')
member_of_operator = self.env['discuss.channel.member'].search([('channel_id', '=', channel_info['id']), ('partner_id', 'in', self.operators.partner_id.ids)])
self.env['discuss.channel'].browse(channel_info['id']).message_post(body='cc')
with freeze_time(fields.Datetime.to_string(fields.datetime.now() + timedelta(days=1))):
@@ -12,7 +12,11 @@ class TestImLivechatReport(TestImLivechatCommon):
super().setUpClass()
cls.env['discuss.channel'].search([('livechat_channel_id', '!=', False)]).unlink()
with patch.object(type(cls.env['im_livechat.channel']), '_get_available_users', lambda _: cls.operators):
def _compute_available_operator_ids(channel_self):
for record in channel_self:
record.available_operator_ids = cls.operators
with patch.object(type(cls.env['im_livechat.channel']), '_compute_available_operator_ids', _compute_available_operator_ids):
channel_id = cls.livechat_channel._open_livechat_discuss_channel('Anonymous')['id']
channel = cls.env['discuss.channel'].browse(channel_id)
@@ -21,7 +21,7 @@
<field name="name">im_livechat.channel.kanban</field>
<field name="model">im_livechat.channel</field>
<field name="arch" type="xml">
<kanban>
<kanban js_class="im_livechat.livechat_channel_kanban" action="im_livechat.discuss_channel_action_from_livechat_channel" type="action">
<field name="id"/>
<field name="name"/>
<field name="web_page" widget="url"/>
@@ -30,28 +30,36 @@
<field name="nbr_channel"/>
<field name="rating_percentage_satisfaction"/>
<field name="rating_count"/>
<field name="image_128"/>
<templates>
<t t-name="kanban-menu">
<div class="container">
<a type="object" name="action_view_rating" class="dropdown-item" role="menuitem">Ratings</a>
<a type="open" class="dropdown-item" role="menuitem">Configure Channel</a>
</div>
</t>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<div class="o_kanban_image">
<div class="oe_kanban_global_click px-4" t-att-class="{'o-livechat-ChannelKanban-highlighted': record.available_operator_ids.raw_value.length > 0}">
<div class="o_kanban_image" t-if="record.image_128.raw_value">
<img t-att-src="kanban_image('im_livechat.channel', 'image_128', record.id.raw_value)" class="img-fluid" alt="Channel"/>
</div>
<div class="oe_kanban_details">
<div class="float-end">
<button t-if="record.are_you_inside.raw_value" name="action_quit" type="object" class="btn btn-primary">Leave</button>
<button t-if="!record.are_you_inside.raw_value" name="action_join" type="object" class="btn btn-primary">Join</button>
</div>
<strong class="o_kanban_record_title" style="word-wrap: break-word;"><field name="name"/></strong>
<div>
<div class="d-flex justify-content-between">
<div>
<i class="fa fa-user" role="img" aria-label="User" title="User"></i> <t t-esc="(record.user_ids.raw_value || []).length"/> Operators
<br/>
<i class="fa fa-comments" role="img" aria-label="Comments" title="Comments"></i> <t t-esc="record.nbr_channel.raw_value"/> Sessions
<div t-if="record.rating_count.raw_value &gt; 0" class="float-end">
<a name="action_view_rating" type="object" tabindex="10">
<i class="fa fa-smile-o text-success" title="Percentage of happy ratings" role="img" aria-label="Happy face"/> <t t-esc="record.rating_percentage_satisfaction.raw_value"/>%
</a>
</div>
<field name="name" class="fs-4" style="word-wrap: break-word;"/>
<p class="fst-italic fs-5"><t t-esc="record.nbr_channel.raw_value"/> Sessions</p>
</div>
<div>
<button t-if="record.are_you_inside.raw_value" name="action_quit" type="object" class="btn btn-primary">Leave</button>
<button t-if="!record.are_you_inside.raw_value" name="action_join" type="object" class="btn btn-secondary">Join</button>
</div>
</div>
<div class="o_kanban_record_bottom">
<field class="oe_kanban_bottom_left" name="available_operator_ids" widget="many2many_avatar_user" attrs="{'readonly': True}"/>
<div t-if="record.rating_count.raw_value > 0" class="oe_kanban_bottom_right">
<a name="action_view_rating" type="object" tabindex="10">
<i class="fa fa-smile-o text-success" title="Percentage of happy ratings" role="img" aria-label="Happy face"/> <t t-esc="record.rating_percentage_satisfaction.raw_value"/>%
</a>
</div>
</div>
</div>
+5 -4
View File
@@ -61,10 +61,11 @@ class TestLivechatCommon(tests.TransactionCase):
self.message_info_url = base_url + "/mail/init_messaging"
# override the get_available_users to return only Michel as available
operators = self.operator
def get_available_users(self):
return operators
self.patch(type(self.env['im_livechat.channel']), '_get_available_users', get_available_users)
def _compute_available_operator_ids(channel_self):
for record in channel_self:
record.available_operator_ids = self.operator
self.patch(type(self.env['im_livechat.channel']), '_compute_available_operator_ids', _compute_available_operator_ids)
# override the _get_visitor_from_request to return self.visitor
self.target_visitor = self.visitor