Files
odoo_source/addons/website/models/website_visitor.py
T
Romain Derie d348bed1ad [IMP] website, *: use upsert to improve visitor perf
* im_livechat, test_event_full, website_blog, website_crm,
  website_event, website_event_track, website_event_track_quiz,
  webite_livechat, website_sale

There is 6 main changes in this commit:

1. Using raw SQL Upsert instead of the ORM methods. While raw SQL should
generally be avoided, it makes sense for such a low level behavior which
is impacting every flows.
Indeed, tracking visitors is a generic behavior done on all pages and
controllers. It is important to optimize it to reduce processing time
and SQL Queries.
Benchmark of that change alone:
> Rendering a tracked page improves from ~19.5ms to ~17ms (using `ab`
  with 1000 loop) and the requests involved in the tracking process are
  reduced from 8 SQL Queries to 3:
  - 1 request to upsert the visitor
  - 1 request to fetch the visitor data
  - 1 request to add the tracking record

2. Adding in that upsert query the `visitor.track` insert, creating both
records in one go, bringing the query count from 3 to 2.

3. Refactoring of the `parent_id` behavior that was introduced in stable
with [1]. The purpose was to keep track of multiple visitor linked to a
same user to merge the tracking together. Especially useful for tracking
a same visitor on different devices (when logged in).
Only one visitor was kept as active, others would be archived and their
tracks would be set/moved to the main partner.
Removing those duplicate visitor was not possible because those archived
duplicated visitor were holding the devices notification push token.
Since [2], those token were moved to their own table, all related to the
main visitor.
We can then now safely remove those duplicate visitors after merging
their track to the main visitor. Thus, the `parent_id` field is no more
useful. Removing it removes a layer of complexity.
Note that thanks to this part, the `active` field can also be removed.

4. Deeper functionnal change, inspired from Plausible: The access_token
is no more stored in a cookie but is the result of a hashing method
based on <IP Adress, User Agent>.
The reason behind that change is that, in an upcoming refactoring,
sessions won't be stored anymore unless absolutely needed (login, add to
cart..). It will also ship a no cookies policy, trying to get rid of all
cookies.
This change is bringing some functional changes:
- Since the IP is included in the hash to generate the token, it means
  that:
  A. If an anonymous user switch IP (eg from 4G to wifi), it is
     considered as a new visitor.
  B. If 2 anonymous users with the exact same user agent (same browser,
     same browser version, same exact os or phone) are on the same IP,
     those will be considered as the same visitor.
- Since the request host is not included in the hash, it means that
  visiting a DB from 2 differents URLs (domain and/or ip) on the same
  device and same browser will result in a shared visitor.
  It shouldn't imply any issue as this is A. not wrong and B. mostly
  used for tests.
As all this is only related to non logged in user, it shouldn't be a
real issue as anonymous visitors are not supposed to be meant to be
business critical, even if we use them for "a bit more" than simple
analytics data.

5. The access_token is now replaced by the partner_id once the user logs
in, so:
- We don't need to either search on the partner_id field or the
access_token field (depending if the user is logged in or not), we can
only use the access_token row/field to do both.
- On logout, everything works out of the box as the access_token will be
regenerated since there is no partner_id anymore.
- On login, if an access_token matches the user's partner_id, that
visitor is returned.
If there is no such token, a new visitor is created for that partner_id.
In both 2 cases, tracks are moved to that visitor and the anonymous
visitor is removed.
- We can remove the code that was in charge of checking if the
access_token / visitor cookie was wrong (coming from another user eg,
different user login on same device). Indeed, such collision is not
possible anymore as the access_token automatically match the logged in
user.
- We can remove the code that was in charge of checking if the
access_token / visitor cookie was wrong (coming from a logged in user
while the current visitor is not loggedin). Such collision is not
possible anymore as the access_token is (re)generated as an anonymous
token (hash) when not logged in.

6. There is no more check to prevent a track to be created if there was
already a track for that URL in the last 30 minutes.
While this can easily be re-introduced (one CTE on the upsert), it was
adding ~100ms (from ~20 to ~110ms) to the request on a big database as
Odoo where there is ~100 millions tracks and ~100 millions visitors.
It has been validated that it was not a real issue as it is not
fundamentally wrong. If a visitor visited 20 times a product or a
specific page in that short amount of time, you might want to know that
because the user is most likely interested by it.

Changes (1+2), 3, (4+5) and 6 are all independant from each other and
could have existed on their own.

[1]: https://github.com/odoo/odoo/commit/c6b8a44b970a46dcd87a4e2cb1ad52fa340b209f
[2]: https://github.com/odoo/enterprise/pull/16781/commits/f75090fe8b42484e89e933976e8441d2f5eb9415

task-2867045

closes odoo/odoo#87857

Related: odoo/enterprise#28004
Related: odoo/upgrade#3566
Signed-off-by: Romain Derie (rde) <rde@odoo.com>
2022-06-07 16:31:20 +02:00

391 lines
18 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from psycopg2 import sql
import hashlib
import pytz
from odoo import fields, models, api, _
from odoo.addons.base.models.res_partner import _tz_get
from odoo.exceptions import UserError
from odoo.tools.misc import _format_time_ago
from odoo.http import request
from odoo.osv import expression
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 WebsiteVisitor(models.Model):
_name = 'website.visitor'
_description = 'Website Visitor'
_order = 'id DESC'
def _get_access_token(self):
""" Either the user's partner.id or a hash. """
if not request:
raise ValueError("Visitors can only be created through the frontend.")
if not self.env.user._is_public():
return self.env.user.partner_id.id
msg = repr((
request.httprequest.remote_addr,
request.httprequest.environ.get('HTTP_USER_AGENT'),
request.session.sid,
)).encode('utf-8')
# Keep same length (32) as before, it will ease the migration without
# any real downside
return hashlib.sha1(msg).hexdigest()[:32]
name = fields.Char('Name', related='partner_id.name')
access_token = fields.Char(required=True, default=_get_access_token, copy=False)
website_id = fields.Many2one('website', "Website", readonly=True)
partner_id = fields.Many2one('res.partner', string="Contact", help="Partner of the last logged in user.", compute='_compute_partner_id', store=True, index='btree_not_null')
partner_image = fields.Binary(related='partner_id.image_1920')
# localisation and info
country_id = fields.Many2one('res.country', 'Country', readonly=True)
country_flag = fields.Char(related="country_id.image_url", string="Country Flag")
lang_id = fields.Many2one('res.lang', string='Language', help="Language from the website when visitor has been created")
timezone = fields.Selection(_tz_get, string='Timezone')
email = fields.Char(string='Email', compute='_compute_email_phone')
mobile = fields.Char(string='Mobile', compute='_compute_email_phone')
# Visit fields
visit_count = fields.Integer('# Visits', default=1, readonly=True, help="A new visit is considered if last connection was more than 8 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", groups="website.group_website_designer", search="_search_page_ids")
page_count = fields.Integer('# Visited Pages', compute="_compute_page_statistics", help="Total number of tracked page visited")
last_visited_page_id = fields.Many2one('website.page', string="Last Visited Page", compute="_compute_last_visited_page_id")
# Time fields
create_date = fields.Datetime('First Connection', readonly=True)
last_connection_datetime = fields.Datetime('Last Connection', default=fields.Datetime.now, help="Last page view date", readonly=True)
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.')
_sql_constraints = [
('access_token_unique', 'unique(access_token)', 'Access token should be unique.'),
]
@api.depends('partner_id')
def name_get(self):
res = []
for record in self:
res.append((
record.id,
record.partner_id.name or _('Website Visitor #%s', record.id)
))
return res
@api.depends('access_token')
def _compute_partner_id(self):
# The browse in the loop is fine, there is no SQL Query on partner here
for visitor in self:
# If the access_token is not a 32 length hexa string, it means that
# the visitor is linked to a logged in user, in which case its
# partner_id is used instead as the token.
visitor.partner_id = len(visitor.access_token) != 32 and self.env['res.partner'].browse([visitor.access_token])
@api.depends('partner_id.email_normalized', 'partner_id.mobile', 'partner_id.phone')
def _compute_email_phone(self):
results = self.env['res.partner'].search_read(
[('id', 'in', self.partner_id.ids)],
['id', 'email_normalized', 'mobile', 'phone'],
)
mapped_data = {
result['id']: {
'email_normalized': result['email_normalized'],
'mobile': result['mobile'] if result['mobile'] else result['phone']
} for result in results
}
for visitor in self:
visitor.email = mapped_data.get(visitor.partner_id.id, {}).get('email_normalized')
visitor.mobile = mapped_data.get(visitor.partner_id.id, {}).get('mobile')
@api.depends('website_track_ids')
def _compute_page_statistics(self):
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, '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_count': 0, 'visitor_page_count': 0, 'page_ids': set()})
visitor.page_ids = [(6, 0, visitor_info['page_ids'])]
visitor.visitor_page_count = visitor_info['visitor_page_count']
visitor.page_count = visitor_info['page_count']
def _search_page_ids(self, operator, value):
if operator not in ('like', 'ilike', 'not like', 'not ilike', '=like', '=ilike', '=', '!='):
raise ValueError(_('This operator is not supported'))
return [('website_track_ids.page_id.name', operator, value)]
@api.depends('website_track_ids.page_id')
def _compute_last_visited_page_id(self):
results = self.env['website.track']._read_group(
[('visitor_id', 'in', self.ids)],
['visitor_id', 'page_id', 'visit_datetime:max'],
['visitor_id', 'page_id'], lazy=False)
mapped_data = {result['visitor_id'][0]: result['page_id'][0] for result in results if result['page_id']}
for visitor in self:
visitor.last_visited_page_id = mapped_data.get(visitor.id, False)
@api.depends('last_connection_datetime')
def _compute_time_statistics(self):
for visitor in self:
visitor.time_since_last_action = _format_time_ago(self.env, (datetime.now() - visitor.last_connection_datetime))
visitor.is_connected = (datetime.now() - visitor.last_connection_datetime) < timedelta(minutes=5)
def _check_for_message_composer(self):
""" Purpose of this method is to actualize visitor model prior to contacting
him. Used notably for inheritance purpose, when dealing with leads that
could update the visitor model. """
return bool(self.partner_id and self.partner_id.email)
def _prepare_message_composer_context(self):
return {
'default_model': 'res.partner',
'default_res_id': self.partner_id.id,
'default_partner_ids': [self.partner_id.id],
}
def action_send_mail(self):
self.ensure_one()
if not self._check_for_message_composer():
raise UserError(_("There are no contact and/or no email linked to this visitor."))
visitor_composer_ctx = self._prepare_message_composer_context()
compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
compose_ctx = dict(
default_use_template=False,
default_composition_mode='comment',
)
compose_ctx.update(**visitor_composer_ctx)
return {
'name': _('Contact Visitor'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mail.compose.message',
'views': [(compose_form.id, 'form')],
'view_id': compose_form.id,
'target': 'new',
'context': compose_ctx,
}
def _upsert_visitor(self, access_token, force_track_values=None):
""" Based on the given `access_token`, either create or return the
related visitor if exists, through a single raw SQL UPSERT Query.
It will also create a tracking record if requested, in the same query.
:param access_token: token to be used to upsert the visitor
:param force_track_values: an optional dict to create a track at the
same time.
:return: a tuple containing the visitor id and the upsert result (either
`inserted` or `updated).
"""
country_code = request.geoip.get('country_code')
country_id = request.env['res.country'].sudo().search([
('code', '=', country_code)
], limit=1).id if country_code else None
create_values = {
'access_token': access_token,
'lang_id': request.lang.id,
'country_id': country_id,
'website_id': request.website.id,
'timezone': self._get_visitor_timezone() or None,
'write_uid': self.env.uid,
'create_uid': self.env.uid,
# If the access_token is not a 32 length hexa string, it means that the
# visitor is linked to a logged in user, in which case its partner_id is
# used instead as the token.
'partner_id': None if len(str(access_token)) == 32 else access_token,
}
query = """
INSERT INTO website_visitor (
partner_id, access_token, last_connection_datetime, visit_count, lang_id,
country_id, website_id, timezone, write_uid, create_uid, write_date, create_date)
VALUES (
%(partner_id)s, %(access_token)s, now() at time zone 'UTC', 1, %(lang_id)s,
%(country_id)s, %(website_id)s, %(timezone)s, %(create_uid)s, %(write_uid)s,
now() at time zone 'UTC', now() at time zone 'UTC')
ON CONFLICT (access_token)
DO UPDATE SET
last_connection_datetime=excluded.last_connection_datetime,
visit_count = CASE WHEN website_visitor.last_connection_datetime < NOW() AT TIME ZONE 'UTC' - INTERVAL '8 hours'
THEN website_visitor.visit_count + 1
ELSE website_visitor.visit_count
END
RETURNING id, CASE WHEN create_date = now() at time zone 'UTC' THEN 'inserted' ELSE 'updated' END AS upsert
"""
if force_track_values:
create_values['url'] = force_track_values['url']
create_values['page_id'] = force_track_values.get('page_id')
query = sql.SQL("""
WITH visitor AS (
{query}, %(url)s AS url, %(page_id)s AS page_id
), track AS (
INSERT INTO website_track (visitor_id, url, page_id, visit_datetime)
SELECT id, url, page_id::integer, now() at time zone 'UTC' FROM visitor
)
SELECT id, upsert from visitor;
""").format(query=sql.SQL(query))
self.env.cr.execute(query, create_values)
return self.env.cr.fetchone()
def _get_visitor_from_request(self, force_create=False, force_track_values=None):
""" Return the visitor as sudo from the request.
:param bool force_create: force a visitor creation if no visitor exists
:param force_track_values: an optional dict to create a track at the
same time.
:return: the website visitor if exists or forced, empty recordset
otherwise.
"""
# This function can be called in json with mobile app.
# In case of mobile app, no uid is set on the jsonRequest env.
# In case of multi db, _env is None on request, and request.env unbound.
if not (request and request.env and request.env.uid):
return None
Visitor = self.env['website.visitor'].sudo()
visitor = Visitor
access_token = self._get_access_token()
if force_create:
visitor_id, _ = self._upsert_visitor(access_token, force_track_values)
visitor = Visitor.browse(visitor_id)
else:
visitor = Visitor.search([('access_token', '=', access_token)])
if not force_create and visitor and not visitor.timezone:
tz = self._get_visitor_timezone()
if tz:
visitor._update_visitor_timezone(tz)
return visitor
def _handle_webpage_dispatch(self, website_page):
""" Create a website.visitor if the http request object is a tracked
website.page or a tracked ir.ui.view.
Since this method is only called on tracked elements, the
last_connection_datetime might not be accurate as the visitor could have
been visiting only untracked page during his last visit."""
url = request.httprequest.url
website_track_values = {'url': url}
if website_page:
website_track_values['page_id'] = website_page.id
self._get_visitor_from_request(force_create=True, force_track_values=website_track_values)
def _add_tracking(self, domain, website_track_values):
""" Add the track and update the visitor"""
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._update_visitor_last_visit()
def _merge_visitor(self, target):
""" Merge an anonymous visitor data to a partner visitor then unlink
that anonymous visitor.
Purpose is to try to aggregate as much sub-records (tracked pages,
leads, ...) as possible.
It is especially useful to aggregate data from the same user on
different devices.
This method is meant to be overridden for other modules to merge their
own anonymous visitor data to the partner visitor before unlink.
This method is only called after the user logs in.
:param target: main visitor, target of link process;
"""
if not target.partner_id:
raise ValueError("The `target` visitor should be linked to a partner.")
self.website_track_ids.visitor_id = target.id
self.unlink()
def _cron_unlink_old_visitors(self):
""" Unlink inactive visitors (see '_inactive_visitors_domain' for
details).
Visitors were previously archived but we came to the conclusion that
archived visitors have very little value and bloat the database for no
reason. """
self.env['website.visitor'].sudo().search(self._inactive_visitors_domain()).unlink()
def _inactive_visitors_domain(self):
""" This method defines the domain of visitors that can be cleaned. By
default visitors not linked to any partner and not active for
'website.visitor.live.days' days (default being 60) are considered as
inactive.
This method is meant to be overridden by sub-modules to further refine
inactivity conditions. """
delay_days = int(self.env['ir.config_parameter'].sudo().get_param('website.visitor.live.days', 60))
deadline = datetime.now() - timedelta(days=delay_days)
return [('last_connection_datetime', '<', deadline), ('partner_id', '=', False)]
def _update_visitor_timezone(self, timezone):
""" We need to do this part here to avoid concurrent updates error. """
query = """
UPDATE website_visitor
SET timezone = %s
WHERE id IN (
SELECT id FROM website_visitor WHERE id = %s
FOR NO KEY UPDATE SKIP LOCKED
)
"""
self.env.cr.execute(query, (timezone, self.id))
def _update_visitor_last_visit(self):
date_now = datetime.now()
query = "UPDATE website_visitor SET "
if self.last_connection_datetime < (date_now - timedelta(hours=8)):
query += "visit_count = visit_count + 1,"
query += """
last_connection_datetime = %s
WHERE id IN (
SELECT id FROM website_visitor WHERE id = %s
FOR NO KEY UPDATE SKIP LOCKED
)
"""
self.env.cr.execute(query, (date_now, self.id), log_exceptions=False)
def _get_visitor_timezone(self):
tz = request.httprequest.cookies.get('tz') if request else None
if tz in pytz.all_timezones:
return tz
elif not self.env.user._is_public():
return self.env.user.tz
else:
return None