bijoe/bijoe/visualization/views.py

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