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 %}
+