diff --git a/analytical/templatetags/piwik.py b/analytical/templatetags/piwik.py new file mode 100644 index 0000000..10819a3 --- /dev/null +++ b/analytical/templatetags/piwik.py @@ -0,0 +1,126 @@ +""" +Piwik template tags and filters. +""" + +from __future__ import absolute_import + +from collections import namedtuple +from itertools import chain +import re + +from django.conf import settings +from django.template import Library, Node, TemplateSyntaxError + +from analytical.utils import (is_internal_ip, disable_html, + get_required_setting, get_identity) + + +# domain name (characters separated by a dot), optional port, optional URI path, no slash +DOMAINPATH_RE = re.compile(r'^(([^./?#@:]+\.)*[^./?#@:]+)+(:[0-9]+)?(/[^/?#@:]+)*$') + +# numeric ID +SITEID_RE = re.compile(r'^\d+$') + +TRACKING_CODE = """ + + +""" # noqa + +VARIABLE_CODE = '_paq.push(["setCustomVariable", %(index)s, "%(name)s", "%(value)s", "%(scope)s"]);' # noqa +IDENTITY_CODE = '_paq.push(["setUserId", "%(userid)s"]);' +DISABLE_COOKIES_CODE = '_paq.push([\'disableCookies\']);' +ASK_FOR_CONSENT_CODE = '_paq.push([\'requireConsent\']);' +FORGET_CONSENT_CODE = 'document.getElementById("piwik_deny_consent").addEventListener("click", () => { _paq.push(["forgetConsentGiven"]); });' +REMEMBER_CONSENT_CODE = 'document.getElementById("piwik_give_consent").addEventListener("click", () => { _paq.push(["setConsentGiven"]); _paq.push(["rememberConsentGiven"]); });' + +DEFAULT_SCOPE = 'page' + +PiwikVar = namedtuple('PiwikVar', ('index', 'name', 'value', 'scope')) + + +register = Library() + + +@register.tag +def piwik(parser, token): + """ + Piwik tracking template tag. + + Renders Javascript code to track page visits. You must supply + your Piwik domain (plus optional URI path), and tracked site ID + in the ``PIWIK_DOMAIN_PATH`` and the ``PIWIK_SITE_ID`` setting. + + Custom variables can be passed in the ``piwik_vars`` context + variable. It is an iterable of custom variables as tuples like: + ``(index, name, value[, scope])`` where scope may be ``'page'`` + (default) or ``'visit'``. Index should be an integer and the + other parameters should be strings. + """ + bits = token.split_contents() + if len(bits) > 1: + raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) + return PiwikNode() + + +class PiwikNode(Node): + def __init__(self): + self.domain_path = \ + get_required_setting('PIWIK_DOMAIN_PATH', DOMAINPATH_RE, + "must be a domain name, optionally followed " + "by an URI path, no trailing slash (e.g. " + "piwik.example.com or my.piwik.server/path)") + self.site_id = \ + get_required_setting('PIWIK_SITE_ID', SITEID_RE, + "must be a (string containing a) number") + + def render(self, context): + custom_variables = context.get('piwik_vars', ()) + + complete_variables = (var if len(var) >= 4 else var + (DEFAULT_SCOPE,) + for var in custom_variables) + + variables_code = (VARIABLE_CODE % PiwikVar(*var)._asdict() + for var in complete_variables) + + commands = [] + if getattr(settings, 'PIWIK_DISABLE_COOKIES', False): + commands.append(DISABLE_COOKIES_CODE) + + if getattr(settings, 'PIWIK_ASK_FOR_CONSENT', False): + commands.append(ASK_FOR_CONSENT_CODE) + commands.append(FORGET_CONSENT_CODE) + commands.append(REMEMBER_CONSENT_CODE) + + userid = get_identity(context, 'piwik') + if userid is not None: + variables_code = chain(variables_code, ( + IDENTITY_CODE % {'userid': userid}, + )) + + html = TRACKING_CODE % { + 'url': self.domain_path, + 'siteid': self.site_id, + 'variables': '\n '.join(variables_code), + 'commands': '\n '.join(commands) + } + if is_internal_ip(context, 'PIWIK'): + html = disable_html(html, 'Piwik') + return html + + +def contribute_to_analytical(add_node): + PiwikNode() # ensure properly configured + add_node('body_bottom', PiwikNode) diff --git a/analytical/tests/test_tag_piwik.py b/analytical/tests/test_tag_piwik.py new file mode 100644 index 0000000..4becce1 --- /dev/null +++ b/analytical/tests/test_tag_piwik.py @@ -0,0 +1,157 @@ +""" +Tests for the Piwik template tags and filters. +""" + +from django.contrib.auth.models import User +from django.http import HttpRequest +from django.template import Context +from django.test.utils import override_settings + +from analytical.templatetags.piwik import PiwikNode +from analytical.tests.utils import TagTestCase +from analytical.utils import AnalyticalException + + +@override_settings(PIWIK_DOMAIN_PATH='example.com', PIWIK_SITE_ID='345') +class PiwikTagTestCase(TagTestCase): + """ + Tests for the ``piwik`` template tag. + """ + + def test_tag(self): + r = self.render_tag('piwik', 'piwik') + self.assertTrue('"//example.com/"' in r, r) + self.assertTrue("_paq.push(['setSiteId', 345]);" in r, r) + self.assertTrue('img src="//example.com/piwik.php?idsite=345"' + in r, r) + + def test_node(self): + r = PiwikNode().render(Context({})) + self.assertTrue('"//example.com/";' in r, r) + self.assertTrue("_paq.push(['setSiteId', 345]);" in r, r) + self.assertTrue('img src="//example.com/piwik.php?idsite=345"' + in r, r) + + @override_settings(PIWIK_DOMAIN_PATH='example.com/piwik', + PIWIK_SITE_ID='345') + def test_domain_path_valid(self): + r = self.render_tag('piwik', 'piwik') + self.assertTrue('"//example.com/piwik/"' in r, r) + + @override_settings(PIWIK_DOMAIN_PATH='example.com:1234', + PIWIK_SITE_ID='345') + def test_domain_port_valid(self): + r = self.render_tag('piwik', 'piwik') + self.assertTrue('"//example.com:1234/";' in r, r) + + @override_settings(PIWIK_DOMAIN_PATH='example.com:1234/piwik', + PIWIK_SITE_ID='345') + def test_domain_port_path_valid(self): + r = self.render_tag('piwik', 'piwik') + self.assertTrue('"//example.com:1234/piwik/"' in r, r) + + @override_settings(PIWIK_DOMAIN_PATH=None) + def test_no_domain(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_SITE_ID=None) + def test_no_siteid(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_SITE_ID='x') + def test_siteid_not_a_number(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_DOMAIN_PATH='http://www.example.com') + def test_domain_protocol_invalid(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_DOMAIN_PATH='example.com/') + def test_domain_slash_invalid(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_DOMAIN_PATH='example.com:123:456') + def test_domain_multi_port(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_DOMAIN_PATH='example.com:') + def test_domain_incomplete_port(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_DOMAIN_PATH='example.com:/piwik') + def test_domain_uri_incomplete_port(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(PIWIK_DOMAIN_PATH='example.com:12df') + def test_domain_port_invalid(self): + self.assertRaises(AnalyticalException, PiwikNode) + + @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) + def test_render_internal_ip(self): + req = HttpRequest() + req.META['REMOTE_ADDR'] = '1.1.1.1' + context = Context({'request': req}) + r = PiwikNode().render(context) + self.assertTrue(r.startswith( + ''), r) + + def test_uservars(self): + context = Context({'piwik_vars': [(1, 'foo', 'foo_val'), + (2, 'bar', 'bar_val', 'page'), + (3, 'spam', 'spam_val', 'visit')]}) + r = PiwikNode().render(context) + msg = 'Incorrect Piwik custom variable rendering. Expected:\n%s\nIn:\n%s' + for var_code in ['_paq.push(["setCustomVariable", 1, "foo", "foo_val", "page"]);', + '_paq.push(["setCustomVariable", 2, "bar", "bar_val", "page"]);', + '_paq.push(["setCustomVariable", 3, "spam", "spam_val", "visit"]);']: + self.assertIn(var_code, r, msg % (var_code, r)) + + @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) + def test_default_usertrack(self): + context = Context({ + 'user': User(username='BDFL', first_name='Guido', last_name='van Rossum') + }) + r = PiwikNode().render(context) + msg = 'Incorrect Piwik user tracking rendering.\nNot found:\n%s\nIn:\n%s' + var_code = '_paq.push(["setUserId", "BDFL"]);' + self.assertIn(var_code, r, msg % (var_code, r)) + + def test_piwik_usertrack(self): + context = Context({ + 'piwik_identity': 'BDFL' + }) + r = PiwikNode().render(context) + msg = 'Incorrect Piwik user tracking rendering.\nNot found:\n%s\nIn:\n%s' + var_code = '_paq.push(["setUserId", "BDFL"]);' + self.assertIn(var_code, r, msg % (var_code, r)) + + def test_analytical_usertrack(self): + context = Context({ + 'analytical_identity': 'BDFL' + }) + r = PiwikNode().render(context) + msg = 'Incorrect Piwik user tracking rendering.\nNot found:\n%s\nIn:\n%s' + var_code = '_paq.push(["setUserId", "BDFL"]);' + self.assertIn(var_code, r, msg % (var_code, r)) + + @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) + def test_disable_usertrack(self): + context = Context({ + 'user': User(username='BDFL', first_name='Guido', last_name='van Rossum'), + 'piwik_identity': None + }) + r = PiwikNode().render(context) + msg = 'Incorrect Piwik user tracking rendering.\nFound:\n%s\nIn:\n%s' + var_code = '_paq.push(["setUserId", "BDFL"]);' + self.assertNotIn(var_code, r, msg % (var_code, r)) + + @override_settings(PIWIK_DISABLE_COOKIES=True) + def test_disable_cookies(self): + r = PiwikNode().render(Context({})) + self.assertTrue("_paq.push(['disableCookies']);" in r, r) + + @override_settings(PIWIK_ASK_FOR_CONSENT=True) + def test_disable_cookies(self): + r = PiwikNode().render(Context({})) + self.assertTrue("_paq.push([\'requireConsent\']);" in r, r) diff --git a/docs/services/piwik.rst b/docs/services/piwik.rst new file mode 100644 index 0000000..d012aca --- /dev/null +++ b/docs/services/piwik.rst @@ -0,0 +1,184 @@ +================================== +Matomo (formerly Piwik) -- open source web analytics +================================== + +Piwik_ is an open analytics platform currently used by individuals, +companies and governments all over the world. With Piwik, your data +will always be yours, because you run your own analytics server. + +.. _Piwik: http://www.piwik.org/ + + +Installation +============ + +To start using the Piwik integration, you must have installed the +django-analytical package and have added the ``analytical`` application +to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. +See :doc:`../install` for details. + +Next you need to add the Piwik template tag to your templates. This +step is only needed if you are not using the generic +:ttag:`analytical.*` tags. If you are, skip to +:ref:`piwik-configuration`. + +The Piwik tracking code is inserted into templates using a template +tag. Load the :mod:`piwik` template tag library and insert the +:ttag:`piwik` tag. Because every page that you want to track must +have the tag, it is useful to add it to your base template. Insert +the tag at the bottom of the HTML body as recommended by the +`Piwik best practice for Integration Plugins`_:: + + {% load piwik %} + ... + {% piwik %} + + + +.. _`Piwik best practice for Integration Plugins`: http://piwik.org/integrate/how-to/ + + + +.. _piwik-configuration: + +Configuration +============= + +Before you can use the Piwik integration, you must first define +domain name and optional URI path to your Piwik server, as well as +the Piwik ID of the website you're tracking with your Piwik server, +in your project settings. + + +Setting the domain +------------------ + +Your Django project needs to know where your Piwik server is located. +Typically, you'll have Piwik installed on a subdomain of its own +(e.g. ``piwik.example.com``), otherwise it runs in a subdirectory of +a website of yours (e.g. ``www.example.com/piwik``). Set +:const:`PIWIK_DOMAIN_PATH` in the project :file:`settings.py` file +accordingly:: + + PIWIK_DOMAIN_PATH = 'piwik.example.com' + +If you do not set a domain the tracking code will not be rendered. + + +Setting the site ID +------------------- + +Your Piwik server can track several websites. Each website has its +site ID (this is the ``idSite`` parameter in the query string of your +browser's address bar when you visit the Piwik Dashboard). Set +:const:`PIWIK_SITE_ID` in the project :file:`settings.py` file to +the value corresponding to the website you're tracking:: + + PIWIK_SITE_ID = '4' + +If you do not set the site ID the tracking code will not be rendered. + + +.. _piwik-uservars: + +User variables +-------------- + +Piwik supports sending `custom variables`_ along with default statistics. If +you want to set a custom variable, use the context variable ``piwik_vars`` when +you render your template. It should be an iterable of custom variables +represented by tuples like: ``(index, name, value[, scope])``, where scope may +be ``'page'`` (default) or ``'visit'``. ``index`` should be an integer and the +other parameters should be strings. :: + + context = Context({ + 'piwik_vars': [(1, 'foo', 'Sir Lancelot of Camelot'), + (2, 'bar', 'To seek the Holy Grail', 'page'), + (3, 'spam', 'Blue', 'visit')] + }) + return some_template.render(context) + +Piwik default settings allow up to 5 custom variables for both scope. Variable +mapping betweeen index and name must stay constant, or the latest name +override the previous one. + +If you use the same user variables in different views and its value can +be computed from the HTTP request, you can also set them in a context +processor that you add to the :data:`TEMPLATE_CONTEXT_PROCESSORS` list +in :file:`settings.py`. + +.. _`custom variables`: http://developer.piwik.org/guides/tracking-javascript-guide#custom-variables + + +.. _piwik-user-tracking: + +User tracking +------------- + +If you use the standard Django authentication system, you can allow Piwik to +`track individual users`_ by setting the :data:`ANALYTICAL_AUTO_IDENTIFY` +setting to :const:`True`. This is enabled by default. Piwik will identify +users based on their ``username``. + +If you disable this settings, or want to customize what user id to use, you can +set the context variable ``analytical_identity`` (for global configuration) or +``piwik_identity`` (for Piwik specific configuration). Setting one to +:const:`None` will disable the user tracking feature:: + + # Piwik will identify this user as 'BDFL' if ANALYTICAL_AUTO_IDENTIFY is True or unset + request.user = User(username='BDFL', first_name='Guido', last_name='van Rossum') + + # Piwik will identify this user as 'Guido van Rossum' + request.user = User(username='BDFL', first_name='Guido', last_name='van Rossum') + context = Context({ + 'piwik_identity': request.user.get_full_name() + }) + + # Piwik will not identify this user (but will still collect statistics) + request.user = User(username='BDFL', first_name='Guido', last_name='van Rossum') + context = Context({ + 'piwik_identity': None + }) + +.. _`track individual users`: http://developer.piwik.org/guides/tracking-javascript-guide#user-id + +Disabling cookies +----------------- + +If you want to `disable cookies`_, set :data:`PIWIK_DISABLE_COOKIES` to +:const:`True`. This is disabled by default. + +.. _`disable cookies`: https://matomo.org/faq/general/faq_157/ + +Ask for consent +----------------- + +If you want to ask for consent set :data:`PIWIK_ASK_FOR_CONSENT` to +:const:`True`. This is disabled by default. + +To ask the visitor for consent just create DOM elements with the following id's: + +`piwik_deny_consent` - id for DOM element to click, if the user denies consent +`piwik_give_consent` - id for DOM element to click, if the user gives consent + +.. _`asking for consent`: https://developer.matomo.org/guides/tracking-javascript-guide#asking-for-consent + +Internal IP addresses +--------------------- + +Usually, you do not want to track clicks from your development or +internal IP addresses. By default, if the tags detect that the client +comes from any address in the :const:`ANALYTICAL_INTERNAL_IPS` (which +takes the value of :const:`INTERNAL_IPS` by default) the tracking code +is commented out. See :ref:`identifying-visitors` for important +information about detecting the visitor IP address. + + +---- + +Thanks go to Piwik for providing an excellent web analytics platform +entirely for free! Consider donating_ to ensure that they continue +their development efforts in the spirit of open source and freedom +for our personal data. + +.. _donating: http://piwik.org/donate/