diff --git a/analytical/templatetags/analytical.py b/analytical/templatetags/analytical.py
index 6e74bd7..f74684e 100644
--- a/analytical/templatetags/analytical.py
+++ b/analytical/templatetags/analytical.py
@@ -30,6 +30,7 @@ TAG_MODULES = [
'analytical.intercom',
'analytical.kiss_insights',
'analytical.kiss_metrics',
+ 'analytical.matomo',
'analytical.mixpanel',
'analytical.olark',
'analytical.optimizely',
diff --git a/analytical/templatetags/matomo.py b/analytical/templatetags/matomo.py
new file mode 100644
index 0000000..98bdf8a
--- /dev/null
+++ b/analytical/templatetags/matomo.py
@@ -0,0 +1,118 @@
+"""
+Matomo 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\']);'
+
+DEFAULT_SCOPE = 'page'
+
+MatomoVar = namedtuple('MatomoVar', ('index', 'name', 'value', 'scope'))
+
+
+register = Library()
+
+
+@register.tag
+def matomo(parser, token):
+ """
+ Matomo tracking template tag.
+
+ Renders Javascript code to track page visits. You must supply
+ your Matomo domain (plus optional URI path), and tracked site ID
+ in the ``MATOMO_DOMAIN_PATH`` and the ``MATOMO_SITE_ID`` setting.
+
+ Custom variables can be passed in the ``matomo_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 MatomoNode()
+
+
+class MatomoNode(Node):
+ def __init__(self):
+ self.domain_path = \
+ get_required_setting('MATOMO_DOMAIN_PATH', DOMAINPATH_RE,
+ "must be a domain name, optionally followed "
+ "by an URI path, no trailing slash (e.g. "
+ "matomo.example.com or my.matomo.server/path)")
+ self.site_id = \
+ get_required_setting('MATOMO_SITE_ID', SITEID_RE,
+ "must be a (string containing a) number")
+
+ def render(self, context):
+ custom_variables = context.get('matomo_vars', ())
+
+ complete_variables = (var if len(var) >= 4 else var + (DEFAULT_SCOPE,)
+ for var in custom_variables)
+
+ variables_code = (VARIABLE_CODE % MatomoVar(*var)._asdict()
+ for var in complete_variables)
+
+ commands = []
+ if getattr(settings, 'MATOMO_DISABLE_COOKIES', False):
+ commands.append(DISABLE_COOKIES_CODE)
+
+ userid = get_identity(context, 'matomo')
+ 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, 'MATOMO'):
+ html = disable_html(html, 'Matomo')
+ return html
+
+
+def contribute_to_analytical(add_node):
+ MatomoNode() # ensure properly configured
+ add_node('body_bottom', MatomoNode)
diff --git a/analytical/templatetags/piwik.py b/analytical/templatetags/piwik.py
index 9eebc4a..ec8b97d 100644
--- a/analytical/templatetags/piwik.py
+++ b/analytical/templatetags/piwik.py
@@ -14,6 +14,8 @@ from django.template import Library, Node, TemplateSyntaxError
from analytical.utils import (is_internal_ip, disable_html,
get_required_setting, get_identity)
+import warnings
+warnings.warn('The Piwik module will be deprecated', DeprecationWarning)
# domain name (characters separated by a dot), optional port, optional URI path, no slash
DOMAINPATH_RE = re.compile(r'^(([^./?#@:]+\.)*[^./?#@:]+)+(:[0-9]+)?(/[^/?#@:]+)*$')
diff --git a/analytical/tests/test_tag_matomo.py b/analytical/tests/test_tag_matomo.py
new file mode 100644
index 0000000..0e34beb
--- /dev/null
+++ b/analytical/tests/test_tag_matomo.py
@@ -0,0 +1,152 @@
+"""
+Tests for the Matomo 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.matomo import MatomoNode
+from analytical.tests.utils import TagTestCase
+from analytical.utils import AnalyticalException
+
+
+@override_settings(MATOMO_DOMAIN_PATH='example.com', MATOMO_SITE_ID='345')
+class MatomoTagTestCase(TagTestCase):
+ """
+ Tests for the ``matomo`` template tag.
+ """
+
+ def test_tag(self):
+ r = self.render_tag('matomo', 'matomo')
+ self.assertTrue('"//example.com/"' in r, r)
+ self.assertTrue("_paq.push(['setSiteId', 345]);" in r, r)
+ self.assertTrue('img src="//example.com/matomo.php?idsite=345"'
+ in r, r)
+
+ def test_node(self):
+ r = MatomoNode().render(Context({}))
+ self.assertTrue('"//example.com/";' in r, r)
+ self.assertTrue("_paq.push(['setSiteId', 345]);" in r, r)
+ self.assertTrue('img src="//example.com/matomo.php?idsite=345"'
+ in r, r)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com/matomo',
+ MATOMO_SITE_ID='345')
+ def test_domain_path_valid(self):
+ r = self.render_tag('matomo', 'matomo')
+ self.assertTrue('"//example.com/matomo/"' in r, r)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com:1234',
+ MATOMO_SITE_ID='345')
+ def test_domain_port_valid(self):
+ r = self.render_tag('matomo', 'matomo')
+ self.assertTrue('"//example.com:1234/";' in r, r)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com:1234/matomo',
+ MATOMO_SITE_ID='345')
+ def test_domain_port_path_valid(self):
+ r = self.render_tag('matomo', 'matomo')
+ self.assertTrue('"//example.com:1234/matomo/"' in r, r)
+
+ @override_settings(MATOMO_DOMAIN_PATH=None)
+ def test_no_domain(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_SITE_ID=None)
+ def test_no_siteid(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_SITE_ID='x')
+ def test_siteid_not_a_number(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_DOMAIN_PATH='http://www.example.com')
+ def test_domain_protocol_invalid(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com/')
+ def test_domain_slash_invalid(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com:123:456')
+ def test_domain_multi_port(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com:')
+ def test_domain_incomplete_port(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com:/matomo')
+ def test_domain_uri_incomplete_port(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @override_settings(MATOMO_DOMAIN_PATH='example.com:12df')
+ def test_domain_port_invalid(self):
+ self.assertRaises(AnalyticalException, MatomoNode)
+
+ @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 = MatomoNode().render(context)
+ self.assertTrue(r.startswith(
+ ''), r)
+
+ def test_uservars(self):
+ context = Context({'matomo_vars': [(1, 'foo', 'foo_val'),
+ (2, 'bar', 'bar_val', 'page'),
+ (3, 'spam', 'spam_val', 'visit')]})
+ r = MatomoNode().render(context)
+ msg = 'Incorrect Matomo 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 = MatomoNode().render(context)
+ msg = 'Incorrect Matomo 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_matomo_usertrack(self):
+ context = Context({
+ 'matomo_identity': 'BDFL'
+ })
+ r = MatomoNode().render(context)
+ msg = 'Incorrect Matomo 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 = MatomoNode().render(context)
+ msg = 'Incorrect Matomo 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'),
+ 'matomo_identity': None
+ })
+ r = MatomoNode().render(context)
+ msg = 'Incorrect Matomo 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(MATOMO_DISABLE_COOKIES=True)
+ def test_disable_cookies(self):
+ r = MatomoNode().render(Context({}))
+ self.assertTrue("_paq.push(['disableCookies']);" in r, r)
diff --git a/docs/install.rst b/docs/install.rst
index b641065..1f97f07 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -155,6 +155,11 @@ settings required to enable each service are listed here:
KISS_METRICS_API_KEY = '0123456789abcdef0123456789abcdef01234567'
+* :doc:`Matomo (formerly Piwik) `::
+
+ MATOMO_DOMAIN_PATH = 'your.matomo.server/optional/path'
+ MATOMO_SITE_ID = '123'
+
* :doc:`Mixpanel `::
MIXPANEL_API_TOKEN = '0123456789abcdef0123456789abcdef'
@@ -171,7 +176,7 @@ settings required to enable each service are listed here:
PERFORMABLE_API_KEY = '123abc'
-* :doc:`Matomo (formerly Piwik) `::
+* :doc:`Piwik (deprecated, see Matomo) `::
PIWIK_DOMAIN_PATH = 'your.piwik.server/optional/path'
PIWIK_SITE_ID = '123'
diff --git a/docs/services/matomo.rst b/docs/services/matomo.rst
new file mode 100644
index 0000000..0aa4731
--- /dev/null
+++ b/docs/services/matomo.rst
@@ -0,0 +1,160 @@
+==================================
+Matomo (formerly Piwik) -- open source web analytics
+==================================
+
+Matomo_ is an open analytics platform currently used by individuals,
+companies and governments all over the world.
+
+.. _Matomo: http://matomo.org/
+
+
+Installation
+============
+
+To start using the Matomo 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 Matomo 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:`matomo-configuration`.
+
+The Matomo tracking code is inserted into templates using a template
+tag. Load the :mod:`matomo` template tag library and insert the
+:ttag:`matomo` 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
+`Matomo best practice for Integration Plugins`_::
+
+ {% load matomo %}
+ ...
+ {% matomo %}
+