From 00be461524918f2151884852fbb03ca36c8fbd4c Mon Sep 17 00:00:00 2001 From: tsm-odoo Date: Wed, 21 Jun 2023 10:43:23 +0000 Subject: [PATCH] [IMP] im_livechat: improve operator assignation This PR improves operator assignation for livechat channels by first trying to match an operator with the same lang than the visitor then an operator of the same country. part of task-3332872 closes odoo/odoo#125911 Signed-off-by: Didier Debondt (did) --- addons/im_livechat/controllers/main.py | 3 +- .../im_livechat/models/chatbot_script_step.py | 5 +- .../im_livechat/models/im_livechat_channel.py | 66 +++++++++----- addons/im_livechat/tests/__init__.py | 1 + .../tests/test_get_random_operator.py | 91 +++++++++++++++++++ 5 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 addons/im_livechat/tests/test_get_random_operator.py diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 91315809b58..6ff9d5d3b18 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -164,7 +164,8 @@ class LivechatController(http.Controller): chatbot_script=chatbot_script, user_id=user_id, country_id=country_id, - persisted=persisted + persisted=persisted, + lang=request.httprequest.cookies.get('frontend_lang') ) def _post_feedback_message(self, channel, rating, reason): diff --git a/addons/im_livechat/models/chatbot_script_step.py b/addons/im_livechat/models/chatbot_script_step.py index 527a3a3df34..91114584a95 100644 --- a/addons/im_livechat/models/chatbot_script_step.py +++ b/addons/im_livechat/models/chatbot_script_step.py @@ -329,7 +329,10 @@ class ChatbotScriptStep(models.Model): posted_message = False if discuss_channel.livechat_channel_id: - human_operator = discuss_channel.livechat_channel_id._get_random_operator() + human_operator = discuss_channel.livechat_channel_id._get_random_operator( + lang=discuss_channel.livechat_visitor_id.lang_id.code, + country_id=discuss_channel.country_id.id + ) # handle edge case where we found yourself as available operator -> don't do anything # it will act as if no-one is available (which is fine) diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index 33f737e0791..fc62d792a0d 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -162,7 +162,7 @@ class ImLivechatChannel(models.Model): 'name': name, } - def _open_livechat_discuss_channel(self, anonymous_name, previous_operator_id=None, chatbot_script=None, user_id=None, country_id=None, persisted=True): + def _open_livechat_discuss_channel(self, anonymous_name, previous_operator_id=None, chatbot_script=None, user_id=None, country_id=None, persisted=True, lang=None): """ Return a livechat session. If the session is persisted, creates a discuss.channel record with a connected operator or with Odoobot as an operator if a chatbot has been configured, or return false otherwise :param anonymous_name : the name of the anonymous person of the session @@ -171,6 +171,7 @@ class ImLivechatChannel(models.Model): :param user_id : the id of the logged in visitor, if any :param country_code : the country of the anonymous person of the session :param persisted: whether or not the session should be persisted + :param lang: code of the preferred lang of the visitor. :type anonymous_name : str :return : channel header :rtype : dict @@ -189,7 +190,7 @@ class ImLivechatChannel(models.Model): 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() + user_operator = self._get_random_operator(lang=lang, country_id=country_id) if not user_operator and not chatbot_script: # no one available return False @@ -211,13 +212,39 @@ class ImLivechatChannel(models.Model): 'chatbot_script_id': chatbot_script.id if chatbot_script else None } - def _get_random_operator(self): + def _get_less_active_operator(self, active_channels, operators): + """ Get the operator with the less active conversations. + :param active_channels: list of active livechats (with at least + one message within the last 30 minutes) + :param operators: list of operators to choose from + """ + if not operators: + return False + active_channels = [active_channel for active_channel in active_channels if active_channel['livechat_operator_id'] in operators.mapped('partner_id').ids] + active_operators = [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_operators] + if inactive_operators: + return random.choice(inactive_operators) + + # Get the less active operator using the active_channels first element's count (since they are sorted 'ascending') + lowest_number_of_conversations = active_channels[0]['count'] + less_active_operator = random.choice([ + active_channel['livechat_operator_id'] for active_channel in active_channels + 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) + + def _get_random_operator(self, lang=None, country_id=None): """ Return a random operator from the available users of the channel that have the lowest number of active livechats. A livechat is considered 'active' if it has at least one message within the 30 minutes. + This method will try to match the given lang and country_id. (Some annoying conversions have to be made on the fly because this model holds 'res.users' as available operators and the discuss_channel model stores the partner_id of the randomly selected operator) + :param lang: code of the preferred lang of the visitor. + :param country_id: id of the country of the visitor. :return : user :rtype : res.users """ @@ -233,24 +260,21 @@ class ImLivechatChannel(models.Model): GROUP BY c.livechat_operator_id 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 self.available_operator_ids if operator.partner_id.id not in active_channel_operator_ids - ] - if inactive_operators: - return random.choice(inactive_operators) - - # If no inactive operator, active_channels is not empty as len(operators) > 0 (see above). - # Get the less active operator using the active_channels first element's count (since they are sorted 'ascending') - lowest_number_of_conversations = active_channels[0]['count'] - less_active_operator = random.choice([ - active_channel['livechat_operator_id'] for active_channel in active_channels - if active_channel['count'] == lowest_number_of_conversations]) - - # convert the selected 'partner_id' to its corresponding res.users - return next(operator for operator in self.available_operator_ids if operator.partner_id.id == less_active_operator) + operator = None + # Try to match an operator with the same lang as the visitor + if lang: + same_lang_operator_ids = self.available_operator_ids.filtered(lambda operator: operator.partner_id.lang == lang) + if same_lang_operator_ids: + operator = self._get_less_active_operator(active_channels, same_lang_operator_ids) + # Try to match an operator with the same country as the visitor + if country_id and not operator: + same_country_operator_ids = self.available_operator_ids.filtered(lambda operator: operator.partner_id.country_id.id == country_id) + if same_country_operator_ids: + operator = self._get_less_active_operator(active_channels, same_country_operator_ids) + # Try to get a random operator, regardless of the lang or the country + if not operator: + operator = self._get_less_active_operator(active_channels, self.available_operator_ids) + return operator def _get_channel_infos(self): self.ensure_one() diff --git a/addons/im_livechat/tests/__init__.py b/addons/im_livechat/tests/__init__.py index 805affd48b6..370f87695ea 100644 --- a/addons/im_livechat/tests/__init__.py +++ b/addons/im_livechat/tests/__init__.py @@ -6,6 +6,7 @@ from . import test_chatbot_form_ui from . import test_chatbot_internals from . import test_digest from . import test_get_discuss_channel +from . import test_get_random_operator from . import test_im_livechat_report from . import test_im_livechat_support_page from . import test_performance diff --git a/addons/im_livechat/tests/test_get_random_operator.py b/addons/im_livechat/tests/test_get_random_operator.py new file mode 100644 index 00000000000..a1840d748e0 --- /dev/null +++ b/addons/im_livechat/tests/test_get_random_operator.py @@ -0,0 +1,91 @@ +import odoo +from odoo.tests import HttpCase +from odoo.tests.common import new_test_user +from unittest.mock import patch +from odoo.addons.bus.models.res_users import ResUsers + +def _compute_im_status(self): + for record in self: + record.im_status = 'online' + + +@odoo.tests.tagged('-at_install', 'post_install') +@patch.object(ResUsers, '_compute_im_status', _compute_im_status) +class TestGetRandomOperator(HttpCase): + def _create_operator(self, lang_code=None, country_code=None): + operator = new_test_user(self.env, login=f'operator_{lang_code or country_code}_{self.operator_id}') + operator.partner_id = self.env['res.partner'].create({ + 'name': f'Operator {lang_code or country_code}', + 'lang': lang_code, + 'country_id': self.env['res.country'].search([('code', '=', country_code)]).id if country_code else None, + }) + self.operator_id += 1 + return operator + + def setUp(self): + super().setUp() + self.operator_id = 0 + self.env['res.lang'].with_context(active_test=False).search([ + ('code', 'in', ['fr_FR', 'es_ES', 'de_DE', 'en_US']) + ]).write({'active': True}) + + def test_get_by_lang(self): + fr_operator = self._create_operator('fr_FR') + en_operator = self._create_operator('en_US') + livechat_channel = self.env['im_livechat.channel'].create({ + 'name': 'Livechat Channel', + 'user_ids': [fr_operator.id, en_operator.id], + }) + self.assertEqual(fr_operator, livechat_channel._get_random_operator(lang='fr_FR')) + self.assertEqual(en_operator, livechat_channel._get_random_operator(lang='en_US')) + + def test_get_by_lang_no_operator_matching_lang(self): + fr_operator = self._create_operator('fr_FR') + livechat_channel = self.env['im_livechat.channel'].create({ + 'name': 'Livechat Channel', + 'user_ids': [fr_operator.id], + }) + self.assertEqual(fr_operator, livechat_channel._get_random_operator(lang='en_US')) + + def test_get_by_country(self): + fr_operator = self._create_operator(country_code='FR') + en_operator = self._create_operator(country_code='US') + livechat_channel = self.env['im_livechat.channel'].create({ + 'name': 'Livechat Channel', + 'user_ids': [fr_operator.id, en_operator.id], + }) + self.assertEqual( + fr_operator, + livechat_channel._get_random_operator(country_id=self.env['res.country'].search([('code', '=', 'FR')]).id) + ) + self.assertEqual( + en_operator, + livechat_channel._get_random_operator(country_id=self.env['res.country'].search([('code', '=', 'US')]).id) + ) + + def test_get_by_country_no_operator_matching_country(self): + fr_operator = self._create_operator(country_code='FR') + livechat_channel = self.env['im_livechat.channel'].create({ + 'name': 'Livechat Channel', + 'user_ids': [fr_operator.id], + }) + self.assertEqual( + fr_operator, + livechat_channel._get_random_operator(country_id=self.env['res.country'].search([('code', '=', 'US')]).id) + ) + + def test_get_by_lang_and_country_prioritize_lang(self): + fr_operator = self._create_operator('fr_FR', 'FR') + en_operator = self._create_operator('en_US', 'US') + livechat_channel = self.env['im_livechat.channel'].create({ + 'name': 'Livechat Channel', + 'user_ids': [fr_operator.id, en_operator.id], + }) + self.assertEqual( + fr_operator, + livechat_channel._get_random_operator(lang='fr_FR', country_id=self.env['res.country'].search([('code', '=', 'US')]).id) + ) + self.assertEqual( + en_operator, + livechat_channel._get_random_operator(lang='en_US', country_id=self.env['res.country'].search([('code', '=', 'FR')]).id) + )