wip/65947-Cellule-Graph-Pouvoir-exporter-l (#65947) #165
|
@ -472,3 +472,13 @@ class ChartFiltersConfigForm(forms.ModelForm):
|
|||
for filter_id in self.instance.filters:
|
||||
self.instance.filters[filter_id]['enabled'] = bool(filter_id in self.cleaned_data['filters'])
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ChartNgExportForm(forms.Form):
|
||||
export_format = forms.ChoiceField(
|
||||
label=_('Format'),
|
||||
choices=(
|
||||
('svg', _('Picture (SVG)')),
|
||||
('ods', _('Table (ODS)')),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -32,6 +32,7 @@ from django.urls import reverse
|
|||
from django.utils import timezone
|
||||
from django.utils.dates import WEEKDAYS
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timesince import timesince
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -288,6 +289,7 @@ class ChartNgCell(CellBase):
|
|||
|
||||
class Media:
|
||||
js = ('js/chartngcell.js',)
|
||||
css = {'all': ('css/combo.chartngcell.css',)}
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
|
@ -301,9 +303,16 @@ class ChartNgCell(CellBase):
|
|||
def get_additional_label(self):
|
||||
return self.title
|
||||
|
||||
def get_download_filename(self):
|
||||
label = slugify(self.title or self.statistic.label)
|
||||
return 'export-%s-%s' % (label, date.today().strftime('%Y%m%d'))
|
||||
|
||||
|
||||
def is_relevant(self, context):
|
||||
return bool(self.statistic)
|
||||
|
||||
def is_table_chart(self):
|
||||
return bool(self.chart_type in ('table', 'table-inverted'))
|
||||
|
||||
def check_validity(self):
|
||||
if not self.statistic:
|
||||
return
|
||||
|
@ -596,7 +605,7 @@ class ChartNgCell(CellBase):
|
|||
data = self.hide_values(chart, data)
|
||||
if data and self.sort_order != 'none':
|
||||
data = self.sort_values(chart, data)
|
||||
if getattr(chart, 'compute_sum', True) and self.chart_type in ('table', 'table-inverted'):
|
||||
if getattr(chart, 'compute_sum', True) and self.is_table_chart():
|
||||
data = self.add_total_to_line_table(chart, data)
|
||||
return data
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
.cell.chart-ng-cell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-ng-cell .download-button {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
line-height: unset;
|
||||
}
|
||||
|
||||
.chart-ng-cell .download-button:after {
|
||||
font-family: FontAwesome;
|
||||
content: "\f019"; /* download */
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
{% load i18n %}
|
||||
{% if cell.title %}<h2>{{cell.title}}</h2>{% endif %}
|
||||
{% if cell.chart_type == "table" or cell.chart_type == "table-inverted" %}
|
||||
{% if cell.is_table_chart %}
|
||||
<div id="chart-{{cell.id}}" class="dataviz-table"></div>
|
||||
<script>
|
||||
$(function() {
|
||||
var extra_context = $('#chart-{{cell.id}}').parents('.cell').data('extra-context');
|
||||
$(window).on('combo:refresh-graphs', function() {
|
||||
var url = "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context);
|
||||
$('#chart-{{cell.id}}-download').attr('href', url + '&export-format=ods');
|
||||
$.ajax({
|
||||
url : "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context),
|
||||
url : url,
|
||||
type: 'GET',
|
||||
success: function(data) {
|
||||
$('#chart-{{cell.id}}').html(data);
|
||||
|
@ -29,13 +31,41 @@
|
|||
var new_width = Math.floor($(chart_cell).width());
|
||||
var ratio = new_width / last_width;
|
||||
if (ratio > 1.2 || ratio < 0.8) {
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context, new_width));
|
||||
var querystring = get_graph_querystring(extra_context, new_width);
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + querystring);
|
||||
$('#chart-{{cell.id}}-download').attr('href', "{% url 'combo-dataviz-graph-export' cell=cell.id %}" + querystring);
|
||||
last_width = new_width;
|
||||
}
|
||||
}).trigger('combo:resize-graphs');
|
||||
$(window).on('combo:refresh-graphs', function() {
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context, last_width));
|
||||
var querystring = get_graph_querystring(extra_context, last_width);
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + querystring);
|
||||
$('#chart-{{cell.id}}-download').attr('href', "{% url 'combo-dataviz-graph-export' cell=cell.id %}" + querystring);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<a
|
||||
class="button download-button"
|
||||
id="chart-{{ cell.id }}-download"
|
||||
title="{% trans "Download" %}"
|
||||
href="{% url 'combo-dataviz-graph-export' cell=cell.id %}"
|
||||
{% if cell.is_table_chart %}
|
||||
download
|
||||
fpeters
commented
Pour l'accessibilité je pense que c'est mieux d'avoir Pour l'accessibilité je pense que c'est mieux d'avoir `<span class="sr-only">{% trans "Download" %}</span>` (plutôt que l'aria-label), ne pas avoir d'élément vide. (mais je n'ai pas de référence pour affirmer ça), tu changes si tu veux.
vdeniaud
commented
Fait, merci Fait, merci
|
||||
{% else %}
|
||||
rel="popup"
|
||||
data-autoclose-dialog="true"
|
||||
{% endif %}
|
||||
>
|
||||
<span class="sr-only">{% trans "Download" %}</span>
|
||||
</a>
|
||||
<script>
|
||||
$(function() {
|
||||
$('#chart-{{cell.id}}').parents('.cell').on('mouseenter', function() {
|
||||
$('#chart-{{ cell.id }}-download').show();
|
||||
}).on('mouseleave', function() {
|
||||
$('#chart-{{ cell.id }}-download').hide();
|
||||
}).trigger('mouseleave');
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
{% extends "combo/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Export data" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Download" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-page-view' pk=object.pk %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div style="position: relative">
|
||||
{{ form|with_template }}
|
||||
{% if cell.statistic and cell.chart_type != "table" and cell.chart_type != "table-inverted" %}
|
||||
{% if cell.statistic and not cell.is_table_chart %}
|
||||
<div style="position: absolute; right: 0; top: 0; width: 300px; height: 150px">
|
||||
<embed type="image/svg+xml" src="{% url 'combo-dataviz-graph' cell=cell.id %}?width=300&height=150"/>
|
||||
</div>
|
||||
|
|
|
@ -16,9 +16,12 @@
|
|||
|
||||
from django.conf.urls import re_path
|
||||
|
||||
from .views import ajax_gauge_count, dataviz_graph
|
||||
from .views import ajax_gauge_count, dataviz_graph, dataviz_graph_export
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^ajax/gauge-count/(?P<cell>[\w_-]+)/$', ajax_gauge_count, name='combo-ajax-gauge-count'),
|
||||
re_path(r'^api/dataviz/graph/(?P<cell>[\w_-]+)/$', dataviz_graph, name='combo-dataviz-graph'),
|
||||
re_path(
|
||||
r'^dataviz/graph/(?P<cell>[\w_-]+)/export/$', dataviz_graph_export, name='combo-dataviz-graph-export'
|
||||
),
|
||||
]
|
||||
|
|
|
@ -14,20 +14,24 @@
|
|||
# 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 io
|
||||
|
||||
import pyexcel_ods
|
||||
from django.core import signing
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import render
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import render, reverse
|
||||
from django.template import TemplateSyntaxError, VariableDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import DetailView
|
||||
from django.views.generic import DetailView, FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from combo.utils import NothingInCacheException, get_templated_url, requests
|
||||
|
||||
from .forms import ChartNgPartialForm
|
||||
from .forms import ChartNgExportForm, ChartNgPartialForm
|
||||
from .models import ChartNgCell, Gauge, MissingVariable, UnsupportedDataSet
|
||||
|
||||
|
||||
|
@ -92,7 +96,13 @@ class DatavizGraphView(DetailView):
|
|||
if self.filters_cell_id and self.cell.statistic.service_slug != 'bijoe':
|
||||
self.update_subfilters_cache(form.instance)
|
||||
|
||||
if self.cell.chart_type in ('table', 'table-inverted'):
|
||||
export_format = request.GET.get('export-format')
|
||||
if export_format == 'svg':
|
||||
return self.export_to_svg(chart)
|
||||
elif export_format == 'ods':
|
||||
return self.export_to_ods(chart)
|
||||
|
||||
if self.cell.is_table_chart():
|
||||
if not chart.raw_series:
|
||||
return self.error(_('No data'))
|
||||
|
||||
|
@ -109,7 +119,7 @@ class DatavizGraphView(DetailView):
|
|||
return HttpResponse(chart.render(), content_type='image/svg+xml')
|
||||
|
||||
def error(self, error_text):
|
||||
if self.cell.chart_type in ('table', 'table-inverted'):
|
||||
if self.cell.is_table_chart():
|
||||
return HttpResponse('<p>%s</p>' % error_text)
|
||||
|
||||
context = {
|
||||
|
@ -128,5 +138,52 @@ class DatavizGraphView(DetailView):
|
|||
cell.get_cache_key(self.filters_cell_id), data.json()['data'].get('subfilters', []), 300
|
||||
)
|
||||
|
||||
def export_to_svg(self, chart):
|
||||
response = HttpResponse(chart.render(), content_type='image/svg+xml')
|
||||
response['Content-Disposition'] = 'attachment; filename="%s.svg"' % self.cell.get_download_filename()
|
||||
return response
|
||||
|
||||
def export_to_ods(self, chart):
|
||||
data = [[''] + chart.x_labels] if any(chart.x_labels) else []
|
||||
for serie in chart.raw_series:
|
||||
line = [serie[1]['title']] + serie[0]
|
||||
line = [x or 0 for x in line]
|
||||
data.append(line)
|
||||
|
||||
output = io.BytesIO()
|
||||
pyexcel_ods.save_data(output, {self.cell.title or self.cell.statistic.label: data})
|
||||
output.seek(0)
|
||||
return FileResponse(
|
||||
output,
|
||||
as_attachment=True,
|
||||
content_type='application/vnd.oasis.opendocument.spreadsheet',
|
||||
filename='%s.ods' % self.cell.get_download_filename(),
|
||||
)
|
||||
|
||||
|
||||
dataviz_graph = xframe_options_sameorigin(DatavizGraphView.as_view())
|
||||
|
||||
|
||||
class DatavizGraphExportView(SingleObjectMixin, FormView):
|
||||
model = ChartNgCell
|
||||
pk_url_kwarg = 'cell'
|
||||
form_class = ChartNgExportForm
|
||||
template_name = 'combo/chartngcell_export_form.html'
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
self.querystring = self.request.GET.copy()
|
||||
self.querystring['export-format'] = form.cleaned_data['export_format']
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return '%s?%s' % (
|
||||
reverse('combo-dataviz-graph', kwargs={'cell': self.object.pk}),
|
||||
self.querystring.urlencode(),
|
||||
)
|
||||
|
||||
|
||||
dataviz_graph_export = DatavizGraphExportView.as_view()
|
||||
|
|
|
@ -23,6 +23,7 @@ Depends: python3-distutils,
|
|||
python3-pil,
|
||||
python3-publik-django-templatetags,
|
||||
python3-pycryptodome,
|
||||
python3-pyexcel-ods,
|
||||
python3-pygal,
|
||||
python3-pyproj,
|
||||
python3-pyquery,
|
||||
|
|
|
@ -5,3 +5,4 @@ xstatic_opensans python3-xstatic-opensans
|
|||
eopayment python3-eopayment
|
||||
gadjo python3-gadjo
|
||||
pywebpush python3-pywebpush
|
||||
pyexcel_ods python3-pyexcel-ods
|
||||
|
|
1
setup.py
1
setup.py
|
@ -179,6 +179,7 @@ setup(
|
|||
'pyquery',
|
||||
'pywebpush',
|
||||
'pygal',
|
||||
'pyexcel-ods',
|
||||
'lxml',
|
||||
'phonenumbers',
|
||||
],
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import datetime
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
import lxml
|
||||
import pyexcel_ods
|
||||
import pytest
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import Group
|
||||
|
@ -1504,6 +1506,8 @@ def test_chartng_cell_view_new_api(app, normal_user, new_api_statistics):
|
|||
resp = app.get('/')
|
||||
assert 'min-height: 250px' in resp.text
|
||||
assert location in resp.text
|
||||
assert resp.pyquery('a.download-button').attr('rel') == 'popup'
|
||||
assert resp.pyquery('a.download-button').attr('download') is None
|
||||
|
||||
# table visualization
|
||||
cell.chart_type = 'table'
|
||||
|
@ -1520,6 +1524,10 @@ def test_chartng_cell_view_new_api(app, normal_user, new_api_statistics):
|
|||
assert '<td>Serie 1</td>' in resp.text
|
||||
assert '<th>2020-10</th>' in resp.text
|
||||
|
||||
resp = app.get('/')
|
||||
assert resp.pyquery('a.download-button').attr('download') == ''
|
||||
assert resp.pyquery('a.download-button').attr('rel') is None
|
||||
|
||||
# deleted visualization
|
||||
cell.statistic = Statistic.objects.get(slug='not-found')
|
||||
cell.save()
|
||||
|
@ -1629,6 +1637,45 @@ def test_chartng_cell_view_new_api_dot_chart_hide_y_label(app, normal_user, new_
|
|||
assert 'Serie 2' in resp.text
|
||||
|
||||
|
||||
@with_httmock(new_api_mock)
|
||||
@pytest.mark.freeze_time('2023-09-28')
|
||||
def test_chartng_cell_view_new_api_export(app, normal_user, new_api_statistics):
|
||||
page = Page.objects.create(title='One', slug='index')
|
||||
cell = ChartNgCell(page=page, order=1, chart_type='table', placeholder='content')
|
||||
cell.statistic = Statistic.objects.get(slug='two-series')
|
||||
cell.save()
|
||||
|
||||
resp = app.get('/')
|
||||
resp = resp.click('Download')
|
||||
|
||||
resp.form['export_format'] = 'svg'
|
||||
svg_resp = resp.form.submit().follow()
|
||||
|
||||
assert svg_resp.content_disposition == 'attachment; filename="export-two-series-stat-20230928.svg"'
|
||||
assert svg_resp.content_type == 'image/svg+xml'
|
||||
assert 'Serie 1' in svg_resp.text
|
||||
|
||||
resp.form['export_format'] = 'ods'
|
||||
ods_resp = resp.form.submit().follow()
|
||||
|
||||
assert ods_resp.content_disposition == 'attachment; filename="export-two-series-stat-20230928.ods"'
|
||||
assert ods_resp.content_type == 'application/vnd.oasis.opendocument.spreadsheet'
|
||||
|
||||
data = pyexcel_ods.get_data(io.BytesIO(ods_resp.body))
|
||||
assert data['Two series stat'] == [
|
||||
['', '2020-10', '2020-11', '2020-12'],
|
||||
['Serie 1', 0, 16, 2],
|
||||
['Serie 2', 2, 1, 0],
|
||||
]
|
||||
|
||||
cell.statistic = Statistic.objects.get(slug='empty-x-labels')
|
||||
cell.save()
|
||||
|
||||
ods_resp = resp.form.submit().follow()
|
||||
data = pyexcel_ods.get_data(io.BytesIO(ods_resp.body))
|
||||
assert data['Empty x_labels'] == [['Serie 1', 4242]]
|
||||
|
||||
|
||||
@with_httmock(bijoe_mock)
|
||||
def test_chartng_cell_manager(app, admin_user, statistics):
|
||||
page = Page(title='One', slug='index')
|
||||
|
|
Loading…
Reference in New Issue
C'est curieux que ça s'appelle get_download_label mais que ça ne retourne pas vraiment un libellé, je proposerais d'avoir
get_download_filename(self, extension)
.Yep, je fais la modif