website/models/website_visitor.py

398 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
import threading
from odoo import fields, models, api, _
from odoo.addons.base.models.res_partner import _tz_get
from odoo.exceptions import UserError
from odoo.tools import split_every
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 request.env.user._is_public():
return request.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', compute_sudo=True)
mobile = fields.Char(string='Mobile', compute='_compute_email_phone', compute_sudo=True)
# 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 _compute_display_name(self):
for record in self:
# Accessing name of partner through sudo to avoid infringing
# record rule if partner belongs to another company.
record.display_name = record.partner_id.sudo().name or _('Website Visitor #%s', record.id)
@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.
partner_id = len(visitor.access_token) != 32 and int(visitor.access_token)
visitor.partner_id = self.env['res.partner'].browse(partner_id)
@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'], ['__count'])
mapped_data = {}
for visitor, page, count in results:
visitor_info = mapped_data.get(visitor.id, {'page_count': 0, 'visitor_page_count': 0, 'page_ids': set()})
visitor_info['visitor_page_count'] += count
visitor_info['page_count'] += 1
if page:
visitor_info['page_ids'].add(page.id)
mapped_data[visitor.id] = 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), ('page_id', '!=', False)],
['visitor_id', 'page_id'],
order='visit_datetime:max')
mapped_data = {visitor.id: page.id for visitor, page in results}
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_ids': self.partner_id.ids,
'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_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).
"""
create_values = {
'access_token': access_token,
'lang_id': request.lang.id,
# Note that it's possible for the GEOIP database to return a country
# code which is unknown in Odoo
'country_code': request.geoip.get('country_code'),
'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,
website_id, timezone, write_uid, create_uid, write_date, create_date, country_id)
VALUES (
%(partner_id)s, %(access_token)s, now() at time zone 'UTC', 1, %(lang_id)s,
%(website_id)s, %(timezone)s, %(create_uid)s, %(write_uid)s,
now() at time zone 'UTC', now() at time zone 'UTC', (
SELECT id FROM res_country WHERE code = %(country_code)s
)
)
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, batch_size=1000, limit=None):
""" 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. """
auto_commit = not getattr(threading.current_thread(), 'testing', False)
visitor_model = self.env['website.visitor']
for inactive_visitors_batch in split_every(
batch_size,
visitor_model.sudo().search(self._inactive_visitors_domain(), limit=limit).ids,
visitor_model.browse,
):
inactive_visitors_batch.unlink()
if auto_commit:
self.env.cr.commit()
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