[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:
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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', '>', 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import crm_lead
|
||||
from . import res_config_settings
|
||||
from . import website_visitor
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user