From 114c65d060f3f714adde985dd7b3dc0b0b5b8ae9 Mon Sep 17 00:00:00 2001 From: ronardcaktus Date: Sat, 28 Feb 2026 08:55:13 -0500 Subject: [PATCH] Remove old files from pre-rebase --- analytical/templatetags/piwik.py | 126 ------------------- analytical/tests/test_tag_matomo.py | 157 ------------------------ analytical/tests/test_tag_piwik.py | 157 ------------------------ docs/services/piwik.rst | 184 ---------------------------- 4 files changed, 624 deletions(-) delete mode 100644 analytical/templatetags/piwik.py delete mode 100644 analytical/tests/test_tag_matomo.py delete mode 100644 analytical/tests/test_tag_piwik.py delete mode 100644 docs/services/piwik.rst diff --git a/analytical/templatetags/piwik.py b/analytical/templatetags/piwik.py deleted file mode 100644 index 10819a3..0000000 --- a/analytical/templatetags/piwik.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -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_matomo.py b/analytical/tests/test_tag_matomo.py deleted file mode 100644 index 01e6cb2..0000000 --- a/analytical/tests/test_tag_matomo.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -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/piwik.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/piwik.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) - - @override_settings(PIWIK_ASK_FOR_CONSENT=True) - def test_ask_for_consent(self): - r = PiwikNode().render(Context({})) - self.assertTrue("_paq.push(['requireConsent']);" in r, r) \ No newline at end of file diff --git a/analytical/tests/test_tag_piwik.py b/analytical/tests/test_tag_piwik.py deleted file mode 100644 index 4becce1..0000000 --- a/analytical/tests/test_tag_piwik.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -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 deleted file mode 100644 index d012aca..0000000 --- a/docs/services/piwik.rst +++ /dev/null @@ -1,184 +0,0 @@ -================================== -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/