wip/65947-Cellule-Graph-Pouvoir-exporter-l (#65947) #165

Merged
vdeniaud merged 2 commits from wip/65947-Cellule-Graph-Pouvoir-exporter-l into main 2023-11-14 10:43:56 +01:00
12 changed files with 204 additions and 13 deletions

View File

@ -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)')),
),
)

View File

@ -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'))

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).

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

Yep, je fais la modif
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

View File

@ -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 */
}

View File

@ -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

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.

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.

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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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'
),
]

View File

@ -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()

1
debian/control vendored
View File

@ -23,6 +23,7 @@ Depends: python3-distutils,
python3-pil,
python3-publik-django-templatetags,
python3-pycryptodome,
python3-pyexcel-ods,
python3-pygal,
python3-pyproj,
python3-pyquery,

View File

@ -5,3 +5,4 @@ xstatic_opensans python3-xstatic-opensans
eopayment python3-eopayment
gadjo python3-gadjo
pywebpush python3-pywebpush
pyexcel_ods python3-pyexcel-ods

View File

@ -179,6 +179,7 @@ setup(
'pyquery',
'pywebpush',
'pygal',
'pyexcel-ods',
'lxml',
'phonenumbers',
],

View File

@ -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')