518 lines
20 KiB
Python
518 lines
20 KiB
Python
# 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.contrib import messages
|
|
from django.core import signing
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.core.signing import BadSignature
|
|
from django.http import Http404, HttpResponse, JsonResponse
|
|
from django.shortcuts import redirect
|
|
from django.urls import reverse, reverse_lazy
|
|
from django.utils.encoding import force_bytes, force_text
|
|
from django.utils.text import slugify
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import ugettext as _
|
|
from django.utils.translation import ungettext
|
|
from django.views.decorators.clickjacking import xframe_options_exempt
|
|
from django.views.generic import DetailView, ListView, TemplateView, View
|
|
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
|
|
from django.views.generic.list import MultipleObjectMixin
|
|
from django_select2.cache import cache
|
|
from rest_framework import generics
|
|
from rest_framework.response import Response
|
|
|
|
from bijoe.utils import export_site, get_warehouses, import_site
|
|
|
|
from .. import views
|
|
from ..engine import Engine
|
|
from . import forms, models, signature
|
|
from .utils import Visualization
|
|
|
|
|
|
class WarehouseView(views.AuthorizationMixin, TemplateView):
|
|
template_name = 'bijoe/warehouse.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
try:
|
|
warehouse = [
|
|
warehouse for warehouse in get_warehouses() 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())
|
|
ctx['visualizations_exist'] = models.Visualization.objects.exists()
|
|
return ctx
|
|
|
|
|
|
class CubeDisplayMixin:
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['warehouse'] = self.warehouse
|
|
ctx['cube'] = self.cube
|
|
ctx['visualization'] = self.visualization
|
|
return ctx
|
|
|
|
|
|
class CubeMixin:
|
|
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() 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().dispatch(request, *args, **kwargs)
|
|
|
|
|
|
class CubeView(views.AuthorizationMixin, CubeDisplayMixin, 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().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}/'
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
if not self.visualization:
|
|
return redirect('homepage')
|
|
return super().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().form_valid(form)
|
|
|
|
|
|
class SaveAsVisualizationView(views.AuthorizationMixin, DetailView, CreateView):
|
|
model = models.Visualization
|
|
form_class = forms.VisualizationForm
|
|
template_name = 'bijoe/create_visualization.html'
|
|
success_url = '/visualization/{id}/'
|
|
|
|
def form_valid(self, form):
|
|
form.instance.parameters = self.get_object().parameters
|
|
return super().form_valid(form)
|
|
|
|
def get_initial(self):
|
|
return {'name': '%s %s' % (self.get_object().name, _('(Copy)'))}
|
|
|
|
|
|
class VisualizationView(views.AuthorizationMixin, CubeDisplayMixin, DetailView):
|
|
model = models.Visualization
|
|
template_name = 'bijoe/visualization.html'
|
|
|
|
def get_object(self):
|
|
named_visualization = super().get_object()
|
|
if not hasattr(self, 'visualization'):
|
|
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()
|
|
if 'save' in request.POST:
|
|
named_visualization.save()
|
|
return self.get(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
initial = {
|
|
'representation': self.visualization.representation,
|
|
'measure': self.visualization.measure.name,
|
|
'loop': self.visualization.loop and self.visualization.loop.name,
|
|
'drilldown_x': self.visualization.drilldown_x and self.visualization.drilldown_x.name,
|
|
'drilldown_y': self.visualization.drilldown_y and self.visualization.drilldown_y.name,
|
|
}
|
|
for key, value in self.visualization.filters.items() or []:
|
|
if isinstance(value, list):
|
|
value = tuple(value)
|
|
initial['filter__%s' % key] = value
|
|
ctx['form'] = forms.CubeForm(cube=self.cube, initial=initial)
|
|
path = reverse('visualization-iframe', args=self.args, kwargs=self.kwargs)
|
|
signature = path + settings.SECRET_KEY
|
|
signature = hashlib.sha1(force_bytes(signature)).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 = request.path + settings.SECRET_KEY
|
|
signature = hashlib.sha1(force_bytes(signature)).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 = reverse_lazy('homepage')
|
|
|
|
|
|
class VisualizationsView(views.AuthorizationMixin, ListView):
|
|
template_name = 'bijoe/visualizations.html'
|
|
model = models.Visualization
|
|
context_object_name = 'visualizations'
|
|
paginate_by = settings.PAGE_LENGTH
|
|
|
|
def get_queryset(self):
|
|
return self.model.all_visualizations()
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['request'] = self.request
|
|
return ctx
|
|
|
|
|
|
class RenameVisualization(views.AuthorizationMixin, UpdateView):
|
|
model = models.Visualization
|
|
form_class = forms.VisualizationForm
|
|
template_name = 'bijoe/rename_visualization.html'
|
|
success_url = '/visualization/{id}/'
|
|
|
|
|
|
class VisualizationsJSONView(MultipleObjectMixin, View):
|
|
model = models.Visualization
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
if not request.user.is_authenticated or not request.user.is_superuser:
|
|
known_services = getattr(settings, 'KNOWN_SERVICES', [])
|
|
if known_services:
|
|
key = None
|
|
for l in known_services.values():
|
|
for service in l.values():
|
|
if service['verif_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 = path + settings.SECRET_KEY
|
|
sig = hashlib.sha1(force_bytes(sig)).hexdigest()
|
|
path += '?signature=' + sig
|
|
data_uri = reverse('visualization-json', kwargs={'pk': visualization.pk})
|
|
data.append(
|
|
{
|
|
'name': visualization.name,
|
|
'slug': visualization.slug,
|
|
'path': request.build_absolute_uri(path),
|
|
'data-url': request.build_absolute_uri(data_uri),
|
|
}
|
|
)
|
|
response = HttpResponse(content_type='application/json')
|
|
response.write(json.dumps(data))
|
|
return response
|
|
|
|
|
|
class CubeIframeView(CubeView):
|
|
template_name = 'bijoe/cube_raw.html'
|
|
|
|
|
|
class VisualizationODSView(views.AuthorizationMixin, DetailView):
|
|
model = models.Visualization
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
instance = self.get_object()
|
|
visualization = Visualization.from_json(instance.parameters)
|
|
response = HttpResponse(content_type='application/vnd.oasis.opendocument.spreadsheet')
|
|
response['Content-Disposition'] = 'attachment; filename=%s.ods' % slugify(instance.name)
|
|
workbook = visualization.ods()
|
|
workbook.save(response)
|
|
return response
|
|
|
|
|
|
class VisualizationGeoJSONView(generics.GenericAPIView):
|
|
permission_classes = ()
|
|
queryset = models.Visualization.objects.all()
|
|
|
|
def get(self, request, pk, format=None):
|
|
instance = self.get_object()
|
|
visualization = Visualization.from_json(instance.parameters)
|
|
visualization.measure = visualization.cube.measures['geolocation']
|
|
geojson = {'type': 'FeatureCollection', 'features': []}
|
|
|
|
for row in visualization.data():
|
|
properties = {}
|
|
for cell in row.dimensions:
|
|
properties[cell.dimension.label] = '%s' % (cell,)
|
|
points = row.measures[0].value or []
|
|
geojson['features'].append(
|
|
{
|
|
'type': 'Feature',
|
|
'geometry': {
|
|
'type': 'MultiPoint',
|
|
'coordinates': [[coord for coord in point] for point in points],
|
|
},
|
|
'properties': properties,
|
|
}
|
|
)
|
|
return Response(geojson)
|
|
|
|
|
|
class VisualizationJSONView(generics.GenericAPIView):
|
|
permission_classes = ()
|
|
queryset = models.Visualization.objects.all()
|
|
|
|
def get(self, request, pk, format=None):
|
|
def cell_value(cell):
|
|
if cell.measure.type == 'duration' and cell.value is not None:
|
|
return cell.value.total_seconds()
|
|
return cell.value
|
|
|
|
def labels(axis):
|
|
return [x.label.strip() for x in axis]
|
|
|
|
instance = self.get_object()
|
|
loop = []
|
|
all_visualizations = Visualization.from_json(instance.parameters)
|
|
for visualization in all_visualizations:
|
|
drilldowns = visualization.drilldown
|
|
if len(drilldowns) == 2:
|
|
(x_axis, y_axis), grid = visualization.table_2d()
|
|
axis = {
|
|
'x_labels': labels(x_axis),
|
|
'y_labels': labels(y_axis),
|
|
}
|
|
|
|
data = []
|
|
for y in y_axis:
|
|
data.append([cell_value(grid[(x.id, y.id)]) for x in x_axis])
|
|
elif len(drilldowns) == 1:
|
|
x_axis, grid = visualization.table_1d()
|
|
if visualization.drilldown_x:
|
|
axis = {'x_labels': labels(x_axis)}
|
|
else:
|
|
axis = {'y_labels': labels(x_axis)}
|
|
data = [cell_value(grid[x.id]) for x in x_axis]
|
|
elif len(drilldowns) == 0:
|
|
data = cell_value(visualization.data()[0].measures[0])
|
|
axis = {}
|
|
loop.append({'data': data, 'axis': axis})
|
|
|
|
if not all_visualizations.loop:
|
|
data = loop[0]['data']
|
|
axis = loop[0]['axis']
|
|
else:
|
|
axis = loop[0]['axis']
|
|
axis['loop'] = [
|
|
x.label for x in all_visualizations.loop.members(all_visualizations.filters.items())
|
|
]
|
|
data = [x['data'] for x in loop]
|
|
|
|
unit = 'seconds' if all_visualizations.measure.type == 'duration' else None
|
|
measure = all_visualizations.measure.type
|
|
|
|
return Response(
|
|
{
|
|
'data': data,
|
|
'axis': axis,
|
|
'format': '1',
|
|
'unit': unit, # legacy, prefer measure.
|
|
'measure': measure,
|
|
}
|
|
)
|
|
|
|
|
|
class ExportVisualizationView(views.AuthorizationMixin, DetailView):
|
|
model = models.Visualization
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
response = HttpResponse(content_type='application/json')
|
|
response['Content-Disposition'] = (
|
|
'attachment; filename="%s"' % 'export_stats_%s.json' % now().strftime('%Y%m%d')
|
|
)
|
|
json.dump({'visualizations': [self.get_object().export_json()]}, response, indent=2)
|
|
return response
|
|
|
|
|
|
class VisualizationsImportView(views.AuthorizationMixin, FormView):
|
|
form_class = forms.VisualizationsImportForm
|
|
template_name = 'bijoe/visualizations_import.html'
|
|
success_url = reverse_lazy('homepage')
|
|
|
|
def form_valid(self, form):
|
|
try:
|
|
visualizations_json = json.loads(force_text(self.request.FILES['visualizations_json'].read()))
|
|
except ValueError:
|
|
form.add_error('visualizations_json', _('File is not in the expected JSON format.'))
|
|
return self.form_invalid(form)
|
|
|
|
results = import_site(visualizations_json)
|
|
|
|
if results.get('created') == 0 and results.get('updated') == 0:
|
|
messages.info(self.request, _('No visualizations were found.'))
|
|
else:
|
|
if results.get('created') == 0:
|
|
message1 = _('No visualization created.')
|
|
else:
|
|
message1 = (
|
|
ungettext(
|
|
'A visualization has been created.',
|
|
'%(count)d visualizations have been created.',
|
|
results['created'],
|
|
)
|
|
% {'count': results['created']}
|
|
)
|
|
if results.get('updated') == 0:
|
|
message2 = _('No visualization updated.')
|
|
else:
|
|
message2 = (
|
|
ungettext(
|
|
'A visualization has been updated.',
|
|
'%(count)d visualizations have been updated.',
|
|
results['updated'],
|
|
)
|
|
% {'count': results['updated']}
|
|
)
|
|
messages.info(self.request, '%s %s' % (message1, message2))
|
|
|
|
return super().form_valid(form)
|
|
|
|
|
|
class VisualizationsExportView(views.AuthorizationMixin, View):
|
|
def get(self, request, *args, **kwargs):
|
|
response = HttpResponse(content_type='application/json')
|
|
response['Content-Disposition'] = (
|
|
'attachment; filename="%s"' % 'export_stats_%s.json' % now().strftime('%Y%m%d')
|
|
)
|
|
json.dump(export_site(), response, indent=2)
|
|
return response
|
|
|
|
|
|
class Select2ChoicesView(View):
|
|
def get(self, request, *args, **kwargs):
|
|
widget = self.get_widget_or_404()
|
|
try:
|
|
warehouse = Engine(
|
|
[warehouse for warehouse in get_warehouses() if warehouse.name == widget.warehouse][0]
|
|
)
|
|
cube = warehouse[widget.cube]
|
|
self.dimension = cube.dimensions[widget.dimension]
|
|
except IndexError:
|
|
raise Http404()
|
|
|
|
try:
|
|
page_number = int(request.GET.get('page', 1)) - 1
|
|
except ValueError:
|
|
raise Http404('Invalid page number.')
|
|
|
|
term = request.GET.get('term', '')
|
|
choices = self.get_choices(term, page_number, widget.max_results)
|
|
|
|
return JsonResponse(
|
|
{
|
|
'results': [{'text': label, 'id': s} for s, label in choices],
|
|
'more': not (len(choices) < widget.max_results),
|
|
}
|
|
)
|
|
|
|
def get_choices(self, term, page_number, max_results):
|
|
members = []
|
|
for _id, label in self.dimension.members():
|
|
members.append((_id, str(_id), label))
|
|
members.append((None, '__none__', _('None')))
|
|
|
|
choices = [(s, label) for v, s, label in members if term in label.lower()]
|
|
choices = choices[page_number * max_results : (page_number * max_results) + max_results]
|
|
return choices
|
|
|
|
def get_widget_or_404(self):
|
|
field_id = self.request.GET.get('field_id', None)
|
|
if not field_id:
|
|
raise Http404('No "field_id" provided.')
|
|
try:
|
|
key = signing.loads(field_id)
|
|
except BadSignature:
|
|
raise Http404('Invalid "field_id".')
|
|
else:
|
|
cache_key = '%s%s' % (settings.SELECT2_CACHE_PREFIX, key)
|
|
widget_dict = cache.get(cache_key)
|
|
if widget_dict is None:
|
|
raise Http404('field_id not found')
|
|
if widget_dict.pop('url') != self.request.path:
|
|
raise Http404('field_id was issued for the view.')
|
|
return widget_dict['widget']
|
|
|
|
|
|
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()
|
|
export_visualization = ExportVisualizationView.as_view()
|
|
save_as_visualization = SaveAsVisualizationView.as_view()
|
|
visualization = VisualizationView.as_view()
|
|
visualization_iframe = xframe_options_exempt(VisualizationIframeView.as_view())
|
|
visualization_geojson = VisualizationGeoJSONView.as_view()
|
|
visualization_ods = VisualizationODSView.as_view()
|
|
visualization_json = VisualizationJSONView.as_view()
|
|
visualizations_import = VisualizationsImportView.as_view()
|
|
visualizations_export = VisualizationsExportView.as_view()
|
|
select2_choices = Select2ChoicesView.as_view()
|
|
|
|
cube_iframe.mellon_no_passive = True
|
|
visualization_iframe.mellon_no_passive = True
|