diff --git a/addons/crm/data/crm_lead_prediction_data.xml b/addons/crm/data/crm_lead_prediction_data.xml index 20a432a11f1..f390b6e6138 100644 --- a/addons/crm/data/crm_lead_prediction_data.xml +++ b/addons/crm/data/crm_lead_prediction_data.xml @@ -17,6 +17,9 @@ + + + diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index b35ac3a9468..7d5c388d033 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -137,6 +137,7 @@ class Lead(models.Model): city = fields.Char('City') state_id = fields.Many2one("res.country.state", string='State') country_id = fields.Many2one('res.country', string='Country') + lang_id = fields.Many2one('res.lang', string='Language', help="Language of the lead.") phone = fields.Char('Phone', tracking=50) mobile = fields.Char('Mobile') function = fields.Char('Job Position') @@ -358,6 +359,10 @@ class Lead(models.Model): res['domain']['state_id'] = [('country_id', '=', self.country_id.id)] return res + @api.onchange('lang_id') + def _onchange_lang_id(self): + self._onchange_compute_probability(optional_field_name='lang_id') + # Phone Validation # ---------------- @api.onchange('phone', 'country_id', 'company_id') diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index ad6b411205e..27e8e9fa24c 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -109,6 +109,7 @@ + diff --git a/addons/crm_iap_lead_website/models/ir.py b/addons/crm_iap_lead_website/models/ir.py index fb844a833dd..765f653d383 100644 --- a/addons/crm_iap_lead_website/models/ir.py +++ b/addons/crm_iap_lead_website/models/ir.py @@ -14,10 +14,10 @@ class IrHttp(models.AbstractModel): def _serve_page(cls): response = super(IrHttp, cls)._serve_page() if response and getattr(response, 'status_code', 0) == 200 and request.env.user._is_public(): - lead_id = request.httprequest.cookies.get('lead_id') + visitor_sudo = request.env['website.visitor']._get_visitor_from_request() # We are avoiding to create a reveal_view if a lead is already # created from another module, e.g. website_form - if not lead_id: + if not (visitor_sudo and visitor_sudo.lead_ids): country_code = 'geoip' in request.session and request.session['geoip'].get('country_code') state_code = 'geoip' in request.session and request.session['geoip'].get('region') if country_code: diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index b90a418f8cd..6803f637e93 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -98,7 +98,7 @@ class LivechatController(http.Controller): if previous_operator_id: previous_operator_id = int(previous_operator_id) - return request.env["im_livechat.channel"].with_context(lang=False).sudo().browse(channel_id)._get_mail_channel(anonymous_name, previous_operator_id, user_id, country_id) + return request.env["im_livechat.channel"].with_context(lang=False).sudo().browse(channel_id)._open_livechat_mail_channel(anonymous_name, previous_operator_id, user_id, country_id) @http.route('/im_livechat/feedback', type='json', auth='public', cors="*") def feedback(self, uuid, rate, reason=None, **kwargs): diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index ed7a6a73252..5f9db25f65e 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -104,7 +104,28 @@ class ImLivechatChannel(models.Model): self.ensure_one() return self.user_ids.filtered(lambda user: user.im_status == 'online') - def _get_mail_channel(self, anonymous_name, previous_operator_id=None, user_id=None, country_id=None): + 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_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.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 @@ -118,7 +139,6 @@ class ImLivechatChannel(models.Model): 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() @@ -131,27 +151,10 @@ class ImLivechatChannel(models.Model): # no one available return False - operator_partner_id = operator.partner_id.id - # partner to add to the mail.channel - 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)) # create the session, and add the link with the given channel - mail_channel = self.env["mail.channel"].with_context(mail_create_nosubscribe=False).sudo().create({ - 'channel_partner_ids': channel_partner_to_add, - '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.name if visitor_user else anonymous_name, operator.livechat_username if operator.livechat_username else operator.name]), - 'public': 'private', - 'email_send': False, - }) - mail_channel._broadcast([operator_partner_id]) + 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): diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index 44c7dad0f05..596357ef3d4 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -178,6 +178,11 @@ var LivechatButton = Widget.extend({ this._livechat.unregisterTyping({ partnerID: partnerID }); } } else { // normal message + // If message from notif is already in chatter messages, stop handling + if (this._messages.some(message => message.getID() === notification[1].id)) { + this._livechat.unregisterTyping({ partnerID: notification[1].author_id[0] }); + return; + } this._addMessage(notification[1]); this._renderMessages(); if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) { diff --git a/addons/im_livechat/tests/test_get_mail_channel.py b/addons/im_livechat/tests/test_get_mail_channel.py index ad464de559d..2ac615dca4a 100644 --- a/addons/im_livechat/tests/test_get_mail_channel.py +++ b/addons/im_livechat/tests/test_get_mail_channel.py @@ -47,20 +47,20 @@ class TestGetMailChannel(TransactionCase): """ for i in range(5): - mail_channels = self._get_mail_channels() + mail_channels = self._open_livechat_mail_channel() channel_operators = [channel_info['operator_pid'] for channel_info in mail_channels] channel_operator_ids = [channel_operator[0] for channel_operator in channel_operators] self.assertTrue(all(partner_id in channel_operator_ids for partner_id in self.operators.mapped('partner_id').ids)) - visitor_user_channel = self.livechat_channel._get_mail_channel('Visitor', user_id=self.visitor_user.id) + visitor_user_channel = self.livechat_channel._open_livechat_mail_channel('Visitor', user_id=self.visitor_user.id) chat_title = '%s (%s)' % (self.visitor_user.name, self.visitor_user.country_id.name) self.assertEqual(visitor_user_channel['correspondent_name'], chat_title, "Chat title should be correct and should contain visitor's country name") - def _get_mail_channels(self): + def _open_livechat_mail_channel(self): mail_channels = [] for i in range(5): - mail_channel = self.livechat_channel._get_mail_channel('Anonymous') + mail_channel = self.livechat_channel._open_livechat_mail_channel('Anonymous') mail_channels.append(mail_channel) # send a message to mark this channel as 'active' self.env['mail.channel'].browse(mail_channel['id']).write({ diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 0131981abe3..550a438b5ea 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -696,9 +696,15 @@ class Channel(models.Model): def channel_pin(self, uuid, pinned=False): # add the person in the channel, and pin it (or unpin it) channel = self.search([('uuid', '=', uuid)]) - channel_partners = self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]) + channel._execute_channel_pin(pinned) + + def _execute_channel_pin(self, pinned=False): + """ Hook for website_livechat channel unpin and cleaning """ + self.ensure_one() + channel_partners = self.env['mail.channel.partner'].search( + [('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', self.id)]) if not pinned: - self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel.channel_info('unsubscribe')[0]) + self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), self.channel_info('unsubscribe')[0]) if channel_partners: channel_partners.write({'is_pinned': pinned}) diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js index cbaaff8194b..f3c6fdfb26c 100644 --- a/addons/mail/static/src/js/services/mail_notification_manager.js +++ b/addons/mail/static/src/js/services/mail_notification_manager.js @@ -202,6 +202,22 @@ MailManager.include({ _handlePartnerActivityUpdateNotification: function (data) { this._mailBus.trigger('activity_updated', data); }, + /** + * Called to open the channel in detach mode (minimized) even if no new message: + * + * @private + * @param {Object} channelData + * @param {integer} channelData.id + * @param {string} [channelData.info] + * @param {boolean} channelData.is_minimized + * @param {string} channelData.state + */ + _handlePartnerChannelMinimizeNotification: function (channelData) { + var self = this; + this._addChannel(channelData).then(function (channelID){ + self.getChannel(channelID).detach() + }); + }, /** * Called when receiving a channel state as a partner notification: * @@ -422,6 +438,8 @@ MailManager.include({ } else if (data.type === 'simple_notification') { var title = _.escape(data.title), message = _.escape(data.message); data.warning ? this.do_warn(title, message, data.sticky) : this.do_notify(title, message, data.sticky); + } else if (data.info === 'channel_minimize') { + this._handlePartnerChannelMinimizeNotification(data); } else { this._handlePartnerChannelNotification(data); } diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 649c092d98b..7a91da01ad7 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -19,12 +19,14 @@ 'installable': True, 'data': [ 'data/website_data.xml', + 'data/website_visitor_cron.xml', 'security/website_security.xml', 'security/ir.model.access.csv', 'views/website_templates.xml', 'views/website_navbar_templates.xml', 'views/snippets.xml', 'views/website_views.xml', + 'views/website_visitor_views.xml', 'views/res_config_settings_views.xml', 'views/ir_actions_views.xml', 'views/ir_attachment_views.xml', diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 3f62209b807..f409c9f2ab5 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -120,16 +120,19 @@ True / + True /contactus True + True True /aboutus + True diff --git a/addons/website/data/website_visitor_cron.xml b/addons/website/data/website_visitor_cron.xml new file mode 100644 index 00000000000..fc5e6aae667 --- /dev/null +++ b/addons/website/data/website_visitor_cron.xml @@ -0,0 +1,14 @@ + + + + Website Visitor : Archive old visitors + + code + model._cron_archive_visitors() + 1 + days + -1 + + + + diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py index 77c01e012a0..1c32e9d514a 100644 --- a/addons/website/models/__init__.py +++ b/addons/website/models/__init__.py @@ -19,3 +19,4 @@ from . import res_partner from . import res_users from . import res_config_settings from . import res_lang +from . import website_visitor diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index ec4e89d7ea0..58c15a8128c 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -86,6 +86,20 @@ class Http(models.AbstractModel): if not request.uid: super(Http, cls)._auth_method_public() + @classmethod + def _extract_website_page(cls, response): + if getattr(response, 'status_code', 0) != 200: + return False + + main_object = getattr(response, 'qcontext', {}).get('main_object') + return main_object if getattr(main_object, '_name', False) == 'website.page' else False + + @classmethod + def _dispatch(cls): + response = super(Http, cls)._dispatch() + request.env['website.visitor']._handle_webpage_dispatch(response, cls._extract_website_page(response)) + return response + @classmethod def _add_dispatch_parameters(cls, func): diff --git a/addons/website/models/res_partner.py b/addons/website/models/res_partner.py index 787e6e6b010..a3905d5f163 100644 --- a/addons/website/models/res_partner.py +++ b/addons/website/models/res_partner.py @@ -3,7 +3,7 @@ import werkzeug -from odoo import api, models +from odoo import models, fields def urlplus(url, params): @@ -14,6 +14,8 @@ class Partner(models.Model): _name = 'res.partner' _inherit = ['res.partner', 'website.published.multi.mixin'] + visitor_ids = fields.Many2many('website.visitor', 'website_visitor_partner_rel', 'partner_id', 'visitor_id', string='Visitors') + def google_map_img(self, zoom=8, width=298, height=298): google_maps_api_key = self.env['website'].get_current_website().google_maps_api_key if not google_maps_api_key: diff --git a/addons/website/models/res_users.py b/addons/website/models/res_users.py index 55a7611995f..c652604cae6 100644 --- a/addons/website/models/res_users.py +++ b/addons/website/models/res_users.py @@ -53,3 +53,19 @@ class ResUsers(models.Model): tools.create_unique_index(self._cr, 'res_users_login_key_unique_website_index', self._table, ['login', 'COALESCE(website_id,-1)']) return result + + @classmethod + def authenticate(cls, db, login, password, user_agent_env): + """ Override to link the logged in user's res.partner to website.visitor """ + uid = super(ResUsers, cls).authenticate(db, login, password, user_agent_env) + if uid: + with cls.pool.cursor() as cr: + env = api.Environment(cr, uid, {}) + visitor_sudo = env['website.visitor']._get_visitor_from_request() + if visitor_sudo: + vals = { + 'user_partner_id': env.user.partner_id.id, + 'name': env.user.partner_id.name + } + visitor_sudo.write(vals) + return uid diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py index ea3b8a27657..8bde2aa8a68 100644 --- a/addons/website/models/website_page.py +++ b/addons/website/models/website_page.py @@ -20,6 +20,7 @@ class Page(models.Model): menu_ids = fields.One2many('website.menu', 'page_id', 'Related Menus') is_homepage = fields.Boolean(compute='_compute_homepage', inverse='_set_homepage', string='Homepage') is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible') + is_tracked = fields.Boolean(string='Is Tracked', default=False, help="A tracked page will be included in visitors browsing history.") # Page options header_overlay = fields.Boolean() diff --git a/addons/website/models/website_visitor.py b/addons/website/models/website_visitor.py new file mode 100644 index 00000000000..9e4764a80c4 --- /dev/null +++ b/addons/website/models/website_visitor.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from hashlib import sha256 +import hmac + +from odoo import fields, models, api, _ +from odoo.tools.translate import _format_time_ago +from odoo.tools.misc import _consteq +from odoo.http import request + + +class WebsitVisitorPage(models.Model): + _name = 'website.visitor.page' + _description = 'Visited Pages' + _order = 'visit_datetime ASC' + _log_access = False + + visitor_id = fields.Many2one('website.visitor', ondelete="cascade", index=True, required=True, readonly=True) + page_id = fields.Many2one('website.page', index=True, ondelete='cascade', readonly=True) + visit_datetime = fields.Datetime('Visit Date', default=fields.Datetime.now, required=True, readonly=True) + + +class WebsiteVisitor(models.Model): + _name = 'website.visitor' + _description = 'Website Visitor' + _order = 'last_connection_datetime DESC' + + name = fields.Char('Name', default=_('Website Visitor')) + active = fields.Boolean('Active', default=True) + website_id = fields.Many2one('website', "Website", readonly=True) + user_partner_id = fields.Many2one('res.partner', string="Linked Partner", help="Partner of the last logged in user.") + create_date = fields.Datetime('First connection date', readonly=True) + last_connection_datetime = fields.Datetime('Last Connection', help="Last page view date", readonly=True) + country_id = fields.Many2one('res.country', 'Country', readonly=True) + country_flag = fields.Binary(related="country_id.image", string="Country Flag") + lang_id = fields.Many2one('res.lang', string='Language', help="Language from the website when visitor has been created") + visit_count = fields.Integer('Number of visits', default=1, readonly=True, help="A new visit is considered if last connection was more than 8 hours ago.") + visitor_page_ids = fields.One2many('website.visitor.page', 'visitor_id', string='Visited Pages History', readonly=True) + visitor_page_count = fields.Integer('Page Views', compute="_compute_page_statistics") + page_ids = fields.Many2many('website.page', string="Visited Pages", compute="_compute_page_statistics", store=True) + page_count = fields.Integer('# Visited Pages', compute="_compute_page_statistics") + time_since_last_action = fields.Char('Last action', compute="_compute_time_statistics", help='Time since last page view. E.g.: 2 minutes ago') + is_connected = fields.Boolean('Is connected ?', compute='_compute_time_statistics', help='A visitor is considered as connected if his last page view was within the last 5 minutes.') + + @api.depends('visitor_page_ids') + def _compute_page_statistics(self): + results = self.env['website.visitor.page'].read_group( + [('visitor_id', 'in', self.ids)], ['visitor_id', 'page_id'], ['visitor_id', 'page_id'], lazy=False) + mapped_data = {} + for result in results: + visitor_info = mapped_data.get(result['visitor_id'][0], {'page_count': 0, 'page_ids': set()}) + visitor_info['page_count'] += result['__count'] + visitor_info['page_ids'].add(result['page_id'][0]) + mapped_data[result['visitor_id'][0]] = visitor_info + + for visitor in self: + visitor_info = mapped_data.get(visitor.id, {'page_ids': [], 'page_count': 0}) + + visitor.page_ids = [(6, 0, visitor_info['page_ids'])] + visitor.visitor_page_count = visitor_info['page_count'] + visitor.page_count = len(visitor_info['page_ids']) + + @api.depends('last_connection_datetime') + def _compute_time_statistics(self): + results = self.env['website.visitor'].search_read([('id', 'in', self.ids)], ['id', 'last_connection_datetime']) + mapped_data = {result['id']: result['last_connection_datetime'] for result in results} + + for visitor in self: + last_connection_date = mapped_data[visitor.id] + visitor.time_since_last_action = _format_time_ago(self.env, (datetime.now() - last_connection_date)) + visitor.is_connected = (datetime.now() - last_connection_date) < timedelta(minutes=5) + + def _get_visitor_sign(self): + return {visitor.id: "%d-%s" % (visitor.id, self._get_visitor_hash(visitor.id)) for visitor in self} + + @api.model + def _get_visitor_hash(self, visitor_id): + db_secret = request.env['ir.config_parameter'].sudo().get_param('database.secret') + return hmac.new(str(visitor_id).encode('utf-8'), db_secret.encode('utf-8'), sha256).hexdigest() + + def _get_visitor_from_request(self): + if not request: + return None + visitor = self.env['website.visitor'] + cookie_content = request.httprequest.cookies.get('visitor_id') + if cookie_content and '-' in cookie_content: + visitor_id, visitor_hash = cookie_content.split('-', 1) + if _consteq(visitor_hash, self._get_visitor_hash(visitor_id)): + return visitor.sudo().with_context(active_test=False).search([('id', '=', visitor_id)]) # search to avoid having to call exists() + return visitor + + def _handle_webpage_dispatch(self, response, website_page): + if website_page: + # get visitor only if page tracked. Done here to avoid having to do it multiple times in case of override. + visitor_sudo = self._get_visitor_from_request() if website_page.is_tracked else False + self._handle_website_page_visit(response, website_page, visitor_sudo) + + def _handle_website_page_visit(self, response, website_page, visitor_sudo): + """ Called on dispatch. This will create a website.visitor if the http request object + is a tracked website page. Only on tracked page to avoid having too much operations done on every page + or other http requests. + Note: The side effect is that the last_connection_datetime is updated ONLY on tracked pages.""" + if website_page.is_tracked: + if not visitor_sudo: + # If visitor does not exist + visitor_sudo = self._create_visitor(website_page.id) + sign = visitor_sudo._get_visitor_sign().get(visitor_sudo.id) + response.set_cookie('visitor_id', sign) + else: + # Add page even if already in visitor_page_ids as checks on relations are done in many2many write method + vals = { + 'last_connection_datetime': datetime.now(), + 'visitor_page_ids': [(0, 0, {'page_id': website_page.id, 'visit_datetime': datetime.now()})], + } + if visitor_sudo.last_connection_datetime < (datetime.now() - timedelta(hours=8)): + vals['visit_count'] = visitor_sudo.visit_count + 1 + if not visitor_sudo.active: + vals['active'] = True + visitor_sudo.write(vals) + + def _create_visitor(self, website_page_id=False): + country_code = request.session.get('geoip', {}).get('country_code', False) + country_id = request.env['res.country'].sudo().search([('code', '=', country_code)], limit=1).id if country_code else False + lang_id = request.env['res.lang'].sudo().search([('code', '=', request.lang)], limit=1).id + vals = { + 'last_connection_datetime': datetime.now(), + 'lang_id': lang_id, + 'country_id': country_id, + 'website_id': request.website.id + } + if not self.env.user._is_public(): + vals['user_partner_id'] = self.env.user.partner_id.id + if website_page_id: + vals['visitor_page_ids'] = [(0, 0, {'page_id': website_page_id, 'visit_datetime': datetime.now()})] + # Set signed visitor id in cookie + return self.sudo().create(vals) + + def _cron_archive_visitors(self): + one_week_ago = datetime.now() - timedelta(days=7) + visitors_to_archive = self.env['website.visitor'].sudo().search([('last_connection_datetime', '<', one_week_ago)]) + visitors_to_archive.write({'active': False}) diff --git a/addons/website/security/ir.model.access.csv b/addons/website/security/ir.model.access.csv index 65cb406029a..7e80fee2dc2 100644 --- a/addons/website/security/ir.model.access.csv +++ b/addons/website/security/ir.model.access.csv @@ -12,3 +12,7 @@ access_website_ir_ui_view,access_website_ir_ui_view,model_ir_ui_view,group_websi access_seo_public,access_seo_public,model_website_seo_metadata,,1,0,0,0 access_seo_manager,access_seo_manager,model_website_seo_metadata,group_website_designer,1,1,1,1 access_seo_designer,access_seo_designer,model_website_seo_metadata,group_website_designer,1,1,1,1 +access_website_visitor_designer,access_website_visitor_designer,model_website_visitor,website.group_website_designer,1,0,0,0 +access_website_visitor_system,access_website_visitor_system,model_website_visitor,base.group_system,1,1,1,1 +access_website_visitor_page_designer,access_website_visitor_page_designer,model_website_visitor_page,website.group_website_designer,1,0,0,0 +access_website_visitor_page_system,access_website_visitor_page_system,model_website_visitor_page,base.group_system,1,1,1,1 diff --git a/addons/website/static/src/scss/website.backend.scss b/addons/website/static/src/scss/website.backend.scss index 13e7667ef0b..14679985b67 100644 --- a/addons/website/static/src/scss/website.backend.scss +++ b/addons/website/static/src/scss/website.backend.scss @@ -216,3 +216,12 @@ } } } + +.oe_stat_button { + &.o_stat_button_info:hover { + color: #666666 !important; + background-color: transparent !important; + opacity: 0.8 !important; + cursor: default !important; + } +} diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py index bfc1659e9b0..dc24bd6e22a 100644 --- a/addons/website/tests/__init__.py +++ b/addons/website/tests/__init__.py @@ -12,3 +12,4 @@ from . import test_menu from . import test_page from . import test_website_favicon from . import test_website_reset_password +from . import test_website_visitor diff --git a/addons/website/tests/test_website_visitor.py b/addons/website/tests/test_website_visitor.py new file mode 100644 index 00000000000..60a10582c97 --- /dev/null +++ b/addons/website/tests/test_website_visitor.py @@ -0,0 +1,38 @@ +# coding: utf-8 +from odoo.tests import HttpCase, tagged + +@tagged('dbetest') +class WebsiteVisitorTests(HttpCase): + def test_create_visitor_on_tracked_page(self): + Page = self.env['website.page'] + View = self.env['ir.ui.view'] + Visitor = self.env['website.visitor'] + base_view = View.create({ + 'name': 'Base', + 'type': 'qweb', + 'arch': ''' + + I am a generic page + + ''', + 'key': 'test.base_view', + }) + [untracked_page, tracked_page] = Page.create([ + { + 'view_id': base_view.id, + 'url': '/untracked_page_1', + 'website_published': True + }, + { + 'view_id': base_view.id, + 'url': '/tracked_page_1', + 'website_published': True, + 'is_tracked': True + } + ]) + + self.assertEqual(len(Visitor.search([])), 0, "No visitor at the moment") + self.url_open(untracked_page.url) + self.assertEqual(len(Visitor.search([])), 0, "No visitor created after visiting an untracked page") + self.url_open(tracked_page.url) + self.assertEqual(len(Visitor.search([])), 1, "A visitor should be created after visiting a tracked page") diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml index ad6a19ede2e..ee310ddf8cc 100644 --- a/addons/website/views/website_views.xml +++ b/addons/website/views/website_views.xml @@ -147,6 +147,7 @@ + @@ -174,10 +175,25 @@ + + + website.page.view.search + website.page + + + + + + + + + + + Website Pages website.page diff --git a/addons/website/views/website_visitor_views.xml b/addons/website/views/website_visitor_views.xml new file mode 100644 index 00000000000..d2919040990 --- /dev/null +++ b/addons/website/views/website_visitor_views.xml @@ -0,0 +1,199 @@ + + + + + Page Views History + website.visitor.page + tree,form + [('visitor_id', '=', active_id)] + +

+ No page views yet for this visitor +

+
+
+ + + website.visitor.page.view.tree + website.visitor.page + + + + + + + + + + website.visitor.page.view.search + website.visitor.page + + + + + + + + + + + + + + + Partners + res.partner + tree,form + [('visitor_ids', 'in', [active_id])] + +

+ No partner linked for this visitor +

+
+
+ + + + website.visitor.view.kanban + website.visitor + + + + + + + + + +
+
+
+ + +
+ + Country + + +
Active
+
Visits
+
Visited Pages
+
+
+
+
+
+
+
+
+ + + website.visitor.view.form + website.visitor + +
+
+ +
+ + + + +
+
+
+
+ + + + + + + + + + + + + + +
+ +
+
+ + + website.visitor.view.tree + website.visitor + + + + + + + + + + + + + + + + website.visitor.view.search + website.visitor + + + + + + + + + + + + + + + + + + + + + + + + Visitors + ir.actions.act_window + website.visitor + {'search_default_is_connected':1} + tree,form,kanban + +

+ Wait for visitors to come to your website to see their history. +

+
+
+ + +
diff --git a/addons/website_crm/__manifest__.py b/addons/website_crm/__manifest__.py index 01be0b6f77e..c402dccf0c5 100644 --- a/addons/website_crm/__manifest__.py +++ b/addons/website_crm/__manifest__.py @@ -16,6 +16,7 @@ This module includes contact phone and mobile numbers validation.""", 'data/website_crm_data.xml', 'views/website_crm_templates.xml', 'views/res_config_settings_views.xml', + 'views/website_visitor_views.xml', ], 'qweb': ['static/src/xml/*.xml'], 'installable': True, diff --git a/addons/website_crm/controllers/main.py b/addons/website_crm/controllers/main.py index 7671ac7f7da..d8f5a20eddc 100644 --- a/addons/website_crm/controllers/main.py +++ b/addons/website_crm/controllers/main.py @@ -52,4 +52,16 @@ class WebsiteForm(WebsiteForm): if model.model == 'crm.lead': if 'company_id' not in values: values['company_id'] = request.website.company_id.id - return super(WebsiteForm, self).insert_record(request, model, values, custom, meta=meta) + lang = request.context.get('lang', False) + lang_id = request.env["res.lang"].sudo().search([('code', '=', lang)], limit=1).id + values['lang_id'] = lang_id + result = super(WebsiteForm, self).insert_record(request, model, values, custom, meta=meta) + visitor_sudo = request.env['website.visitor']._get_visitor_from_request() + if visitor_sudo and result: + lead_sudo = request.env['crm.lead'].browse(result).sudo() + if lead_sudo.exists(): + vals = {'lead_ids': [(4, result)]} + if not visitor_sudo.lead_ids and not visitor_sudo.user_partner_id: + vals['name'] = request.env['crm.lead'].browse(result).sudo().contact_name + visitor_sudo.write(vals) + return result diff --git a/addons/website_crm/models/__init__.py b/addons/website_crm/models/__init__.py index 7ac0406ee23..cfa52fe08d4 100644 --- a/addons/website_crm/models/__init__.py +++ b/addons/website_crm/models/__init__.py @@ -3,3 +3,4 @@ from . import crm_lead from . import res_config_settings +from . import website_visitor diff --git a/addons/website_crm/models/crm_lead.py b/addons/website_crm/models/crm_lead.py index 354e7bf372a..e5c7f53b2be 100644 --- a/addons/website_crm/models/crm_lead.py +++ b/addons/website_crm/models/crm_lead.py @@ -7,6 +7,8 @@ from odoo import fields, models class Lead(models.Model): _inherit = 'crm.lead' + visitor_id = fields.Many2one('website.visitor', "Web Visitor") + def website_form_input_filter(self, request, values): values['medium_id'] = values.get('medium_id') or \ self.default_get(['medium_id']).get('medium_id') or \ diff --git a/addons/website_crm/models/website_visitor.py b/addons/website_crm/models/website_visitor.py new file mode 100644 index 00000000000..576c11d6fea --- /dev/null +++ b/addons/website_crm/models/website_visitor.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + + +class WebsiteVisitor(models.Model): + _inherit = 'website.visitor' + + lead_ids = fields.One2many('crm.lead', 'visitor_id', string='Leads') + lead_count = fields.Integer('# Leads', compute="_compute_lead_count") + + @api.depends('lead_ids.visitor_id') + def _compute_lead_count(self): + page_data = self.env['crm.lead'].read_group([('visitor_id', 'in', self.ids)], ['visitor_id'], ['visitor_id']) + mapped_data = dict([(data['visitor_id'][0], data['visitor_id_count']) for data in page_data]) + for visitor in self: + visitor.lead_count = mapped_data.get(visitor.id, 0) diff --git a/addons/website_crm/views/website_visitor_views.xml b/addons/website_crm/views/website_visitor_views.xml new file mode 100644 index 00000000000..5c5807a0416 --- /dev/null +++ b/addons/website_crm/views/website_visitor_views.xml @@ -0,0 +1,53 @@ + + + + + Leads + crm.lead + tree,form + [('visitor_id', '=', active_id)] + +

+ No lead linked for this visitor +

+
+
+ + + + website.visitor.view.form.inherit.website.crm + website.visitor + + + + + + + + + + website.visitor.view.tree.inherit.website.crm + website.visitor + + + + + + + + + + website.visitor.view.search.inherit.website.crm + website.visitor + + + + + + + + +
diff --git a/addons/website_livechat/__manifest__.py b/addons/website_livechat/__manifest__.py index 2a4154a3d63..9677350cb25 100644 --- a/addons/website_livechat/__manifest__.py +++ b/addons/website_livechat/__manifest__.py @@ -15,8 +15,12 @@ Allow website visitors to chat with the collaborators. This module also brings a 'views/website_livechat.xml', 'views/res_config_settings_views.xml', 'views/website_livechat_view.xml', + 'views/website_visitor_views.xml', 'security/ir.model.access.csv', 'security/website_livechat.xml', 'data/website_livechat_data.xml', ], + 'qweb': [ + 'static/src/xml/thread.xml', + ], } diff --git a/addons/website_livechat/controllers/main.py b/addons/website_livechat/controllers/main.py index 8b5a6fba317..4525191b916 100644 --- a/addons/website_livechat/controllers/main.py +++ b/addons/website_livechat/controllers/main.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import http +from odoo import http, _ from odoo.http import request @@ -55,3 +55,22 @@ class WebsiteLivechat(http.Controller): 'ratings_per_user': ratings_per_partner } return request.render("website_livechat.channel_page", values) + + @http.route('/im_livechat/visitor_leave_session', type='json', auth="public") + def visitor_leave_session(self, uuid): + """ Called when the livechat visitor leaves the conversation. + This will clean the chat request and warn the operator that the conversation is over. + This allows also to re-send a new chat request to the visitor, as while the visitor is + in conversation with an operator, it's not possible to send the visitor a chat request.""" + mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', uuid)]) + if mail_channel: + mail_channel.close_livechat_request_session(_('has left the conversation.')) + + @http.route('/im_livechat/close_empty_livechat', type='json', auth="public") + def close_empty_livechat(self, uuid): + """ Called when an operator send a chat request to a visitor but does not speak to him and closes + the chatter. (when the operator does not complete the 'send chat request' flow in other terms) + This will clean the chat request and allows operators to send the visitor a new chat request.""" + mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', uuid)]) + if mail_channel: + mail_channel.channel_pin(uuid, False) diff --git a/addons/website_livechat/models/__init__.py b/addons/website_livechat/models/__init__.py index 18f7d71a23d..7da0d975cd0 100644 --- a/addons/website_livechat/models/__init__.py +++ b/addons/website_livechat/models/__init__.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- from . import im_livechat -from . import website -from . import res_config_settings +from . import im_livechat_channel from . import ir_http +from . import mail_channel +from . import res_config_settings +from . import website +from . import website_visitor diff --git a/addons/website_livechat/models/im_livechat_channel.py b/addons/website_livechat/models/im_livechat_channel.py new file mode 100644 index 00000000000..db41f626a83 --- /dev/null +++ b/addons/website_livechat/models/im_livechat_channel.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, _ + + +class ImLivechatChannel(models.Model): + _inherit = 'im_livechat.channel' + + def _get_livechat_mail_channel_vals(self, anonymous_name, operator, user_id=None, country_id=None): + mail_channel_vals = super(ImLivechatChannel, self)._get_livechat_mail_channel_vals(anonymous_name, operator, user_id=user_id, country_id=country_id) + visitor_sudo = self.env['website.visitor']._get_visitor_from_request() + if visitor_sudo: + mail_channel_vals.update({ + 'livechat_visitor_id': visitor_sudo.id, + 'livechat_active': True + }) + if not user_id: + mail_channel_vals['anonymous_name'] = visitor_sudo.name + (' (%s)' % visitor_sudo.country_id.name if visitor_sudo.country_id else '') + # As chat requested by the visitor, delete the chat requested by an operator if any to avoid conflicts between two flows + chat_request_channel = self.env['mail.channel'].sudo().search([('livechat_visitor_id', '=', visitor_sudo.id), ('livechat_active', '=', True)]) + for mail_channel in chat_request_channel: + mail_channel.close_livechat_request_session(_('has started a conversation with %s. The chat request has been canceled.') % operator.name) + + return mail_channel_vals diff --git a/addons/website_livechat/models/mail_channel.py b/addons/website_livechat/models/mail_channel.py new file mode 100644 index 00000000000..ba8fb18fec7 --- /dev/null +++ b/addons/website_livechat/models/mail_channel.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, _ + + +class MailChannel(models.Model): + _inherit = 'mail.channel' + + livechat_visitor_id = fields.Many2one('website.visitor', string='Visitor') + livechat_active = fields.Boolean('Is livechat ongoing?', help='Livechat session is not considered as active if the visitor left the conversation.') + + def _execute_channel_pin(self, pinned=False): + """ Override to clean an empty livechat channel. + This is typically called when the operator send a chat request to a website.visitor + but don't speak to him and closes the chatter. + This allows operators to send the visitor a new chat request. + If active empty livechat channel, + delete mail_channel as not useful to keep empty chat + """ + super(MailChannel, self)._execute_channel_pin(pinned) + if self.livechat_active and not self.channel_message_ids: + self.unlink() + + def channel_info(self, extra_info=False): + """ + Override to add visitor information on the mail channel infos. + This will be used to display a banner with visitor informations + at the top of the livechat channel discussion view in discuss module. + """ + channel_infos = super(MailChannel, self).channel_info(extra_info) + channel_infos_dict = dict((c['id'], c) for c in channel_infos) + for channel in self: + visitor = channel.livechat_visitor_id + if visitor: + channel_infos_dict[channel.id]['visitor'] = { + 'name': visitor.name, + 'country_code': visitor.country_id.code.lower() if visitor.country_id else False, + 'is_connected': visitor.is_connected, + 'history': self._get_visitor_history(visitor), + 'website': visitor.website_id.name, + 'lang': visitor.lang_id.name, + 'partner_id': visitor.user_partner_id.id, + } + return list(channel_infos_dict.values()) + + def _get_visitor_history(self, visitor): + """ + Prepare history string to render it in the visitor info div on discuss livechat channel view. + :param visitor: website.visitor of the channel + :return: arrow separated string containing navigation history information + """ + recent_history = visitor.visitor_page_ids[:3] if len(visitor.visitor_page_ids) >= 3 else visitor.visitor_page_ids + return ' → '.join(page.page_id.name + ' (' + page.visit_datetime.strftime('%H:%M') + ')' for page in recent_history) + + def close_livechat_request_session(self, message): + self.ensure_one() + if self.livechat_active: + self.livechat_active = False + # Notify that the visitor has left the conversation + name = _('The visitor') if not self.livechat_visitor_id else self.livechat_visitor_id.name + leave_message = '%s %s' % (name, message) + self.message_post(author_id=self.env.ref('base.user_root').sudo().partner_id.id, + body=leave_message, message_type='comment', subtype='mt_comment') + + def message_post(self, **kwargs): + """Override to mark the visitor as still connected. + If the message sent is not from the operator (so if it's the visitor or + odoobot sending closing chat notification, the visitor last action date is updated.""" + message = super(MailChannel, self).message_post(**kwargs) + message_author_id = message.author_id + visitor = self.livechat_visitor_id + if len(self) == 1 and visitor and message_author_id != self.livechat_operator_id: + visitor.sudo().write({ + 'last_connection_datetime': fields.datetime.now(), + }) + return message + diff --git a/addons/website_livechat/models/website_visitor.py b/addons/website_livechat/models/website_visitor.py new file mode 100644 index 00000000000..c27c002a99a --- /dev/null +++ b/addons/website_livechat/models/website_visitor.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api, fields, _ +from odoo.exceptions import UserError +import json + + +class WebsiteVisitor(models.Model): + _inherit = 'website.visitor' + + livechat_operator_id = fields.Many2one('res.partner', compute='_compute_livechat_operator_id', store=True, string='Speaking with') + livechat_operator_name = fields.Char('Operator Name', related="livechat_operator_id.name") + mail_channel_ids = fields.One2many('mail.channel', 'livechat_visitor_id', + string="Visitor's livechat channels", readonly=True) + session_count = fields.Integer('# Sessions', compute="_compute_session_count") + + @api.depends('mail_channel_ids.livechat_active', 'mail_channel_ids.livechat_operator_id') + def _compute_livechat_operator_id(self): + results = self.env['mail.channel'].search_read( + [('livechat_visitor_id', 'in', self.ids), ('livechat_active', '=', True)], + ['livechat_visitor_id', 'livechat_operator_id'] + ) + visitor_operator_map = {int(result['livechat_visitor_id'][0]): int(result['livechat_operator_id'][0]) for result in results} + for visitor in self: + visitor.livechat_operator_id = visitor_operator_map.get(visitor.id, False) + + @api.depends('mail_channel_ids') + def _compute_session_count(self): + sessions = self.env['mail.channel'].read_group([('livechat_visitor_id', 'in', self.ids)], ['livechat_visitor_id'], ['livechat_visitor_id']) + sessions_count = {session['livechat_visitor_id'][0]: session['livechat_visitor_id_count'] for session in sessions} + for visitor in self: + visitor.session_count = sessions_count.get(visitor.id, 0) + + def action_send_chat_request(self): + """ Send a chat request to website_visitor(s). + This creates a chat_request and a mail_channel with livechat active flag. + But for the visitor to get the chat request, the operator still has to speak to the visitor. + The visitor will receive the chat request the next time he navigates to a website page. + (see _handle_webpage_dispatch for next step)""" + # check if visitor is available + unavailable_visitors_count = self.env['mail.channel'].search_count([('livechat_visitor_id', 'in', self.ids), ('livechat_active', '=', True)]) + if unavailable_visitors_count: + raise UserError(_('Recipients are not available. Please refresh the page to get latest visitors status.')) + # check if user is available as operator + for website in self.mapped('website_id'): + if not website.channel_id: + raise UserError(_('No Livechat Channel allows you to send a chat request for website %s.' % website.name)) + self.website_id.channel_id.write({'user_ids': [(4, self.env.user.id)]}) + # Create chat_requests and linked mail_channels + mail_channel_vals_list = [] + for visitor in self: + operator = self.env.user + country = visitor.country_id + visitor_name = "%s (%s)" % (visitor.name, country.name) if country else visitor.name + mail_channel_vals_list.append({ + 'channel_partner_ids': [(4, operator.partner_id.id)], + 'livechat_channel_id': visitor.website_id.channel_id.id, + 'livechat_operator_id': self.env.user.partner_id.id, + 'channel_type': 'livechat', + 'public': 'private', + 'email_send': False, + 'country_id': country.id, + 'anonymous_name': visitor_name, + 'name': ', '.join([visitor_name, operator.livechat_username if operator.livechat_username else operator.name]), + 'livechat_visitor_id': visitor.id, + 'livechat_active': True, + }) + if mail_channel_vals_list: + mail_channels = self.env['mail.channel'].create(mail_channel_vals_list) + # Open empty chatter to allow the operator to start chatting with the visitor. + mail_channels_info = mail_channels.channel_info('channel_minimize') + for mail_channel_info in mail_channels_info: + self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', operator.partner_id.id), mail_channel_info) + + def _handle_website_page_visit(self, response, website_page, visitor_sudo): + """ Called when the visitor navigates to a website page. + This checks if there is a chat request for the visitor. + It will set the livechat session cookie of the visitor with the mail channel information + to make the usual livechat mechanism do the rest. + (opening the chatter if a livechat session exist for the visitor) + This will only happen if the mail channel linked to the chat request already has a message. + So that empty livechat channel won't pop up at client side. """ + super(WebsiteVisitor, self)._handle_website_page_visit(response, website_page, visitor_sudo) + visitor_id = self.env['website.visitor']._get_visitor_from_request().id if not visitor_sudo else visitor_sudo.id + if visitor_id: + # get active chat_request linked to visitor + chat_request_channel = self.env['mail.channel'].sudo().search([('livechat_visitor_id', '=', visitor_id), ('livechat_active', '=', True)], order='create_date desc', limit=1) + if chat_request_channel and chat_request_channel.channel_message_ids: + livechat_session = json.dumps({ + "folded": False, + "id": chat_request_channel.id, + "message_unread_counter": 0, + "operator_pid": [ + chat_request_channel.livechat_operator_id.id, + chat_request_channel.livechat_operator_id.display_name + ], + "name": chat_request_channel.name, + "uuid": chat_request_channel.uuid, + "type": "chat_request" + }) + response.set_cookie('im_livechat_session', livechat_session) diff --git a/addons/website_livechat/security/ir.model.access.csv b/addons/website_livechat/security/ir.model.access.csv index f33a4ac2f2d..90de53247be 100644 --- a/addons/website_livechat/security/ir.model.access.csv +++ b/addons/website_livechat/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_im_livechat_channel_public,im_livechat.channel.public,im_livechat.model_im_livechat_channel,,1,0,0,0 \ No newline at end of file +access_im_livechat_channel_public,im_livechat.channel.public,im_livechat.model_im_livechat_channel,,1,0,0,0 +access_website_visitor_livechat_users,website.visitor.livechat.users,model_website_visitor,im_livechat.im_livechat_group_user,1,0,0,0 diff --git a/addons/website_livechat/static/src/js/channel.js b/addons/website_livechat/static/src/js/channel.js new file mode 100644 index 00000000000..24d679e8593 --- /dev/null +++ b/addons/website_livechat/static/src/js/channel.js @@ -0,0 +1,34 @@ +odoo.define('website.livechat.mail.model.Channel', function (require) { +"use strict"; + +var Channel = require('mail.model.Channel'); +var session = require('web.session'); + +/** + * This class represent channels in JS. In this context, the word channel + * has the same meaning of channel on the server, meaning that direct messages + * (DM) and livechats are also channels. + * + * Any piece of code in JS that make use of channels must ideally interact with + * such objects, instead of direct data from the server. + */ +Channel.include({ + /** + * @override + * Add the visitor if is set on the channel + * @param {Object} params + * @param {Object} params.data + * @param {string} params.data.visitor + */ + init: function (params) { + var self = this; + var data = params.data; + this._visitor = data.visitor; + this._super.apply(this, arguments); + }, + +}); + +return Channel; + +}); diff --git a/addons/website_livechat/static/src/js/im_livechat.js b/addons/website_livechat/static/src/js/im_livechat.js new file mode 100644 index 00000000000..d5ce27af2e1 --- /dev/null +++ b/addons/website_livechat/static/src/js/im_livechat.js @@ -0,0 +1,69 @@ +odoo.define('website_livechat.livechat_request', function (require) { +"use strict"; + +var utils = require('web.utils'); +var session = require('web.session'); +var LivechatButton = require('im_livechat.im_livechat').LivechatButton; + + +LivechatButton.include({ + + /** + * @override + * This will will correctly format the livechat session cookie + * that comes from server side (and that is not properly formatted) + * This is used for chat request mechanism, when an operator send a chat request + * from backend to a website visitor. + */ + willStart: function () { + var self = this; + var cookie = utils.get_cookie('im_livechat_session'); + var ready; + if (cookie) { + var cleanedLivechatSessionCookie = this.decode_server_cookie(cookie); + utils.set_cookie('im_livechat_session', cleanedLivechatSessionCookie, 60*60); + } + return this._super(); + }, + + /** + * @override + * Called when the visitor closes the livechat chatter + * (no matter the way : close, send feedback, ..) + * this will deactivate the mail_channel, clean the chat request if any + * and allow the operators to send the visitor a new chat request + */ + _closeChat: function () { + var self = this; + var cookie = utils.get_cookie('im_livechat_session'); + if (cookie) { + var channel = JSON.parse(cookie); + var ready = session.rpc('/im_livechat/visitor_leave_session', {uuid: channel.uuid}); + ready.then(self._super()); + } + else { + this._super(); + } + }, + + /** + * Utils to correctly re-encode json string sent by server. + * Copied from StackOverflow. + */ + decode_server_cookie: function (val) { + if (val.indexOf('\\') === -1) { + return val; // not encoded + } + val = val.slice(1, -1).replace(/\\"/g, '"'); + val = val.replace(/\\(\d{3})/g, function(match, octal) { + return String.fromCharCode(parseInt(octal, 8)); + }); + return val.replace(/\\\\/g, '\\'); + }, +}); + +return { + LivechatButton: LivechatButton, +}; + +}); diff --git a/addons/website_livechat/static/src/js/thread_window.js b/addons/website_livechat/static/src/js/thread_window.js new file mode 100644 index 00000000000..a53d2bcddd5 --- /dev/null +++ b/addons/website_livechat/static/src/js/thread_window.js @@ -0,0 +1,34 @@ +odoo.define('website_livechat.ThreadWindow', function (require) { +"use strict"; + +var ThreadWindow = require('mail.ThreadWindow'); +var session = require('web.session'); + +/** + * This is the main widget for rendering small windows for mail.model.Thread. + * Almost all instances of this class are linked to a thread. The sole + * exception is the "blank" thread window. This window let us open another + * thread window, using this "blank" thread window. + */ +ThreadWindow.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + close: function () { + var self = this; + if (this.hasThread() && this._thread._type === "livechat" && this._threadWidget._messages.length == 0) { + session.rpc('/im_livechat/close_empty_livechat', {uuid: this._thread._uuid}); + } + else { + this._super(); + } + }, +}); + +return ThreadWindow; + +}); diff --git a/addons/website_livechat/static/src/scss/mail_thread_visitor_info.scss b/addons/website_livechat/static/src/scss/mail_thread_visitor_info.scss new file mode 100644 index 00000000000..2520b54fde7 --- /dev/null +++ b/addons/website_livechat/static/src/scss/mail_thread_visitor_info.scss @@ -0,0 +1,39 @@ +.o_web_client { + &:not(.o_no_thread_window) { + .o_w_visitor_info { + display:none !important; + } + } + &.o_no_thread_window { + .o_mail_thread { + position: sticky; + } + .o_w_visitor_info { + height: 87px; + border-bottom: 1px solid #ced4da; + position: fixed; + background: white; + width: 100%; + z-index: 1; + .o_w_visitor_status { + position: absolute; + bottom: 0; + right: 0; + &.o_w_visitor_disconnected { + color: red; + } + &.o_w_visitor_connected { + color: #00A09D; + } + } + } + .o_sub_w_visitor_info { + padding-top: 80px; + } + } +} + +.o_country_flag { + width:24px; + height: 16px; +} diff --git a/addons/website_livechat/static/src/xml/thread.xml b/addons/website_livechat/static/src/xml/thread.xml new file mode 100644 index 00000000000..a8872290b2a --- /dev/null +++ b/addons/website_livechat/static/src/xml/thread.xml @@ -0,0 +1,60 @@ + + + + + +
+ + + +
+ +
+
+
+ + + + +
+ + + + +
+
+
+ + + + +
+
+
+ + + + + +
+
+
+
+ + + + +
+
+
+ +

+

+
+
+
+
+
+ diff --git a/addons/website_livechat/views/website_livechat.xml b/addons/website_livechat/views/website_livechat.xml index 75e884bdcf1..452c19c4f7e 100644 --- a/addons/website_livechat/views/website_livechat.xml +++ b/addons/website_livechat/views/website_livechat.xml @@ -1,6 +1,14 @@ + + + diff --git a/addons/website_livechat/views/website_visitor_views.xml b/addons/website_livechat/views/website_visitor_views.xml new file mode 100644 index 00000000000..96386ac5d5e --- /dev/null +++ b/addons/website_livechat/views/website_visitor_views.xml @@ -0,0 +1,128 @@ + + + + Visitor's Sessions + mail.channel + tree,form + + [('livechat_visitor_id', '=', active_id)] + + + + 1 + tree + + + + + + 2 + form + + + + + + + website.visitor.view.kanban.inherit.website.livechat + website.visitor + + + + + + +
+ Speaking With +
+ +
+
+
+ + + +
+
+ + + website.visitor.view.form.inherit.website.livechat + website.visitor + + + + + + + + + + website.visitor.view.tree.inherit.website.livechat + website.visitor + + + + +