allow saving cube display and naming them (fixes #12969)

This commit is contained in:
Benjamin Dauvergne 2016-08-26 14:50:28 +02:00
parent 25c8566c8b
commit 4ca7b774f6
30 changed files with 983 additions and 256 deletions

View File

@ -4,3 +4,4 @@ include tox.ini
recursive-include tests *.py
recursive-include bijoe/templates *.html
recursive-include bijoe/static *.js *.css
recursive-include bijoe/locale *.po *.mo

View File

@ -0,0 +1,139 @@
# bijoe - BI dashboard
# Copyright (C) 2016 Entr'ouvert
# This file is distributed under the same license as the bijoe package.
# Benjamin Dauvergne <bdauvergne@entrouvert.com>, 2016.
#
msgid ""
msgstr ""
"Project-Id-Version: bijoe 0.x\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-08-26 14:57+0000\n"
"PO-Revision-Date: 2016-08-26 16:50\n"
"Last-Translator: Benjamin Dauvergne <bdauvergne@entrouvert.com>\n"
"Language-Team: fr <fr@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: templates/bijoe/base.html:10 templates/bijoe/base.html.py:14
#: templates/bijoe/warehouse.html:5 views.py:79
msgid "Statistics"
msgstr "Statistiques"
#: templates/bijoe/create_visualization.html:6
msgid "Save visualization"
msgstr "Enregistrer une visualisation"
#: templates/bijoe/create_visualization.html:17 templates/bijoe/cube.html:59
msgid "Save"
msgstr "Enregistrer"
#: templates/bijoe/create_visualization.html:18
msgid "Cancel"
msgstr "Annuler"
#: templates/bijoe/cube.html:20 templates/bijoe/homepage.html:6
#: templates/bijoe/visualization.html:9 templates/bijoe/warehouse.html:10
msgid "Homepage"
msgstr "Accueil"
#: templates/bijoe/cube.html:57 templates/bijoe/visualization.html:17
msgid "URL for IFRAME"
msgstr "URL pour IFRAME"
#: templates/bijoe/cube.html:62
msgid "Please choose some measures and groupings."
msgstr "Veuillez choisir des mesures et des regroupements."
#: templates/bijoe/cube_table.html:18 templates/bijoe/cube_table.html.py:20
msgid "None"
msgstr "Aucun(e)"
#: templates/bijoe/homepage.html:11 templates/bijoe/visualizations.html:6
msgid "Visualizations"
msgstr "Visualisation"
#: templates/bijoe/homepage.html:20
msgid "Data sources"
msgstr "Sources de données"
#: templates/bijoe/rename_visualization.html:6
msgid "Rename visualization"
msgstr "Renommer une visualisation"
#: templates/bijoe/rename_visualization.html:10
#: templates/bijoe/visualization.html:16
msgid "Rename"
msgstr "Renommer"
#: templates/bijoe/visualizations.html:12 templates/bijoe/warehouse.html:18
msgid "Name"
msgstr "Nom"
#: templates/bijoe/warehouse.html:19
msgid "Fact count"
msgstr "Nombre de faits"
#: utils.py:30
#, python-brace-format
msgid "{0} and {1}"
msgstr "{0} et {1}"
#: views.py:44
msgid "You must be superuser"
msgstr "Vous devez être super-utilisateur"
#: visualization/forms.py:50
msgid "start"
msgstr "début"
#: visualization/forms.py:52
msgid "end"
msgstr "fin"
#: visualization/forms.py:83
msgid "Presentation"
msgstr "Représentation"
#: visualization/forms.py:84
msgid "table"
msgstr "tableau"
#: visualization/forms.py:85
msgid "chart"
msgstr "graphique"
#: visualization/forms.py:111
msgid "Group by"
msgstr "Regroupement(s)"
#: visualization/forms.py:117
msgid "Measures"
msgstr "Mesure(s)"
#: visualization/models.py:23
msgid "name"
msgstr "nom"
#: visualization/models.py:24
msgid "parameters"
msgstr "paramètres"
#: visualization/models.py:28
msgid "visualization"
msgstr "visualisation"
#: visualization/models.py:29
msgid "visualizations"
msgstr "visualisations"
#: visualization/utils.py:98
msgid "Not applicable"
msgstr "Non applicable"
#: visualization/utils.py:180
#, python-brace-format
msgid "{0} by {1}"
msgstr "{0} par {1}"

View File

@ -42,6 +42,7 @@ INSTALLED_APPS = (
'django_select2',
'gadjo',
'bijoe',
'bijoe.visualization',
)
MIDDLEWARE_CLASSES = (
@ -75,7 +76,7 @@ DATABASES = {
# Internationalization
# https://docs.djangoproject.com/en/1.7/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'UTC'

View File

@ -21,7 +21,7 @@
}
/* Table of forms */
.bijoe-form-label {
.main .bijoe-visualization-name, .main .bijoe-form-label {
text-align: left;
}
@ -30,10 +30,11 @@
/* make a form a column on the right if not on mobile */
@media screen and (min-width: 800px) {
form {
.cube-form {
float: right;
width: 20em;
padding-left: 2em;
clear: right;
}
}
@ -45,10 +46,10 @@ input[type=date] {
select {
width: 100%;
}
form p { /* reduce spacing between fields */
.cube-form p { /* reduce spacing between fields */
margin-bottom: 0.5ex;
}
form h3 { /* reduce spacing between field groups headers */
.cube-form h3 { /* reduce spacing between field groups headers */
margin-top: 0.5ex;
margin-bottom: 0.5ex;
}

View File

@ -0,0 +1,22 @@
{% extends "bijoe/base.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "Save visualization" %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
{% csrf_token %}
{{ form.as_p }}
</div>
{% block buttons %}
<div class="buttons">
<button>{% block main-button-name %}{% trans "Save" %}{% endblock %}</button>
<a class="cancel" href="{% url 'homepage' %}">{% trans 'Cancel' %}</a>
</div>
{% endblock %}
</form>
{% endblock %}

View File

@ -17,14 +17,14 @@
{% url 'homepage' as homepage_url %}
{% url 'warehouse' warehouse=warehouse.name as warehouse_url %}
<a href="{{ homepage_url }}">{% trans "Accueil" %}</a>
<a href="{{ homepage_url }}">{% trans "Homepage" %}</a>
<a href="{{ warehouse_url }}">{{ warehouse.label }}</a>
<a>{{ cube.label }}</a>
{% endblock %}
{% block content %}
<form method="post">
<form class="cube-form" method="post">
{% csrf_token %}
<input type="submit" value="ODS" name="ods" id="ods"/>
<h3>Représentation</h3>
@ -40,20 +40,26 @@
{% endif %}
{% endfor %}
<input type="submit">
</form>
<div id="data">
{% if data %}
{% if representation == 'table' %}
{% if visualization %}
{% if visualization.representation == 'table' %}
{% include "bijoe/cube_table.html" %}
{% else %}
{% include "bijoe/cube_chart.html" %}
{% endif %}
{% if data_title %}
<a href="?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}" title="{{ data_title }}" class="bijoe-button">URL</a>
<a href="iframe/?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}" title="{{ data_title }}" class="bijoe-button">IFRAME</a>
{% endif %}
{% block actions %}
<a href="?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}" title="{{
visualization.title }}" class="bijoe-button">URL</a>
<a href="iframe/?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}"
title="{{ visualization.title }}" class="bijoe-button">{% trans "URL for IFRAME" %}</a>
<a rel="popup" href="{% url "create-visualization" warehouse=warehouse.name cube=cube.name %}?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}"
title="{{ visualization.title }}" class="bijoe-button">{% trans "Save" %}</a>
{% endblock %}
{% else %}
<div class="big-msg-info">Veuillez choisir des mesures et des regroupements</div>
<div class="big-msg-info">{% trans "Please choose some measures and groupings." %}</div>
{% endif %}
</div>
{% endblock %}

View File

@ -8,7 +8,7 @@
}
</script>
{% for measure in measures %}
{% for measure in visualization.measures %}
<a href="#" target="none" class="bijoe-button bijoe-png-button">PNG</a>
<canvas id="canvas-{{ measure.name }}"></canvas>
<script>

View File

@ -14,16 +14,15 @@
</head>
<body>
<div id="data">
{% if data %}
{% if representation == 'table' %}
{% include "bijoe/cube_table.html" %}
{% else %}
{% include "bijoe/cube_chart.html" %}
{% if visualization %}
{% if visualization.representation == 'table' %}
{% include "bijoe/cube_table.html" %}
{% else %}
{% include "bijoe/cube_chart.html" %}
{% endif %}
<a href="?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}" title="{{
visualization.title }}" class="bijoe-button">URL</a>
{% endif %}
{% if data_title %}
<a href="?{% firstof view.request.POST.urlencode view.request.GET.urlencode %}" title="{{ data_title }}" class="bijoe-button">URL</a>
{% endif %}
{% endif %}
</div>
</body>
</html>

View File

@ -2,22 +2,22 @@
<table class="bijoe-table main">
<thead>
{% for dimension in drilldown %}
{% for dimension in visualization.drilldown %}
<th class="bijoe-drilldown"><span>{{ dimension.label.capitalize }}</span></th>
{% endfor %}
{% for measure in measures %}
{% for measure in visualization.measures %}
<th class="bijoe-measure"><span>{{ measure.label.capitalize }}</span></th>
{% endfor %}
</thead>
<tbody>
{% for row in grouped_data %}
{% for row in visualization.grouped_data %}
<tr>
{% for cell, count in row %}
<td {% if count > 1 %}rowspan="{{ count }}"{% endif %} {% if count > 0 %}class="bijoe-drilldown"{% elif count == 0 %}class="bijoe-measure"{% endif %}>
{% if count == 0 %}
{% if cell.value == None %}{% trans "Aucun(e)" %}{% else %}{{ cell.value }}{% endif %}
{% if cell.value == None %}{% trans "None" %}{% else %}{{ cell.value }}{% endif %}
{% else %}
{% if cell == None %}{% trans "Aucun(e)" %}{% else %}{{ cell }}{% endif %}
{% if cell == None %}{% trans "None" %}{% else %}{{ cell }}{% endif %}
{% endif %}
</td>
{% endfor %}

View File

@ -7,10 +7,22 @@
{% endblock %}
{% block content %}
<ul class="bijoe-warehouses">
{% for warehouse in warehouses %}
{% url 'warehouse' warehouse=warehouse.name as warehouse_url %}
<li><a href="{{ warehouse_url }}">{{ warehouse.label }}</a></li>
{% endfor %}
</ul>
{% if visualizations %}
<h2>{% trans "Visualizations" %}</h2>
<ul class="bijoe-visualizations">
{% for visualization in visualizations %}
{% url 'visualization' pk=visualization.pk as visualization_url %}
<li><a href="{{ visualization_url }}">{{ visualization.name }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if warehouses %}
<h2>{% trans "Data sources" %}</h2>
<ul class="bijoe-warehouses">
{% for warehouse in warehouses %}
{% url 'warehouse' warehouse=warehouse.name as warehouse_url %}
<li><a href="{{ warehouse_url }}">{{ warehouse.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "bijoe/create_visualization.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "Rename visualization" %}</h2>
{% endblock %}
{% block main-button-name %}
{% trans "Rename" %}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "bijoe/cube.html" %}
{% load i18n %}
{% block breadcrumb %}
{% url 'homepage' as homepage_url %}
{% url 'warehouse' warehouse=warehouse.name as warehouse_url %}
{% url 'cube' warehouse=warehouse.name cube=cube.name as cube_url %}
<a href="{{ homepage_url }}">{% trans "Homepage" %}</a>
<a href="{{ warehouse_url }}">{{ warehouse.label }}</a>
<a href="{{ cube_url }}">{{ cube.label }}</a>
<a>{{ object.name }}</a>
{% endblock %}
{% block actions %}
<a rel="popup" class="bijoe-button" href="{% url "rename-visualization" pk=object.pk %}">{% trans "Rename" %}</a>
<a href="{{ iframe_url }}" class="bijoe-button">{% trans "URL for IFRAME" %}</a>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "bijoe/base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a>{% trans "Visualizations" %}</a>
{% endblock %}
{% block content %}
<table class="main">
<thead>
<th class="bijoe-visualization-name">{% trans "Name" %}</th>
</thead>
<tbody>
{% for visualization in object_list %}
<tr>
<td class="bijoe-visualization-name">
<a href="{% url "visualization" pk=visualization.pk %}">{{ visualization.name }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% load i18n %}
{% block page-title %}
{% trans "Statistiques" %} - {{ warehouse.label }}
{% trans "Statistics" %} - {{ warehouse.label }}
{% endblock %}
{% block breadcrumb %}
@ -15,8 +15,8 @@
<table class="bijoe-table bijoe-form-table main">
<thead>
<tr>
<th class="bijoe-form-label">{% trans "Label" %}</th>
<th class="bijoe-form-count">{% trans "Nombre de demandes" %}</th></tr>
<th class="bijoe-form-label">{% trans "Name" %}</th>
<th class="bijoe-form-count">{% trans "Fact count" %}</th></tr>
</thead>
<tbody>
{% for cube in cubes %}

View File

@ -1,3 +1,19 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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 template
register = template.Library()

View File

@ -27,9 +27,7 @@ urlpatterns = patterns(
url(r'^accounts/login/$', views.login, name='login'),
url(r'^accounts/logout/$', views.logout, name='logout'),
url(r'^manage/menu.json$', views.menu_json, name='menu-json'),
url(r'^(?P<warehouse>[^/]*)/$', views.warehouse, name='warehouse'),
url(r'^(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/$', views.cube, name='cube'),
url(r'^(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/iframe/$', views.cube_iframe, name='cube-iframe'),
url(r'^visualization/', include('bijoe.visualization.urls')),
)
if 'mellon' in settings.INSTALLED_APPS:

View File

@ -1,3 +1,19 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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/>.
import os
import glob
import json
@ -27,4 +43,4 @@ def human_join(l):
return l[0]
if len(l) > 2:
l = u', '.join(l[:-1]), l[-1]
return _(u'{0} et {1}').format(l[0], l[1])
return _(u'{0} and {1}').format(l[0], l[1])

View File

@ -15,41 +15,40 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import datetime
import decimal
import urllib
from collections import OrderedDict
import hashlib
from django.shortcuts import resolve_url
from django.core.urlresolvers import reverse
from django.views.generic import TemplateView, FormView, View
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.views.generic import TemplateView, View
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.translation import ugettext as _
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import views as auth_views
from django.contrib.auth.views import redirect_to_login
from django.core.cache import cache
from django.views.decorators.clickjacking import xframe_options_exempt
from django.core.exceptions import PermissionDenied
try:
from mellon.utils import get_idps
except ImportError:
get_idps = lambda: []
from .utils import get_warehouses, human_join
from .utils import get_warehouses
from .engine import Engine
from .forms import CubeForm
from .ods import Workbook
from .visualization.models import Visualization
class AuthorizationMixin(object):
def authorize(self, request):
if request.user.is_authenticated():
if not request.user.is_superuser:
raise PermissionDenied(_('You must be superuser'))
return True
else:
return False
def dispatch(self, request, *args, **kwargs):
if self.request.user.is_authenticated():
if self.request.user.is_superuser:
return super(AuthorizationMixin, self).dispatch(request, *args, **kwargs)
return render(request, 'bijoe/unauthorized.html', status=403)
if self.authorize(request):
return super(AuthorizationMixin, self).dispatch(request, *args, **kwargs)
else:
return redirect_to_login(request.build_absolute_uri())
@ -59,203 +58,26 @@ class HomepageView(AuthorizationMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super(HomepageView, self).get_context_data(**kwargs)
ctx['visualizations'] = Visualization.objects.all()
ctx['warehouses'] = sorted((Engine(w) for w in get_warehouses(self.request)),
key=lambda w: w.label)
return ctx
def get(self, request, *args, **kwargs):
ctx = self.get_context_data()['warehouses']
if len(ctx) == 1:
return HttpResponseRedirect(reverse('warehouse', kwargs={'warehouse': ctx[0].name}),
ctx = self.get_context_data()
if not len(ctx['visualizations']) and len(ctx['warehouses']) == 1:
return HttpResponseRedirect(reverse('warehouse',
kwargs={'warehouse': ctx['warehouses'][0].name}),
status=307)
return super(HomepageView, self).get(request, *args, **kwargs)
class WarehouseView(AuthorizationMixin, TemplateView):
template_name = 'bijoe/warehouse.html'
def get_context_data(self, **kwargs):
ctx = super(WarehouseView, self).get_context_data(**kwargs)
try:
warehouse = [warehouse for warehouse in get_warehouses(self.request)
if warehouse.name == self.kwargs['warehouse']][0]
except IndexError:
raise Http404
ctx['warehouse'] = Engine(warehouse)
ctx['cubes'] = sorted(ctx['warehouse'].cubes, key=lambda cube: cube.label.strip().lower())
return ctx
class CubeMixin(object):
def get_key(self, cleaned_data, stringify=True):
measures = cleaned_data.get('measures', [])
drilldown = cleaned_data.get('drilldown', [])
l = []
for kw, values in cleaned_data.iteritems():
if values and kw.startswith('filter__'):
l.append('$'.join([kw] + sorted(map(str, values))))
l += drilldown + sorted(measures)
return hashlib.md5('$'.join(l) + str(stringify)).hexdigest()
def get_data(self, cleaned_data, stringify=True):
key = self.get_key(cleaned_data, stringify=stringify)
data = cache.get(key)
if data is not None:
return data
filters = []
for kw, values in cleaned_data.iteritems():
if values and kw.startswith('filter__'):
dimension_name = kw[8:]
filters.append((dimension_name, values))
measures = cleaned_data.get('measures', [])
drilldown = cleaned_data.get('drilldown', [])
data = []
for row in self.cube.query(filters, drilldown, measures):
for cell in row:
value = cell['value']
if stringify and cell['type'] == 'percent':
# FIXME find how to format decimal number using locale with Django
try:
value = ('%05.2f' % float(value)).replace('.', ',') + u' %'
except:
value = _('Non applicable')
elif value is not None and cell['type'] == 'duration':
if stringify:
s = ''
if value.days:
s += '%d jour(s)' % value.days
if value.seconds / 3600:
s += ' %d heure(s)' % (value.seconds / 3600)
if not s:
s = 'moins d\'1 heure'
value = s
else:
# convert duration for float number of days
value = value.days + value.seconds / 86400.
cell['value'] = value
data.append(row)
cache.set(key, data)
return data
def get_grouped_data(self, cleaned_data, stringify=True):
data = self.get_data(cleaned_data, stringify=stringify)
dims = len(cleaned_data.get('drilldown', []))
if not dims:
return ([(v, 0) for v in row] for row in data)
grouped = OrderedDict()
for row in data:
d = grouped
for cell in row[:dims - 1]:
dim = cell['value']
if dim not in d:
d[dim] = OrderedDict(), 1
else:
d[dim] = d[dim][0], d[dim][1] + 1
d, count = d[dim]
d[row[dims - 1]['value']] = row[dims:], 0
def helper(d, prefix=[]):
for key in d:
row, count = d[key]
if count == 0:
yield prefix + [(key, 1)] + [(v, 0) for v in row]
else:
for r in helper(row, prefix + [(key, count)]):
yield r
prefix = []
return helper(grouped)
class CubeView(AuthorizationMixin, CubeMixin, FormView):
template_name = 'bijoe/cube.html'
form_class = CubeForm
def dispatch(self, request, *args, **kwargs):
try:
self.warehouse = Engine([warehouse for warehouse in get_warehouses(self.request)
if warehouse.name == self.kwargs['warehouse']][0])
except IndexError:
raise Http404
try:
self.cube = self.warehouse[self.kwargs['cube']]
except KeyError:
raise Http404
return super(CubeView, self).dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super(CubeView, self).get_form_kwargs()
kwargs['cube'] = self.cube
if self.request.method == 'GET' and self.request.GET:
kwargs.update({
'data': self.request.GET,
})
return kwargs
def form_valid(self, form):
if 'ods' in self.request.POST:
return self.ods(form)
else:
return self.form_invalid(form)
def ods(self, form):
workbook = Workbook()
sheet = workbook.add_sheet(self.cube.label)
ctx = self.get_context_data(form=form)
for j, m in enumerate(ctx['drilldown'] + ctx['measures']):
sheet.write(0, j, m.label)
for i, row in enumerate(ctx['data']):
for j, cell in enumerate(row):
sheet.write(i + 1, j, unicode(cell['value']))
response = HttpResponse(content_type='application/vnd.oasis.opendocument.spreadsheet')
response['Content-Disposition'] = 'attachment; filename=%s.ods' % self.cube.name
workbook.save(response)
return response
def get_context_data(self, **kwargs):
ctx = super(CubeView, self).get_context_data(**kwargs)
ctx['warehouse'] = self.warehouse
ctx['cube'] = self.cube
form = ctx['form']
ctx['data'] = False
if form.is_valid():
ctx['representation'] = form.cleaned_data['representation']
ctx['data'] = self.get_data(form.cleaned_data)
ctx['grouped_data'] = list(self.get_grouped_data(form.cleaned_data))
ctx['measures'] = measures = [self.cube.measures[measure] for measure in
form.cleaned_data['measures']]
ctx['measures_json'] = json.dumps([measure.to_json() for measure in measures])
ctx['drilldown'] = dimensions = [self.cube.dimensions[dimension] for dimension in
form.cleaned_data['drilldown']]
ctx['drilldown_json'] = json.dumps([dim.to_json() for dim in dimensions])
if dimensions:
ctx['data_title'] = _('{0} par {1}').format(
human_join([measure.label for measure in measures]),
human_join([dimension.label for dimension in dimensions]))
else:
ctx['data_title'] = human_join([measure.label for measure in measures])
json_data = []
for row in self.get_data(form.cleaned_data, stringify=False):
coords = []
for dimension, cell in zip(ctx['drilldown'], row):
coords.append(cell)
measures = []
for measure, cell in zip(ctx['measures'], row[len(ctx['drilldown']):]):
if isinstance(cell['value'], decimal.Decimal):
cell['value'] = float(cell['value'])
measures.append(cell)
json_data.append({'coords': coords, 'measures': measures})
ctx['json'] = json.dumps(json_data, indent=2)
return ctx
class MenuJSONView(AuthorizationMixin, View):
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type='application/json')
menu = [
{
'label': _('Statistiques'),
'label': _('Statistics'),
'slug': 'statistics',
'url': request.build_absolute_uri(reverse('homepage')),
}
@ -269,14 +91,8 @@ class MenuJSONView(AuthorizationMixin, View):
response.write(json_str)
return response
class CubeIframeView(CubeView):
template_name = 'bijoe/cube_raw.html'
homepage = HomepageView.as_view()
warehouse = WarehouseView.as_view()
cube = CubeView.as_view()
cube_iframe = xframe_options_exempt(CubeIframeView.as_view())
menu_json = MenuJSONView.as_view()

View File

View File

@ -0,0 +1,25 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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.contrib import admin
from . import models
class VisualizationAdmin(admin.ModelAdmin):
list_display = ['name']
admin.site.register(models.Visualization, VisualizationAdmin)

View File

@ -15,11 +15,9 @@
# 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/>.
import datetime
from django import forms
from django.utils.translation import ugettext as _
from django.forms import ModelForm, TextInput
try:
from django_select2.forms import Select2MultipleWidget
@ -32,14 +30,26 @@ except ImportError:
return Select2MultipleWidget(select2_options={'width': '100%'})
from . import models
class VisualizationForm(ModelForm):
class Meta:
model = models.Visualization
exclude = ('parameters',)
widgets = {
'name': TextInput,
}
class DateRangeWidget(forms.MultiWidget):
def __init__(self, attrs=None):
attrs = attrs.copy() if attrs else {}
attrs.update({'type': 'date', 'autocomplete': 'off'})
attrs1 = attrs.copy()
attrs1['placeholder'] = _(u'début')
attrs1['placeholder'] = _(u'start')
attrs2 = attrs.copy()
attrs2['placeholder'] = _(u'fin')
attrs2['placeholder'] = _(u'end')
widgets = (
forms.DateInput(attrs=attrs1),
forms.DateInput(attrs=attrs2),
@ -70,9 +80,9 @@ class DateRangeField(forms.MultiValueField):
class CubeForm(forms.Form):
representation = forms.ChoiceField(
label=_(u'Représentation'),
choices=[('table', _('tableau')),
('graphical', _('graphique')),],
label=_(u'Presentation'),
choices=[('table', _('table')),
('graphical', _('chart'))],
widget=forms.RadioSelect())
def __init__(self, *args, **kwargs):

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='Visualization',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.TextField(verbose_name='name')),
('parameters', jsonfield.fields.JSONField(default=dict, verbose_name='parameters')),
],
options={
'ordering': ('name', 'id'),
'verbose_name': 'visualization',
'verbose_name_plural': 'visualizations',
},
),
]

View File

@ -0,0 +1,32 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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.db import models
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
class Visualization(models.Model):
name = models.TextField(verbose_name=_('name'))
parameters = JSONField(verbose_name=_('parameters'))
class Meta:
ordering = ('name', 'id')
verbose_name = _('visualization')
verbose_name_plural = _('visualizations')
def __unicode__(self):
return self.name

View File

@ -0,0 +1,95 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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/>.
import datetime
import base64
import hmac
import hashlib
import urllib
import random
import urlparse
'''Simple signature scheme for query strings'''
# from http://repos.entrouvert.org/portail-citoyen.git/tree/portail_citoyen/apps/data_source_plugin/signature.py
def sign_url(url, key, algo='sha256', timestamp=None, nonce=None):
parsed = urlparse.urlparse(url)
new_query = sign_query(parsed.query, key, algo, timestamp, nonce)
return urlparse.urlunparse(parsed[:4] + (new_query,) + parsed[5:])
def sign_query(query, key, algo='sha256', timestamp=None, nonce=None):
if timestamp is None:
timestamp = datetime.datetime.utcnow()
timestamp = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
if nonce is None:
nonce = hex(random.getrandbits(128))[2:]
new_query = query
if new_query:
new_query += '&'
new_query += urllib.urlencode((
('algo', algo),
('timestamp', timestamp),
('nonce', nonce)))
signature = base64.b64encode(sign_string(new_query, key, algo=algo))
new_query += '&signature=' + urllib.quote(signature)
return new_query
def sign_string(s, key, algo='sha256', timedelta=30):
digestmod = getattr(hashlib, algo)
if isinstance(key, unicode):
key = key.encode('utf-8')
hash = hmac.HMAC(key, digestmod=digestmod, msg=s)
return hash.digest()
def check_url(url, key, known_nonce=None, timedelta=30):
parsed = urlparse.urlparse(url, 'https')
return check_query(parsed.query, key)
def check_query(query, key, known_nonce=None, timedelta=30):
parsed = urlparse.parse_qs(query)
signature = base64.b64decode(parsed['signature'][0])
algo = parsed['algo'][0]
timestamp = parsed['timestamp'][0]
timestamp = datetime.datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')
nonce = parsed['nonce']
unsigned_query = query.split('&signature=')[0]
if known_nonce is not None and known_nonce(nonce):
return False
if abs(datetime.datetime.utcnow() - timestamp) > datetime.timedelta(seconds=timedelta):
return False
return check_string(unsigned_query, signature, key, algo=algo)
def check_string(s, signature, key, algo='sha256'):
# constant time compare
signature2 = sign_string(s, key, algo=algo)
if len(signature2) != len(signature):
return False
res = 0
for a, b in zip(signature, signature2):
res |= ord(a) ^ ord(b)
return res == 0
if __name__ == '__main__':
key = '12345'
signed_query = sign_query('NameId=_12345&orig=montpellier', key)
assert check_query(signed_query, key, timedelta=0) is False
assert check_query(signed_query, key) is True

View File

@ -0,0 +1,37 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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 patterns, url
from . import views
urlpatterns = patterns(
'',
url(r'^$',
views.visualizations, name='visualizations'),
url(r'^json/$',
views.visualizations_json, name='visualizations-json'),
url(r'^warehouse/(?P<warehouse>[^/]*)/$', views.warehouse, name='warehouse'),
url(r'^warehouse/(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/$', views.cube, name='cube'),
url(r'^warehouse/(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/iframe/$', views.cube_iframe,
name='cube-iframe'),
url(r'warehouse/(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/save/$',
views.create_visualization, name='create-visualization'),
url(r'(?P<pk>\d+)/$', views.visualization, name='visualization'),
url(r'(?P<pk>\d+)/iframe/$', views.visualization_iframe, name='visualization-iframe'),
url(r'(?P<pk>\d+)/rename/$', views.rename_visualization, name='rename-visualization'),
url(r'(?P<pk>\d+)/delete/$', views.visualization, name='delete-visualization'),
)

View File

@ -0,0 +1,184 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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/>.
import hashlib
import datetime
import decimal
from collections import OrderedDict
from django.utils.translation import ugettext_lazy as _
from django.core.cache import cache
from ..utils import get_warehouses, human_join
from ..engine import Engine
from .ods import Workbook
class Visualization(object):
def __init__(self, cube, representation, measures, drilldown=None, filters=None):
self.cube = cube
self.representation = representation
self.measures = measures
self.drilldown = drilldown or []
self.filters = filters or {}
def to_json(self):
return {
'warehouse': self.cube.engine.warehouse.name,
'cube': self.cube.name,
'representation': self.representation,
'measures': [measure.name for measure in self.measures],
'drilldown': [drilldown.name for drilldown in self.drilldown],
'filters': self.filters,
}
@classmethod
def from_json(cls, d):
for warehouse in get_warehouses():
if d['warehouse'] == warehouse.name:
break
else:
raise LookupError('warehouse %s not found' % d['warehouse'])
cube = Engine(warehouse)[d['cube']]
representation = d['representation']
measures = [cube.measures[name] for name in d['measures']]
drilldown = [cube.dimensions[name] for name in d.get('drilldown', [])]
filters = d.get('filters', {})
return cls(cube, representation, measures, drilldown=drilldown, filters=filters)
@classmethod
def from_form(cls, cube, form):
cleaned_data = form.cleaned_data
filters = {}
for kw, values in cleaned_data.iteritems():
if values and kw.startswith('filter__'):
dimension_name = kw[8:]
filters[dimension_name] = values
measures = cleaned_data.get('measures', [])
measures = [cube.measures[name] for name in measures]
drilldown = cleaned_data.get('drilldown', [])
drilldown = [cube.dimensions[name] for name in drilldown]
return cls(cube, cleaned_data['representation'], measures, drilldown=drilldown,
filters=filters)
@property
def key(self):
l = []
for kw, values in self.filters.iteritems():
if values:
l.append('$'.join([kw] + sorted(map(unicode, values))))
l += [dim.name for dim in self.drilldown]
l += [measure.name for measure in self.measures]
key = '$'.join(v.encode('utf8') for v in l)
return hashlib.md5(key).hexdigest()
def stringified(self):
data = self.cached()
for row in data:
for cell in row:
value = cell['value']
if cell['type'] == 'percent':
try:
value = ('%05.2f' % float(value)).replace('.', ',') + u' %'
except:
value = _('Not applicable')
elif value is not None and cell['type'] == 'duration':
s = ''
if value.days:
s += '%d jour(s)' % value.days
if value.seconds / 3600:
s += ' %d heure(s)' % (value.seconds / 3600)
if not s:
s = 'moins d\'1 heure'
value = s
cell['value'] = value
return data
def data(self):
return self.cube.query(self.filters.iteritems(),
[dim.name for dim in self.drilldown],
[measure.name for measure in self.measures])
def cached(self):
key = self.key
data = cache.get(key)
if data is None:
data = list(self.data())
cache.set(key, data)
return data
def grouped_data(self):
data = self.stringified()
dims = len(self.drilldown)
if not dims:
return ([(v, 0) for v in row] for row in data)
grouped = OrderedDict()
for row in data:
d = grouped
for cell in row[:dims - 1]:
dim = cell['value']
if dim not in d:
d[dim] = OrderedDict(), 1
else:
d[dim] = d[dim][0], d[dim][1] + 1
d, count = d[dim]
d[row[dims - 1]['value']] = row[dims:], 0
def helper(d, prefix=[]):
for key in d:
row, count = d[key]
if count == 0:
yield prefix + [(key, 1)] + [(v, 0) for v in row]
else:
for r in helper(row, prefix + [(key, count)]):
yield r
prefix = []
return helper(grouped)
def json_data(self):
json_data = []
for row in self.data():
coords = []
for cell in row[:len(self.drilldown)]:
coords.append(cell)
measures = []
for cell in row[len(self.drilldown):]:
if isinstance(cell['value'], decimal.Decimal):
cell['value'] = float(cell['value'])
if isinstance(cell['value'], datetime.timedelta):
cell['value'] = cell['value'].days + cell['value'].seconds / 86400.
measures.append(cell)
json_data.append({'coords': coords, 'measures': measures})
return json_data
def ods(self):
workbook = Workbook()
sheet = workbook.add_sheet(self.cube.label)
for j, m in enumerate(self.drilldown + self.measures):
sheet.write(0, j, m.label)
for i, row in enumerate(self.stringified()):
for j, cell in enumerate(row):
sheet.write(i + 1, j, unicode(cell['value']))
return workbook
def title(self):
if self.drilldown:
return _('{0} by {1}').format(human_join([measure.label for measure in self.measures]),
human_join([dimension.label for dimension in
self.drilldown]))
else:
return human_join([measure.label for measure in self.measures])

View File

@ -0,0 +1,237 @@
# bijoe - BI dashboard
# Copyright (C) 2015 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/>.
import hashlib
import json
from django.conf import settings
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from django.views.generic.list import MultipleObjectMixin
from django.views.generic import DetailView, ListView, View, TemplateView
from django.shortcuts import redirect
from django.core.urlresolvers import reverse
from django.http import HttpResponse, Http404
from django.core.exceptions import PermissionDenied
from django.views.decorators.clickjacking import xframe_options_exempt
from ..utils import get_warehouses
from ..engine import Engine
from . import models, forms, signature
from .utils import Visualization
from .. import views
class WarehouseView(views.AuthorizationMixin, TemplateView):
template_name = 'bijoe/warehouse.html'
def get_context_data(self, **kwargs):
ctx = super(WarehouseView, self).get_context_data(**kwargs)
try:
warehouse = [warehouse for warehouse in get_warehouses(self.request)
if warehouse.name == self.kwargs['warehouse']][0]
except IndexError:
raise Http404
ctx['warehouse'] = Engine(warehouse)
ctx['cubes'] = sorted(ctx['warehouse'].cubes, key=lambda cube: cube.label.strip().lower())
return ctx
class CubeDisplayMixin(object):
def get_context_data(self, **kwargs):
ctx = super(CubeDisplayMixin, self).get_context_data(**kwargs)
ctx['warehouse'] = self.warehouse
ctx['cube'] = self.cube
if self.visualization:
ctx['visualization'] = self.visualization
ctx['measures_json'] = json.dumps(
[measure.to_json() for measure in self.visualization.measures])
ctx['drilldown_json'] = json.dumps(
[dim.to_json() for dim in self.visualization.drilldown])
ctx['json'] = json.dumps(self.visualization.json_data(), indent=2)
return ctx
class CubeMixin(object):
def visualization(self, request, cube):
self.form = forms.CubeForm(cube=self.cube, data=request.GET or request.POST)
if self.form.is_valid():
return Visualization.from_form(self.cube, self.form)
def dispatch(self, request, *args, **kwargs):
try:
self.warehouse = Engine([warehouse for warehouse in get_warehouses(self.request)
if warehouse.name == self.kwargs['warehouse']][0])
except IndexError:
raise Http404
try:
self.cube = self.warehouse[self.kwargs['cube']]
except KeyError:
raise Http404
self.visualization = self.visualization(request, cube)
return super(CubeMixin, self).dispatch(request, *args, **kwargs)
class ODSMixin(object):
def ods(self, visualization):
response = HttpResponse(content_type='application/vnd.oasis.opendocument.spreadsheet')
response['Content-Disposition'] = 'attachment; filename=%s.ods' % self.cube.name
workbook = visualization.ods()
workbook.save(response)
return response
class CubeView(CubeDisplayMixin, ODSMixin, CubeMixin, TemplateView):
template_name = 'bijoe/cube.html'
def post(self, request, *args, **kwargs):
if 'ods' in self.request.POST:
return self.ods(self.visualization)
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super(CubeView, self).get_context_data(**kwargs)
ctx['form'] = self.form
return ctx
class CreateVisualizationView(views.AuthorizationMixin, CubeMixin, CreateView):
model = models.Visualization
form_class = forms.VisualizationForm
template_name = 'bijoe/create_visualization.html'
success_url = '/visualization/%(id)s/'
def get(self, request, *args, **kwargs):
if not self.visualization:
return redirect('homepage')
return super(CreateVisualizationView, self).get(request, *args, **kwargs)
def form_valid(self, form):
if not self.visualization:
return redirect('homepage')
form.instance.parameters = self.visualization.to_json()
return super(CreateVisualizationView, self).form_valid(form)
class VisualizationView(views.AuthorizationMixin, ODSMixin, CubeDisplayMixin,
DetailView):
model = models.Visualization
template_name = 'bijoe/visualization.html'
def get_object(self):
named_visualization = super(VisualizationView, self).get_object()
self.visualization = Visualization.from_json(named_visualization.parameters)
self.cube = self.visualization.cube
self.warehouse = self.cube.engine
return named_visualization
def post(self, request, *args, **kwargs):
named_visualization = self.get_object()
if 'ods' in self.request.POST:
return self.ods(self.visualization)
form = forms.CubeForm(cube=self.cube, data=request.POST)
if form.is_valid():
self.visualization = Visualization.from_form(self.cube, form)
named_visualization.parameters = self.visualization.to_json()
named_visualization.save()
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super(VisualizationView, self).get_context_data(**kwargs)
ctx['form'] = forms.CubeForm(cube=self.cube, initial={
'representation': self.visualization.representation,
'measures': [m.name for m in self.visualization.measures],
'drilldown': [d.name for d in self.visualization.drilldown],
})
path = reverse('visualization-iframe', args=self.args, kwargs=self.kwargs)
signature = hashlib.sha1(path + settings.SECRET_KEY).hexdigest()
path += '?signature=' + signature
ctx['iframe_url'] = path
return ctx
class SignatureAuthorizationMixin(views.AuthorizationMixin):
def authorize(self, request):
if request.user.is_authenticated() and request.user.is_superuser:
return True
if 'signature' in request.GET:
signature = hashlib.sha1(request.path + settings.SECRET_KEY).hexdigest()
if request.GET['signature'] == signature:
return True
return False
class VisualizationIframeView(SignatureAuthorizationMixin, VisualizationView):
template_name = 'bijoe/cube_raw.html'
class DeleteVisualizationView(views.AuthorizationMixin, DeleteView):
model = models.Visualization
template_name = 'bijoe/delete_visualization.html'
success_url = 'homepage'
class VisualizationsView(views.AuthorizationMixin, ListView):
template_name = 'bijoe/visualizations.html'
model = models.Visualization
class RenameVisualization(views.AuthorizationMixin, UpdateView):
model = models.Visualization
form_class = forms.VisualizationForm
template_name = 'bijoe/rename_visualization.html'
success_url = '/visualization/%(id)s/'
class VisualizationsJSONView(MultipleObjectMixin, View):
model = models.Visualization
def get(self, request, *args, **kwargs):
known_services = getattr(settings, 'KNOWN_SERVICES', [])
if known_services:
key = None
for service in known_services:
if service['orig'] == request.GET.get('orig', ''):
key = service['secret']
if key is None or not signature.check_query(request.META['QUERY_STRING'], key):
raise PermissionDenied('signature is missing or wrong')
data = []
for visualization in self.get_queryset():
path = reverse('visualization-iframe', kwargs={'pk': visualization.pk})
sig = hashlib.sha1(path + settings.SECRET_KEY).hexdigest()
path += '?signature=' + sig
data.append({
'name': visualization.name,
'path': request.build_absolute_uri(path),
})
response = HttpResponse(content_type='application/json')
response.write(json.dumps(data))
return response
class CubeIframeView(CubeView):
template_name = 'bijoe/cube_raw.html'
warehouse = WarehouseView.as_view()
cube = CubeView.as_view()
cube_iframe = xframe_options_exempt(CubeIframeView.as_view())
visualizations = VisualizationsView.as_view()
visualizations_json = VisualizationsJSONView.as_view()
create_visualization = CreateVisualizationView.as_view()
delete_visualization = DeleteVisualizationView.as_view()
rename_visualization = RenameVisualization.as_view()
visualization = VisualizationView.as_view()
visualization_iframe = VisualizationIframeView.as_view()

View File

@ -55,6 +55,6 @@ setup(name="bijoe",
packages=find_packages(),
include_package_data=True,
install_requires=['requests', 'django', 'psycopg2', 'isodate', 'Django-Select2<5',
'XStatic-ChartNew.js', 'gadjo'],
'XStatic-ChartNew.js', 'gadjo', 'django-jsonfield<1.0.0'],
scripts=['bijoe-ctl'],
cmdclass={'sdist': eo_sdist})