[MERGE][IMP] website, website_crm, website_livechat : add website visitors tracking and chat request from operators

This merge 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.

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 useful to see his last page views.

If a website_visitor submit a contact form (and becomes a lead),
the website_visitor is linked to the crm.lead.

If a website_visitor logs in,
the website_visitor is linked to the user's res.partner.

This also allows a livechat operator to send a chat request to a
connected and available website_visitor.

See sub commits for more detailed informations about functional flows
and technical changes/decisions.

Task : https://www.odoo.com/web?#id=2028059&action=327&model=project.task&view_type=form&menu_id=4720

Linked to enterprise PR odoo/enterprise#4834

closes odoo/odoo#34624

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Robot Odoo
2019-08-19 10:42:54 +02:00
committed by GitHub
47 changed files with 1270 additions and 38 deletions
@@ -17,6 +17,9 @@
<record id="frequency_field_source_id" model="crm.lead.scoring.frequency.field">
<field name="field_id" eval="ref('crm.field_crm_lead__source_id')"/>
</record>
<record id="frequency_field_lang_id" model="crm.lead.scoring.frequency.field">
<field name="field_id" eval="ref('crm.field_crm_lead__lang_id')"/>
</record>
</data>
<record id="website_crm_score_cron" model="ir.cron">
+5
View File
@@ -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')
+3
View File
@@ -109,6 +109,7 @@
<field name="country_id" placeholder="Country" class="o_address_country" options='{"no_open": True, "no_create": True}'/>
</div>
<field name="website" widget="url" placeholder="e.g. https://www.odoo.com"/>
<field name="lang_id"/>
</group>
<group>
<label for="contact_name"/>
@@ -447,6 +448,7 @@
<field name="team_id"/>
<field name="country_id"/>
<field name="city"/>
<field name="lang_id"/>
<field name="create_date"/>
<field name="source_id"/>
<field name="medium_id"/>
@@ -658,6 +660,7 @@
<field name="country_id" placeholder="Country" class="o_address_country" options='{"no_open": True, "no_create": True}'/>
</div>
<field name="website" widget="url" placeholder="e.g. https://www.odoo.com"/>
<field name="lang_id" />
</group>
<group class="mt48">
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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):
@@ -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):
@@ -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()) {
@@ -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({
+8 -2
View File
@@ -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})
@@ -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);
}
+2
View File
@@ -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',
+3
View File
@@ -120,16 +120,19 @@
<field name="is_published">True</field>
<field name="url">/</field>
<field name="view_id" ref="homepage"/>
<field name="is_tracked">True</field>
</record>
<record id="contactus_page" model="website.page">
<field name="url">/contactus</field>
<field name="is_published">True</field>
<field name="view_id" ref="contactus"/>
<field name="is_tracked">True</field>
</record>
<record id="aboutus_page" model="website.page">
<field name="is_published">True</field>
<field name="url">/aboutus</field>
<field name="view_id" ref="aboutus"/>
<field name="is_tracked">True</field>
</record>
<!-- Default Menu to store module menus for new website -->
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding='UTF-8'?>
<odoo>
<record id="website_visitor_cron" model="ir.cron">
<field name="name">Website Visitor : Archive old visitors</field>
<field name="model_id" ref="model_website_visitor"/>
<field name="state">code</field>
<field name="code">model._cron_archive_visitors()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
<field name="doall" eval="False"/>
</record>
</odoo>
+1
View File
@@ -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
+14
View File
@@ -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):
+3 -1
View File
@@ -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:
+16
View File
@@ -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
+1
View File
@@ -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()
+143
View File
@@ -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})
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_seo_public access_seo_public model_website_seo_metadata 1 0 0 0
13 access_seo_manager access_seo_manager model_website_seo_metadata group_website_designer 1 1 1 1
14 access_seo_designer access_seo_designer model_website_seo_metadata group_website_designer 1 1 1 1
15 access_website_visitor_designer access_website_visitor_designer model_website_visitor website.group_website_designer 1 0 0 0
16 access_website_visitor_system access_website_visitor_system model_website_visitor base.group_system 1 1 1 1
17 access_website_visitor_page_designer access_website_visitor_page_designer model_website_visitor_page website.group_website_designer 1 0 0 0
18 access_website_visitor_page_system access_website_visitor_page_system model_website_visitor_page base.group_system 1 1 1 1
@@ -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;
}
}
+1
View File
@@ -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
@@ -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': '''<t name="Homepage" t-name="website.base_view">
<t t-call="website.layout">
I am a generic page
</t>
</t>''',
'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")
+16
View File
@@ -147,6 +147,7 @@
<field name="url"/>
<field name="view_id" context="{'display_website': True}"/>
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
<field name="is_tracked"/>
</group>
<group>
<field name="website_indexed"/>
@@ -174,10 +175,25 @@
<field name="create_uid" invisible="1"/>
<field name="write_uid"/>
<field name="write_date"/>
<field name="is_tracked"/>
</tree>
</field>
</record>
<record id="website_pages_view_search" model="ir.ui.view">
<field name="name">website.page.view.search</field>
<field name="model">website.page</field>
<field name="arch" type="xml">
<search string="Website Pages" >
<filter string="Published" name="published" domain="[('website_published', '=', True)]"/>
<filter string="Not published" name="not_published" domain="[('website_published', '=', False)]"/>
<separator/>
<filter string="Tracked" name="tracked" domain="[('is_tracked', '=', True)]"/>
<filter string="Not tracked" name="not_tracked" domain="[('is_tracked', '=', False)]"/>
</search>
</field>
</record>
<record id="action_website_pages_list" model="ir.actions.act_window">
<field name="name">Website Pages</field>
<field name="res_model">website.page</field>
@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<!--page history-->
<record id="website_visitor_page_action" model="ir.actions.act_window">
<field name="name">Page Views History</field>
<field name="res_model">website.visitor.page</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('visitor_id', '=', active_id)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No page views yet for this visitor
</p>
</field>
</record>
<record id="website_visitor_page_view_tree" model="ir.ui.view">
<field name="name">website.visitor.page.view.tree</field>
<field name="model">website.visitor.page</field>
<field name="arch" type="xml">
<tree string="Visitor Page Views History">
<field name="page_id"/>
<field name="visit_datetime"/>
</tree>
</field>
</record>
<record id="website_visitor_page_view_search" model="ir.ui.view">
<field name="name">website.visitor.page.view.search</field>
<field name="model">website.visitor.page</field>
<field name="arch" type="xml">
<search string="Search Visitor">
<field name="page_id"/>
<field name="visit_datetime"/>
<group string="Group By">
<filter string="Page" name="group_by_page" domain="[]" context="{'group_by': 'page_id'}"/>
</group>
</search>
</field>
</record>
<!--Website visitor actions-->
<record id="website.visitor_partner_action" model="ir.actions.act_window">
<field name="name">Partners</field>
<field name="res_model">res.partner</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('visitor_ids', 'in', [active_id])]</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No partner linked for this visitor
</p>
</field>
</record>
<!-- website visitor views -->
<record id="website_visitor_view_kanban" model="ir.ui.view">
<field name="name">website.visitor.view.kanban</field>
<field name="model">website.visitor</field>
<field name="arch" type="xml">
<kanban class="o_website_visitor_kanban">
<field name="id"/>
<field name="country_id"/>
<field name="is_connected"/>
<field name="display_name"/>
<field name="page_ids"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<div class="oe_kanban_details">
<div class="float-right">
<span class="fa fa-circle text-success" t-if="record.is_connected.raw_value" aria-label="Online" title="Online"/>
<span class="fa fa-circle text-danger" t-else="" aria-label="Offline" title="Offline"/>
</div>
<strong>
<img t-if="record.country_id.raw_value" t-att-src="kanban_image('res.country', 'image', record.country_id.raw_value)" class="o_country_flag" alt="Country"/>
<field name="display_name"/>
</strong>
<div class="mb-2">Active <field name="time_since_last_action"/></div>
<div>Visits<span class="float-right font-weight-bold"><field name="visit_count"/></span></div>
<div>Visited Pages<span class="float-right font-weight-bold"><field name="page_count"/></span></div>
<div id="w_visitor_kanban_actions"></div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="website_visitor_view_form" model="ir.ui.view">
<field name="name">website.visitor.view.form</field>
<field name="model">website.visitor</field>
<field name="arch" type="xml">
<form string="Website Visitor">
<header></header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button o_stat_button_info" attrs="{'invisible': [('is_connected', '=', False)]}">
<i class="fa fa-fw o_button_icon fa-circle text-success"/>
<span>Connected</span>
</button>
<button class="oe_stat_button o_stat_button_info" attrs="{'invisible': [('is_connected', '=', True)]}">
<i class="fa fa-fw o_button_icon fa-circle text-danger"/>
<span>Offline</span>
</button>
<button id="w_visitor_visit_counter" class="oe_stat_button o_stat_button_info" icon="fa-globe">
<field name="visit_count" widget="statinfo" string="Visits"/>
</button>
<button name="%(website.website_visitor_page_action)d" type="action"
class="oe_stat_button"
icon="fa-tags">
<field name="visitor_page_count" widget="statinfo" string="Page views"/>
</button>
</div>
<div class="float-right" attrs="{'invisible': [('country_id', '=', False)]}"><field name="country_flag" widget="image" options='{"size": [32, 32]}'/></div>
<div class="oe_title">
<label for="name" class="oe_edit_only" string="Visitor"/>
<h1><field name="name"/></h1>
</div>
<group id="general_info">
<group string="Visitor Informations">
<field name="is_connected" invisible="1"/>
<field name="user_partner_id" attrs="{'invisible': [('user_partner_id', '=', False)]}"/>
<field name="country_id" attrs="{'invisible': [('country_id', '=', False)]}"/>
<field name="lang_id"/>
</group>
<group string="Visits">
<field name="create_date"/>
<field name="last_connection_datetime"/>
<field name="page_ids" widget="many2many_tags"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="website_visitor_view_tree" model="ir.ui.view">
<field name="name">website.visitor.view.tree</field>
<field name="model">website.visitor</field>
<field name="arch" type="xml">
<tree string="Web Visitors" create="0" delete="0">
<!--TODO DBE : Handle no_label in treeview-->
<field name="country_flag" widget="image" options='{"size": [20, 20]}' string=" "/>
<field name="name"/>
<field name="time_since_last_action"/>
<field name="lang_id"/>
<field name="visit_count"/>
<field name="page_ids" widget="many2many_tags"/>
<field name="is_connected" invisible="1"/>
</tree>
</field>
</record>
<record id="website_visitor_view_search" model="ir.ui.view">
<field name="name">website.visitor.view.search</field>
<field name="model">website.visitor</field>
<field name="arch" type="xml">
<search string="Search Visitor">
<field name="name"/>
<field name="lang_id"/>
<field name="country_id"/>
<field name="visit_count"/>
<field name="page_ids"/>
<filter string="Visitors" name="type_visitor" domain="[('user_partner_id', '=', False)]"/>
<filter string="Customers" name="type_customer" domain="[('user_partner_id', '!=', False)]"/>
<separator/>
<filter string="Archived" name="is_archived" domain="[('active', '=', False)]"/>
<separator/>
<filter string="Is Connected" name="is_connected" domain="[('last_connection_datetime', '&gt;', datetime.datetime.now() - datetime.timedelta(minutes=5))]"/>
<group string="Group By">
<filter string="Country" name="group_by_country" context="{'group_by': 'country_id'}"/>
<filter string="Language" name="group_by_lang" context="{'group_by': 'lang_id'}"/>
</group>
</search>
</field>
</record>
<record id="website_visitors_action" model="ir.actions.act_window">
<field name="name">Visitors</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">website.visitor</field>
<field name="context">{'search_default_is_connected':1}</field>
<field name="view_mode">tree,form,kanban</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Wait for visitors to come to your website to see their history.
</p>
</field>
</record>
<menuitem id="website_visitor_menu"
name="Visitors"
sequence="80"
parent="website.menu_website_configuration"
action="website.website_visitors_action"/>
</data></odoo>
+1
View File
@@ -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,
+13 -1
View File
@@ -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
+1
View File
@@ -3,3 +3,4 @@
from . import crm_lead
from . import res_config_settings
from . import website_visitor
+2
View File
@@ -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 \
@@ -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)
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<!--Website visitor actions-->
<record id="website_crm.website_visitor_crm_lead_action" model="ir.actions.act_window">
<field name="name">Leads</field>
<field name="res_model">crm.lead</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('visitor_id', '=', active_id)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No lead linked for this visitor
</p>
</field>
</record>
<!-- website visitor views -->
<record id="website_visitor_view_form" model="ir.ui.view">
<field name="name">website.visitor.view.form.inherit.website.crm</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@id='w_visitor_visit_counter']" position="before">
<button name="%(website_crm.website_visitor_crm_lead_action)d" type="action" class="oe_stat_button" icon="fa-star"
attrs="{'invisible': [('lead_count', '=', 0)]}">
<field name="lead_count" widget="statinfo" string="Leads"/>
</button>
</xpath>
</field>
</record>
<record id="website_visitor_view_tree" model="ir.ui.view">
<field name="name">website.visitor.view.tree.inherit.website.crm</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='page_ids']" position="after">
<field name="lead_count"/>
</xpath>
</field>
</record>
<record id="website_visitor_view_search" model="ir.ui.view">
<field name="name">website.visitor.view.search.inherit.website.crm</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='type_visitor']" position="replace">
<filter string="Visitors" name="type_visitor" domain="[('user_partner_id', '=', False), ('lead_ids', '=', False)]"/>
<filter string="Leads" name="type_lead" domain="[('user_partner_id', '=', False), ('lead_ids', '!=', False)]"/>
</xpath>
</field>
</record>
</data></odoo>
+4
View File
@@ -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',
],
}
+20 -1
View File
@@ -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)
+5 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_im_livechat_channel_public im_livechat.channel.public im_livechat.model_im_livechat_channel 1 0 0 0
3 access_website_visitor_livechat_users website.visitor.livechat.users model_website_visitor im_livechat.im_livechat_group_user 1 0 0 0
@@ -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;
});
@@ -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,
};
});
@@ -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;
});
@@ -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;
}
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-extend="mail.widget.Thread.Content.ASC">
<t t-jquery=".o_mail_thread_content" t-operation="replace">
<t t-call="mail.widget.Thread.website.visitor.banner"/>
<div class="o_mail_thread_content o_sub_w_visitor_info">
<t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
<t t-call="mail.widget.Thread.Messages"/>
<t t-if="options.displayBottomThreadFreeSpace">
<div class="o_thread_bottom_free_space"/>
</t>
</div>
</t>
</t>
<t t-extend="mail.widget.Thread.Content.DESC">
<t t-jquery=".o_mail_thread_content" t-operation="replace">
<t t-call="mail.widget.Thread.website.visitor.banner"/>
<div class="o_mail_thread_content">
<t t-if="options.messagesSeparatorPosition == 'top'" t-call="mail.MessagesSeparator"/>
<t t-set="messages" t-value="messages.slice().reverse()"/>
<t t-call="mail.widget.Thread.Messages"/>
<t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
</div>
</t>
</t>
<t t-name="mail.widget.Thread.website.visitor.banner">
<t t-set="visitor" t-value="thread._visitor"/>
<t t-if="visitor">
<div class="o_w_visitor_info o_thread_message o_mail_discussion d-flex">
<div class="o_thread_message_sidebar align-self-center">
<div class="o_thread_message_sidebar_image">
<img alt=""
t-att-src="visitor.partner_id ? '/web/image/res.partner/' + visitor.partner_id + '/image_small' : '/mail/static/src/img/smiley/avatar.jpg'"
class="o_thread_message_avatar rounded-circle"/>
<span>
<i t-if="visitor.is_connected" aria-label="Visitor is connected" class="o_w_visitor_status o_w_visitor_connected fa fa-circle" role="img" title="Online"></i>
<i t-else="" aria-label="Visitor is offline" class="o_w_visitor_status o_w_visitor_disconnected fa fa-circle" role="img" title="Offline"></i>
</span>
</div>
</div>
<div class="o_thread_message_core pt-2 align-self-center">
<div class="d-flex">
<img t-if="visitor.country_code" t-attf-src='base/static/img/country_flags/#{visitor.country_code}.png' class="o_country_flag mr-2 mb-1"/>
<strong class="o_thread_author" t-esc="visitor.name"/>
<span><i class="fa fa-comment-o ml-4 mr-2" aria-label="Lang"/><t t-esc="visitor.lang"/></span>
<span t-if="visitor.website"><i class="fa fa-globe ml-4 mr-2" aria-label="Website"/><span t-esc="visitor.website"/></span>
</div>
<div class="o_thread_message_content">
<div class="d-flex">
<span><i class="fa fa-history mr-2" aria-label="History"/></span>
<p t-esc="visitor.history"/>
</div>
</div>
</div>
</div>
</t>
</t>
</templates>
@@ -1,6 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="assets_backend" inherit_id="website.assets_backend" name="website_livechat assets backend">
<xpath expr="." position="inside">
<!-- Visitor Chat Request -->
<script type="text/javascript" src="/website_livechat/static/src/js/thread_window.js"></script>
<script type="text/javascript" src="/website_livechat/static/src/js/channel.js"></script>
<link rel="stylesheet" type="text/scss" href="/website_livechat/static/src/scss/mail_thread_visitor_info.scss"/>
</xpath>
</template>
<!--
Integrate Livechat in Common Frontend for Website
@@ -32,6 +40,9 @@
<script type="text/javascript" src="/mail/static/src/js/models/utils/timer.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/utils/timers.js"></script>
<script type="text/javascript" src="/mail/static/src/js/models/utils/cc_throttle_function.js"></script>
<!--Chat Request-->
<script type="text/javascript" src="/website_livechat/static/src/js/im_livechat.js"></script>
<!--Stylesheets-->
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/abstract_thread_window.scss"></link>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/thread.scss"></link>
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat.scss"/>
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="website_visitor_livechat_session_action" model="ir.actions.act_window">
<field name="name">Visitor's Sessions</field>
<field name="res_model">mail.channel</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="im_livechat.mail_channel_view_tree"/>
<field name="domain">[('livechat_visitor_id', '=', active_id)]</field>
</record>
<record id="website_visitor_livechat_session_action_tree" model="ir.actions.act_window.view">
<field name="sequence">1</field>
<field name="view_mode">tree</field>
<field name="view_id" ref="im_livechat.mail_channel_view_tree"/>
<field name="act_window_id" ref="website_livechat.website_visitor_livechat_session_action"/>
</record>
<record id="website_visitor_livechat_session_action_form" model="ir.actions.act_window.view">
<field name="sequence">2</field>
<field name="view_mode">form</field>
<field name="view_id" ref="im_livechat.mail_channel_view_form"/>
<field name="act_window_id" ref="website_livechat.website_visitor_livechat_session_action"/>
</record>
<!-- website visitor views -->
<record id="website_visitor_view_kanban" model="ir.ui.view">
<field name="name">website.visitor.view.kanban.inherit.website.livechat</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_kanban"/>
<field name="arch" type="xml">
<field name="page_ids" position="after">
<field name="livechat_operator_id"/>
</field>
<xpath expr="//div[@id='w_visitor_kanban_actions']" position="before">
<div t-if="record.livechat_operator_id.raw_value">
<span class="fa fa-comments mr-2"/>Speaking With <span class="font-weight-bold"><field name="livechat_operator_name"/></span>
</div>
<t t-else="">
<br attrs="{'invisible': ['|', ('livechat_operator_id', '!=', False), ('is_connected', '=', False)]}"/>
</t>
</xpath>
<xpath expr="//div[@id='w_visitor_kanban_actions']" position="inside">
<button name="action_send_chat_request" type="object"
class="btn btn-primary"
attrs="{'invisible': ['|', ('livechat_operator_id', '!=', False), ('is_connected', '=', False)]}">
<i class="fa fa-comments"/> Chat
</button>
</xpath>
</field>
</record>
<record id="website_visitor_view_form" model="ir.ui.view">
<field name="name">website.visitor.view.form.inherit.website.livechat</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_send_chat_request" string="Send chat request" type="object" class="oe_highlight"
attrs="{'invisible': ['|', ('livechat_operator_id', '!=', False), ('is_connected', '=', False)]}"/>
</xpath>
<xpath expr="//group[@id='general_info']" position="before">
<group id="livechat_info">
<field name="livechat_operator_id"/>
</group>
</xpath>
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="%(website_visitor_livechat_session_action)d" type="action" class="oe_stat_button" icon="fa-comment"
attrs="{'invisible': [('session_count', '=', 0)]}">
<field name="session_count" widget="statinfo" string="Sessions"/>
</button>
</xpath>
</field>
</record>
<record id="website_visitor_view_tree" model="ir.ui.view">
<field name="name">website.visitor.view.tree.inherit.website.livechat</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='is_connected']" position="after">
<field name="livechat_operator_id"/>
<button name="action_send_chat_request" string="Send chat request" type="object" icon="fa-comments"
attrs="{'invisible': ['|', ('livechat_operator_id', '!=', False), ('is_connected', '=', False)]}"/>
</xpath>
</field>
</record>
<record id="website_visitor_view_search" model="ir.ui.view">
<field name="name">website.visitor.view.search.website.livechat</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_search"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<filter string="Busy" name="in_conversation" domain="[('livechat_operator_id', '!=', False)]"/>
<filter string="Available" name="not_in_conversation" domain="[('livechat_operator_id', '=', False)]"/>
</xpath>
</field>
</record>
<record id="website_livechat_send_chat_request_action_server" model="ir.actions.server">
<field name="name">Send Chat Requests</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_website_visitor"/>
<field name="binding_model_id" ref="model_website_visitor"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
if records:
action = records.action_send_chat_request()
</field>
</record>
<record id="website.website_visitors_action" model="ir.actions.act_window">
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Wait for visitors to come to your website to see their history.</p>
<p>Interact with them by sending them messages.</p>
</field>
</record>
<menuitem
id="website_livechat_visitor_menu"
name="Visitors"
parent="im_livechat.menu_livechat_root"
action="website.website_visitors_action"
groups="im_livechat.im_livechat_group_user"
sequence="15"/>
</data></odoo>
-1
View File
@@ -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')
+33
View File
@@ -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')