398 lines
18 KiB
Python
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
|