# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import random import re from odoo import api, fields, models, modules, _ class ImLivechatChannel(models.Model): """ Livechat Channel Define a communication channel, which can be accessed with 'script_external' (script tag to put on external website), 'script_internal' (code to be integrated with odoo website) or via 'web_page' link. It provides rating tools, and access rules for anonymous people. """ _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()) def _default_user_ids(self): return [(6, 0, [self._uid])] # attribute fields name = fields.Char('Name', required=True, help="The name of the channel") button_text = fields.Char('Text of the Button', default='Have a Question? Chat with us.', help="Default text displayed on the Livechat Support Button") default_message = fields.Char('Welcome Message', default='How may I help you?', help="This is an automated 'welcome' message that your visitor will see when they initiate a new conversation.") input_placeholder = fields.Char('Chat Input Placeholder', help='Text that prompts the user to initiate the chat.') header_background_color = fields.Char(default="#875A7B", help="Default background color of the channel header once open") title_color = fields.Char(default="#FFFFFF", help="Default title color of the channel once open") button_background_color = fields.Char(default="#878787", help="Default background color of the Livechat button") button_text_color = fields.Char(default="#FFFFFF", help="Default text color of the Livechat button") # computed fields web_page = fields.Char('Web Page', compute='_compute_web_page_link', store=False, readonly=True, 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) script_external = fields.Text('Script (external)', compute='_compute_script_external', store=False, readonly=True) 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) # relationnal fields user_ids = fields.Many2many('res.users', 'im_livechat_channel_im_user', 'channel_id', 'user_id', string='Operators', default=_default_user_ids) channel_ids = fields.One2many('mail.channel', 'livechat_channel_id', 'Sessions') rule_ids = fields.One2many('im_livechat.channel.rule', 'channel_id', 'Rules') def _are_you_inside(self): for channel in self: channel.are_you_inside = bool(self.env.uid in [u.id for u in channel.user_ids]) def _compute_script_external(self): view = self.env['ir.model.data'].get_object('im_livechat', 'external_loader') values = { "url": self.env['ir.config_parameter'].sudo().get_param('web.base.url'), "dbname": self._cr.dbname, } for record in self: values["channel_id"] = record.id record.script_external = view._render(values) if record.id else False def _compute_web_page_link(self): base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for record in self: record.web_page = "%s/im_livechat/support/%i" % (base_url, record.id) if record.id else False @api.depends('channel_ids') def _compute_nbr_channel(self): data = self.env['mail.channel'].read_group([ ('livechat_channel_id', 'in', self._ids), ('channel_message_ids', '!=', False)], ['__count'], ['livechat_channel_id'], lazy=False) channel_count = {x['livechat_channel_id'][0]: x['__count'] for x in data} for record in self: record.nbr_channel = channel_count.get(record.id, 0) # -------------------------- # Action Methods # -------------------------- def action_join(self): self.ensure_one() return self.write({'user_ids': [(4, self._uid)]}) def action_quit(self): self.ensure_one() return self.write({'user_ids': [(3, self._uid)]}) def action_view_rating(self): """ Action to display the rating relative to the channel, so all rating of the sessions of the current channel :returns : the ir.action 'action_view_rating' with the correct domain """ self.ensure_one() action = self.env['ir.actions.act_window']._for_xml_id('im_livechat.rating_rating_action_view_livechat_rating') action['domain'] = [('parent_res_id', '=', self.id), ('parent_res_model', '=', 'im_livechat.channel')] return action # -------------------------- # 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_mail_channel_vals(self, anonymous_name, operator, user_id=None, country_id=None): # partner to add to the mail.channel operator_partner_id = operator.partner_id.id channel_partner_to_add = [(4, operator_partner_id)] visitor_user = False if user_id: visitor_user = self.env['res.users'].browse(user_id) if visitor_user and visitor_user.active: # valid session user (not public) channel_partner_to_add.append((4, visitor_user.partner_id.id)) return { 'channel_partner_ids': channel_partner_to_add, 'livechat_active': True, 'livechat_operator_id': operator_partner_id, 'livechat_channel_id': self.id, 'anonymous_name': False if user_id else anonymous_name, 'country_id': country_id, 'channel_type': 'livechat', 'name': ' '.join([visitor_user.display_name if visitor_user else anonymous_name, operator.livechat_username if operator.livechat_username else operator.name]), 'public': 'private', 'email_send': False, } def _open_livechat_mail_channel(self, anonymous_name, previous_operator_id=None, user_id=None, country_id=None): """ Return a mail.channel given a livechat channel. It creates one with a connected operator, or return false otherwise :param anonymous_name : the name of the anonymous person of the channel :param previous_operator_id : partner_id.id of the previous operator that this visitor had in the past :param user_id : the id of the logged in visitor, if any :param country_code : the country of the anonymous person of the channel :type anonymous_name : str :return : channel header :rtype : dict If this visitor already had an operator within the last 7 days (information stored with the 'im_livechat_previous_operator_pid' cookie), the system will first try to assign that operator if he's available (to improve user experience). """ self.ensure_one() operator = False if 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: operator = next(available_user for available_user in available_users if available_user.partner_id.id == previous_operator_id) if not operator: operator = self._get_random_operator() if not operator: # no one available return False # create the session, and add the link with the given channel mail_channel_vals = self._get_livechat_mail_channel_vals(anonymous_name, operator, user_id=user_id, country_id=country_id) mail_channel = self.env["mail.channel"].with_context(mail_create_nosubscribe=False).sudo().create(mail_channel_vals) mail_channel._broadcast([operator.partner_id.id]) return mail_channel.sudo().channel_info()[0] def _get_random_operator(self): """ 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. (Some annoying conversions have to be made on the fly because this model holds 'res.users' as available operators and the mail_channel model stores the partner_id of the randomly selected operator) :return : user :rtype : res.users """ operators = self._get_available_users() if len(operators) == 0: return False self.env.cr.execute("""SELECT COUNT(DISTINCT c.id), c.livechat_operator_id FROM mail_channel c LEFT OUTER JOIN mail_message_mail_channel_rel r ON c.id = r.mail_channel_id LEFT OUTER JOIN mail_message m ON r.mail_message_id = m.id WHERE c.channel_type = 'livechat' 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),)) 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] 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 operators if operator.partner_id.id == less_active_operator) def _get_channel_infos(self): self.ensure_one() return { 'header_background_color': self.header_background_color, 'button_background_color': self.button_background_color, 'title_color': self.title_color, 'button_text_color': self.button_text_color, 'button_text': self.button_text, 'input_placeholder': self.input_placeholder, 'default_message': self.default_message, "channel_name": self.name, "channel_id": self.id, } def get_livechat_info(self, username='Visitor'): self.ensure_one() if username == 'Visitor': username = _('Visitor') info = {} info['available'] = len(self._get_available_users()) > 0 info['server_url'] = self.env['ir.config_parameter'].sudo().get_param('web.base.url') if info['available']: info['options'] = self._get_channel_infos() info['options']['current_partner_id'] = self.env.user.partner_id.id info['options']["default_username"] = username return info class ImLivechatChannelRule(models.Model): """ Channel Rules Rules defining access to the channel (countries, and url matching). It also provide the 'auto pop' option to open automatically the conversation. """ _name = 'im_livechat.channel.rule' _description = 'Livechat Channel Rules' _order = 'sequence asc' regex_url = fields.Char('URL Regex', help="Regular expression specifying the web pages this rule will be applied on.") action = fields.Selection([('display_button', 'Display the button'), ('auto_popup', 'Auto popup'), ('hide_button', 'Hide the button')], string='Action', required=True, default='display_button', help="* 'Display the button' displays the chat button on the pages.\n"\ "* 'Auto popup' displays the button and automatically open the conversation pane.\n"\ "* 'Hide the button' hides the chat button on the pages.") auto_popup_timer = fields.Integer('Auto popup timer', default=0, help="Delay (in seconds) to automatically open the conversation window. Note: the selected action must be 'Auto popup' otherwise this parameter will not be taken into account.") channel_id = fields.Many2one('im_livechat.channel', 'Channel', help="The channel of the rule") country_ids = fields.Many2many('res.country', 'im_livechat_channel_country_rel', 'channel_id', 'country_id', 'Country', help="The rule will only be applied for these countries. Example: if you select 'Belgium' and 'United States' and that you set the action to 'Hide Button', the chat button will be hidden on the specified URL from the visitors located in these 2 countries. This feature requires GeoIP installed on your server.") sequence = fields.Integer('Matching order', default=10, help="Given the order to find a matching rule. If 2 rules are matching for the given url/country, the one with the lowest sequence will be chosen.") def match_rule(self, channel_id, url, country_id=False): """ determine if a rule of the given channel matches with the given url :param channel_id : the identifier of the channel_id :param url : the url to match with a rule :param country_id : the identifier of the country :returns the rule that matches the given condition. False otherwise. :rtype : im_livechat.channel.rule """ def _match(rules): for rule in rules: # url might not be set because it comes from referer, in that # case match the first rule with no regex_url if re.search(rule.regex_url or '', url or ''): return rule return False # first, search the country specific rules (the first match is returned) if country_id: # don't include the country in the research if geoIP is not installed domain = [('country_ids', 'in', [country_id]), ('channel_id', '=', channel_id)] rule = _match(self.search(domain)) if rule: return rule # second, fallback on the rules without country domain = [('country_ids', '=', False), ('channel_id', '=', channel_id)] return _match(self.search(domain))