Projet

Général

Profil

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

Valentin Deniaud, 19 décembre 2019 17:21

Télécharger (23,9 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/homepage.html           | 14 ++-
 bijoe/templates/bijoe/visualization.html      |  1 +
 bijoe/templates/bijoe/visualizations.html     |  9 +-
 .../bijoe/visualizations_import.html          | 20 ++++
 bijoe/templates/bijoe/warehouse.html          |  3 +
 bijoe/utils.py                                | 29 +++++-
 bijoe/visualization/forms.py                  |  4 +
 bijoe/visualization/models.py                 | 17 ++++
 bijoe/visualization/urls.py                   |  5 +
 bijoe/visualization/views.py                  | 63 ++++++++++++-
 tests/test_import_export.py                   | 92 +++++++++++++++++++
 tests/test_views.py                           | 71 ++++++++++++++
 16 files changed, 405 insertions(+), 6 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/homepage.html
6 6
<a>{% trans "Homepage" %}</a>
7 7
{% endblock %}
8 8

  
9
{% block appbar %}
10
  <h2>{% trans "Visualizations" %}</h2>
11
  <span class="actions">
12
  <a class="extra-actions-menu-opener"></a>
13
  </span>
14
  <ul class="extra-actions-menu">
15
    <li><a rel="popup" href="{% url 'visualizations-import' %}">{% trans 'Import' %}</a></li>
16
    <li><a download href="{% url 'visualizations-export' %}">{% trans 'Export' %}</a></li>
17
  </ul>
18
{% endblock %}
19

  
9 20
{% block content %}
10 21
  {% if visualizations %}
11
    <h2>{% trans "Visualizations" %}</h2>
12 22
    {% include "bijoe/visualizations_list.html" %}
23
  {% else %}
24
    {% trans "No visualizations to display yet." %}
13 25
  {% endif %}
14 26
  {% if warehouses %}
15 27
    <h2>{% trans "Data sources" %}</h2>
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.html
7 7
{% endblock %}
8 8

  
9 9
{% block appbar %}
10
<h2>{% trans "Visualizations" %}</h2>
10
  <h2>{% trans "Visualizations" %}</h2>
11
  <span class="actions">
12
  <a class="extra-actions-menu-opener"></a>
13
  </span>
14
  <ul class="extra-actions-menu">
15
    <li><a rel="popup" href="{% url 'visualizations-import' %}">{% trans 'Import' %}</a></li>
16
    <li><a download href="{% url 'visualizations-export' %}">{% trans 'Export' %}</a></li>
17
  </ul>
11 18
{% endblock %}
12 19

  
13 20
{% block content %}
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/warehouse.html
28 28
      {% endfor %}
29 29
    </tbody>
30 30
  </table>
31
{% if not visualizations_exist %}
32
  <a rel="popup" href="{% url 'visualizations-import' %}">{% trans 'Import' %}</a>
33
{% endif %}
31 34
{% 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
......
52 54
            raise Http404
53 55
        ctx['warehouse'] = Engine(warehouse)
54 56
        ctx['cubes'] = sorted(ctx['warehouse'].cubes, key=lambda cube: cube.label.strip().lower())
57
        ctx['visualizations_exist'] = models.Visualization.objects.exists()
55 58
        return ctx
56 59

  
57 60

  
......
341 344
        })
342 345

  
343 346

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

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

  
355

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

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

  
369
        results = import_site(visualizations_json)
370

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

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

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

  
391

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

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

  
399

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

  
358 417
cube_iframe.mellon_no_passive = True
359 418
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
-