From 21425e481a4d91e00b3638ba2851ef6d5b8a0ed9 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Tue, 12 May 2020 10:28:54 +0200
Subject: [PATCH] environment: import and export parameters (#33672)

---
 hobo/environment/forms.py                     |   4 +
 .../templates/environment/import.html         |  22 ++++
 hobo/environment/urls.py                      |   3 +
 hobo/environment/utils.py                     |  47 +++++++
 hobo/environment/views.py                     |  36 +++++-
 hobo/templates/hobo/home.html                 |   2 +
 tests/test_environment.py                     | 116 ++++++++++++++++++
 7 files changed, 229 insertions(+), 1 deletion(-)
 create mode 100644 hobo/environment/templates/environment/import.html

diff --git a/hobo/environment/forms.py b/hobo/environment/forms.py
index afc0e08..b54f30c 100644
--- a/hobo/environment/forms.py
+++ b/hobo/environment/forms.py
@@ -205,8 +205,12 @@ class VariablesFormMixin(object):
             if variable.value != form.cleaned_data[variable_name]:
                 variable.value = form.cleaned_data[variable_name]
                 variable.save()
                 changed = True
         if changed and self.success_message:
             messages.info(self.request, self.success_message)
 
         return HttpResponseRedirect('.')
+
+
+class ImportForm(forms.Form):
+    parameters_json = forms.FileField(label=_('Parameters Export File'))
diff --git a/hobo/environment/templates/environment/import.html b/hobo/environment/templates/environment/import.html
new file mode 100644
index 0000000..099cb65
--- /dev/null
+++ b/hobo/environment/templates/environment/import.html
@@ -0,0 +1,22 @@
+{% extends "hobo/base.html" %}
+{% load i18n %}
+
+{% block appbar %}
+<h2>{% trans "Parameters Import" %}</h2>
+{% endblock %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'environment-import' %}">{% trans 'Parameters Import' %}</a>
+{% endblock %}
+
+{% block content %}
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Import" %}</button>
+    <a class="cancel" href="{% url 'home' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/hobo/environment/urls.py b/hobo/environment/urls.py
index 657403b..95e8d15 100644
--- a/hobo/environment/urls.py
+++ b/hobo/environment/urls.py
@@ -29,10 +29,13 @@ urlpatterns = [
     url(r'^check_operational/(?P<service>\w+)/(?P<slug>[\w-]+)$',
         views.operational_check_view, name='operational-check'),
     url(r'^new-(?P<service>\w+)$', views.ServiceCreateView.as_view(), name='create-service'),
     url(r'^save-(?P<service>\w+)/(?P<slug>[\w-]+)$', views.ServiceUpdateView.as_view(), name='save-service'),
     url(r'^delete-(?P<service>\w+)/(?P<slug>[\w-]+)$', views.ServiceDeleteView.as_view(), name='delete-service'),
 
     url(r'^new-variable-(?P<service>\w+)/(?P<slug>[\w-]+)$',
         views.VariableCreateView.as_view(), name='new-variable-service',),
+
+    url(r'^import/$', views.ImportView.as_view(), name='environment-import'),
+    url(r'^export/$', views.ExportView.as_view(), name='environment-export'),
     url(r'^debug.json$', views.debug_json, name='debug-json'),
 ]
diff --git a/hobo/environment/utils.py b/hobo/environment/utils.py
index fee3792..6e7c84e 100644
--- a/hobo/environment/utils.py
+++ b/hobo/environment/utils.py
@@ -19,16 +19,17 @@ import hashlib
 from django.conf import settings
 from django.urls import reverse
 from django.db import connection
 from django.utils.six.moves.urllib.parse import urlparse
 from django.utils.encoding import force_text
 
 from hobo.middleware.utils import StoreRequestMiddleware
 from hobo.multitenant.settings_loaders import KnownServices
+from hobo.profile.utils import get_profile_dict
 
 
 def get_installed_services():
     from .models import AVAILABLE_SERVICES
     installed_services = []
     for available_service in AVAILABLE_SERVICES:
         installed_services.extend(available_service.objects.all())
     return installed_services
@@ -139,8 +140,54 @@ def get_setting_variable(setting_name, label=None, service=None):
     except Variable.DoesNotExist:
         variable = Variable(
             name='SETTING_' + setting_name,
             label=label or '',
             service=service,
             auto=True)
 
     return variable
+
+
+def export_parameters():
+    from .models import Variable
+
+    variables = []
+    for var in Variable.objects.filter(service_pk__isnull=True):
+        variables.append({
+            'name': var.name,
+            'label': var.label,
+            'value': var.value,
+            'auto': var.auto})
+    parameters = {'variables': variables}
+    parameters.update(get_profile_dict())
+    return parameters
+
+
+def import_parameters(parameters):
+    from .models import Variable
+    from hobo.profile.models import AttributeDefinition
+
+    objects = []
+    try:
+        for variables in parameters.get('variables'):
+            obj, created = Variable.objects.get_or_create(name=variables['name'])
+            for key, value in variables.items():
+                if key == 'name':
+                    continue
+                setattr(obj, key, value)
+            objects.append(obj)
+
+        for fields in parameters.get('profile', []).get('fields'):
+            obj, created = AttributeDefinition.objects.get_or_create(
+                label=fields['label'], name=fields['name'])
+            for key, value in fields.items():
+                if key == 'label' or key == 'name':
+                    continue
+                setattr(obj, key, value)
+            objects.append(obj)
+
+    except Exception as exc:
+        return {'err': True, 'message': '%s, %s' % (exc.__class__.__name__, exc)}
+
+    for obj in objects:
+        obj.save()
+    return {'err': False}
diff --git a/hobo/environment/views.py b/hobo/environment/views.py
index 69c8f19..62942e4 100644
--- a/hobo/environment/views.py
+++ b/hobo/environment/views.py
@@ -17,18 +17,21 @@
 import json
 import string
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse_lazy
 from django.http import HttpResponse, HttpResponseRedirect, Http404
 from django.shortcuts import get_object_or_404
+from django.utils.encoding import force_text
+from django.utils.translation import ugettext_lazy as _
+from django.views.generic import View
 from django.views.generic.base import TemplateView
-from django.views.generic.edit import CreateView, UpdateView, DeleteView
+from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
 
 from .models import Variable, AVAILABLE_SERVICES
 from . import forms, utils
 
 
 class AvailableService(object):
     def __init__(self, klass):
         self.id = klass.Extra.service_id
@@ -194,16 +197,47 @@ class ServiceDeleteView(DeleteView):
         service_id = self.kwargs.pop('service')
         service_slug = self.kwargs.pop('slug')
         for service in AVAILABLE_SERVICES:
             if service.Extra.service_id == service_id:
                 return service.objects.get(slug=service_slug)
         return None
 
 
+class ImportView(FormView):
+    form_class = forms.ImportForm
+    template_name = 'environment/import.html'
+    success_url = reverse_lazy('home')
+
+    def form_valid(self, form):
+        try:
+            parameters_json = json.loads(
+                force_text(self.request.FILES['parameters_json'].read()))
+        except ValueError:
+            form.add_error('parameters_json', _('File is not in the expected JSON format.'))
+            return self.form_invalid(form)
+
+        result = utils.import_parameters(parameters_json)
+        if result.get('err'):
+            form.add_error('parameters_json',
+                           _('Parameters import fails: %s') % force_text(result.get('message')))
+            return self.form_invalid(form)
+
+        return super(ImportView, self).form_valid(form)
+
+
+class ExportView(View):
+
+    def get(self, request, *args, **kwargs):
+        response = HttpResponse(content_type='application/json')
+        response['Content-Disposition'] = 'attachment; filename="hobo-export.json"'
+        json.dump(utils.export_parameters(), response, indent=2)
+        return response
+
+
 def operational_check_view(request, service, slug, **kwargs):
 
     for klass in AVAILABLE_SERVICES:
         if klass.Extra.service_id == service:
             break
     else:
         raise Http404()
 
diff --git a/hobo/templates/hobo/home.html b/hobo/templates/hobo/home.html
index fd29d39..3e47a59 100644
--- a/hobo/templates/hobo/home.html
+++ b/hobo/templates/hobo/home.html
@@ -13,16 +13,18 @@
     <li><a href="{% url 'emails-home' %}">{% trans 'Emails' %}</a></li>
     {% if has_authentic %}
     <li><a href="{% url 'franceconnect-home' %}">FranceConnect</a></li>
     {% endif %}
     <li><a href="{% url 'matomo-home' %}">{% trans 'User tracking' %}</a></li>
     <li><a href="{% url 'seo-home' %}">{% trans 'Indexing' %}</a></li>
     <li><a href="{% url 'environment-home' %}">{% trans 'Services' %}</a></li>
     <li><a href="{% url 'environment-variables' %}">{% trans 'Variables' %}</a></li>
+    <li><a rel="popup" href="{% url 'environment-import' %}">{% trans 'Import' %}</a></li>
+    <li><a href="{% url 'environment-export' %}">{% trans 'Export' %}</a></li>
     <li><a href="{% url 'debug-home' %}">{% trans 'Debugging' %}</a></li>
   </ul>
   </span>
 {% endblock %}
 
 {% block content %}
 
 {% if not has_global_title %}
diff --git a/tests/test_environment.py b/tests/test_environment.py
index 02ce816..7596ca5 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -1,16 +1,19 @@
 # -*- coding: utf-8 -*-
+import json
 import pytest
+from webtest import Upload
 
 from django.core.exceptions import ValidationError
 from django.core.management import call_command
 from django.utils import timezone
 
 from hobo.environment.models import AVAILABLE_SERVICES, Combo, Passerelle, ServiceBase, Variable
+from hobo.profile.models import AttributeDefinition
 
 from test_manager import login
 
 pytestmark = pytest.mark.django_db
 
 
 def test_service_id():
     for service in AVAILABLE_SERVICES:
@@ -311,8 +314,121 @@ def test_check_operational_command(monkeypatch, capsys):
     combo.last_operational_success_timestamp = '2022-2-22'
     combo.save()
     call_command('check_operational', '-v2')
     captured = capsys.readouterr()
     assert captured.out.split('\n')[:-1] == [
         'foo is NOT operational',
         '  last operational success: 2022-02-22 00:00:00+00:00'
     ]
+
+
+def test_export_import_view(app, admin_user):
+    combo = Combo.objects.create(base_url='https://combo.agglo.love',
+                                 template_name='...portal-user...',
+                                 slug='portal')
+    Variable.objects.create(name='foo', value='bar').save()
+    Variable.objects.create(name='foo2', value='bar2', service=combo).save()
+    app = login(app, 'admin', 'password')
+    resp = app.get('/sites/export/', status=200)
+    assert sorted(resp.json.keys()) == ['profile', 'variables']
+    assert resp.json['variables'] == [
+        {'name': 'foo', 'label': '', 'value': 'bar', 'auto': False}]
+    assert resp.json['profile']['fields'][0]['name'] == 'title'
+    assert resp.json['profile']['fields'][0]['required'] is False
+    assert resp.json['profile']['fields'][1]['name'] == 'first_name'
+    assert resp.json['profile']['fields'][1]['required'] is True
+
+    # modify exported file
+    export = resp.json
+    export['variables'][0]['label'] = 'bar'
+    fields = export['profile']['fields']
+    assert fields[0]['name'] == 'title'
+    assert fields[1]['name'] == 'first_name'
+    fields[0]['description'] = 'genre'
+    fields[0], fields[1] = fields[1], fields[0]
+    export_json = json.dumps(export)
+
+    # add new content
+    Variable.objects.create(name='foo3', value='bar3').save()
+    AttributeDefinition.objects.create(name='prefered_color').save()
+    assert Variable.objects.count() == 3
+    assert AttributeDefinition.objects.count() == 12
+    assert Variable.objects.get(name='foo').label == ''
+    assert AttributeDefinition.objects.get(name='title').description == ''
+    assert AttributeDefinition.objects.get(name='title').order == 1
+    assert AttributeDefinition.objects.get(name='first_name').order == 2
+    assert AttributeDefinition.objects.get(name='prefered_color').order == 12
+
+    # import valid content
+    resp = app.get('/', status=200)
+    resp = resp.click('Import')
+    resp.form['parameters_json'] = Upload(
+        'export.json', export_json.encode('utf-8'), 'application/json')
+    resp = resp.form.submit()
+    assert Variable.objects.count() == 3
+    assert AttributeDefinition.objects.count() == 12
+    assert Variable.objects.get(name='foo').label == 'bar'
+    assert AttributeDefinition.objects.get(name='title').description == 'genre'
+    assert AttributeDefinition.objects.get(name='title').order == 1
+    assert AttributeDefinition.objects.get(name='first_name').order == 2
+    assert AttributeDefinition.objects.get(name='prefered_color').order == 12
+
+    # import empty json
+    resp = app.get('/', status=200)
+    resp = resp.click('Import')
+    resp.form['parameters_json'] = Upload(
+        'export.json', b'{}', 'application/json')
+    resp = resp.form.submit()
+    assert Variable.objects.count() == 3
+    assert AttributeDefinition.objects.count() == 12
+    assert Variable.objects.get(name='foo').label == 'bar'
+    assert AttributeDefinition.objects.get(name='title').description == 'genre'
+    assert AttributeDefinition.objects.get(name='title').order == 1
+    assert AttributeDefinition.objects.get(name='first_name').order == 2
+    assert AttributeDefinition.objects.get(name='prefered_color').order == 12
+
+    # import from scratch
+    Variable.objects.all().delete()
+    AttributeDefinition.objects.all().delete()
+    Variable.objects.create(name='foo2', value='bar2', service=combo).save()
+    AttributeDefinition.objects.create(name='prefered_color').save()
+    assert Variable.objects.count() == 1
+    assert AttributeDefinition.objects.count() == 1
+    resp = app.get('/', status=200)
+    resp = resp.click('Import')
+    resp.form['parameters_json'] = Upload(
+        'export.json', export_json.encode('utf-8'), 'application/json')
+    resp = resp.form.submit()
+    assert Variable.objects.count() == 2
+    assert AttributeDefinition.objects.count() == 12
+    assert Variable.objects.get(name='foo').label == 'bar'
+    assert AttributeDefinition.objects.get(name='title').order == 3
+    assert AttributeDefinition.objects.get(name='first_name').order == 2
+    assert AttributeDefinition.objects.get(name='prefered_color').order == 1
+
+    # import invalid json
+    resp = app.get('/', status=200)
+    resp = resp.click('Import')
+    resp.form['parameters_json'] = Upload(
+        'export.json', b'garbage', 'application/json')
+    resp = resp.form.submit()
+    assert resp.html.find('ul', {'class': 'errorlist'}).li.text == \
+        'File is not in the expected JSON format.'
+    assert Variable.objects.count() == 2
+    assert AttributeDefinition.objects.count() == 12
+
+    # import corrupted json
+    export['variables'][0]['label'] = 'foofoo'
+    fields = export['profile']['fields']
+    assert fields[1]['name'] == 'title'
+    fields[1]['label'] = 'Genre'
+    export_json = json.dumps(export)
+    resp = app.get('/', status=200)
+    resp = resp.click('Import')
+    resp.form['parameters_json'] = Upload(
+        'export.json', export_json.encode('utf-8'), 'application/json')
+    resp = resp.form.submit()
+    assert 'Parameters import fails: IntegrityError' in resp.html.find(
+        'ul', {'class': 'errorlist'}).li.text
+    assert Variable.objects.count() == 2
+    assert AttributeDefinition.objects.count() == 12
+    assert Variable.objects.get(name='foo').label == 'bar'
-- 
2.28.0

