[IMP] website_sale, *: keep track of products viewed

*= website, website_livechat, website_rating

///// Tracking Product /////

Now when a user browse products in eCommerce, we keep track of the
products he looked at. We use the website_visitor
to store the products viewed. A cookie is added with a TTl of 30 min it
will prevent the RPC for that time. We track the page only if the
product view is tracked.

The recently viewed products are displayed as a snippet but also
with the customize option in product pages of website_sale.

Products that are in cart will not be returned as recently viewed.

It is possible to add a recently viewed product to the cart directly
from the carousel, it will not redirect to the cart. If we are on the
cart page, the product is displayed in the cart.

The Visitor page in website now references products viewed

///// Tracking Page /////

Feature to track a view was remove in: https://github.com/odoo/enterprise/pull/4834

That feature is now reintroduced and will use website_track instead of
leads to be stored.

The track field is now on the view instead of the page.

url field is added to website.track, it will store the url for pages and
views

The Visitor page in website now references urls viewed

Add some tests

task-1984575

closes odoo/odoo#35810

Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
This commit is contained in:
fja-odoo
2019-09-16 09:38:53 +00:00
parent 1bc4c1697f
commit e33172e832
36 changed files with 1117 additions and 183 deletions
+3 -3
View File
@@ -120,19 +120,19 @@
<field name="is_published">True</field>
<field name="url">/</field>
<field name="view_id" ref="homepage"/>
<field name="is_tracked">True</field>
<field name="track">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>
<field name="track">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>
<field name="track">True</field>
</record>
<!-- Default Menu to store module menus for new website -->
+11 -7
View File
@@ -12,12 +12,13 @@ import werkzeug.routing
import werkzeug.utils
import odoo
from odoo import api, models, registry
from odoo import api, models, registry, fields
from odoo import SUPERUSER_ID
from odoo.http import request
from odoo.tools import config, ormcache
from odoo.tools.safe_eval import safe_eval
from odoo.osv.expression import FALSE_DOMAIN, OR
from odoo.osv import osv
from odoo.addons.base.models.qweb import QWebException
from odoo.addons.http_routing.models.ir_http import ModelConverter, _guess_mimetype
@@ -87,17 +88,20 @@ class Http(models.AbstractModel):
super(Http, cls)._auth_method_public()
@classmethod
def _extract_website_page(cls, response):
if getattr(response, 'status_code', 0) != 200:
def _register_website_track(cls, response):
if getattr(response, 'status_code', 0) != 200 or not hasattr(response, 'qcontext'):
return False
main_object = getattr(response, 'qcontext', {}).get('main_object')
return main_object if getattr(main_object, '_name', False) == 'website.page' else False
main_object = response.qcontext.get('main_object')
website_page = getattr(main_object, '_name', False) == 'website.page' and main_object
template = response.qcontext.get('response_template')
view = template and request.env['website'].get_template(template)
if view and view.track:
request.env['website.visitor']._handle_webpage_dispatch(response, website_page)
@classmethod
def _dispatch(cls):
response = super(Http, cls)._dispatch()
request.env['website.visitor']._handle_webpage_dispatch(response, cls._extract_website_page(response))
cls._register_website_track(response)
return response
@classmethod
+1
View File
@@ -23,6 +23,7 @@ class View(models.Model):
website_id = fields.Many2one('website', ondelete='cascade', string="Website")
page_ids = fields.One2many('website.page', 'view_id')
first_page_id = fields.Many2one('website.page', string='Website Page', help='First page linked to this view', compute='_compute_first_page_id')
track = fields.Boolean(string='Track', default=False, help="Allow to specify for one page of the website to be trackable or not")
def _compute_first_page_id(self):
for view in self:
-1
View File
@@ -20,7 +20,6 @@ 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()
+132 -72
View File
@@ -4,139 +4,199 @@
from datetime import datetime, timedelta
from hashlib import sha256
import hmac
import uuid
from odoo import fields, models, api, _
from odoo.tools.misc import _consteq, _format_time_ago
from odoo.http import request
from odoo.osv import expression
class WebsitVisitorPage(models.Model):
_name = 'website.visitor.page'
class WebsiteTrack(models.Model):
_name = 'website.track'
_description = 'Visited Pages'
_order = 'visit_datetime DESC'
_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)
url = fields.Text('Url', index=True)
visit_datetime = fields.Datetime('Visit Date', default=fields.Datetime.now, required=True, readonly=True)
class VisitorLastConnection(models.Model):
_name = 'website.visitor.lastconnection'
_description = 'temporary table for Visitor'
visitor_id = fields.Many2one('website.visitor', ondelete="cascade", index=True, required=True, readonly=True)
connection_datetime = fields.Datetime(default=fields.Datetime.now, required=True, readonly=True)
class WebsiteVisitor(models.Model):
_name = 'website.visitor'
_description = 'Website Visitor'
_order = 'last_connection_datetime DESC'
_order = 'create_date DESC'
name = fields.Char('Name', default=_('Website Visitor'))
access_token = fields.Char(required=True, default=lambda x: uuid.uuid4().hex, index=True, copy=False, groups='base.group_website_publisher')
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)
last_connection_datetime = fields.Datetime('Last Connection', compute="_compute_time_statistics", help="Last page view date", search='_search_last_connection', readonly=True)
last_connections_ids = fields.One2many('website.visitor.lastconnection', 'visitor_id', 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")
visit_count = fields.Integer('Number of visits', default=1, readonly=True, help="A new visit is considered if last connection was more than 24 hours ago.")
website_track_ids = fields.One2many('website.track', 'visitor_id', string='Visited Pages History', readonly=True)
visitor_page_count = fields.Integer('Page Views', compute="_compute_page_statistics", help="Total number of visits on tracked pages")
page_ids = fields.Many2many('website.page', string="Visited Pages", compute="_compute_page_statistics")
page_count = fields.Integer('# Visited Pages', compute="_compute_page_statistics", help="Total number of tracked page visited")
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')
@api.depends('name')
def name_get(self):
ret_list = []
for record in self:
name = '%s #%d' % (record.name, record.id)
ret_list.append((record.id, name))
return ret_list
@api.model
def _search_last_connection(self, operator, value):
assert operator in expression.TERM_OPERATORS
self.env['website.visitor.lastconnection'].flush(['visitor_id'])
query = """
SELECT v.visitor_id
FROM website_visitor_lastconnection v
WHERE v.connection_datetime %s %%s
GROUP BY v.visitor_id, v.connection_datetime
ORDER BY v.connection_datetime
""" % (operator,)
return [('id', 'inselect', (query, [value]))]
@api.depends('website_track_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)
results = self.env['website.track'].read_group(
[('visitor_id', 'in', self.ids), ('url', '!=', False)], ['visitor_id', 'page_id', 'url'], ['visitor_id', 'page_id', 'url'], 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])
visitor_info = mapped_data.get(result['visitor_id'][0], {'page_count': 0, 'visitor_page_count': 0, 'page_ids': set()})
visitor_info['visitor_page_count'] += result['__count']
visitor_info['page_count'] += 1
if result['page_id']:
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'])
visitor.visitor_page_count = visitor_info['visitor_page_count']
visitor.page_count = visitor_info['page_count']
@api.depends('last_connection_datetime')
@api.depends('last_connections_ids')
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}
results = self.env['website.visitor.lastconnection'].read_group([('visitor_id', 'in', self.ids)], ['visitor_id', 'connection_datetime:max'], ['visitor_id'])
mapped_data = {result['visitor_id'][0]: result['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)
last_connection_datetime = mapped_data.get(visitor.id, False)
if last_connection_datetime:
visitor.last_connection_datetime = last_connection_datetime
visitor.time_since_last_action = _format_time_ago(self.env, (datetime.now() - last_connection_datetime))
visitor.is_connected = (datetime.now() - last_connection_datetime) < 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):
def _get_visitor_from_request(self, with_previous_visitors=False):
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
if with_previous_visitors and not request.env.user._is_public():
partner_id = request.env.user.partner_id
# Retrieve all the previous partner's visitors to have full history of his last products viewed.
return request.env['website.visitor'].sudo().with_context(active_test=False).search([('user_partner_id', '=', partner_id.id)])
else:
visitor = self.env['website.visitor']
access_token = request.httprequest.cookies.get('visitor_id')
if access_token:
visitor = visitor.sudo().with_context(active_test=False).search([('access_token', '=', access_token)])
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)
# get visitor. Done here to avoid having to do it multiple times in case of override.
visitor_sudo = self._get_visitor_from_request()
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)
expiration_date = datetime.now() + timedelta(days=100*365) # never expire
response.set_cookie('visitor_id', sign, expires=expiration_date.timestamp())
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)
is a tracked website page or a tracked view. Only on tracked elements 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 elements."""
url = request.httprequest.url
website_track_values = {
'url': url,
'visit_datetime': datetime.now(),
}
if website_page:
website_track_values['page_id'] = website_page.id
domain = [('page_id', '=', website_page.id)]
else:
domain = [('url', '=', url)]
if not visitor_sudo:
visitor_sudo = self._create_visitor(website_track_values)
expiration_date = datetime.now() + timedelta(days=365)
response.set_cookie('visitor_id', visitor_sudo.access_token, expires=expiration_date)
else:
visitor_sudo._add_tracking(domain, website_track_values)
def _create_visitor(self, website_page_id=False):
def _add_tracking(self, domain, website_track_values):
""" Update the visitor when a website_track is added"""
domain = expression.AND([domain, [('visitor_id', '=', self.id)]])
last_view = self.env['website.track'].sudo().search(domain, limit=1)
if not last_view or last_view.visit_datetime < datetime.now() - timedelta(minutes=30):
website_track_values['visitor_id'] = self.id
self.env['website.track'].create(website_track_values)
self.env['website.visitor.lastconnection'].create({'visitor_id': self.id,
'connection_datetime': website_track_values['visit_datetime']})
def _create_visitor(self, website_track_values=None):
""" Create a visitor and add a track to it if website_track_values is set."""
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
vals = {
'last_connection_datetime': datetime.now(),
'lang_id': request.lang.id,
'country_id': country_id,
'website_id': request.website.id
'last_connections_ids': [(0, 0, {'connection_datetime': website_track_values['visit_datetime'] or datetime.now()})],
}
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
vals['name'] = self.env.user.partner_id.name
if website_track_values:
vals['website_track_ids'] = [(0, 0, website_track_values)]
return self.sudo().create(vals)
def _cron_archive_visitors(self):
self.flush(['visit_count'])
yesterday = datetime.now() - timedelta(days=1)
query = """
UPDATE website_visitor
SET visit_count = visit_count + 1
WHERE id in (
SELECT visitor_id
FROM website_visitor_lastconnection
GROUP BY visitor_id
HAVING COUNT(*) > 1)
"""
self.env.cr.execute(query, [yesterday])
query = """
DELETE FROM website_visitor_lastconnection
WHERE (visitor_id, connection_datetime) not in (
SELECT v.visitor_id, max(connection_datetime)
FROM website_visitor_lastconnection v
GROUP BY v.visitor_id)
"""
self.env.cr.execute(query, [])
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})
+5 -3
View File
@@ -12,7 +12,9 @@ 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_designer,access_website_visitor_designer,model_website_visitor,website.group_website_designer,1,1,1,1
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
access_website_visitor_lastconnection_designer,access_website_visitor_lastconnection_designer,model_website_visitor_lastconnection,website.group_website_designer,1,1,1,1
access_website_visitor_lastconnection_system,access_website_visitor_lastconnection_system,model_website_visitor_lastconnection,base.group_system,1,1,1,1
access_website_track_designer,access_website_track_designer,model_website_track,website.group_website_designer,1,1,1,1
access_website_track_system,access_website_track_system,model_website_track,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 1 0 1 0 1
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_lastconnection_designer access_website_visitor_page_designer access_website_visitor_lastconnection_designer model_website_visitor_page model_website_visitor_lastconnection website.group_website_designer 1 0 1 0 1 0 1
18 access_website_visitor_page_system access_website_visitor_lastconnection_system access_website_visitor_page_system access_website_visitor_lastconnection_system model_website_visitor_page model_website_visitor_lastconnection base.group_system 1 1 1 1
19 access_website_track_designer access_website_track_designer model_website_track website.group_website_designer 1 1 1 1
20 access_website_track_system access_website_track_system model_website_track base.group_system 1 1 1 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

@@ -0,0 +1,89 @@
odoo.define('website.set_view_track', function (require) {
"use strict";
var CustomizeMenu = require('website.customizeMenu');
var Widget = require('web.Widget');
var TrackPage = Widget.extend({
template: 'website.track_page',
xmlDependencies: ['/website/static/src/xml/track_page.xml'],
events: {
'change #switch-track-page': '_onTrackChange',
},
/**
* @override
*/
start: function () {
this.$input = this.$('#switch-track-page');
this._isTracked().then((data) => {
if (data[0]['track']) {
this.track = true;
this.$input.attr('checked', 'checked');
} else {
this.track = false;
}
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_isTracked: function (val) {
var viewid = $('html').data('viewid');
if (!viewid) {
return Promise.reject();
} else {
return this._rpc({
model: 'ir.ui.view',
method: 'read',
args: [[viewid], ['track']],
});
}
},
/**
* @private
*/
_onTrackChange: function (ev) {
var checkboxValue = this.$input.is(':checked');
if (checkboxValue !== this.track) {
this.track = checkboxValue;
this._trackPage(checkboxValue);
}
},
/**
* @private
*/
_trackPage: function (val) {
var viewid = $('html').data('viewid');
if (!viewid) {
return Promise.reject();
} else {
return this._rpc({
model: 'ir.ui.view',
method: 'write',
args: [[viewid], {track: val}],
});
}
},
});
CustomizeMenu.include({
_loadCustomizeOptions: function () {
var self = this;
var def = this._super.apply(this, arguments);
return def.then(function () {
if (!self.__trackpageLoaded) {
self.__trackpageLoaded = true;
self.trackPage = new TrackPage(self);
self.trackPage.appendTo(self.$el.children('.dropdown-menu'));
}
});
},
});
});
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="website.track_page">
<div role="separator" class="dropdown-divider"/>
<a href="#" name="switch-track-page" class="dropdown-item" role="menuitem">
<label class="o_switch" for="switch-track-page">
<input id="switch-track-page" type="checkbox"/>
<span/>
Track visitor
</label>
</a>
</t>
</templates>
+33 -15
View File
@@ -1,13 +1,14 @@
# coding: utf-8
from odoo.tests import HttpCase, tagged
from odoo.tests import HttpCase
@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({
Track = self.env['website.track']
untracked_view = View.create({
'name': 'Base',
'type': 'qweb',
'arch': '''<t name="Homepage" t-name="website.base_view">
@@ -16,23 +17,40 @@ class WebsiteVisitorTests(HttpCase):
</t>
</t>''',
'key': 'test.base_view',
'track': False,
})
[untracked_page, tracked_page] = Page.create([
tracked_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',
'track': True,
})
[untracked_view, tracked_view] = Page.create([
{
'view_id': base_view.id,
'url': '/untracked_page_1',
'website_published': True
'view_id': untracked_view.id,
'url': '/untracked_view',
'website_published': True,
},
{
'view_id': base_view.id,
'url': '/tracked_page_1',
'view_id': tracked_view.id,
'url': '/tracked_view',
'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")
self.assertEqual(len(Track.search([])), 0, "No track at the moment")
self.url_open(untracked_view.url)
self.assertEqual(len(Visitor.search([])), 0, "No visitor created after visiting an untracked view")
self.assertEqual(len(Track.search([])), 0, "No track created after visiting an untracked view")
self.url_open(tracked_view.url)
self.assertEqual(len(Visitor.search([])), 1, "A visitor should be created after visiting a tracked view")
self.assertEqual(len(Track.search([])), 1, "A track should be created after visiting a tracked view")
self.url_open(tracked_view.url)
self.assertEqual(len(Visitor.search([])), 1, "No visitor should be created after visiting another tracked view")
self.assertEqual(len(Track.search([])), 1, "No track should be created after visiting another tracked view before 30 min")
+1
View File
@@ -100,6 +100,7 @@ class MockRequest(object):
'httprequest': {
'path': '/hello/',
'app': app,
'cookies': kw.get('cookies', {}),
},
'lang': lang,
'redirect': werkzeug.utils.redirect,
+1
View File
@@ -967,6 +967,7 @@
<t t-install="website_twitter" string="Twitter Scroller" t-thumbnail="/website/static/src/img/snippets_thumbs/s_twitter_scroll.png"/>
<t t-install="website_form" string="Form Builder" t-thumbnail="/website/static/src/img/s_website_form.png"/>
<t id="s_products_searchbar" t-install="website_sale" string="Products Search" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
<t id="s_products_recently_viewed" t-install="website_sale" string="Products Recently Viewed" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png"/>
</xpath>
<xpath expr="//div[@id='snippet_content']//t[@t-snippet][last()]" position="after">
<t t-install="website_event" string="Local Events" t-thumbnail="/website/static/src/img/snippets_thumbs/s_local_events.png"/>
@@ -64,6 +64,7 @@
<script type="text/javascript" src="/website/static/src/js/content/website_root_instance.js"/>
</xpath>
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/website/static/src/js/set_view_track.js"/>
<script type="text/javascript" src="/website/static/src/js/utils.js"/>
<script type="text/javascript" src="/website/static/src/js/content/website_root.js"/>
+4 -4
View File
@@ -147,7 +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"/>
<field name="track"/>
</group>
<group>
<field name="website_indexed"/>
@@ -175,7 +175,7 @@
<field name="create_uid" invisible="1"/>
<field name="write_uid"/>
<field name="write_date"/>
<field name="is_tracked"/>
<field name="track"/>
</tree>
</field>
</record>
@@ -188,8 +188,8 @@
<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)]"/>
<filter string="Tracked" name="tracked" domain="[('track', '=', True)]"/>
<filter string="Not tracked" name="not_tracked" domain="[('track', '=', False)]"/>
</search>
</field>
</record>
+103 -23
View File
@@ -1,44 +1,66 @@
<?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</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="name">website.track.view.tree</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<tree string="Visitor Page Views History" create="0">
<field name="visitor_id"/>
<field name="page_id"/>
<field name="url"/>
<field name="visit_datetime"/>
</tree>
</field>
</record>
<record id="website_visitor_page_view_graph" model="ir.ui.view">
<field name="name">website.track.view.graph</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<graph string="Visitor Page Views">
<field name="url"/>
</graph>
</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="name">website.track.view.search</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<search string="Search Visitor">
<field name="visitor_id"/>
<field name="page_id"/>
<field name="url"/>
<field name="visit_datetime"/>
<filter string="Pages" name="type_page" domain="[('page_id', '!=', False)]"/>
<filter string="Urls &amp; Pages" name="type_url" domain="[('url', '!=', False)]"/>
<group string="Group By">
<filter string="Visitor" name="group_by_visitor" domain="[]" context="{'group_by': 'visitor_id'}"/>
<filter string="Page" name="group_by_page" domain="[]" context="{'group_by': 'page_id'}"/>
<filter string="Url" name="group_by_url" domain="[]" context="{'group_by': 'url'}"/>
<filter string="Date" name="group_by_date" domain="[]" context="{'group_by': 'visit_datetime'}"/>
</group>
</search>
</field>
</record>
<record id="website_visitor_page_action" model="ir.actions.act_window">
<field name="name">Page Views History</field>
<field name="res_model">website.track</field>
<field name="view_mode">tree</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('website_visitor_page_view_tree')}),
(0, 0, {'view_mode': 'graph', 'view_id': ref('website_visitor_page_view_graph')}),
]"/>
<field name="domain">[('visitor_id', '=', active_id), ('url', '!=', False)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No page views yet for this visitor
</p>
</field>
</record>
<!--Website visitor actions-->
<record id="website.visitor_partner_action" model="ir.actions.act_window">
<field name="name">Partners</field>
@@ -77,7 +99,7 @@
</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="o_page_count">Visited Pages<span class="float-right font-weight-bold"><field name="page_count"/></span></div>
<div id="w_visitor_kanban_actions"></div>
</div>
</div>
@@ -115,7 +137,7 @@
<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>
<h1><field name="display_name"/></h1>
</div>
<group id="general_info">
<group string="Visitor Informations">
@@ -125,7 +147,7 @@
<field name="lang_id"/>
</group>
<group string="Visits">
<group id="visits" string="Visits">
<field name="create_date"/>
<field name="last_connection_datetime"/>
<field name="page_ids" widget="many2many_tags"/>
@@ -143,7 +165,7 @@
<tree string="Web Visitors" create="0">
<!--TODO DBE : Handle no_label in treeview-->
<field name="country_flag" widget="image" options='{"size": [20, 20]}' string=" "/>
<field name="name"/>
<field name="display_name"/>
<field name="time_since_last_action"/>
<field name="lang_id"/>
<field name="visit_count"/>
@@ -153,6 +175,16 @@
</field>
</record>
<record id="website_visitor_view_graph" model="ir.ui.view">
<field name="name">website.visitor.view.graph</field>
<field name="model">website.visitor</field>
<field name="arch" type="xml">
<graph string="Visitors last connection">
<field name="visit_count"/>
</graph>
</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>
@@ -183,7 +215,7 @@
<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="view_mode">tree,form,kanban,graph</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.
@@ -191,9 +223,57 @@
</field>
</record>
<record id="website_visitor_track_view_tree" model="ir.ui.view">
<field name="name">website.track.view.tree</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<tree string="Visitor Views History" create="0" edit="0">
<field name="visitor_id"/>
<field name="page_id"/>
<field name="url"/>
<field name="visit_datetime"/>
</tree>
</field>
</record>
<record id="website_visitor_track_view_graph" model="ir.ui.view">
<field name="name">website.track.view.graph</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<graph string="Visitor Views">
<field name="url"/>
</graph>
</field>
</record>
<record id="website_visitor_view_action" model="ir.actions.act_window">
<field name="name">Views</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">website.track</field>
<field name="view_mode">tree</field>
<field name="context">{'search_default_type_url':1}</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('website_visitor_track_view_tree')}),
(0, 0, {'view_mode': 'graph', 'view_id': ref('website_visitor_track_view_graph')}),
]"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Wait for visitors to come to your website to see the pages they viewed.
</p>
</field>
</record>
<menuitem id="website_visitor_menu"
name="Visitors"
sequence="80"
parent="website.menu_website_configuration"
parent="website.menu_website_configuration"/>
<menuitem id="menu_visitor_sub_menu" name="Visitors"
sequence="1"
parent="website_visitor_menu"
action="website.website_visitors_action"/>
<menuitem id="menu_visitor_view_menu" name="Views"
sequence="2"
parent="website_visitor_menu"
action="website.website_visitor_view_action"/>
</data></odoo>
+1 -1
View File
@@ -17,7 +17,7 @@ class Lead(models.Model):
FROM crm_lead l
JOIN crm_lead_website_visitor_rel lv ON l.id = lv.crm_lead_id
JOIN website_visitor v ON v.id = lv.website_visitor_id
JOIN website_visitor_page p ON p.visitor_id = v.id
JOIN website_track p ON p.visitor_id = v.id
WHERE l.id in %s
GROUP BY l.id"""
self.env.cr.execute(sql, (tuple(self.ids),))
@@ -50,8 +50,13 @@ class MailChannel(models.Model):
: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)
history = []
for page in visitor.website_track_ids:
if page.page_id:
history += page.page_id.name + ' (' + page.visit_datetime.strftime('%H:%M') + ')'
if len(history) == 3:
break
return ' → '.join(history)
def close_livechat_request_session(self, message):
self.ensure_one()
@@ -75,4 +80,3 @@ class MailChannel(models.Model):
'last_connection_datetime': fields.datetime.now(),
})
return message
@@ -1,5 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Rating widget static: show 5 stars (full or empty) regarding the given rating_avg and rating_count
-->
<template id="rating_widget_stars_static" name="Rating: static star widget">
<t t-set="rating_avg" t-value="round(rating_avg * 100) / 100 / 2"/>
<t t-set="val_decimal" t-value="round(rating_avg % 1, 1)"/>
<t t-set="val_integer" t-value="int(rating_avg)"/>
<t t-set="empty_star" t-value="5 - (val_integer+1) if val_decimal else 5 - val_integer"/>
<div class="o_website_rating_static" t-att-style="inline_mode and 'display:inline'" t-att-title="rating_avg">
<t t-foreach="range(0, val_integer)" t-as="num">
<i class="fa fa-star" role="img"></i>
</t>
<t t-if="val_decimal">
<i class="fa fa-star-half-o" role="img"></i>
</t>
<t t-foreach="range(0, empty_star)" t-as="num">
<i class="fa fa-star text-black-25" role="img"></i>
</t>
(<t t-esc="rating_count"/>)
</div>
</template>
<!--
Display static star widget, and open rating composer on click
This template provide the container of the Popup Rating Composer. The rest is done in js.
+1
View File
@@ -23,6 +23,7 @@
'views/snippets.xml',
'views/res_config_settings_views.xml',
'views/digest_views.xml',
'views/website_sale_visitor_views.xml',
],
'demo': [
'data/demo.xml',
+61
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from datetime import datetime
from werkzeug.exceptions import Forbidden, NotFound
from odoo import fields, http, tools, _
@@ -333,6 +334,9 @@ class WebsiteSale(http.Controller):
product_context['pricelist'] = pricelist.id
product = product.with_context(product_context)
# Needed to trigger the recently viewed product rpc
view_track = request.website.viewref("website_sale.product").track
return {
'search': search,
'category': category,
@@ -344,6 +348,7 @@ class WebsiteSale(http.Controller):
'main_object': product,
'product': product,
'add_qty': add_qty,
'view_track': view_track,
}
@http.route(['/shop/change_pricelist/<model("product.pricelist"):pl_id>'], type='http', auth="public", website=True, sitemap=False)
@@ -1203,3 +1208,59 @@ class WebsiteSale(http.Controller):
res_product['price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options)
return res
# --------------------------------------------------------------------------
# Products Recently Viewed
# --------------------------------------------------------------------------
@http.route('/shop/products/recently_viewed', type='json', auth='public', website=True)
def products_recently_viewed(self, **kwargs):
"""
Returns list of recently viewed products according to current user and product options
"""
max_number_of_product_for_carousel = 12
visitors = request.env['website.visitor']._get_visitor_from_request(with_previous_visitors=True)
if visitors:
excluded_products = request.website.sale_get_order().mapped('order_line.product_id.id')
products = request.env['website.track'].sudo().read_group(
[('visitor_id', 'in', visitors.ids), ('product_id', '!=', False), ('product_id', 'not in', excluded_products)],
['product_id', 'visit_datetime:max'], ['product_id'], limit=max_number_of_product_for_carousel, orderby='visit_datetime DESC')
products_ids = [product['product_id'][0] for product in products]
if products_ids:
viewed_products = request.env['product.product'].browse(products_ids)
res = {
'products': viewed_products.read(['id', 'name', 'website_url']),
}
FieldMonetary = request.env['ir.qweb.field.monetary']
monetary_options = {
'display_currency': request.website.get_current_pricelist().currency_id,
}
rating = request.website.viewref('website_sale.product_comment').active
for res_product, product in zip(res['products'], viewed_products):
combination_info = product._get_combination_info_variant()
res_product.update(combination_info)
res_product['list_price'] = FieldMonetary.value_to_html(res_product['list_price'], monetary_options)
res_product['price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options)
if rating:
res_product['rating'] = request.env["ir.ui.view"].render_template('website_rating.rating_widget_stars_static', values={
'rating_avg': product.rating_avg,
'rating_count': product.rating_count,
})
return res
return {}
@http.route('/shop/products/recently_viewed_update', type='json', auth='public', website=True)
def products_recently_viewed_update(self, product_id, **kwargs):
res = {}
Visitor = request.env['website.visitor']
visitor = Visitor._get_visitor_from_request()
if not visitor:
visitor_sudo = Visitor._create_visitor({
'product_id': product_id,
'visit_datetime': datetime.now(),
})
res['visitor_id'] = visitor_sudo.access_token
else:
visitor._add_viewed_product(product_id)
return res
+1
View File
@@ -15,3 +15,4 @@ from . import res_config_settings
from . import digest
from . import res_company
from . import product_attribute
from . import website_visitor
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import fields, models, api
class WebsiteTrack(models.Model):
_inherit = 'website.track'
product_id = fields.Many2one('product.product', index=True, ondelete='cascade', readonly=True)
class WebsiteVisitor(models.Model):
_inherit = 'website.visitor'
visitor_product_count = fields.Integer('Product Views', compute="_compute_product_statistics", help="Total number of views on products")
product_ids = fields.Many2many('product.product', string="Visited Products", compute="_compute_product_statistics")
product_count = fields.Integer('Products Views', compute="_compute_product_statistics", help="Total number of product viewed")
@api.depends('website_track_ids')
def _compute_product_statistics(self):
results = self.env['website.track'].read_group(
[('visitor_id', 'in', self.ids), ('product_id', '!=', False)], ['visitor_id', 'product_id'], ['visitor_id', 'product_id'], lazy=False)
mapped_data = {}
for result in results:
visitor_info = mapped_data.get(result['visitor_id'][0], {'product_count': 0, 'product_ids': set()})
visitor_info['product_count'] += result['__count']
visitor_info['product_ids'].add(result['product_id'][0])
mapped_data[result['visitor_id'][0]] = visitor_info
for visitor in self:
visitor_info = mapped_data.get(visitor.id, {'product_ids': [], 'product_count': 0})
visitor.product_ids = [(6, 0, visitor_info['product_ids'])]
visitor.visitor_product_count = visitor_info['product_count']
visitor.product_count = len(visitor_info['product_ids'])
def _add_viewed_product(self, product_id):
""" add a website_track with a page marked as viewed"""
self.ensure_one()
if product_id and self.env['product.product'].browse(product_id)._is_variant_possible():
domain = [('product_id', '=', product_id)]
website_track_values = {'product_id': product_id, 'visit_datetime': datetime.now()}
self._add_tracking(domain, website_track_values)
@@ -392,4 +392,50 @@ options.registry.ProductsSearchBar = options.Class.extend({
}).open();
},
});
/**
* Handles the edition of products search bar snippet.
*/
options.registry.ProductsRecentlyViewed = options.Class.extend({
xmlDependencies: ['/website_sale/static/src/xml/website_sale_recently_viewed.xml'],
/**
* @override
*/
start: function () {
var carouselOptions = {
productsGroups: [[{
id: 0,
website_url: '#',
display_name: 'Product 1',
price: '$ <span class="oe_currency_value">750.00</span>',
}, {
id: 0,
website_url: '#',
display_name: 'Product 2',
price: '$ <span class="oe_currency_value">750.00</span>',
}, {
id: 0,
website_url: '#',
display_name: 'Product 3',
price: '$ <span class="oe_currency_value">750.00</span>',
}, {
id: 0,
website_url: '#',
display_name: 'Product 4',
price: '$ <span class="oe_currency_value">750.00</span>',
}]],
uniqueId: 1,
productFrame: 4
};
this.$target.find('.slider').html($(qweb.render('website_sale.productsRecentlyViewed', carouselOptions))).fadeIn();
return this._super.apply(this, arguments);
},
/**
* @override
*/
cleanForSave: function () {
this.$target.find('.slider').html('').css('display', 'none');
},
});
});
@@ -123,6 +123,7 @@ var config = require('web.config');
var concurrency = require('web.concurrency');
var publicWidget = require('web.public.widget');
var VariantMixin = require('sale.VariantMixin');
var wSaleUtils = require('website_sale.utils');
require("web.zoomodoo");
var qweb = core.qweb;
@@ -169,25 +170,10 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
* @override
*/
start: function () {
var self = this;
var def = this._super.apply(this, arguments);
var hash = window.location.hash.substring(1);
if (hash) {
var params = $.deparam(hash);
if (params['attr']) {
var attributeIds = params['attr'].split(',');
var $inputs = this.$('input.js_variant_change, select.js_variant_change option');
_.each(attributeIds, function (id) {
var $toSelect = $inputs.filter('[data-value_id="' + id + '"]');
if ($toSelect.is('input[type="radio"]')) {
$toSelect.prop('checked', true);
} else if ($toSelect.is('option')) {
$toSelect.prop('selected', true);
}
});
this._changeColorAttribute();
}
}
this._applyHash();
_.each(this.$('div.js_product'), function (product) {
$('input.js_product_change', product).first().trigger('change');
@@ -208,6 +194,11 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
this._startZoom();
window.addEventListener('hashchange', function (e) {
self._applyHash();
self.triggerVariantChange($(self.el));
});
return def;
},
/**
@@ -229,6 +220,26 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
// Private
//--------------------------------------------------------------------------
_applyHash: function () {
var hash = window.location.hash.substring(1);
if (hash) {
var params = $.deparam(hash);
if (params['attr']) {
var attributeIds = params['attr'].split(',');
var $inputs = this.$('input.js_variant_change, select.js_variant_change option');
_.each(attributeIds, function (id) {
var $toSelect = $inputs.filter('[data-value_id="' + id + '"]');
if ($toSelect.is('input[type="radio"]')) {
$toSelect.prop('checked', true);
} else if ($toSelect.is('option')) {
$toSelect.prop('selected', true);
}
});
this._changeColorAttribute();
}
}
},
/**
* Sets the url hash from the selected product options.
*
@@ -278,21 +289,13 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, {
$input.trigger('change');
return;
}
var $q = $(".my_cart_quantity");
if (data.cart_quantity) {
$q.parents('li:first').removeClass('d-none');
if (!data.cart_quantity) {
return window.location = '/shop/cart';
}
else {
window.location = '/shop/cart';
}
$q.html(data.cart_quantity).hide().fadeIn(600);
wSaleUtils.updateCartNavBar(data);
$input.val(data.quantity);
$('.js_quantity[data-line-id='+line_id+']').val(data.quantity).html(data.quantity);
$(".js_cart_lines").first().before(data['website_sale.cart_lines']).end().remove();
$(".js_cart_summary").first().before(data['website_sale.short_cart_summary']).end().remove();
if (data.warning) {
var cart_alert = $('.oe_cart').parent().find('#data_warning');
if (cart_alert.length === 0) {
@@ -0,0 +1,185 @@
odoo.define('website_sale.recently_viewed', function (require) {
var concurrency = require('web.concurrency');
var config = require('web.config');
var core = require('web.core');
var publicWidget = require('web.public.widget');
var utils = require('web.utils');
var wSaleUtils = require('website_sale.utils');
var qweb = core.qweb;
publicWidget.registry.productsRecentlyViewedSnippet = publicWidget.Widget.extend({
selector: '.s_wsale_products_recently_viewed',
xmlDependencies: ['/website_sale/static/src/xml/website_sale_recently_viewed.xml'],
events: {
'click .js_add_cart': '_onAddToCart',
},
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this._dp = new concurrency.DropPrevious();
this.uniqueId = _.uniqueId('o_carousel_recently_viewed_products_');
this._onResizeChange = _.debounce(this._addCarousel, 100);
},
/**
* @override
*/
start: function () {
this._dp.add(this._fetch()).then(this._render.bind(this));
$(window).resize(() => {
this._onResizeChange();
});
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_fetch: function () {
return this._rpc({
route: '/shop/products/recently_viewed',
});
},
/**
* @private
*/
_render: function (res) {
if (!res) {
return;
}
var products = res['products'];
if (products && products.length) {
var mobileProducts = [], webProducts = [], productsTemp = [];
_.each(products, function (product) {
if (productsTemp.length === 4) {
webProducts.push(productsTemp);
productsTemp = [];
}
productsTemp.push(product);
mobileProducts.push([product]);
});
if (productsTemp.length) {
webProducts.push(productsTemp);
}
this.mobileCarousel = $(qweb.render('website_sale.productsRecentlyViewed', {
uniqueId: this.uniqueId,
productFrame: 1,
productsGroups: mobileProducts,
}));
this.webCarousel = $(qweb.render('website_sale.productsRecentlyViewed', {
uniqueId: this.uniqueId,
productFrame: 4,
productsGroups: webProducts,
}));
this._addCarousel();
}
},
/**
* Add the right carousel depending on screen size.
* @private
*/
_addCarousel: function () {
var carousel = config.device.size_class <= config.device.SIZES.SM ? this.mobileCarousel : this.webCarousel;
this.$('.slider').html(carousel).fadeIn(1000);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Add product to cart and reload the carousel.
* @private
* @param {Event} ev
*/
_onAddToCart: function (ev) {
var self = this;
var $card = $(ev.currentTarget).closest('.card');
this._rpc({
route: "/shop/cart/update_json",
params: {
product_id: $card.find('input[data-product-id]').data('product-id'),
add_qty: 1
},
}).then(function (data) {
wSaleUtils.updateCartNavBar(data);
var $navButton = wSaleUtils.getNavBarButton('.o_wsale_my_cart');
wSaleUtils.animateClone($navButton, $(ev.currentTarget).parents('.o_carousel_product_card'), 25, 40);
self._dp.add(self._fetch()).then(self._render.bind(self));
});
},
});
publicWidget.registry.productsRecentlyViewedUpdate = publicWidget.Widget.extend({
selector: '#product_detail',
events: {
'change input.product_id[name="product_id"]': '_onProductChange',
},
debounceValue: 8000,
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this._onProductChange = _.debounce(this._onProductChange, this.debounceValue);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Debounced method that wait some time before marking the product as viewed.
* @private
* @param {HTMLInputElement} $input
*/
_updateProductView: function ($input) {
var productId = parseInt($input.val());
var cookieName = 'seen_product_id_' + productId;
if (! parseInt(this.el.dataset.viewTrack, 10)) {
return; // Is not tracked
}
if (utils.get_cookie(cookieName)) {
return; // Already tracked in the last 30min
}
if ($(this.el).find('.js_product.css_not_available').length) {
return; // Variant not possible
}
this._rpc({
route: '/shop/products/recently_viewed_update',
params: {
product_id: productId,
}
}).then(function (res) {
if (res && res.visitor_id) {
utils.set_cookie('visitor_id', res.visitor_id);
}
utils.set_cookie(cookieName, productId, 30 * 60);
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Call debounced method when product change to reset timer.
* @private
* @param {Event} ev
*/
_onProductChange: function (ev) {
this._updateProductView($(ev.currentTarget));
},
});
});
@@ -1,6 +1,24 @@
odoo.define('website_sale.utils', function (require) {
'use strict';
/**
* Gets the element in the navbar currently displayed.
* Depending on the scroll position, it could either be the one in the main
* top bar or the one in the affixed navbar.
*
* @private
* @param {string} selector
* @returns {jQuery}
*/
function getNavBarButton(selector) {
var $affixedHeaderButton = $('header.affixed ' + selector);
if ($affixedHeaderButton.length) {
return $affixedHeaderButton;
} else {
return $('header ' + selector).first();
}
}
function animateClone($cart, $elem, offsetTop, offsetLeft) {
$cart.find('.o_animate_blink').addClass('o_red_highlight o_shadow_animation').delay(500).queue(function(){
$(this).removeClass("o_shadow_animation").dequeue();
@@ -32,7 +50,20 @@ function animateClone($cart, $elem, offsetTop, offsetLeft) {
}
}
/**
* @param {Object} data
*/
function updateCartNavBar(data) {
var $q = $(".my_cart_quantity");
$q.parents('li:first').removeClass('d-none');
$q.html(data.cart_quantity).hide().fadeIn(600);
$(".js_cart_lines").first().before(data['website_sale.cart_lines']).end().remove();
$(".js_cart_summary").first().before(data['website_sale.short_cart_summary']).end().remove();
}
return {
animateClone: animateClone,
getNavBarButton: getNavBarButton,
updateCartNavBar: updateCartNavBar,
};
});
@@ -537,3 +537,34 @@ a.no-decoration {
.o_shadow_animation {
box-shadow: 0 0 5px 10px rgba(240,8,0,0.4)!important;
}
/* product recently viewed snippet */
.o_carousel_product_card {
.o_carousel_product_card_img_top {
object-fit: scale-down;
@include media-breakpoint-down(sm) {
height: 12rem;
}
@include media-breakpoint-up(md) {
height: 8rem;
}
@include media-breakpoint-up(lg) {
height: 12rem;
}
}
}
.o_carousel_product_card_wrap {
@include media-breakpoint-up(sm) {
float: left;
}
}
.o_carousel_product_control {
top: percentage(1/3);
bottom: percentage(1/3);
width: 2rem;
border-radius: 5px;
background-color: $o-enterprise-primary-color;
}
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Recently Viewed Products Carousel -->
<t t-name="website_sale.productsRecentlyViewed">
<div t-att-id="uniqueId" class="carousel slide o_not_editable" data-interval="false">
<div class="carousel-inner">
<t t-foreach="productsGroups" t-as="products">
<div t-attf-class="carousel-item #{!products_index and 'active' or ''}">
<div class="row">
<t t-foreach="products" t-as="product">
<div t-attf-class="o_carousel_product_card_wrap col-md-#{12 / productFrame}">
<div class="o_carousel_product_card card h-100">
<input type="hidden" name="product-id" t-att-data-product-id="product.id"/>
<a t-att-href="product.website_url">
<img class="o_carousel_product_card_img_top card-img-top" t-attf-src="/web/image/product.product/#{product.id}#{productFrame == 1 ? '/image_256' : '/image_512'}" t-att-alt="product.display_name"/>
</a>
<div class="o_carousel_product_card_body card-body border-top">
<a t-att-href="product.website_url" class="text-decoration-none">
<h6 class="card-title mb-0 text-truncate" t-raw="product.display_name"/>
</a>
<t t-if="product.rating" t-raw="product.rating"/>
</div>
<div class="o_carousel_product_card_footer card-footer d-flex align-items-center">
<div class="d-block font-weight-bold" t-raw="product.price"/>
<button type="button" role="button" class="btn btn-primary js_add_cart ml-auto" title="Add to Cart">
<i class="fa fa-fw fa-shopping-cart"/>
</button>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</div>
<t t-if='productsGroups.length > 1'>
<a class="o_carousel_product_control carousel-control-prev" t-att-href="'#' + uniqueId" role="button" data-slide="prev">
<span class="carousel-control-prev-icon"></span>
<span class="sr-only">Previous</span>
</a>
<a class="o_carousel_product_control carousel-control-next" t-att-href="'#' + uniqueId" role="button" data-slide="next">
<span class="carousel-control-next-icon"></span>
<span class="sr-only">Next</span>
</a>
</t>
</div>
</t>
</templates>
+1
View File
@@ -7,3 +7,4 @@ from . import test_website_sale_pricelist
from . import test_website_sale_product_attribute_value_config
from . import test_website_sale_image
from . import test_website_sequence
from . import test_website_sale_visitor
@@ -0,0 +1,42 @@
# coding: utf-8
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.addons.website.tools import MockRequest
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class WebsiteSaleVisitorTests(TransactionCase):
def setUp(self):
super().setUp()
self.website = self.env['website'].browse(1)
self.WebsiteSaleController = WebsiteSale()
self.cookies = {}
def test_create_visitor_on_tracked_product(self):
self.WebsiteSaleController = WebsiteSale()
Visitor = self.env['website.visitor']
Track = self.env['website.track']
self.assertEqual(len(Visitor.search([])), 0, "No visitor at the moment")
self.assertEqual(len(Track.search([])), 0, "No track at the moment")
product = self.env.ref('product.product_product_7')
with MockRequest(self.env, website=self.website):
self.cookies = self.WebsiteSaleController.products_recently_viewed_update(product.id)
self.assertEqual(len(Visitor.search([])), 1, "A visitor should be created after visiting a tracked product")
self.assertEqual(len(Track.search([])), 1, "A track should be created after visiting a tracked product")
with MockRequest(self.env, website=self.website, cookies=self.cookies):
self.WebsiteSaleController.products_recently_viewed_update(product.id)
self.assertEqual(len(Visitor.search([])), 1, "No visitor should be created after visiting another tracked product")
self.assertEqual(len(Track.search([])), 1, "No track should be created after visiting the same tracked product before 30 min")
product = self.env.ref('product.product_product_6')
with MockRequest(self.env, website=self.website, cookies=self.cookies):
self.WebsiteSaleController.products_recently_viewed_update(product.id)
self.assertEqual(len(Visitor.search([])), 1, "No visitor should be created after visiting another tracked product")
self.assertEqual(len(Track.search([])), 2, "A track should be created after visiting another tracked product")
+24
View File
@@ -24,9 +24,28 @@
</section>
</template>
<template id="s_products_recently_viewed" name="Products Recently Viewed">
<section class="s_wsale_products_recently_viewed pt24 pb24" style="min-height: 400px;">
<div class="alert alert-info alert-dismissible rounded-0 fade show d-print-none css_non_editable_mode_hidden">
<div class="container">
<div class="text-center">
This is a preview of the recently viewed products by the user.<br/>
Once the user has seen at least one product this snippet will be visible.
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close"> × </button>
</div>
</div>
<div class="container">
<h3 class="text-center mb32">Recently viewed Products</h3>
<div class="slider o_not_editable" style="display: none;"/>
</div>
</section>
</template>
<template id="snippets" inherit_id="website.snippets" name="e-commerce snippets">
<xpath expr="//div[@id='snippet_feature']//t[@t-snippet][last()]" position="after">
<t t-snippet="website_sale.s_products_searchbar" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
<t t-snippet="website_sale.s_products_recently_viewed" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png"/>
</xpath>
<xpath expr="//div[@id='snippet_content']//t[@t-snippet][last()]" position="after">
<t t-snippet="website_sale.s_products_searchbar_input" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/>
@@ -36,6 +55,7 @@
<template id="remove_external_snippets" inherit_id="website.external_snippets">
<xpath expr="//t[@t-install='website_sale'][@id='s_products_searchbar']" position="replace"/>
<xpath expr="//t[@t-install='website_sale'][@id='s_products_searchbar_input']" position="replace"/>
<xpath expr="//t[@t-install='website_sale'][@id='s_products_recently_viewed']" position="replace"/>
</template>
<template id="snippet_options" inherit_id="website.snippet_options" name="e-commerce snippet options">
@@ -101,6 +121,10 @@
<i class="fa fa-search"/> Products Search Bar
</we-button>
</div>
<div data-js="ProductsRecentlyViewed"
data-selector=".s_wsale_products_recently_viewed">
</div>
</xpath>
<xpath expr="//div[@id='so_content_addition']" position="attributes">
<attribute name="data-selector" add=".s_wsale_products_searchbar_input" separator=","/>
+11 -4
View File
@@ -31,6 +31,7 @@
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_utils.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_payment.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_validate.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_recently_viewed.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tracking.js"></script>
</xpath>
</template>
@@ -463,7 +464,7 @@
</xpath>
</template>
<template id="product" name="Product">
<template id="product" name="Product" track="1">
<t t-set="combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
@@ -472,7 +473,7 @@
<t t-call="website.layout">
<t t-set="additional_title" t-value="product.name" />
<div itemscope="itemscope" itemtype="http://schema.org/Product" id="wrap" class="js_sale">
<section t-attf-class="container py-2 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail">
<section t-attf-class="container py-2 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail" t-att-data-view-track="view_track and '1' or '0'">
<div class="row">
<div class="col-md-4">
<ol class="breadcrumb">
@@ -595,8 +596,14 @@
</xpath>
</template>
<template id="recently_viewed_products_product" inherit_id="website_sale.product" active="True" customize_show="True" name="Recently Viewed Products" priority="16">
<xpath expr="//div[@t-field='product.website_description']" position="after">
<t t-call="website_sale.s_products_recently_viewed"/>
</xpath>
</template>
<!-- Product options: OpenChatter -->
<template id="product_comment" inherit_id="website_sale.product" active="False" customize_show="True" name="Discussion and Rating">
<template id="product_comment" inherit_id="website_sale.product" active="False" customize_show="True" name="Discussion and Rating" priority="15">
<xpath expr="//div[@t-field='product.website_description']" position="after">
<div class="o_shop_discussion_rating">
<section class="container mt16 mb16">
@@ -936,7 +943,7 @@
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="col-12 col-xl-4" id="o_cart_summary">
<t t-call="website_sale.short_cart_summary"/>
<div class="d-xl-none mt8">
<a role="button" href="/shop" class="btn btn-secondary mb32">
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--product history-->
<record id="website_sale_visitor_page_view_tree" model="ir.ui.view">
<field name="name">website.track.view.tree</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<tree string="Visitor Product Views History" create="0">
<field name="visitor_id"/>
<field name="product_id"/>
<field name="visit_datetime"/>
</tree>
</field>
</record>
<record id="website_sale_visitor_page_view_graph" model="ir.ui.view">
<field name="name">website.track.view.graph</field>
<field name="model">website.track</field>
<field name="arch" type="xml">
<graph string="Visitor Product Views">
<field name="product_id"/>
</graph>
</field>
</record>
<record id="website_sale_visitor_product_action" model="ir.actions.act_window">
<field name="name">Product Views History</field>
<field name="res_model">website.track</field>
<field name="view_mode">tree</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('website_sale_visitor_page_view_tree')}),
(0, 0, {'view_mode': 'graph', 'view_id': ref('website_sale_visitor_page_view_graph')}),
]"/>
<field name="domain">[('visitor_id', '=', active_id), ('product_id', '!=', False)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No product views yet for this visitor
</p>
</field>
</record>
<record id="website_sale_visitor_page_view_search" model="ir.ui.view">
<field name="name">website.track.view.search</field>
<field name="model">website.track</field>
<field name="inherit_id" ref="website.website_visitor_page_view_search"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='url']" position="after">
<field name="product_id"/>
</xpath>
<xpath expr="//filter[@name='type_url']" position="after">
<filter string="Products" name="type_product" domain="[('product_id', '!=', False)]"/>
</xpath>
<xpath expr="//filter[@name='group_by_url']" position="after">
<filter string="Product" name="group_by_product" domain="[]" context="{'group_by': 'product_id'}"/>
</xpath>
</field>
</record>
<!-- website visitor views -->
<record id="website_sale_visitor_view_form" model="ir.ui.view">
<field name="name">website.visitor.view.form</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[@name='%(website.website_visitor_page_action)d']" position="after">
<button name="%(website_sale.website_sale_visitor_product_action)d" type="action"
class="oe_stat_button"
icon="fa-tags">
<field name="visitor_product_count" widget="statinfo" string="Product views"/>
</button>
</xpath>
<xpath expr="//group[@id='visits']/field[@name='page_ids']" position="after">
<field name="product_ids" widget="many2many_tags"/>
</xpath>
</field>
</record>
<record id="website_sale_visitor_view_tree" model="ir.ui.view">
<field name="name">website.visitor.view.tree</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="product_ids" widget="many2many_tags"/>
</xpath>
</field>
</record>
<record id="website_sale_visitor_view_kanban" model="ir.ui.view">
<field name="name">website.visitor.view.kanban</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='page_ids']" position="after">
<field name="product_ids"/>
</xpath>
<xpath expr="//div[@id='o_page_count']" position="after">
<div id="o_product_count">Visited Products<span class="float-right font-weight-bold"><field name="product_count"/></span></div>
</xpath>
</field>
</record>
<!-- website track views -->
<record id="website_sale_visitor_track_view_tree" model="ir.ui.view">
<field name="name">website.track.view.tree</field>
<field name="model">website.track</field>
<field name="inherit_id" ref="website.website_visitor_track_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='url']" position="after">
<field name="product_id"/>
</xpath>
</field>
</record>
<record id="website_sale_visitor_track_view_graph" model="ir.ui.view">
<field name="name">website.track.view.graph</field>
<field name="model">website.track</field>
<field name="inherit_id" ref="website.website_visitor_track_view_graph"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='url']" position="after">
<field name="product_id"/>
</xpath>
</field>
</record>
</odoo>
@@ -68,23 +68,6 @@ publicWidget.registry.ProductWishlist = publicWidget.Widget.extend(VariantMixin,
// Private
//--------------------------------------------------------------------------
/**
* Gets the element in the navbar currently displayed.
* Depending on the scroll position, it could either be the one in the main
* top bar or the one in the affixed navbar.
*
* @private
* @param {string} selector
* @returns {jQuery}
*/
_getNavBarButton: function (selector) {
var $affixedHeaderButton = $('header.affixed ' + selector);
if ($affixedHeaderButton.length) {
return $affixedHeaderButton;
} else {
return $('header ' + selector).first();
}
},
/**
* @private
*/
@@ -122,7 +105,7 @@ publicWidget.registry.ProductWishlist = publicWidget.Widget.extend(VariantMixin,
product_id: productId,
},
}).then(function () {
var $navButton = self._getNavBarButton('.o_wsale_my_wish');
var $navButton = wSaleUtils.getNavBarButton('.o_wsale_my_wish');
self.wishlistProductIDs.push(productId);
self._updateWishlistView();
wSaleUtils.animateClone($navButton, $el.closest('form'), 25, 40);
@@ -174,7 +157,7 @@ publicWidget.registry.ProductWishlist = publicWidget.Widget.extend(VariantMixin,
* @private
*/
_addOrMoveWish: function (e) {
var $navButton = this._getNavBarButton('.o_wsale_my_cart');
var $navButton = wSaleUtils.getNavBarButton('.o_wsale_my_cart');
var tr = $(e.currentTarget).parents('tr');
var product = tr.data('product-id');
$('.o_wsale_my_cart').removeClass('d-none');
+1
View File
@@ -191,6 +191,7 @@
<rng:optional><rng:attribute name="priority"/></rng:optional>
<rng:optional><rng:attribute name="key"/></rng:optional>
<rng:optional><rng:attribute name="website_id"/></rng:optional>
<rng:optional><rng:attribute name="track"/></rng:optional>
<rng:group>
<rng:optional>
<rng:attribute name="inherit_id"/>
+2
View File
@@ -616,6 +616,8 @@ form: module.record_id""" % (xml_id,)
record.append(Field(name, name='name'))
record.append(Field(full_tpl_id, name='key'))
record.append(Field("qweb", name='type'))
if 'track' in el.attrib:
record.append(Field(el.get('track'), name='track'))
if 'priority' in el.attrib:
record.append(Field(el.get('priority'), name='priority'))
if 'inherit_id' in el.attrib: