summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorfja-odoo <fja@odoo.com>2019-08-21 16:40:10 +0000
committerfja-odoo <fja@odoo.com>2019-09-16 09:38:53 +0000
commite33172e83210a5e0044a6fb92e315b84deefc440 (patch)
tree96edafcf0a9f2807bfe011fcfae414978f0f88a1
parent1bc4c1697f33c70927f204ffbd38d1e0ad9cba07 (diff)
[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>
-rw-r--r--addons/website/data/website_data.xml6
-rw-r--r--addons/website/models/ir_http.py18
-rw-r--r--addons/website/models/ir_ui_view.py1
-rw-r--r--addons/website/models/website_page.py1
-rw-r--r--addons/website/models/website_visitor.py206
-rw-r--r--addons/website/security/ir.model.access.csv8
-rw-r--r--addons/website/static/src/img/snippets_thumbs/s_products_recently_viewed.pngbin0 -> 833 bytes
-rw-r--r--addons/website/static/src/js/set_view_track.js89
-rw-r--r--addons/website/static/src/xml/track_page.xml15
-rw-r--r--addons/website/tests/test_website_visitor.py48
-rw-r--r--addons/website/tools.py1
-rw-r--r--addons/website/views/snippets.xml1
-rw-r--r--addons/website/views/website_templates.xml1
-rw-r--r--addons/website/views/website_views.xml8
-rw-r--r--addons/website/views/website_visitor_views.xml126
-rw-r--r--addons/website_crm/models/crm_lead.py2
-rw-r--r--addons/website_livechat/models/mail_channel.py10
-rw-r--r--addons/website_rating/views/rating_templates.xml22
-rw-r--r--addons/website_sale/__manifest__.py1
-rw-r--r--addons/website_sale/controllers/main.py61
-rw-r--r--addons/website_sale/models/__init__.py1
-rw-r--r--addons/website_sale/models/website_visitor.py45
-rw-r--r--addons/website_sale/static/src/js/website_sale.editor.js46
-rw-r--r--addons/website_sale/static/src/js/website_sale.js59
-rw-r--r--addons/website_sale/static/src/js/website_sale_recently_viewed.js185
-rw-r--r--addons/website_sale/static/src/js/website_sale_utils.js31
-rw-r--r--addons/website_sale/static/src/scss/website_sale.scss31
-rw-r--r--addons/website_sale/static/src/xml/website_sale_recently_viewed.xml48
-rw-r--r--addons/website_sale/tests/__init__.py1
-rw-r--r--addons/website_sale/tests/test_website_sale_visitor.py42
-rw-r--r--addons/website_sale/views/snippets.xml24
-rw-r--r--addons/website_sale/views/templates.xml15
-rw-r--r--addons/website_sale/views/website_sale_visitor_views.xml125
-rw-r--r--addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js21
-rw-r--r--odoo/import_xml.rng1
-rw-r--r--odoo/tools/convert.py2
36 files changed, 1118 insertions, 184 deletions
diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml
index e0df9ef806e..ce4091a6352 100644
--- a/addons/website/data/website_data.xml
+++ b/addons/website/data/website_data.xml
@@ -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 -->
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index ecacdc62bac..da99aa35287 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -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
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index a3aca36ced5..5d7a992dca1 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -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:
diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py
index 105aa49d89c..5473efeffe6 100644
--- a/addons/website/models/website_page.py
+++ b/addons/website/models/website_page.py
@@ -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()
diff --git a/addons/website/models/website_visitor.py b/addons/website/models/website_visitor.py
index 7f7e50073b4..d2d9ca0bf7d 100644
--- a/addons/website/models/website_visitor.py
+++ b/addons/website/models/website_visitor.py
@@ -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)
-
- def _create_visitor(self, website_page_id=False):
+ 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 _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})
diff --git a/addons/website/security/ir.model.access.csv b/addons/website/security/ir.model.access.csv
index 7e80fee2dc2..624735ca4a4 100644
--- a/addons/website/security/ir.model.access.csv
+++ b/addons/website/security/ir.model.access.csv
@@ -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
diff --git a/addons/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png b/addons/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png
new file mode 100644
index 00000000000..c55ad28b628
--- /dev/null
+++ b/addons/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png
Binary files differ
diff --git a/addons/website/static/src/js/set_view_track.js b/addons/website/static/src/js/set_view_track.js
new file mode 100644
index 00000000000..d94fcddfffd
--- /dev/null
+++ b/addons/website/static/src/js/set_view_track.js
@@ -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'));
+ }
+ });
+ },
+});
+
+});
diff --git a/addons/website/static/src/xml/track_page.xml b/addons/website/static/src/xml/track_page.xml
new file mode 100644
index 00000000000..11311156ca7
--- /dev/null
+++ b/addons/website/static/src/xml/track_page.xml
@@ -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>
diff --git a/addons/website/tests/test_website_visitor.py b/addons/website/tests/test_website_visitor.py
index 60a10582c97..427816a0452 100644
--- a/addons/website/tests/test_website_visitor.py
+++ b/addons/website/tests/test_website_visitor.py
@@ -1,13 +1,25 @@
# 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">
+ <t t-call="website.layout">
+ I am a generic page
+ </t>
+ </t>''',
+ 'key': 'test.base_view',
+ 'track': False,
+ })
+ tracked_view = View.create({
'name': 'Base',
'type': 'qweb',
'arch': '''<t name="Homepage" t-name="website.base_view">
@@ -16,23 +28,29 @@ class WebsiteVisitorTests(HttpCase):
</t>
</t>''',
'key': 'test.base_view',
+ 'track': True,
})
- [untracked_page, tracked_page] = Page.create([
+ [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")
diff --git a/addons/website/tools.py b/addons/website/tools.py
index e7058d65541..3639776706f 100644
--- a/addons/website/tools.py
+++ b/addons/website/tools.py
@@ -100,6 +100,7 @@ class MockRequest(object):
'httprequest': {
'path': '/hello/',
'app': app,
+ 'cookies': kw.get('cookies', {}),
},
'lang': lang,
'redirect': werkzeug.utils.redirect,
diff --git a/addons/website/views/snippets.xml b/addons/website/views/snippets.xml
index 8febb5d94a1..bbda42f5ffb 100644
--- a/addons/website/views/snippets.xml
+++ b/addons/website/views/snippets.xml
@@ -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"/>
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index 6cb36994dc5..49e6dc6de0b 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -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"/>
diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml
index 42bafa3883d..92841cb2063 100644
--- a/addons/website/views/website_views.xml
+++ b/addons/website/views/website_views.xml
@@ -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>
diff --git a/addons/website/views/website_visitor_views.xml b/addons/website/views/website_visitor_views.xml
index acc67074762..c82b66bbe99 100644
--- a/addons/website/views/website_visitor_views.xml
+++ b/addons/website/views/website_visitor_views.xml
@@ -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>
diff --git a/addons/website_crm/models/crm_lead.py b/addons/website_crm/models/crm_lead.py
index 7e3aa41389d..256a597f25a 100644
--- a/addons/website_crm/models/crm_lead.py
+++ b/addons/website_crm/models/crm_lead.py
@@ -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),))
diff --git a/addons/website_livechat/models/mail_channel.py b/addons/website_livechat/models/mail_channel.py
index ba8fb18fec7..e6d00133eff 100644
--- a/addons/website_livechat/models/mail_channel.py
+++ b/addons/website_livechat/models/mail_channel.py
@@ -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
-
diff --git a/addons/website_rating/views/rating_templates.xml b/addons/website_rating/views/rating_templates.xml
index 03ef04a4412..a1faae6aa21 100644
--- a/addons/website_rating/views/rating_templates.xml
+++ b/addons/website_rating/views/rating_templates.xml
@@ -1,6 +1,28 @@
<?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.
To use this template, you need to call it after setting the following variable in your template or in your controller:
diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py
index b07dc32747b..8703258bc36 100644
--- a/addons/website_sale/__manifest__.py
+++ b/addons/website_sale/__manifest__.py
@@ -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',
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 9159fc59ce8..a311c3f1f3f 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -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
diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py
index ee8d61ec221..605059cc5bc 100644
--- a/addons/website_sale/models/__init__.py
+++ b/addons/website_sale/models/__init__.py
@@ -15,3 +15,4 @@ from . import res_config_settings
from . import digest
from . import res_company
from . import product_attribute
+from . import website_visitor
diff --git a/addons/website_sale/models/website_visitor.py b/addons/website_sale/models/website_visitor.py
new file mode 100644
index 00000000000..7fc2a2d5075
--- /dev/null
+++ b/addons/website_sale/models/website_visitor.py
@@ -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)
diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js
index e4f271604b5..84acbbc1008 100644
--- a/addons/website_sale/static/src/js/website_sale.editor.js
+++ b/addons/website_sale/static/src/js/website_sale.editor.js
@@ -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');
+ },
+});
});
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index ef636055df4..ec6c165d8d0 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -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');
- }
- else {
- window.location = '/shop/cart';
+ if (!data.cart_quantity) {
+ return 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) {
diff --git a/addons/website_sale/static/src/js/website_sale_recently_viewed.js b/addons/website_sale/static/src/js/website_sale_recently_viewed.js
new file mode 100644
index 00000000000..e587aab01b9
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_recently_viewed.js
@@ -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));
+ },
+});
+});
diff --git a/addons/website_sale/static/src/js/website_sale_utils.js b/addons/website_sale/static/src/js/website_sale_utils.js
index 48fdd981ca9..3b672063551 100644
--- a/addons/website_sale/static/src/js/website_sale_utils.js
+++ b/addons/website_sale/static/src/js/website_sale_utils.js
@@ -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,
};
});
diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss
index 0e15b99557e..4ce1008a10a 100644
--- a/addons/website_sale/static/src/scss/website_sale.scss
+++ b/addons/website_sale/static/src/scss/website_sale.scss
@@ -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;
+}
diff --git a/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml b/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml
new file mode 100644
index 00000000000..d13bff2c49d
--- /dev/null
+++ b/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml
@@ -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>
diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py
index bfdce9609a3..e3c172556f0 100644
--- a/addons/website_sale/tests/__init__.py
+++ b/addons/website_sale/tests/__init__.py
@@ -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
diff --git a/addons/website_sale/tests/test_website_sale_visitor.py b/addons/website_sale/tests/test_website_sale_visitor.py
new file mode 100644
index 00000000000..d418853b188
--- /dev/null
+++ b/addons/website_sale/tests/test_website_sale_visitor.py
@@ -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")
diff --git a/addons/website_sale/views/snippets.xml b/addons/website_sale/views/snippets.xml
index 17b8ebf69e7..17fc75eb0d6 100644
--- a/addons/website_sale/views/snippets.xml
+++ b/addons/website_sale/views/snippets.xml
@@ -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=","/>
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index f14eb73e4e9..e6690ef37dd 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -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">
diff --git a/addons/website_sale/views/website_sale_visitor_views.xml b/addons/website_sale/views/website_sale_visitor_views.xml
new file mode 100644
index 00000000000..aebd5e77fea
--- /dev/null
+++ b/addons/website_sale/views/website_sale_visitor_views.xml
@@ -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>
diff --git a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js
index dce756d5bbb..9890d0013d2 100644
--- a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js
+++ b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js
@@ -69,23 +69,6 @@ publicWidget.registry.ProductWishlist = publicWidget.Widget.extend(VariantMixin,
//--------------------------------------------------------------------------
/**
- * 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
*/
_addNewProducts: function ($el) {
@@ -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');
diff --git a/odoo/import_xml.rng b/odoo/import_xml.rng
index 280a2d6ab5f..1a182ce6ffc 100644
--- a/odoo/import_xml.rng
+++ b/odoo/import_xml.rng
@@ -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"/>
diff --git a/odoo/tools/convert.py b/odoo/tools/convert.py
index 5ace904749b..cd893d6ab1a 100644
--- a/odoo/tools/convert.py
+++ b/odoo/tools/convert.py
@@ -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: