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 @@ True / - True + True /contactus True - True + True True /aboutus - True + True 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) + 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}) 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 Binary files /dev/null and b/addons/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png 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 @@ + + + + +