[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:
@@ -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()
|
||||
|
||||
+15
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||
|
||||
export class LivechatChannelKanbanRecord extends KanbanRecord {}
|
||||
LivechatChannelKanbanRecord.menuTemplate = "im_livechat.KanbanRecordMenu";
|
||||
+11
@@ -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,
|
||||
};
|
||||
+13
@@ -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) {
|
||||
|
||||
@@ -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 > 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user