Projet

Général

Profil

0002-misc-add-visualization-import-export-30854.patch

Valentin Deniaud, 19 décembre 2019 14:11

Télécharger (22,3 ko)

Voir les différences:

Subject: [PATCH 2/2] misc: add visualization import/export (#30854)

 bijoe/management/__init__.py                  |  0
 bijoe/management/commands/__init__.py         |  0
 bijoe/management/commands/export_site.py      | 40 ++++++++
 bijoe/management/commands/import_site.py      | 43 +++++++++
 bijoe/templates/bijoe/visualization.html      |  1 +
 .../bijoe/visualizations_import.html          | 20 ++++
 .../templates/bijoe/visualizations_list.html  |  2 +
 bijoe/templates/bijoe/warehouse.html          |  1 +
 bijoe/utils.py                                | 29 +++++-
 bijoe/visualization/forms.py                  |  4 +
 bijoe/visualization/models.py                 | 17 ++++
 bijoe/visualization/urls.py                   |  5 +
 bijoe/visualization/views.py                  | 62 ++++++++++++-
 tests/test_import_export.py                   | 92 +++++++++++++++++++
 tests/test_views.py                           | 71 ++++++++++++++
 15 files changed, 383 insertions(+), 4 deletions(-)
 create mode 100644 bijoe/management/__init__.py
 create mode 100644 bijoe/management/commands/__init__.py
 create mode 100644 bijoe/management/commands/export_site.py
 create mode 100644 bijoe/management/commands/import_site.py
 create mode 100644 bijoe/templates/bijoe/visualizations_import.html
 create mode 100644 tests/test_import_export.py
bijoe/management/commands/export_site.py
1
# bijoe - BI dashboard
2
# Copyright (C) 2015  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18
import sys
19

  
20
from django.core.management.base import BaseCommand, CommandError
21

  
22
from bijoe.utils import export_site
23
from bijoe.visualization.models import Visualization
24

  
25

  
26
class Command(BaseCommand):
27
    help = 'Export the site'
28

  
29
    def add_arguments(self, parser):
30
        parser.add_argument(
31
                '--output', metavar='FILE', default=None,
32
                help='name of a file to write output to')
33

  
34
    def handle(self, *args, **options):
35
        Visualization.objects.all()
36
        if options['output']:
37
            output = open(options['output'], 'w')
38
        else:
39
            output = sys.stdout
40
        json.dump(export_site(), output, indent=4)
bijoe/management/commands/import_site.py
1
# bijoe - BI dashboard
2
# Copyright (C) 2015  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18
import sys
19

  
20
from django.core.management.base import BaseCommand, CommandError
21

  
22
from bijoe.utils import import_site
23

  
24

  
25
class Command(BaseCommand):
26
    help = 'Import an exported site'
27

  
28
    def add_arguments(self, parser):
29
        parser.add_argument('filename', metavar='FILENAME', type=str,
30
                help='name of file to import')
31
        parser.add_argument(
32
                '--clean', action='store_true', default=False,
33
                help='Clean site before importing')
34
        parser.add_argument(
35
                '--if-empty', action='store_true', default=False,
36
                help='Import only if site is empty')
37

  
38
    def handle(self, filename, **options):
39
        if filename == '-':
40
            fd = sys.stdin
41
        else:
42
            fd = open(filename)
43
        import_site(json.load(fd), if_empty=options['if_empty'], clean=options['clean'])
bijoe/templates/bijoe/visualization.html
16 16
  <a rel="popup" class="button" href="{% url "rename-visualization" pk=object.pk %}">{% trans "Rename" %}</a>
17 17
  <a rel="popup" class="button" href="{% url "delete-visualization" pk=object.pk %}">{% trans "Delete" %}</a>
18 18
  <a class="button" href="{% url "visualization-ods" pk=object.pk %}">{% trans "Export as ODS" %}</a>
19
  <a download class="button" href="{% url "export-visualization" pk=object.pk %}">{% trans "Export as JSON" %}</a>
19 20
  <a href="{{ iframe_url }}" class="button">{% trans "URL for IFRAME" %}</a>
20 21
{% endblock %}
21 22

  
bijoe/templates/bijoe/visualizations_import.html
1
{% extends "bijoe/base.html" %}
2
{% load i18n %}
3

  
4
{% block appbar %}
5
<h2>{% trans "Visualizations Import" %}</h2>
6
{% endblock %}
7

  
8
{% block content %}
9
<form method="post" enctype="multipart/form-data">
10
  {% csrf_token %}
11
  {{ form.as_p }}
12
  <div class="buttons">
13
    <button class="submit-button">{% trans "Import" %}</button>
14
    <a class="cancel" href="{% url 'homepage' %}">{% trans 'Cancel' %}</a>
15
  </div>
16
</form>
17
{% endblock %}
18

  
19

  
20

  
bijoe/templates/bijoe/visualizations_list.html
10 10
  {% endfor %}
11 11
</ul>
12 12
{% include "gadjo/pagination.html" %}
13
<a rel="popup" href="{% url 'visualizations-import' %}">{% trans 'Import' %}</a>
14
<a download href="{% url 'visualizations-export' %}">{% trans 'Export' %}</a>
bijoe/templates/bijoe/warehouse.html
28 28
      {% endfor %}
29 29
    </tbody>
30 30
  </table>
31
<a rel="popup" href="{% url 'visualizations-import' %}">{% trans 'Import' %}</a>
31 32
{% endblock %}
bijoe/utils.py
19 19
import json
20 20

  
21 21
from django.conf import settings
22
from django.db import connection
22
from django.db import connection, transaction
23 23
from django.utils.translation import ugettext as _
24 24
try:
25 25
    from functools import lru_cache
26 26
except ImportError:
27 27
    from django.utils.lru_cache import lru_cache
28 28

  
29

  
30 29
from .schemas import Warehouse
31 30

  
32 31

  
......
63 62
    if len(l) > 2:
64 63
        l = u', '.join(l[:-1]), l[-1]
65 64
    return _(u'{0} and {1}').format(l[0], l[1])
65

  
66

  
67
def export_site():
68
    from bijoe.visualization.models import Visualization
69

  
70
    return {'visualizations': [v.export_json() for v in Visualization.objects.all()]}
71

  
72

  
73
def import_site(data, if_empty=False, clean=False):
74
    from bijoe.visualization.models import Visualization
75

  
76
    if if_empty and Visualization.objects.exists():
77
        return
78

  
79
    if clean:
80
        Visualization.objects.all().delete()
81

  
82
    results = {'created': 0, 'updated': 0}
83
    with transaction.atomic():
84
        for data in data.get('visualizations', []):
85
            created = Visualization.import_json(data)
86
            if created:
87
                results['created'] += 1
88
            else:
89
                results['updated'] += 1
90
    return results
bijoe/visualization/forms.py
235 235
            raise ValidationError({'loop': _('You cannot use the same dimension for looping and'
236 236
                                             ' grouping')})
237 237
        return cleaned_data
238

  
239

  
240
class VisualizationsImportForm(forms.Form):
241
    visualizations_json = forms.FileField(label=_('Visualizations Export File'))
bijoe/visualization/models.py
54 54
    def natural_key(self):
55 55
        return (self.slug,)
56 56

  
57
    def export_json(self):
58
        visualization = {
59
            'slug': self.slug,
60
            'name': self.name,
61
            'parameters': self.parameters
62
        }
63
        return visualization
64

  
65
    @classmethod
66
    def import_json(cls, data):
67
        defaults = {
68
            'name': data['name'],
69
            'parameters': data['parameters']
70
        }
71
        _, created = cls.objects.update_or_create(slug=data['slug'], defaults=defaults)
72
        return created
73

  
57 74
    def save(self, *args, **kwargs):
58 75
        if not self.slug:
59 76
            slug = base_slug = slugify(self.name)[:40].strip('-')
bijoe/visualization/urls.py
23 23
        views.visualizations, name='visualizations'),
24 24
    url(r'^json/$',
25 25
        views.visualizations_json, name='visualizations-json'),
26
    url(r'^import/$',
27
        views.visualizations_import, name='visualizations-import'),
28
    url(r'^export$',
29
        views.visualizations_export, name='visualizations-export'),
26 30
    url(r'^warehouse/(?P<warehouse>[^/]*)/$', views.warehouse, name='warehouse'),
27 31
    url(r'^warehouse/(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/$', views.cube, name='cube'),
28 32
    url(r'^warehouse/(?P<warehouse>[^/]*)/(?P<cube>[^/]*)/iframe/$', views.cube_iframe,
......
36 40
    url(r'(?P<pk>\d+)/ods/$', views.visualization_ods, name='visualization-ods'),
37 41
    url(r'(?P<pk>\d+)/rename/$', views.rename_visualization, name='rename-visualization'),
38 42
    url(r'(?P<pk>\d+)/delete/$', views.delete_visualization, name='delete-visualization'),
43
    url(r'(?P<pk>\d+)/export$', views.export_visualization, name='export-visualization'),
39 44
]
bijoe/visualization/views.py
19 19
import json
20 20

  
21 21
from django.conf import settings
22
from django.contrib import messages
22 23
from django.utils.encoding import force_text
23 24
from django.utils.text import slugify
24
from django.views.generic.edit import CreateView, DeleteView, UpdateView
25
from django.utils.translation import ungettext, ugettext as _
26
from django.views.generic.edit import CreateView, DeleteView, UpdateView, FormView
25 27
from django.views.generic.list import MultipleObjectMixin
26 28
from django.views.generic import DetailView, ListView, View, TemplateView
27 29
from django.shortcuts import redirect
......
33 35
from rest_framework import generics
34 36
from rest_framework.response import Response
35 37

  
36
from ..utils import get_warehouses
38
from bijoe.utils import get_warehouses, import_site, export_site
37 39
from ..engine import Engine
38 40
from . import models, forms, signature
39 41
from .utils import Visualization
......
341 343
        })
342 344

  
343 345

  
346
class ExportVisualizationView(views.AuthorizationMixin, DetailView):
347
    model = models.Visualization
348

  
349
    def get(self, request, *args, **kwargs):
350
        response = HttpResponse(content_type='application/json')
351
        json.dump({'visualizations': [self.get_object().export_json()]}, response, indent=2)
352
        return response
353

  
354

  
355
class VisualizationsImportView(views.AuthorizationMixin, FormView):
356
    form_class = forms.VisualizationsImportForm
357
    template_name = 'bijoe/visualizations_import.html'
358
    success_url = reverse_lazy('homepage')
359

  
360
    def form_valid(self, form):
361
        try:
362
            visualizations_json = json.loads(
363
                force_text(self.request.FILES['visualizations_json'].read()))
364
        except ValueError:
365
            form.add_error('visualizations_json', _('File is not in the expected JSON format.'))
366
            return self.form_invalid(form)
367

  
368
        results = import_site(visualizations_json)
369

  
370
        if results.get('created') == 0 and results.get('updated') == 0:
371
            messages.info(self.request, _('No visualizations were found.'))
372
        else:
373
            if results.get('created') == 0:
374
                message1 = _('No visualization created.')
375
            else:
376
                message1 = ungettext('A visualization has been created.',
377
                        '%(count)d visualizations have been created.', results['created']) % {
378
                                'count': results['created']}
379

  
380
            if results.get('updated') == 0:
381
                message2 = _('No visualization updated.')
382
            else:
383
                message2 = ungettext('A visualization has been updated.',
384
                        '%(count)d visualizations have been updated.', results['updated']) % {
385
                                'count': results['updated']}
386
            messages.info(self.request, u'%s %s' % (message1, message2))
387

  
388
        return super(VisualizationsImportView, self).form_valid(form)
389

  
390

  
391
class VisualizationsExportView(views.AuthorizationMixin, View):
392

  
393
    def get(self, request, *args, **kwargs):
394
        response = HttpResponse(content_type='application/json')
395
        json.dump(export_site(), response, indent=2)
396
        return response
397

  
398

  
344 399
warehouse = WarehouseView.as_view()
345 400
cube = CubeView.as_view()
346 401
cube_iframe = xframe_options_exempt(CubeIframeView.as_view())
......
349 404
create_visualization = CreateVisualizationView.as_view()
350 405
delete_visualization = DeleteVisualizationView.as_view()
351 406
rename_visualization = RenameVisualization.as_view()
407
export_visualization = ExportVisualizationView.as_view()
352 408
visualization = VisualizationView.as_view()
353 409
visualization_iframe = xframe_options_exempt(VisualizationIframeView.as_view())
354 410
visualization_geojson = VisualizationGeoJSONView.as_view()
355 411
visualization_ods = VisualizationODSView.as_view()
356 412
visualization_json = VisualizationJSONView.as_view()
413
visualizations_import = VisualizationsImportView.as_view()
414
visualizations_export = VisualizationsExportView.as_view()
357 415

  
358 416
cube_iframe.mellon_no_passive = True
359 417
visualization_iframe.mellon_no_passive = True
tests/test_import_export.py
1
# -*- coding: utf-8 -*-
2

  
3
from __future__ import unicode_literals
4

  
5
import json
6
import os
7
import shutil
8
import sys
9
import tempfile
10

  
11
import pytest
12
from django.core.management import call_command
13
from django.utils.encoding import force_bytes
14
from django.utils.six import StringIO
15

  
16
from bijoe.visualization.models import Visualization
17
from bijoe.utils import import_site
18

  
19
pytestmark = pytest.mark.django_db
20

  
21

  
22
def get_output_of_command(command, *args, **kwargs):
23
    old_stdout = sys.stdout
24
    output = sys.stdout = StringIO()
25
    call_command(command, *args, **kwargs)
26
    sys.stdout = old_stdout
27
    return output.getvalue()
28

  
29

  
30
def test_import_export(schema1, app):
31
    parameters = {
32
        'cube': 'facts1',
33
        'warehouse': 'schema1',
34
        'measure': 'duration',
35
        'representation': 'table',
36
        'loop': '',
37
        'filters': {},
38
        'drilldown_x': 'date__yearmonth'
39
    }
40

  
41
    def create_visu(i=0):
42
        Visualization.objects.create(name='test' + str(i), parameters=parameters)
43

  
44
    for i in range(3):
45
        create_visu(i)
46
    output = get_output_of_command('export_site')
47
    assert len(json.loads(output)['visualizations']) == 3
48

  
49
    import_site(data={}, clean=True)
50
    empty_output = get_output_of_command('export_site')
51
    assert len(json.loads(empty_output)['visualizations']) == 0
52

  
53
    create_visu()
54
    old_stdin = sys.stdin
55
    sys.stdin = StringIO(json.dumps({}))
56
    assert Visualization.objects.count() == 1
57
    try:
58
        call_command('import_site', '-', clean=True)
59
    finally:
60
        sys.stdin = old_stdin
61
    assert Visualization.objects.count() == 0
62

  
63
    with tempfile.NamedTemporaryFile() as f:
64
        f.write(force_bytes(output))
65
        f.flush()
66
        call_command('import_site', f.name)
67
    assert Visualization.objects.count() == 3
68
    for i in range(3):
69
        visu = Visualization.objects.get(name='test' + str(i))
70
        assert visu.parameters == parameters
71

  
72
    visu = Visualization.objects.get(name='test0')
73
    slug = visu.slug
74
    visu_json = visu.export_json()
75
    visu_json['name'] = 'new_name'
76
    visu_json['parameters']['measure'] = 'test'
77
    result = import_site(data={'visualizations': [visu_json]})
78
    assert result['created'] == 0 and result['updated'] == 1
79
    visu = Visualization.objects.get(slug=slug)
80
    assert visu.name == 'new_name'
81
    new_params = visu.parameters
82
    assert new_params.pop('measure') == 'test'
83
    assert new_params == {k: v for k, v in parameters.items() if k != 'measure'}
84

  
85
    import_site(data={}, if_empty=True)
86
    assert Visualization.objects.count() == 3
87

  
88
    import_site(data={}, clean=True)
89
    tempdir = tempfile.mkdtemp('bijoe-test')
90
    empty_output = get_output_of_command('export_site', output=os.path.join(tempdir, 't.json'))
91
    assert os.path.exists(os.path.join(tempdir, 't.json'))
92
    shutil.rmtree(tempdir)
tests/test_views.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import copy
18
import json
19

  
17 20
import pytest
21
from webtest import Upload
18 22

  
19 23
from django.core.urlresolvers import reverse
20 24

  
......
97 101

  
98 102
    response = app.get('/')
99 103
    assert response.pyquery('ul li a.disabled').text() == visualization.name
104

  
105

  
106
def test_import_visualization(schema1, app, admin, visualization):
107
    login(app, admin)
108
    resp = app.get('/visualization/%s/' % visualization.id)
109
    resp = resp.click('Export as JSON')
110
    assert resp.headers['content-type'] == 'application/json'
111
    visualization_export = resp.text
112

  
113
    # invalid json
114
    resp = app.get('/', status=200)
115
    resp = resp.click('Import')
116
    resp.form['visualizations_json'] = Upload('export.json', b'garbage', 'application/json')
117
    resp = resp.form.submit()
118
    assert 'File is not in the expected JSON format.' in resp.text
119

  
120
    # empty json
121
    resp = app.get('/', status=200)
122
    resp = resp.click('Import')
123
    resp.form['visualizations_json'] = Upload('export.json', b'{}', 'application/json')
124
    resp = resp.form.submit().follow()
125
    assert 'No visualizations were found.' in resp.text
126

  
127
    # existing visualization
128
    resp = app.get('/', status=200)
129
    resp = resp.click('Import')
130
    resp.form['visualizations_json'] = Upload('export.json', visualization_export.encode('utf-8'), 'application/json')
131
    resp = resp.form.submit().follow()
132
    assert 'No visualization created. A visualization has been updated.' in resp.text
133
    assert Visualization.objects.count() == 1
134

  
135
    # new visualization
136
    Visualization.objects.all().delete()
137
    resp = app.get('/').follow()
138
    resp = resp.click('Import')
139
    resp.form['visualizations_json'] = Upload('export.json', visualization_export.encode('utf-8'), 'application/json')
140
    resp = resp.form.submit().follow()
141
    assert 'A visualization has been created. No visualization updated.' in resp.text
142
    assert Visualization.objects.count() == 1
143

  
144
    # multiple visualizations
145
    visualizations = json.loads(visualization_export)
146
    visualizations['visualizations'].append(copy.copy(visualizations['visualizations'][0]))
147
    visualizations['visualizations'].append(copy.copy(visualizations['visualizations'][0]))
148
    visualizations['visualizations'][1]['name'] = 'test 2'
149
    visualizations['visualizations'][1]['slug'] = 'test-2'
150
    visualizations['visualizations'][2]['name'] = 'test 3'
151
    visualizations['visualizations'][2]['slug'] = 'test-3'
152

  
153
    resp = app.get('/', status=200)
154
    resp = resp.click('Import')
155
    resp.form['visualizations_json'] = Upload('export.json', json.dumps(visualizations).encode('utf-8'), 'application/json')
156
    resp = resp.form.submit().follow()
157
    assert '2 visualizations have been created. A visualization has been updated.' in resp.text
158
    assert Visualization.objects.count() == 3
159

  
160
    # global export/import
161
    resp = app.get('/').click('Export')
162
    visualizations_export = resp.text
163
    Visualization.objects.all().delete()
164

  
165
    resp = app.get('/').follow()
166
    resp = resp.click('Import')
167
    resp.form['visualizations_json'] = Upload('export.json', visualizations_export.encode('utf-8'), 'application/json')
168
    resp = resp.form.submit().follow()
169
    assert '3 visualizations have been created. No visualization updated.' in resp.text
170
    assert Visualization.objects.count() == 3
100
-