matomo: views form manual and automatic configuration (#31778)

This commit is contained in:
Nicolas Roche 2019-03-18 20:02:26 +01:00
parent 71fdf9f7c5
commit 2ffbf9ec43
13 changed files with 546 additions and 0 deletions

0
hobo/matomo/__init__.py Normal file
View File

36
hobo/matomo/forms.py Normal file
View File

@ -0,0 +1,36 @@
# hobo - portal to configure and deploy applications
# Copyright (C) 2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from django.utils.translation import ugettext_lazy as _
class SettingsForm(forms.Form):
"""
According to publik-base-theme/templates/includes/tracking.html,
the 2 tracking_js variables are merged into the unique below field.
If JS code added is compliant to CNIL policy, we store it into
'cnil_compliant_visits_tracking_js' else into 'visits_tracking_js'.
The goal is to display a banner advertising users about intrusive JS.
"""
tracking_js = forms.CharField(
label=_('Tracking Javascript'),
required=False,
widget=forms.Textarea())
class EnableForm(forms.Form):
pass

View File

@ -0,0 +1,20 @@
{% extends "hobo/matomo_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "User tracking" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>
{% trans "Are you sure you want to disable user tracking support?" %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Disable" %}</button>
<a class="cancel" href="{% url 'matomo-home' %}">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "hobo/matomo_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "User tracking" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>
{% trans "Are you sure you want to enable automatic user tracking support?" %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Enable" %}</button>
<a class="cancel" href="{% url 'matomo-home' %}">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "hobo/matomo_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "User tracking" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<p>
<div class="buttons">
<button class="submit-button">{% trans "Enable" %}</button>
<a class="cancel" href="{% url 'matomo-home' %}">{% trans "Cancel" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,101 @@
{% extends "hobo/base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'matomo-home' %}">Matomo</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "User tracking" %}</h2>
{% if enabled %}
<span class="actions">
<a rel="popup" href="{% url 'matomo-disable' %}">{% trans 'Disable' %}</a>
{% if mode == 'auto' %}
<a href={{ logme_url }}>{% trans "Open visit tracking dashboard" %}</a>
{% endif %}
</span>
{% endif %}
{% endblock %}
{% block content %}
<div class="infonotice">
{% if not enabled %}
<p>
{% blocktrans %}
The audience measurement tools are deployed to obtain information about
visitor navigation. They help to understand where users come from on a site
and reconstruct their browsing activity. These tools use technologies that
permit to trace users on your site and associate a "referrer" or campaign
with a unique identifier.
{% endblocktrans %}
</p>
{% if ws_available %}
<p>
{% blocktrans %}
Publik can automatically use a tool called "Matomo", which is the tracker
solution recommended by the National Commission for Data Protection and Liberties (<a
href="https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience">CNIL-France</a>).
{% endblocktrans %}
</p>
{% else %}
<p>
{% blocktrans %}
Matomo is the tracker solution recommended by the National Commission for Data
Protection and Liberties (<a
href="https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience">CNIL-France</a>).
It requires <a href="https://www.cnil.fr/sites/default/files/typo/document/Configuration_piwik.pdf"
>little configuration</a> to be exempt from legal consent.
{% endblocktrans %}
</p>
{% endif %}
{% else %}
{% if mode == 'manual' %}
{% trans "Manual configuration." %}
{% elif mode == 'auto' %}
{% trans "Automatic configuration." %}
{% endif %}
{% endif %}
</div>
{% if not enabled %}
<p>
{% trans "Support is currently disabled." %}
</p>
<p>
<a class="button" rel="popup" href="{% url 'matomo-enable-manual' %}">{% trans 'Manual Configuration' %}</a>
{% if ws_available %}
<a class="button" rel="popup" href="{% url 'matomo-enable-auto' %}">{% trans 'Automatic Configuration' %}</a>
{% endif %}
</p>
{% else %}
{% if tracking_js != '' %}
{% if cnil_ack_level == 'success' %}
<div class="successnotice">
{% trans "Excellent respect of user rights." %}
</div>
{% elif cnil_ack_level == 'warning' %}
<div class="warningnotice">
{% trans "Good respect of user rights." %}
</div>
{% elif cnil_ack_level == 'error' %}
<div class="errornotice">
{% trans "No respect of user rights." %}
</div>
{% endif %}
{% endif %}
{% if mode == 'manual' %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
</div>
</form>
{% endif %}
{% endif %}
{% endblock %}

26
hobo/matomo/urls.py Normal file
View File

@ -0,0 +1,26 @@
# hobo - portal to configure and deploy applications
# Copyright (C) 2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.home, name='matomo-home'),
url(r'^enable-manual$', views.enable_manual, name='matomo-enable-manual'),
url(r'^enable-auto$', views.enable_auto, name='matomo-enable-auto'),
url(r'^disable$', views.disable, name='matomo-disable'),
]

115
hobo/matomo/views.py Normal file
View File

@ -0,0 +1,115 @@
# hobo - portal to configure and deploy applications
# Copyright (C) 2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.urlresolvers import reverse_lazy
from django.conf import settings
from django.contrib import messages
from django.views.generic import RedirectView, FormView
from .forms import SettingsForm, EnableForm
from .utils import get_variable, get_variable_value, \
get_tracking_js, put_tracking_js, \
MatomoException, MatomoError, MatomoWS, \
compute_cnil_acknowledgment_level, auto_configure_matomo
class HomeView(FormView):
template_name = 'hobo/matomo_home.html'
form_class = SettingsForm
success_url = reverse_lazy('matomo-home')
def get_initial(self):
initial = super(HomeView, self).get_initial()
initial['tracking_js'] = get_tracking_js()
return initial
def form_valid(self, form):
tracking_js = form.cleaned_data['tracking_js']
put_tracking_js(tracking_js)
return super(HomeView, self).form_valid(form)
def get_context_data(self, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)
tracking_js = get_tracking_js()
logme_url = get_variable_value('matomo_logme_url')
context['logme_url'] = logme_url
context['tracking_js'] = tracking_js
# compute contextual values
context['cnil_ack_level'] = compute_cnil_acknowledgment_level(tracking_js)
try:
MatomoWS()
except MatomoError:
context['ws_available'] = False
else:
context['ws_available'] = True
context['enabled'] = tracking_js != ''
if logme_url != '':
context['mode'] = 'auto'
else:
context['mode'] = 'manual'
return context
home = HomeView.as_view()
class EnableManualView(FormView):
form_class = SettingsForm
template_name = 'hobo/matomo_enable_manual.html'
success_url = reverse_lazy('matomo-home')
def get_initial(self):
initial = super(EnableManualView, self).get_initial()
initial['tracking_js'] = get_tracking_js()
return initial
def form_valid(self, form):
tracking_js = form.cleaned_data['tracking_js']
put_tracking_js(tracking_js)
logme_url = get_variable('matomo_logme_url')
logme_url.delete()
return super(EnableManualView, self).form_valid(form)
enable_manual = EnableManualView.as_view()
class EnableAutoView(FormView):
form_class = EnableForm
template_name = 'hobo/matomo_enable_auto.html'
success_url = reverse_lazy('matomo-home')
def form_valid(self, form):
try:
auto_configure_matomo()
except MatomoException as exc:
messages.error(self.request, str(exc))
return super(EnableAutoView, self).form_valid(form)
enable_auto = EnableAutoView.as_view()
class DisableView(FormView):
form_class = EnableForm
template_name = 'hobo/matomo_disable.html'
success_url = reverse_lazy('matomo-home')
def form_valid(self, form):
put_tracking_js('')
variable = get_variable('matomo_logme_url')
variable.delete()
return super(DisableView, self).form_valid(form)
disable = DisableView.as_view()

View File

@ -43,6 +43,7 @@ INSTALLED_APPS = (
'gadjo',
'hobo.environment',
'hobo.franceconnect',
'hobo.matomo',
'hobo.profile',
'hobo.theme',
'hobo.emails',

View File

@ -214,3 +214,7 @@ div#services span.op-nok::before {
div#services span {
margin-right: 1rem;
}
textarea#id_tracking_js {
width: 100%;
}

View File

@ -10,6 +10,7 @@
<li><a href="{% url 'theme-home' %}">{% trans 'Theme' %}</a></li>
<li><a href="{% url 'emails-home' %}">{% trans 'Emails' %}</a></li>
<li><a href="{% url 'franceconnect-home' %}">FranceConnect</a></li>
<li><a href="{% url 'matomo-home' %}">{% trans 'User tracking' %}</a></li>
<li><a href="{% url 'environment-home' %}">{% trans 'Services' %}</a></li>
<li><a href="{% url 'environment-variables' %}">{% trans 'Variables' %}</a></li>
</ul>

View File

@ -8,6 +8,7 @@ from .views import admin_required, login, login_local, logout, home, health_json
from .urls_utils import decorated_includes
from .environment.urls import urlpatterns as environment_urls
from .franceconnect.urls import urlpatterns as franceconnect_urls
from .matomo.urls import urlpatterns as matomo_urls
from .profile.urls import urlpatterns as profile_urls
from .theme.urls import urlpatterns as theme_urls
from .emails.urls import urlpatterns as emails_urls
@ -20,6 +21,8 @@ urlpatterns = [
include(profile_urls))),
url(r'^franceconnect/',
decorated_includes(admin_required, include(franceconnect_urls))),
url(r'^matomo/',
decorated_includes(admin_required, include(matomo_urls))),
url(r'^theme/', decorated_includes(admin_required,
include(theme_urls))),
url(r'^emails/', decorated_includes(admin_required, include(emails_urls))),

199
tests/test_matomo_views.py Normal file
View File

@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
import mock
import pytest
import re
from requests import Response
from webtest import TestApp
from django.conf import settings
from django.contrib.auth.models import User
from django.test import override_settings
from hobo.environment.models import Variable, Wcs, Combo, Fargo
from hobo.wsgi import application
pytestmark = pytest.mark.django_db
CONFIG = {'URL': 'https://matomo.test',
'TOKEN_AUTH': '1234',
'EMAIL_TEMPLATE': 'noreply+%s@entrouvert.test'}
GET_NO_SITE_FROM_URL = """<?xml version="1.0" encoding="utf-8" ?>
<result />
"""
ADD_SITE_SUCCESS = """<?xml version="1.0" encoding="utf-8" ?>
<result>42</result>
"""
DEL_UNKNOWN_USER = """<?xml version="1.0" encoding="utf-8" ?>
<result>
<error message="User 'hobo.dev.publik.love' doesn't exist." />
</result>
"""
MATOMO_SUCCESS = """<?xml version="1.0" encoding="utf-8" ?>
<result>
<success message="ok" />
</result>
"""
JAVASCRIPT_TAG_BAD_RESPONSE = """<?xml version="1.0" encoding="utf-8" ?>
<no_result_tag/>
"""
JAVASCRIPT_TAG = """<?xml version="1.0" encoding="utf-8" ?>
<result>&lt;!-- Matomo --&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
var _paq = window._paq || [];
/* tracker methods like &quot;setCustomDimension&quot; should be called before &quot;trackPageView&quot; */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u=&quot;//matomo-test.entrouvert.org/&quot;;
_paq.push(['setTrackerUrl', u+'piwik.php']);
_paq.push(['setSiteId', '7']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})();
&lt;/script&gt;
&lt;!-- End Matomo Code --&gt;
</result>
"""
@pytest.fixture
def admin_user():
try:
user = User.objects.get(username='admin')
except User.DoesNotExist:
user = User.objects.create_superuser('admin', email=None, password='admin')
return user
def login(app, username='admin', password='admin'):
login_page = app.get('/login/')
login_form = login_page.forms[0]
login_form['username'] = username
login_form['password'] = password
resp = login_form.submit()
assert resp.status_int == 302
return app
def test_unlogged_access():
# connect while not being logged in
app = TestApp(application)
resp = app.get('/matomo/', status=302)
assert resp.location.endswith('/login/?next=/matomo/')
def test_access(admin_user):
app = login(TestApp(application))
assert app.get('/matomo/', status=200)
def test_disable(admin_user):
app = login(TestApp(application))
resp1 = app.get('/matomo/disable', status=200)
resp2 = resp1.form.submit()
assert resp2.location.endswith('/matomo/')
def test_enable_manual(admin_user):
"""scenario where user manually paste a javascript code"""
app = login(TestApp(application))
# get matomo's validation page
resp1 = app.get('/matomo/enable-manual', status=200)
assert re.search('<textarea.* name="tracking_js"', resp1.body)
# validate and get matomo's home page
resp1.form['tracking_js'] = '...js_code_1...'
resp2 = resp1.form.submit().follow()
assert resp2.body.find('Manual configuration.')
assert re.search('<textarea.* name="tracking_js"', resp2.body)
assert resp2.body.find('...js_code_1...</textarea>') != -1
assert resp2.body.find('<button class="submit-button">Save</button>') != -1
# update JS code on matomo's home page
resp2.form['tracking_js'] = '...js_code_2...'
resp3 = resp2.form.submit().follow()
assert resp3.body.find('Manual configuration.') != -1
assert re.search('<textarea.* name="tracking_js"', resp3.body)
assert resp3.body.find('...js_code_2...</textarea>') != -1
assert resp3.body.find('<button class="submit-button">Save</button>') != -1
assert resp3.body.find('Good respect of user rights') != -1
def auto_conf_mocked_post(url, **kwargs):
contents = [GET_NO_SITE_FROM_URL, ADD_SITE_SUCCESS,
DEL_UNKNOWN_USER, MATOMO_SUCCESS,
JAVASCRIPT_TAG]
response = Response()
response._content = contents[auto_conf_mocked_post.cpt]
response.status_code = 200
auto_conf_mocked_post.cpt += 1
return response
def auto_conf_failure_mocked_post(url, **kwargs):
contents = [GET_NO_SITE_FROM_URL, ADD_SITE_SUCCESS,
DEL_UNKNOWN_USER, MATOMO_SUCCESS,
JAVASCRIPT_TAG_BAD_RESPONSE]
response = Response()
response._content = contents[auto_conf_mocked_post.cpt]
response.status_code = 200
auto_conf_mocked_post.cpt += 1
return response
def test_available_options(admin_user):
"""check available buttons (manual/automatic configurations)"""
with override_settings(MATOMO_SERVER=CONFIG):
app = login(TestApp(application))
resp = app.get('/matomo/', status=200)
assert str(resp).find('href="/matomo/enable-manual"') != -1
assert str(resp).find('href="/matomo/enable-auto"') != -1
# without configuration: no automatic configuration available
app = login(TestApp(application))
resp = app.get('/matomo/', status=200)
assert str(resp).find('href="/matomo/enable-manual"') != -1
assert str(resp).find('href="/matomo/enable-auto"') == -1
@mock.patch('requests.post', side_effect=auto_conf_mocked_post)
def test_enable_auto(mocked_post, admin_user):
"""succesfull automatic scenario"""
Combo.objects.create(base_url='https://combo.dev.publik.love',
template_name='portal-user')
Wcs.objects.create(base_url='https://wcs.dev.publik.love')
Fargo.objects.create(base_url='https://fargo.dev.publik.love')
auto_conf_mocked_post.cpt = 0
with override_settings(MATOMO_SERVER=CONFIG):
app = login(TestApp(application))
resp1 = app.get('/matomo/enable-auto', status=200)
resp2 = resp1.form.submit()
# call utils.py::auto_configure_matomo()
resp3 = resp2.follow()
print resp3.body
# expect the CNIL compliance message is displayed
assert resp3.body.find('Excellent respect of user rights') != -1
@mock.patch('requests.post', side_effect=auto_conf_failure_mocked_post)
def test_enable_auto_failure(mocked_post, admin_user):
"""error on automatic scenario"""
Combo.objects.create(base_url='https://combo.dev.publik.love',
template_name='portal-user')
Wcs.objects.create(base_url='https://wcs.dev.publik.love')
Fargo.objects.create(base_url='https://fargo.dev.publik.love')
auto_conf_mocked_post.cpt = 0
with override_settings(MATOMO_SERVER=CONFIG):
app = login(TestApp(application))
resp1 = app.get('/matomo/enable-auto', status=200)
resp2 = resp1.form.submit()
# call utils.py::auto_configure_matomo()
resp3 = resp2.follow()
# expect a Django error message is displayed
assert resp3.body.find('class="error">get_javascript_tag fails') != -1