From 6bec0e4d29e6b33b74962b2893a7a405667ef58c Mon Sep 17 00:00:00 2001 From: David Beguin Date: Wed, 3 Jul 2019 15:41:54 +0000 Subject: [PATCH 1/5] [IMP] website : add website visitors activity tracking This commit adds the website_visitor model that will be used to track website visitor activity (page viewed, number of visits and more general info about the visitor (country, lang, etc..) This model will, in later commit, be used to send chat requests and push notification from the operators (or backend users) directly to the visitor. - A website_visitor is created once the visitor is requesting a website.page that is tracked. - A website_visitor is considered as connected if his last tracked website_page request is within the last 5 minutes. - The number of visits for a website_visitor is incremented if his last tracked website_page request was at least 8 hours ago. - A website_visitor is only handled by the system. Users cannot create, edit or delete a website_visitor. - A unique website_visitor is created per website. That means that the same real person can triggers multiple visitor creation if visits multiple websites. This is because, for livechat purpose on later commit, for example, the chat request can be created on the correct livecaht channel (linked to the correct website) - The visitor is recognized via his cookie (visitor_id). So if the visitor flush his cookies, a new visitor will be created the next time he will request a tracked website_page. - Link user's res.partner to website.visitor. If a website_visitor log in (a visitor that has visitor_id in his cookie), the website_visitor is linked to the res.partner. The website visitor name is than adapted to match the name of the first res.partner linked to the visitor. A visitor can have multiple partners as the same session can be used by multiple person (one PC for a team for example). To keep a detailed history of the visitor page views, we add a website.visitor.page model that makes the link between visitor and website.page but that keeps the visit date. So that we can see if a visitor went mulitple times on the same page and when. It's usefull to see his last page views. Task ID : 2028059 PR #34624 --- addons/website/__manifest__.py | 2 + addons/website/data/website_data.xml | 3 + addons/website/data/website_visitor_cron.xml | 14 ++ addons/website/models/__init__.py | 1 + addons/website/models/ir_http.py | 14 ++ addons/website/models/res_partner.py | 4 +- addons/website/models/res_users.py | 16 ++ addons/website/models/website_page.py | 1 + addons/website/models/website_visitor.py | 143 +++++++++++++ addons/website/security/ir.model.access.csv | 4 + .../static/src/scss/website.backend.scss | 9 + addons/website/tests/__init__.py | 1 + addons/website/tests/test_website_visitor.py | 38 ++++ addons/website/views/website_views.xml | 16 ++ .../website/views/website_visitor_views.xml | 199 ++++++++++++++++++ odoo/tools/misc.py | 1 - odoo/tools/translate.py | 33 +++ 17 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 addons/website/data/website_visitor_cron.xml create mode 100644 addons/website/models/website_visitor.py create mode 100644 addons/website/tests/test_website_visitor.py create mode 100644 addons/website/views/website_visitor_views.xml 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/odoo/tools/misc.py b/odoo/tools/misc.py index ad51c5b74d0..14e207d7db1 100644 --- a/odoo/tools/misc.py +++ b/odoo/tools/misc.py @@ -1256,7 +1256,6 @@ def format_time(env, value, tz=False, time_format='medium', lang_code=False): return babel.dates.format_time(value, format=time_format, locale=locale) - def format_amount(env, amount, currency, lang_code=False): fmt = "%.{0}f".format(currency.decimal_places) lang = env['res.lang']._lang_get(lang_code or env.context.get('lang') or 'en_US') diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index 37db19b2bdb..ae36cc3a2cf 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -1166,3 +1166,36 @@ def load_language(cr, lang): env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {}) installer = env['base.language.install'].create({'lang': lang}) installer.lang_install() + +def _format_time_ago(env, time): + # Set Context to be used by translate _get_Lang (by introspection) + # DO NOT REMOVE THIS context VARIABLE + context = env.context + seconds = time.total_seconds() + days = round(seconds / (60 * 60 * 24)) + + if not days: + if seconds < 10: + return _("just now") + if seconds / 60 < 1: + return _("%s seconds ago") % round(seconds) + if seconds < 120: + return _("a minute ago") + if seconds / 3600 < 1: + return _("%s minutes ago") % round(seconds / 60) + if seconds < 7200: + return _("an hour ago") + return _("%s hours ago") % round(seconds / (60*60)) + else: + if days == 1: + return _("yesterday") + if days < 7: + return _("%s days ago") % days + if days < 31: + nbr_weeks = round(days / 7) + return _("%s week%s ago") % (nbr_weeks, '' if nbr_weeks == 1 else 's') + if days < 365: + nbr_months = round(days / 30) + return _("%s month%s ago") % (nbr_months, '' if nbr_months == 1 else 's') + nbr_years = round(days / 365) + return _("%s year%s ago") % (nbr_years, '' if nbr_years == 1 else 's') From 498d2a80801b43619f8d3f343abf721de319137a Mon Sep 17 00:00:00 2001 From: David Beguin Date: Fri, 5 Jul 2019 13:27:28 +0000 Subject: [PATCH 2/5] [IMP] website_crm : link crm.lead to website.visitor If a website_visitor submit a contact form (and becomes a lead), the website_visitor is linked to the crm.lead. the website visitor name is than adapted to match the name of the first crm.lead linked to the visitor, ONLY if the visitor doesn't already have a res.partner. A visitor can have multiple crm.lead as he can send multiple contact form and create multiple leads. Task ID: 34624 PR #2028059 --- addons/crm_iap_lead_website/models/ir.py | 4 +- addons/website_crm/__manifest__.py | 1 + addons/website_crm/controllers/main.py | 11 +++- addons/website_crm/models/__init__.py | 1 + addons/website_crm/models/crm_lead.py | 2 + addons/website_crm/models/website_visitor.py | 18 +++++++ .../views/website_visitor_views.xml | 53 +++++++++++++++++++ 7 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 addons/website_crm/models/website_visitor.py create mode 100644 addons/website_crm/views/website_visitor_views.xml 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/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..b222bbab1a8 100644 --- a/addons/website_crm/controllers/main.py +++ b/addons/website_crm/controllers/main.py @@ -52,4 +52,13 @@ 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) + 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 + + + + + + + + +
From a8ce2886be2e02315fa3526257a941c91c5385b9 Mon Sep 17 00:00:00 2001 From: David Beguin Date: Wed, 31 Jul 2019 14:42:35 +0000 Subject: [PATCH 3/5] [FIX] im_livechat : avoid to send twice first operator message to visitor What was going on here is that when the operator start the discussion with the visitor and the visitor navigates to a new page, the livechat button widget checks the cookie and see there is already a active conversation. It loads the mail_channel and write all the history of the conversation. So the first message is writting a first time. Then, it handles the notifications. As the visitor did not receive the first message notification yet, the first message notification comes up and the widget handle it. Adding the message a second time. To avoid this, we just stop the notification handling if the message is already present in the conversation. Task ID: 2028059 PR #34624 --- addons/im_livechat/static/src/js/im_livechat.js | 5 +++++ 1 file changed, 5 insertions(+) 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()) { From 78e6ce3c3fe565c488b956450ed875629cdd2de0 Mon Sep 17 00:00:00 2001 From: David Beguin Date: Mon, 8 Jul 2019 08:13:29 +0000 Subject: [PATCH 4/5] [IMP] website_livechat : allow to send chat request to a website_visitor This commit allows a livechat operator to send a chat request to a connected and available website_visitor. A visitor is considered as connected if his last tracked website.page request was within the last 5 minutes. A visitor is available if he doesn't have an active livechat conversation (mail_channel with type = livechat). - If another operator sent him a chat request - Or if the visitor asked himself to speak with an operator (via the normal and existing flow) A livechat conversation is active while the visitor haven't left the conversation. An operator cannot leave a livechat conversation, only the visitor can. The flow to send a chat request: - On the visitor view (tree or form), operator click on 'send chat request' (button or livechat icon) - A empty conversation with the visitor pops up at operator side. - While the operator didn't send a message, the visitor won't see the conversation. - The operator can type a message to the user - If the operator close the chat without sending any messages, the chat request AND the mail_channel are both deleted. In this case, the visitor is then available to send him a new chat request. - If the operator send a message, at the visitor's next action (page navigation on pages that allows livechat, based on livechat rules), the conversation will pop up at visitor side, using the livechat button widget. The visitor won't be able to request a livechat conversation with an operator until he leaves the chat requested by the operator. - Once the visitor leaves (with or without rating) the conversation : - the operator is notified that the visitor has left the conversation - the chat request is deleted to keep the chat request table clean and minimal - the livechat conversation if set to inactive. - The visitor is now available again to send him a chat request. This feature uses the already existing livechat_session cookie mechanism, so no further code modification was needed to make this work. It's directly integrated is existing livechat flow. The chat_request model is only useful to quickly check if a visitor has a chat request and to send the livechat conversation info to the visitor via the livechat_session cookie. This commit also add a website_visitor banner info on discuss view : To be able to quickly see all the relevant information of a website visitor while talking with in discuss view, a fixed banner have been added. Only the discuss view will benefit from this because detached chatter window is to small to display such banner. Task ID: 34624 PR #2028059 --- addons/im_livechat/controllers/main.py | 2 +- .../im_livechat/models/im_livechat_channel.py | 47 ++++--- .../tests/test_get_mail_channel.py | 8 +- addons/mail/models/mail_channel.py | 10 +- .../js/services/mail_notification_manager.js | 18 +++ addons/website_livechat/__manifest__.py | 4 + addons/website_livechat/controllers/main.py | 21 ++- addons/website_livechat/models/__init__.py | 7 +- .../models/im_livechat_channel.py | 25 ++++ .../website_livechat/models/mail_channel.py | 78 +++++++++++ .../models/website_visitor.py | 102 ++++++++++++++ .../security/ir.model.access.csv | 3 +- .../website_livechat/static/src/js/channel.js | 34 +++++ .../static/src/js/im_livechat.js | 69 ++++++++++ .../static/src/js/thread_window.js | 34 +++++ .../src/scss/mail_thread_visitor_info.scss | 39 ++++++ .../static/src/xml/thread.xml | 60 ++++++++ .../views/website_livechat.xml | 11 ++ .../views/website_visitor_views.xml | 128 ++++++++++++++++++ 19 files changed, 667 insertions(+), 33 deletions(-) create mode 100644 addons/website_livechat/models/im_livechat_channel.py create mode 100644 addons/website_livechat/models/mail_channel.py create mode 100644 addons/website_livechat/models/website_visitor.py create mode 100644 addons/website_livechat/static/src/js/channel.js create mode 100644 addons/website_livechat/static/src/js/im_livechat.js create mode 100644 addons/website_livechat/static/src/js/thread_window.js create mode 100644 addons/website_livechat/static/src/scss/mail_thread_visitor_info.scss create mode 100644 addons/website_livechat/static/src/xml/thread.xml create mode 100644 addons/website_livechat/views/website_visitor_views.xml 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/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_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 + + + + +
+
diff --git a/addons/website_crm/controllers/main.py b/addons/website_crm/controllers/main.py index b222bbab1a8..d8f5a20eddc 100644 --- a/addons/website_crm/controllers/main.py +++ b/addons/website_crm/controllers/main.py @@ -52,6 +52,9 @@ class WebsiteForm(WebsiteForm): if model.model == 'crm.lead': if 'company_id' not in values: values['company_id'] = request.website.company_id.id + 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: