[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:
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
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>
|
||||
@@ -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")
|
||||
|
||||
@@ -100,6 +100,7 @@ class MockRequest(object):
|
||||
'httprequest': {
|
||||
'path': '/hello/',
|
||||
'app': app,
|
||||
'cookies': kw.get('cookies', {}),
|
||||
},
|
||||
'lang': lang,
|
||||
'redirect': werkzeug.utils.redirect,
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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=","/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user