[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>
This commit is contained in:
Romain Derie
2022-06-07 16:31:20 +02:00
parent 92feb6a67f
commit d348bed1ad
35 changed files with 330 additions and 377 deletions
@@ -452,7 +452,7 @@ class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf):
# website customer data
with freeze_time(self.reference_now):
self.authenticate(None, None)
with self.assertQueryCount(default=39): # com runbot: 39 - ent runbot: 39
with self.assertQueryCount(default=34): # com runbot: 34 - ent runbot: 34
self._test_url_open('/event')
# @warmup
@@ -37,10 +37,8 @@ class TestWEventRegister(TestWEventCommon):
)
# check visitor stored information
self.assertEqual(visitor.name, "Raoulette Poiluchette")
self.assertEqual(visitor.display_name, "Raoulette Poiluchette")
self.assertEqual(visitor.event_registration_ids, new_registrations)
self.assertEqual(visitor.partner_id, self.env['res.partner'])
self.assertEqual(visitor.mobile, "0456112233")
self.assertEqual(visitor.email, "raoulette@example.com")
self.assertFalse(visitor.parent_id)
self.assertTrue(visitor.active)
+3 -15
View File
@@ -2,34 +2,22 @@
<odoo>
<record id="website_visitor_0" model="website.visitor">
<field name="name">Edwin Hansen</field>
<field name="partner_id" ref="base.res_partner_address_5"/>
<field name="country_id" ref="base.nl"/>
<field name="page_ids" eval="[(4, ref('website.homepage_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.homepage_page'), 'url': '/'})]"/>
<field name="access_token" ref="base.res_partner_address_5"/>
</record>
<record id="website_visitor_1" model="website.visitor">
<field name="name">Soham Palmer</field>
<field name="partner_id" ref="base.res_partner_address_11"/>
<field name="country_id" ref="base.us"/>
<field name="page_ids" eval="[(4, ref('website.contactus_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.contactus_page'), 'url': '/contactus'})]"/>
<field name="access_token" ref="base.res_partner_address_11"/>
</record>
<record id="website_visitor_2" model="website.visitor">
<field name="name">Philipe J. Fry</field>
<field name="partner_id" eval="False"/>
<field name="country_id" ref="base.be"/>
<field name="page_ids" eval="[(4, ref('website.homepage_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.homepage_page'), 'url': '/'})]"/>
</record>
<record id="website_visitor_2_1" model="website.visitor">
<field name="name">Philipe J. Fry (old)</field>
<field name="active" eval="False"></field>
<field name="parent_id" ref="website.website_visitor_2"/>
<field name="partner_id" eval="False"/>
<field name="country_id" ref="base.be"/>
<field name="page_ids" eval="[(4, ref('website.contactus_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.contactus_page'), 'url': '/contactus'})]"/>
<field name="access_token">f9d24e459528093a790f38e5cc989a8d</field>
</record>
</odoo>
+1 -1
View File
@@ -148,7 +148,7 @@ class Http(models.AbstractModel):
view = template and request.env['website'].get_template(template)
if view and view.track:
request.env['website.visitor']._handle_webpage_dispatch(response, website_page)
request.env['website.visitor']._handle_webpage_dispatch(website_page)
return False
+21 -26
View File
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models, tools, _
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.http import request
@@ -69,32 +69,27 @@ class ResUsers(models.Model):
@classmethod
def authenticate(cls, db, login, password, user_agent_env):
""" Override to link the logged in user's res.partner to website.visitor.
If both a request-based visitor and a user-based visitor exist we try
to update them (have same partner_id), and move sub records to the main
visitor (user one). Purpose is to try to keep a main visitor with as
much sub-records (tracked pages, leads, ...) as possible. """
If a visitor already exists for that user, assign it data from the
current anonymous visitor (if exists).
Purpose is to try to aggregate as much sub-records (tracked pages,
leads, ...) as possible. """
visitor_pre_authenticate_sudo = None
if request and request.env:
visitor_pre_authenticate_sudo = request.env['website.visitor']._get_visitor_from_request()
uid = super(ResUsers, cls).authenticate(db, login, password, user_agent_env)
if uid:
if uid and visitor_pre_authenticate_sudo:
with cls.pool.cursor() as cr:
env = api.Environment(cr, uid, {})
visitor_sudo = env['website.visitor']._get_visitor_from_request()
if visitor_sudo:
user_partner = env.user.partner_id
other_user_visitor_sudo = env['website.visitor'].with_context(active_test=False).sudo().search(
[('partner_id', '=', user_partner.id), ('id', '!=', visitor_sudo.id)],
order='last_connection_datetime DESC',
) # current 13.3 state: 1 result max as unique visitor / partner
if other_user_visitor_sudo:
visitor_main = other_user_visitor_sudo[0]
other_visitors = other_user_visitor_sudo[1:] # normally void
(visitor_sudo + other_visitors)._link_to_visitor(visitor_main)
visitor_main.name = user_partner.name
visitor_main.active = True
visitor_main._update_visitor_last_visit()
else:
if visitor_sudo.partner_id != user_partner:
visitor_sudo._link_to_partner(
user_partner,
update_values={'partner_id': user_partner.id})
visitor_sudo._update_visitor_last_visit()
user_partner = env.user.partner_id
visitor_current_user_sudo = env['website.visitor'].sudo().search([
('partner_id', '=', user_partner.id)
], limit=1)
if visitor_current_user_sudo:
# A visitor exists for the logged in user, link public
# visitor records to it.
visitor_pre_authenticate_sudo._merge_visitor(visitor_current_user_sudo)
visitor_current_user_sudo._update_visitor_last_visit()
else:
visitor_pre_authenticate_sudo.access_token = user_partner.id
visitor_pre_authenticate_sudo._update_visitor_last_visit()
return uid
+164 -149
View File
@@ -2,7 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
import uuid
from psycopg2 import sql
import hashlib
import pytz
from odoo import fields, models, api, _
@@ -28,14 +30,29 @@ class WebsiteTrack(models.Model):
class WebsiteVisitor(models.Model):
_name = 'website.visitor'
_description = 'Website Visitor'
_order = 'last_connection_datetime DESC'
_order = 'id DESC'
name = fields.Char('Name')
access_token = fields.Char(required=True, default=lambda x: uuid.uuid4().hex, copy=False, groups='website.group_website_publisher')
active = fields.Boolean('Active', default=True)
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.", index='btree_not_null')
parent_id = fields.Many2one('website.visitor', string="Parent", ondelete='set null', index='btree_not_null', help="Main identity")
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
@@ -62,19 +79,27 @@ class WebsiteVisitor(models.Model):
_sql_constraints = [
('access_token_unique', 'unique(access_token)', 'Access token should be unique.'),
('partner_uniq', 'unique(partner_id)', 'A partner is linked to only one visitor.'),
]
@api.depends('name')
@api.depends('partner_id')
def name_get(self):
res = []
for record in self:
res.append((
record.id,
record.name or _('Website Visitor #%s', 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(
@@ -118,9 +143,10 @@ class WebsiteVisitor(models.Model):
@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)
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)
@@ -166,88 +192,115 @@ class WebsiteVisitor(models.Model):
'context': compose_ctx,
}
def _get_visitor_from_request(self, force_create=False):
""" Return the visitor as sudo from the request if there is a
visitor_uuid cookie.
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.
When fetching visitor, now that duplicates are linked to a main visitor
instead of unlinked, you may have more collisions issues with cookie
being set (after a de-connection for example).
It will also create a tracking record if requested, in the same query.
The visitor associated to a partner in case of public user is not taken
into account, it is considered as desynchronized cookie.
In addition, we also discard if the visitor has a main visitor whose
partner is set (aka wrong after logout partner). """
: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 = request.httprequest.cookies.get('visitor_uuid')
if access_token:
visitor = Visitor.with_context(active_test=False).search([('access_token', '=', access_token)])
# Prefetch access_token and other fields. Since access_token has a restricted group and we access
# a non restricted field (partner_id) first it is not fetched and will require an additional query to be retrieved.
visitor.access_token
access_token = self._get_access_token()
if not self.env.user._is_public():
partner_id = self.env.user.partner_id
if not visitor or visitor.partner_id and visitor.partner_id != partner_id:
# Partner and no cookie or wrong cookie
visitor = Visitor.with_context(active_test=False).search([('partner_id', '=', partner_id.id)])
elif visitor and visitor.partner_id:
# Cookie associated to a Partner
visitor = Visitor
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)])
# also check that visitor parent partner is not different from user's one
# (indicates duplicate due to invalid or wrong cookie)
if visitor and visitor.parent_id.partner_id:
if self.env.user._is_public():
visitor = self.env['website.visitor'].sudo()
elif not visitor.partner_id:
visitor = self.env['website.visitor'].sudo().with_context(active_test=False).search(
[('partner_id', '=', self.env.user.partner_id.id)]
)
if visitor and not visitor.timezone:
if not force_create and visitor and not visitor.timezone:
tz = self._get_visitor_timezone()
if tz:
visitor._update_visitor_timezone(tz)
if not visitor and force_create:
visitor = self._create_visitor()
return visitor
def _handle_webpage_dispatch(self, response, website_page):
# get visitor. Done here to avoid having to do it multiple times in case of override.
visitor_sudo = self._get_visitor_from_request(force_create=True)
if request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token:
expiration_date = datetime.now() + timedelta(days=365)
response.set_cookie('visitor_uuid', visitor_sudo.access_token, expires=expiration_date)
self._handle_website_page_visit(website_page, visitor_sudo)
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."""
def _handle_website_page_visit(self, website_page, visitor_sudo):
""" Called on dispatch. This will create a website.visitor if the http request object
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(),
}
website_track_values = {'url': url}
if website_page:
website_track_values['page_id'] = website_page.id
domain = [('page_id', '=', website_page.id)]
else:
domain = [('url', '=', url)]
visitor_sudo._add_tracking(domain, website_track_values)
if visitor_sudo.lang_id.id != request.lang.id:
visitor_sudo.write({'lang_id': request.lang.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"""
@@ -258,76 +311,44 @@ class WebsiteVisitor(models.Model):
self.env['website.track'].create(website_track_values)
self._update_visitor_last_visit()
def _create_visitor(self):
""" Create a visitor. Tracking is added after the visitor has been created."""
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 False
vals = {
'lang_id': request.lang.id,
'country_id': country_id,
'website_id': request.website.id,
}
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.
tz = self._get_visitor_timezone()
if tz:
vals['timezone'] = tz
This method is meant to be overridden for other modules to merge their
own anonymous visitor data to the partner visitor before unlink.
if not self.env.user._is_public():
vals['partner_id'] = self.env.user.partner_id.id
vals['name'] = self.env.user.partner_id.name
return self.sudo().create(vals)
def _link_to_partner(self, partner, update_values=None):
""" Link visitors to a partner. This method is meant to be overridden in
order to propagate, if necessary, partner information to sub records.
:param partner: partner used to link sub records;
:param update_values: optional values to update visitors to link;
"""
vals = {'name': partner.name}
if update_values:
vals.update(update_values)
self.write(vals)
def _link_to_visitor(self, target):
""" Link visitors to target visitors, because they are linked to the
same identity. Purpose is mainly to propagate partner identity to sub
records to ease database update and decide what to do with "duplicated".
This method is meant to be overridden in order to implement some specific
behavior linked to sub records of duplicate management.
This method is only called after the user logs in.
:param target: main visitor, target of link process;
"""
# Link sub records of self to target partner
if target.partner_id:
self._link_to_partner(target.partner_id)
# Link sub records of self to target visitor
self.website_track_ids.write({'visitor_id': target.id})
# Archive current record and set its parent visitor
self.partner_id = False
self.parent_id = target.id
self.active = False
return target
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).
""" 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. """
Visitors were previously archived but we came to the conclusion that
archived visitors have very little value and bloat the database for no
reason. """
inactive_visitors = self.env['website.visitor'].sudo() \
.with_context(active_test=False) \
.search(self._inactive_visitors_domain())
inactive_visitors.unlink()
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 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. """
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)
@@ -346,24 +367,18 @@ class WebsiteVisitor(models.Model):
self.env.cr.execute(query, (timezone, self.id))
def _update_visitor_last_visit(self):
""" We need to do this part here to avoid concurrent updates error. """
try:
with self.env.cr.savepoint():
query_lock = "SELECT * FROM website_visitor where id = %s FOR NO KEY UPDATE NOWAIT"
self.env.cr.execute(query_lock, (self.id,), log_exceptions=False)
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 += """
active = True,
last_connection_datetime = %s
WHERE id = %s
"""
self.env.cr.execute(query, (date_now, self.id), log_exceptions=False)
except Exception:
pass
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
+6 -6
View File
@@ -104,16 +104,16 @@ class TestWebsitePerformance(UtilPerf):
def test_15_perf_sql_queries_page(self):
# standard tracked website.page
self.page.track = True
self.assertEqual(self._get_url_hot_query(self.page.url), 14)
self.assertEqual(self._get_url_hot_query(self.page.url, cache=False), 18)
self.assertEqual(self._get_url_hot_query(self.page.url), 7)
self.assertEqual(self._get_url_hot_query(self.page.url, cache=False), 11)
self.menu.unlink()
self.assertEqual(self._get_url_hot_query(self.page.url), 14)
self.assertEqual(self._get_url_hot_query(self.page.url, cache=False), 18)
self.assertEqual(self._get_url_hot_query(self.page.url), 7)
self.assertEqual(self._get_url_hot_query(self.page.url, cache=False), 11)
def test_20_perf_sql_queries_homepage(self):
# homepage "/" has its own controller
self.assertEqual(self._get_url_hot_query('/'), 14)
self.assertEqual(self._get_url_hot_query('/', cache=False), 16)
self.assertEqual(self._get_url_hot_query('/'), 7)
self.assertEqual(self._get_url_hot_query('/', cache=False), 9)
def test_30_perf_sql_queries_page_no_layout(self):
# website.page with no call to layout templates
+47 -46
View File
@@ -1,12 +1,13 @@
# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
from contextlib import contextmanager
from datetime import datetime, timedelta
from unittest.mock import patch
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.addons.website.tools import MockRequest
from odoo.addons.website.models.website_visitor import WebsiteVisitor
from odoo.tests import common, tagged
@@ -34,7 +35,6 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
self.website = self.env['website'].search([
('company_id', '=', self.env.user.company_id.id)
], limit=1)
self.cookies = {}
untracked_view = self.env['ir.ui.view'].create({
'name': 'UntackedView',
@@ -122,17 +122,28 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
def assertVisitorDeactivated(self, visitor, main_visitor):
""" Method that checks that a visitor has been de-activated / merged
with other visitor, notably in case of login (see User.authenticate() as
well as Visitor._link_to_visitor() ). """
self.assertTrue(bool(visitor))
self.assertFalse(visitor.active)
self.assertTrue(main_visitor.active)
self.assertEqual(visitor.parent_id, main_visitor)
# tracks are linked
well as Visitor._merge_visitor() ). """
self.assertFalse(visitor.exists(), "The anonymous visitor should be deleted")
self.assertTrue(visitor.website_track_ids < main_visitor.website_track_ids)
def test_visitor_creation_on_tracked_page(self):
""" Test various flows involving visitor creation and update. """
def authenticate(login, pwd):
# We can't call `self.authenticate` because that tour util is
# regenerating a new session.id before calling the real
# `authenticate` method.
# But we need the session id in the `authenticate` method because
# we need to retrieve the visitor before the authentication, which
# require the session id.
res = self.url_open('/web/login')
csrf_anchor = '<input type="hidden" name="csrf_token" value="'
self.url_open('/web/login', timeout=200, data={
'login': login,
'password': pwd,
'csrf_token': res.text.partition(csrf_anchor)[2].partition('"')[0],
})
existing_visitors = self.env['website.visitor'].search([])
existing_tracks = self.env['website.track'].search([])
self.url_open(self.untracked_page.url)
@@ -142,25 +153,25 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
new_visitor = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
new_track = self.env['website.track'].search([('id', 'not in', existing_tracks.ids)])
self.assertEqual(len(new_visitor), 1, "1 visitor should be created")
self.assertEqual(len(new_track), 1, "There should be 1 tracked page")
self.assertEqual(len(new_track), 2, "There should be 2 tracked page")
self.assertEqual(new_visitor.visit_count, 1)
self.assertEqual(new_visitor.website_track_ids, new_track)
self.assertVisitorTracking(new_visitor, self.tracked_page)
self.assertVisitorTracking(new_visitor, self.tracked_page + self.tracked_page)
# ------------------------------------------------------------
# Admin connects
# ------------------------------------------------------------
self.cookies = {'visitor_uuid': new_visitor.access_token}
with MockRequest(self.env, website=self.website, cookies=self.cookies):
self.authenticate(self.user_admin.login, 'admin')
authenticate(self.user_admin.login, 'admin')
visitor_admin = new_visitor
# visit a page
self.url_open(self.tracked_page_2.url)
# check tracking and visitor / user sync
self.assertVisitorTracking(visitor_admin, self.tracked_page | self.tracked_page_2)
new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
self.assertEqual(len(new_visitors), 1, "There should still be only one visitor.")
self.assertVisitorTracking(visitor_admin, self.tracked_page + self.tracked_page + self.tracked_page_2)
self.assertEqual(visitor_admin.partner_id, self.partner_admin)
self.assertEqual(visitor_admin.name, self.partner_admin.name)
@@ -168,8 +179,8 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
# Portal connects
# ------------------------------------------------------------
with MockRequest(self.env, website=self.website, cookies=self.cookies):
self.authenticate(self.user_portal.login, 'portal')
self.url_open('/web/session/logout')
authenticate(self.user_portal.login, 'portal')
self.assertFalse(
self.env['website.visitor'].search([('id', 'not in', (existing_visitors | visitor_admin).ids)]),
@@ -179,7 +190,6 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
self.url_open(self.tracked_page.url)
self.url_open(self.untracked_page.url)
self.url_open(self.tracked_page_2.url)
self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
# new visitor is created
new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
@@ -194,19 +204,17 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
# ------------------------------------------------------------
# portal user disconnects
self.logout()
self.url_open('/web/session/logout')
# visit some pages
self.url_open(self.tracked_page.url)
self.url_open(self.untracked_page.url)
self.url_open(self.tracked_page_2.url)
self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
# new visitor is created
new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
self.assertEqual(len(new_visitors), 3, "One extra visitor should be created")
visitor_anonymous = new_visitors[0]
self.cookies['visitor_uuid'] = visitor_anonymous.access_token
self.assertFalse(visitor_anonymous.name)
self.assertFalse(visitor_anonymous.partner_id)
self.assertVisitorTracking(visitor_anonymous, self.tracked_page | self.tracked_page_2)
@@ -216,37 +224,31 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
# Admin connects again
# ------------------------------------------------------------
with MockRequest(self.env, website=self.website, cookies=self.cookies):
self.authenticate(self.user_admin.login, 'admin')
authenticate(self.user_admin.login, 'admin')
# one visitor is deleted
visitor_anonymous = self.env['website.visitor'].with_context(active_test=False).search([('id', '=', visitor_anonymous.id)])
self.assertVisitorDeactivated(visitor_anonymous, visitor_admin)
new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
self.assertEqual(new_visitors, visitor_admin | visitor_portal)
visitor_admin = self.env['website.visitor'].search([('partner_id', '=', self.partner_admin.id)])
# tracks are linked
self.assertTrue(visitor_anonymous_tracks < visitor_admin.website_track_ids)
self.assertEqual(len(visitor_admin.website_track_ids), 4, "There should be 4 tracked page for the admin")
self.assertEqual(len(visitor_admin.website_track_ids), 5, "There should be 5 tracked page for the admin")
# ------------------------------------------------------------
# Back to anonymous
# ------------------------------------------------------------
# admin disconnects
self.logout()
self.url_open('/web/session/logout')
# visit some pages
self.url_open(self.tracked_page.url)
self.url_open(self.untracked_page.url)
self.url_open(self.tracked_page_2.url)
self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
# new visitor created
new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
self.assertEqual(len(new_visitors), 3, "One extra visitor should be created")
visitor_anonymous_2 = new_visitors[0]
self.cookies['visitor_uuid'] = visitor_anonymous_2.access_token
self.assertFalse(visitor_anonymous_2.name)
self.assertFalse(visitor_anonymous_2.partner_id)
self.assertVisitorTracking(visitor_anonymous_2, self.tracked_page | self.tracked_page_2)
@@ -255,8 +257,7 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
# ------------------------------------------------------------
# Portal connects again
# ------------------------------------------------------------
with MockRequest(self.env, website=self.website, cookies=self.cookies):
self.authenticate(self.user_portal.login, 'portal')
authenticate(self.user_portal.login, 'portal')
# one visitor is deleted
new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
@@ -276,7 +277,7 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
self.assertEqual(len(visitor_portal.website_track_ids), 5, "There should be 5 tracked page for the portal user")
# simulate the portal user comes back 8hours later
visitor_portal.write({'last_connection_datetime': visitor_portal.last_connection_datetime - timedelta(hours=8)})
visitor_portal.write({'last_connection_datetime': visitor_portal.last_connection_datetime - timedelta(hours=9)})
self.url_open(self.tracked_page.url)
visitor_portal.invalidate_model(['visit_count'])
# check number of visits
@@ -284,32 +285,32 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
def test_clean_inactive_visitors(self):
inactive_visitors = self.env['website.visitor'].create([{
'name': 'Crazy Lazy Bernie',
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'last_connection_datetime': datetime.now() - timedelta(days=8)
'last_connection_datetime': datetime.now() - timedelta(days=8),
'access_token': 'f9d2b14b21be669518b14a9590cb62ed',
}, {
'name': 'Sleeping Joe',
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'last_connection_datetime': datetime.now() - timedelta(days=15)
'last_connection_datetime': datetime.now() - timedelta(days=15),
'access_token': 'f9d2d261a725da7f596574ca84e52f47',
}])
active_visitors = self.env['website.visitor'].create([{
'name': 'Active Donald',
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'last_connection_datetime': datetime.now() - timedelta(days=1)
'last_connection_datetime': datetime.now() - timedelta(days=1),
'access_token': 'f9d2526d9c15658bdc91d2119e54b554',
}, {
'name': 'Vladimir Customer',
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'partner_id': self.partner_demo.id,
'last_connection_datetime': datetime.now() - timedelta(days=15)
'last_connection_datetime': datetime.now() - timedelta(days=15),
'access_token': self.partner_demo.id,
}])
self._test_unlink_old_visitors(inactive_visitors, active_visitors)
@@ -325,7 +326,7 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
We use this method as a private tool so that sub-module can also test the cleaning of visitors
based on their own sets of conditions. """
WebsiteVisitor = self.env['website.visitor'].with_context(active_test=False)
WebsiteVisitor = self.env['website.visitor']
self.env['ir.config_parameter'].sudo().set_param('website.visitor.live.days', 7)
@@ -351,7 +352,7 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
This can happen quite often if the user switches browsers / hardwares.
When 'linking' visitors together, the new visitor is archived and all its relevant data is
merged within the main visitor. See 'website.visitor#_link_to_visitor' for more details.
merged within the main visitor. See 'website.visitor#_merge_visitor' for more details.
This test ensures that all the relevant data are properly merged.
@@ -362,16 +363,16 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
self._prepare_main_visitor_data(),
self._prepare_linked_visitor_data()
])
linked_visitor._link_to_visitor(main_visitor)
linked_visitor._merge_visitor(main_visitor)
self.assertVisitorDeactivated(linked_visitor, main_visitor)
def _prepare_main_visitor_data(self):
return {
'name': "Mitchell Main",
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'access_token': self.partner_admin.id,
'website_track_ids': [(0, 0, {
'page_id': self.tracked_page.id,
'url': self.tracked_page.url
@@ -380,10 +381,10 @@ class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
def _prepare_linked_visitor_data(self):
return {
'name': "Mitchell Main's Phone",
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'access_token': '%032x' % random.randrange(16**32),
'website_track_ids': [(0, 0, {
'page_id': self.tracked_page_2.id,
'url': self.tracked_page_2.url
+7 -4
View File
@@ -16,12 +16,13 @@ from odoo.tools.misc import DotDict, frozendict
def MockRequest(
env, *, path='/mockrequest/', routing=True, multilang=True,
context=frozendict(), cookies=frozendict(), country_code=None,
website=None, sale_order_id=None, website_sale_current_pl=None,
website=None, remote_addr=HOST, environ_base=None,
# website_sale
sale_order_id=None, website_sale_current_pl=None,
):
lang_code = context.get('lang', env.context.get('lang', 'en_US'))
env = env(context=dict(context, lang=lang_code))
request = Mock(
# request
httprequest=Mock(
@@ -31,12 +32,14 @@ def MockRequest(
environ=dict(
EnvironBuilder(
path=path,
base_url=HttpCase.base_url()
base_url=HttpCase.base_url(),
environ_base=environ_base,
).get_environ(),
REMOTE_ADDR=HOST,
REMOTE_ADDR=remote_addr,
),
cookies=cookies,
referrer='',
remote_addr=remote_addr,
),
type='http',
future_response=odoo.http.FutureResponse(),
@@ -212,7 +212,6 @@
</div>
<group id="general_info">
<group string="Details">
<field name="active" invisible="1"/>
<field name="is_connected" invisible="1"/>
<field name="partner_id"/>
<field name="email"/>
@@ -271,8 +270,6 @@
<separator/>
<filter string="Connected" name="filter_is_connected" domain="[('last_connection_datetime', '&gt;', datetime.datetime.now() - datetime.timedelta(minutes=5))]"/>
<separator/>
<filter string="Archived" name="filter_is_archived" domain="[('active', '=', False)]"/>
<separator/>
<group string="Group By">
<filter string="Country" name="group_by_country" context="{'group_by': 'country_id'}"/>
<filter string="Timezone" name="group_by_timezone" context="{'group_by': 'timezone'}"/>
@@ -28,8 +28,8 @@ class TestBlogPerformance(UtilPerf):
blog_tags = blog_tags[:-1]
self.assertEqual(self._get_url_hot_query('/blog'), 11)
self.assertEqual(self._get_url_hot_query('/blog', cache=False), 34)
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url), 26)
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url, cache=False), 29)
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url), 19)
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url, cache=False), 22)
def test_30_perf_sql_blog_bigger_data_scaling(self):
BlogPost = self.env['blog.post']
+1 -1
View File
@@ -21,7 +21,7 @@ class Lead(models.Model):
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_track p ON p.visitor_id = v.id
WHERE l.id in %s AND v.active = TRUE
WHERE l.id in %s
GROUP BY l.id"""
self.env.cr.execute(sql, (tuple(self.ids),))
page_data = self.env.cr.dictfetchall()
+2 -2
View File
@@ -48,14 +48,14 @@ class WebsiteVisitor(models.Model):
domain = super()._inactive_visitors_domain()
return expression.AND([domain, [('lead_ids', '=', False)]])
def _link_to_visitor(self, target):
def _merge_visitor(self, target):
""" Link the leads to the main visitor to avoid them being lost. """
if self.lead_ids:
target.write({
'lead_ids': [(4, lead.id) for lead in self.lead_ids]
})
return super(WebsiteVisitor, self)._link_to_visitor(target)
return super()._merge_visitor(target)
def _prepare_message_composer_context(self):
if not self.partner_id and self.lead_ids:
@@ -22,11 +22,13 @@ class TestLeadVisitorMerge(TestLeadMergeCommon):
TestLeadMergeCommon.merge_fields.append('visitor_ids')
visitors = self.env['website.visitor'].sudo().create([
{'name': 'Visitor 1',
'lead_ids': [(4, self.lead_w_partner_company.id)],
{
'access_token': 'f9d2ffa0427d4e4b1d740cf5eb3cdc20',
'lead_ids': [(4, self.lead_w_partner_company.id)],
},
{'name': 'Visitor 2',
'lead_ids': [(4, self.lead_w_partner.id)],
{
'access_token': 'f9d2c3f741a79200487728eac989e678',
'lead_ids': [(4, self.lead_w_partner.id)],
}
])
self.assertEqual(self.lead_w_partner_company.visitor_ids, visitors[0])
@@ -24,7 +24,7 @@ class TestWebsiteVisitor(TestCrmCommon, WebsiteVisitorTests):
@users('user_sales_manager')
def test_compute_email_phone(self):
visitor_sudo = self.env['website.visitor'].sudo().create({
'name': 'Mega Visitor',
'access_token': 'f9d2c6f3b024320ac31248595ac7fcb6',
})
visitor = visitor_sudo.with_user(self.env.user) # as of 13.0 salesmen cannot create visitors, only read them
customer = self.test_partner.with_user(self.env.user)
@@ -75,11 +75,11 @@ class TestWebsiteVisitor(TestCrmCommon, WebsiteVisitorTests):
def test_clean_inactive_visitors_crm(self):
""" Visitors attached to leads should not be deleted even if not connected recently. """
active_visitors = self.env['website.visitor'].create([{
'name': 'Lead Carl',
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'last_connection_datetime': datetime.now() - timedelta(days=8),
'access_token': 'f9d28aad05ebee0bca215837b129aa00',
'lead_ids': [(0, 0, {
'name': 'Lead Carl'
})]
@@ -96,7 +96,7 @@ class TestWebsiteVisitor(TestCrmCommon, WebsiteVisitorTests):
self._prepare_linked_visitor_data()
])
all_leads = (main_visitor + linked_visitor).lead_ids
linked_visitor._link_to_visitor(main_visitor)
linked_visitor._merge_visitor(main_visitor)
self.assertVisitorDeactivated(linked_visitor, main_visitor)
+2 -11
View File
@@ -287,8 +287,6 @@ class WebsiteEventController(http.Controller):
as much informations as possible, notably to ease future communications.
Also try to update visitor informations based on registration info. """
visitor_sudo = request.env['website.visitor']._get_visitor_from_request(force_create=True)
visitor_sudo._update_visitor_last_visit()
visitor_values = {}
registrations_to_create = []
for registration_values in registration_data:
@@ -298,18 +296,11 @@ class WebsiteEventController(http.Controller):
elif not registration_values.get('partner_id'):
registration_values['partner_id'] = False if request.env.user._is_public() else request.env.user.partner_id.id
if visitor_sudo:
# registration may give a name to the visitor, yay
if registration_values.get('name') and not visitor_sudo.name and not visitor_values.get('name'):
visitor_values['name'] = registration_values['name']
# update registration based on visitor
registration_values['visitor_id'] = visitor_sudo.id
# update registration based on visitor
registration_values['visitor_id'] = visitor_sudo.id
registrations_to_create.append(registration_values)
if visitor_values:
visitor_sudo.write(visitor_values)
return request.env['event.registration'].sudo().create(registrations_to_create)
@http.route(['''/event/<model("event.event"):event>/registration/confirm'''], type='http', auth="public", methods=['POST'], website=True)
+19 -20
View File
@@ -21,6 +21,16 @@ class WebsiteVisitor(models.Model):
search="_search_event_registered_ids",
groups="event.group_event_registration_desk")
@api.depends('partner_id, event_registration_ids.name')
def name_get(self):
""" If there is an event registration for an anonymous visitor, use that
registered attendee name as visitor name. """
res_dict = dict(super().name_get())
# sudo is needed for `event_registration_ids`
for visitor in self.sudo().filtered(lambda v: not v.partner_id and v.event_registration_ids):
res_dict[visitor.id] = visitor.event_registration_ids[-1].name
return list(res_dict.items())
@api.depends('event_registration_ids')
def _compute_event_registration_count(self):
if self.ids:
@@ -46,12 +56,12 @@ class WebsiteVisitor(models.Model):
if not visitor.mobile:
visitor.mobile = next((reg.mobile or reg.phone for reg in linked_registrations if reg.mobile or reg.phone), False)
@api.depends('parent_id', 'event_registration_ids')
@api.depends('event_registration_ids')
def _compute_event_registered_ids(self):
# include parent's registrations in a visitor o2m field. We don't add
# child one as child should not have registrations (moved to the parent)
for visitor in self:
all_registrations = visitor.event_registration_ids | visitor.parent_id.event_registration_ids
all_registrations = visitor.event_registration_ids
visitor.event_registered_ids = all_registrations.mapped('event_id')
def _search_event_registered_ids(self, operator, operand):
@@ -65,12 +75,7 @@ class WebsiteVisitor(models.Model):
('event_id', operator, operand)
])
if all_registrations:
# search children, even archived one, to contact them
visitors = all_registrations.with_context(active_test=False).mapped('visitor_id')
children = self.env['website.visitor'].with_context(
active_test=False
).sudo().search([('parent_id', 'in', visitors.ids)])
visitor_ids = (visitors + children).ids
visitor_ids = all_registrations.with_context(active_test=False).visitor_id.ids
else:
visitor_ids = []
@@ -81,16 +86,10 @@ class WebsiteVisitor(models.Model):
domain = super()._inactive_visitors_domain()
return expression.AND([domain, [('event_registration_ids', '=', False)]])
def _link_to_partner(self, partner, update_values=None):
""" Propagate partner update to registration records """
if partner:
registration_wo_partner = self.event_registration_ids.filtered(lambda registration: not registration.partner_id)
if registration_wo_partner:
registration_wo_partner.partner_id = partner
super(WebsiteVisitor, self)._link_to_partner(partner, update_values=update_values)
def _link_to_visitor(self, target):
def _merge_visitor(self, target):
""" Override linking process to link registrations to the final visitor. """
self.event_registration_ids.write({'visitor_id': target.id})
return super(WebsiteVisitor, self)._link_to_visitor(target)
self.event_registration_ids.visitor_id = target.id
registration_wo_partner = self.event_registration_ids.filtered(lambda registration: not registration.partner_id)
if registration_wo_partner:
registration_wo_partner.partner_id = target.partner_id
return super()._merge_visitor(target)
@@ -15,11 +15,11 @@ class TestEventVisitor(TestEventOnlineCommon, WebsiteVisitorTests):
def test_clean_inactive_visitors_event(self):
""" Visitors registered to events should not be deleted even if not connected recently. """
active_visitors = self.env['website.visitor'].create([{
'name': 'Lead Douglas',
'lang_id': self.env.ref('base.lang_en').id,
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'last_connection_datetime': datetime.now() - timedelta(days=8),
'access_token': 'f9d2af99f543874642f89bd334fa4a49',
'event_registration_ids': [(0, 0, {
'event_id': self.event_0.id
})]
@@ -50,7 +50,7 @@ class TestEventVisitor(TestEventOnlineCommon, WebsiteVisitorTests):
self.assertEqual(self.event_0, main_visitor.event_registered_ids)
self.assertEqual(event_1, linked_visitor.event_registered_ids)
linked_visitor._link_to_visitor(main_visitor)
linked_visitor._merge_visitor(main_visitor)
self.assertVisitorDeactivated(linked_visitor, main_visitor)
# main_visitor is now attending both events
@@ -1,17 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="website_visitor_view_search" model="ir.ui.view">
<field name="name">website.visitor.view.search.inherit.event</field>
<field name="model">website.visitor</field>
<field name="inherit_id" ref="website.website_visitor_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='group_by_lang']" position="after">
<filter string="Main Contact" name="group_by_parent_id" groups="base.group_no_one" context="{'group_by': 'parent_id'}"/>
</xpath>
</field>
</record>
<record id="website_visitor_view_tree" model="ir.ui.view">
<field name="name">website.visitor.view.tree.inherit.event</field>
<field name="model">website.visitor</field>
@@ -36,9 +25,6 @@
<field name="event_registration_count" widget="statinfo" string="Registrations"/>
</button>
</xpath>
<xpath expr="//field[@name='page_ids']" position="after">
<field name="parent_id" groups="base.group_no_one"/>
</xpath>
</field>
</record>
</data></odoo>
@@ -25,7 +25,7 @@
t-att-src="image_data_uri(visitor['visitor'].partner_image) if visitor['visitor'].partner_image else '/web/static/img/user_placeholder.jpg'"
t-att-alt="visitor['visitor'].display_name"/>
<div class="o_we_leaderboard_name">
<span t-if="visitor['visitor'] == current_visitor and not visitor['visitor'].name" class="font-weight-bold">You</span>
<span t-if="visitor['visitor'] == current_visitor and not visitor['visitor'].partner_id" class="font-weight-bold">You</span>
<span t-else="" class="font-weight-bold" t-out="visitor['visitor'].display_name"/>
</div>
</div></td>
@@ -384,7 +384,6 @@ class EventTrackController(http.Controller):
track = self._fetch_track(track_id, allow_sudo=True)
force_create = set_reminder_on or track.wishlisted_by_default
event_track_partner = track._get_event_track_visitors(force_create=force_create)
visitor_sudo = event_track_partner.visitor_id
if not track.wishlisted_by_default:
if not event_track_partner or event_track_partner.is_wishlisted == set_reminder_on: # ignore if new state = old state
@@ -396,8 +395,6 @@ class EventTrackController(http.Controller):
event_track_partner.is_blacklisted = not set_reminder_on
result = {'reminderOn': set_reminder_on}
if request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token:
result['visitor_uuid'] = visitor_sudo.access_token
return result
@@ -53,15 +53,10 @@ class WebsiteVisitor(models.Model):
domain = super()._inactive_visitors_domain()
return expression.AND([domain, [('event_track_visitor_ids', '=', False)]])
def _link_to_partner(self, partner, update_values=None):
""" Propagate partner update to track_visitor records """
if partner:
track_visitor_wo_partner = self.event_track_visitor_ids.filtered(lambda track_visitor: not track_visitor.partner_id)
if track_visitor_wo_partner:
track_visitor_wo_partner.partner_id = partner
super(WebsiteVisitor, self)._link_to_partner(partner, update_values=update_values)
def _link_to_visitor(self, target):
def _merge_visitor(self, target):
""" Override linking process to link wishlist to the final visitor. """
self.event_track_visitor_ids.visitor_id = target.id
return super(WebsiteVisitor, self)._link_to_visitor(target)
track_visitor_wo_partner = self.event_track_visitor_ids.filtered(lambda track_visitor: not track_visitor.partner_id)
if track_visitor_wo_partner:
track_visitor_wo_partner.partner_id = target.partner_id
return super()._merge_visitor(target)
@@ -3,7 +3,6 @@ odoo.define('website_event_track.website_event_track_reminder', function (requir
var core = require('web.core');
var _t = core._t;
var utils = require('web.utils');
var publicWidget = require('web.public.widget');
publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({
@@ -71,9 +70,6 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({
});
}
}
if (result.visitor_uuid) {
utils.set_cookie('visitor_uuid', result.visitor_uuid);
}
});
},
@@ -145,8 +145,8 @@ class TestTrackSuggestions(TestEventOnlineCommon):
}])
emp_visitor = self.env['website.visitor'].create({
'name': 'Visitor',
'partner_id': self.user_employee.partner_id.id
'partner_id': self.user_employee.partner_id.id,
'access_token': self.user_employee.partner_id.id,
})
visitor_track = self.env['event.track.visitor'].create({
'visitor_id': emp_visitor.id,
@@ -25,6 +25,7 @@ class WebsiteVisitorTestsEventTrack(TestEventOnlineCommon, WebsiteVisitorTests):
'country_id': self.env.ref('base.be').id,
'website_id': 1,
'last_connection_datetime': datetime.now() - timedelta(days=8),
'access_token': 'f9d2b93591d6f602e5e8afa238e35a6c',
'event_track_visitor_ids': [(0, 0, {
'track_id': track_1.id,
'is_wishlisted': True
@@ -60,7 +61,7 @@ class WebsiteVisitorTestsEventTrack(TestEventOnlineCommon, WebsiteVisitorTests):
'is_wishlisted': True,
}])
linked_visitor._link_to_visitor(main_visitor)
linked_visitor._merge_visitor(main_visitor)
self.assertVisitorDeactivated(linked_visitor, main_visitor)
@@ -19,7 +19,6 @@ class WebsiteEventTrackQuiz(EventTrackController):
track_sudo = track.sudo()
event_track_visitor = track._get_event_track_visitors(force_create=True)
visitor_sudo = event_track_visitor.visitor_id
if event_track_visitor.quiz_completed:
return {'error': 'track_quiz_done'}
@@ -45,8 +44,6 @@ class WebsiteEventTrackQuiz(EventTrackController):
'quiz_completed': event_track_visitor.quiz_completed,
'quiz_points': answers_details['points']
}
if visitor_sudo and request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token:
result['visitor_uuid'] = visitor_sudo.access_token
return result
@http.route('/event_track/quiz/reset', type="json", auth="public", website=True)
@@ -5,7 +5,6 @@ odoo.define('website_event_track_quiz.event.quiz', function (require) {
var publicWidget = require('web.public.widget');
var core = require('web.core');
var session = require('web.session');
var utils = require('web.utils');
var QWeb = core.qweb;
var _t = core._t;
@@ -235,9 +234,6 @@ var Quiz = publicWidget.Widget.extend({
}
self._renderAnswersHighlightingAndComments();
self._renderValidationInfo();
if (data.visitor_uuid) {
utils.set_cookie('visitor_uuid', data.visitor_uuid);
}
}
return Promise.resolve(data);
@@ -61,7 +61,7 @@
t-att-src="image_data_uri(visitor['visitor'].partner_image) if visitor['visitor'].partner_image else '/web/static/img/user_placeholder.jpg'"/>
<img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{visitor['position']}.svg" alt="User rank" style="bottom: 0; right: -10px"/>
</div>
<h3 t-if="visitor['visitor'] == current_visitor and not visitor['visitor'].name" class="mt-2 mb-0">You</h3>
<h3 t-if="visitor['visitor'] == current_visitor and not visitor['visitor'].partner_id" class="mt-2 mb-0">You</h3>
<h3 t-else="" class="mt-2 mb-0" t-out="visitor['visitor'].display_name"/>
</div>
<div class="row mx-0 o_wprofile_top3_card_footer text-nowrap">
@@ -81,7 +81,7 @@
t-att-src="image_data_uri(visitor['visitor'].partner_image) if visitor['visitor'].partner_image else '/web/static/img/user_placeholder.jpg'"/>
</td>
<td class="align-middle w-md-75">
<span t-if="visitor['visitor'] == current_visitor and not visitor['visitor'].name" class="font-weight-bold">You</span>
<span t-if="visitor['visitor'] == current_visitor and not visitor['visitor'].partner_id" class="font-weight-bold">You</span>
<span t-else="" class="font-weight-bold" t-out="visitor['visitor'].display_name"/><br/>
</td>
<td class="align-middle font-weight-bold text-right text-nowrap">
@@ -92,28 +92,24 @@ class WebsiteVisitor(models.Model):
notifications.append([operator.partner_id, 'website_livechat.send_chat_request', mail_channel_info])
self.env['bus.bus']._sendmany(notifications)
def _link_to_visitor(self, target):
def _merge_visitor(self, target):
""" Copy sessions of the secondary visitors to the main partner visitor. """
if target.partner_id:
target.mail_channel_ids |= self.mail_channel_ids
super(WebsiteVisitor, self)._link_to_visitor(target)
target.mail_channel_ids |= self.mail_channel_ids
self.mail_channel_ids.channel_partner_ids = [
(3, self.env.ref('base.public_partner').id),
(4, target.partner_id.id),
]
return super()._merge_visitor(target)
def _link_to_partner(self, partner, update_values=None):
""" Adapt partner in members of related livechats """
if partner:
self.mail_channel_ids.channel_partner_ids = [
(3, self.env.ref('base.public_partner').id),
(4, partner.id),
]
super(WebsiteVisitor, self)._link_to_partner(partner, update_values=update_values)
def _create_visitor(self):
visitor = super(WebsiteVisitor, self)._create_visitor()
mail_channel_uuid = json.loads(request.httprequest.cookies.get('im_livechat_session', '{}')).get('uuid')
if mail_channel_uuid:
mail_channel = request.env["mail.channel"].sudo().search([("uuid", "=", mail_channel_uuid)])
mail_channel.write({
'livechat_visitor_id': visitor.id,
'anonymous_name': visitor.display_name
})
return visitor
def _upsert_visitor(self, access_token, force_track_values=None):
visitor_id, upsert = super()._upsert_visitor(access_token, force_track_values=force_track_values)
if upsert == 'inserted':
visitor_sudo = self.sudo().browse(visitor_id)
mail_channel_uuid = json.loads(request.httprequest.cookies.get('im_livechat_session', '{}')).get('uuid')
if mail_channel_uuid:
mail_channel = request.env["mail.channel"].sudo().search([("uuid", "=", mail_channel_uuid)])
mail_channel.write({
'livechat_visitor_id': visitor_sudo.id,
'anonymous_name': visitor_sudo.display_name
})
return visitor_id, upsert
+7 -1
View File
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
from odoo import fields, tests
@@ -39,7 +41,11 @@ class TestLivechatCommon(tests.TransactionCase):
'country_id': self.env.ref('base.de').id,
'website_id': self.env.ref('website.default_website').id,
'partner_id': self.env.ref('base.user_demo').partner_id.id,
}] + [visitor_vals]*self.max_sessions_per_operator)
'access_token': self.env.ref('base.user_demo').partner_id.id,
}] + [
dict(visitor_vals, access_token='%032x' % random.randrange(16**32))
for _ in range(self.max_sessions_per_operator)
])
self.visitor_demo, self.visitor = self.visitors[0], self.visitors[1]
base_url = self.livechat_channel.get_base_url()
+1 -1
View File
@@ -10,7 +10,7 @@ class TestLivechatUI(tests.HttpCase, TestLivechatCommon):
def setUp(self):
super(TestLivechatUI, self).setUp()
self.visitor_tour = self.env['website.visitor'].create({
'name': 'Visitor Tour',
'access_token': 'f9d2e784d3d96a904fca2f5e2a559a19',
'website_id': self.env.ref('website.default_website').id,
})
self.target_visitor = self.visitor_tour
@@ -16,7 +16,7 @@ class WebsiteVisitorTestsLivechat(WebsiteVisitorTests):
self._prepare_linked_visitor_data()
])
all_mail_channels = (main_visitor + linked_visitor).mail_channel_ids
linked_visitor._link_to_visitor(main_visitor)
linked_visitor._merge_visitor(main_visitor)
self.assertVisitorDeactivated(linked_visitor, main_visitor)
+1 -4
View File
@@ -1241,10 +1241,7 @@ class WebsiteSale(http.Controller):
def products_recently_viewed_update(self, product_id, **kwargs):
res = {}
visitor_sudo = request.env['website.visitor']._get_visitor_from_request(force_create=True)
if visitor_sudo:
if request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token:
res['visitor_uuid'] = visitor_sudo.access_token
visitor_sudo._add_viewed_product(product_id)
visitor_sudo._add_viewed_product(product_id)
return res
@http.route('/shop/products/recently_viewed_delete', type='json', auth='public', website=True)
@@ -44,5 +44,5 @@ class WebsiteVisitor(models.Model):
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()}
website_track_values = {'product_id': product_id}
self._add_tracking(domain, website_track_values)
@@ -45,9 +45,6 @@ publicWidget.registry.productsRecentlyViewedUpdate = publicWidget.Widget.extend(
product_id: productId,
}
}).then(function (res) {
if (res && res.visitor_uuid) {
utils.set_cookie('visitor_uuid', res.visitor_uuid);
}
utils.set_cookie(cookieName, productId, 30 * 60);
});
},