From ac337a2fff4e0404c9a1bb5f992b8ecec9041c54 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 19 May 2022 17:39:54 +0200
Subject: [PATCH 1/3] pricing: import/export (#65442)

---
 lingo/agendas/models.py                       |  32 +-
 lingo/pricing/forms.py                        |  12 +
 lingo/pricing/management/__init__.py          |   0
 lingo/pricing/management/commands/__init__.py |   0
 .../management/commands/export_site.py        |  39 ++
 .../management/commands/import_site.py        |  54 +++
 .../templates/lingo/pricing/export.html       |  22 +
 .../templates/lingo/pricing/import.html       |  22 +
 .../lingo/pricing/manager_pricing_list.html   |   9 +-
 lingo/pricing/urls.py                         |   2 +
 lingo/pricing/utils.py                        |  75 ++++
 lingo/pricing/views.py                        | 144 ++++++-
 tests/pricing/test_import_export.py           | 375 ++++++++++++++++++
 tests/pricing/test_manager.py                 |  78 ++--
 14 files changed, 836 insertions(+), 28 deletions(-)
 create mode 100644 lingo/pricing/management/__init__.py
 create mode 100644 lingo/pricing/management/commands/__init__.py
 create mode 100644 lingo/pricing/management/commands/export_site.py
 create mode 100644 lingo/pricing/management/commands/import_site.py
 create mode 100644 lingo/pricing/templates/lingo/pricing/export.html
 create mode 100644 lingo/pricing/templates/lingo/pricing/import.html
 create mode 100644 lingo/pricing/utils.py
 create mode 100644 tests/pricing/test_import_export.py

diff --git a/lingo/agendas/models.py b/lingo/agendas/models.py
index 6a4d6f8..e4c5153 100644
--- a/lingo/agendas/models.py
+++ b/lingo/agendas/models.py
@@ -14,11 +14,13 @@
 # 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 copy
+
 from django.db import models
 from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 
-from lingo.utils.misc import clean_import_data, generate_slug
+from lingo.utils.misc import AgendaImportError, clean_import_data, generate_slug
 
 
 class Agenda(models.Model):
@@ -37,6 +39,34 @@ class Agenda(models.Model):
     def base_slug(self):
         return slugify(self.label)
 
+    def export_json(self):
+        return {
+            'slug': self.slug,
+            'pricings': [x.export_json() for x in self.agendapricing_set.all()],
+        }
+
+    @classmethod
+    def import_json(cls, data, overwrite=False):
+        from lingo.pricing.models import AgendaPricing, Pricing
+
+        data = copy.deepcopy(data)
+        try:
+            agenda = Agenda.objects.get(slug=data['slug'])
+        except Agenda.DoesNotExist:
+            raise AgendaImportError(_('Missing "%s" agenda') % data['slug'])
+        pricings = data.pop('pricings', None) or []
+        for pricing_data in pricings:
+            try:
+                pricing_data['pricing'] = Pricing.objects.get(slug=pricing_data['pricing'])
+            except Pricing.DoesNotExist:
+                raise AgendaImportError(_('Missing "%s" pricing model') % pricing_data['pricing'])
+
+        for pricing_data in pricings:
+            pricing_data['agenda'] = agenda
+            AgendaPricing.import_json(pricing_data)
+
+        return False, agenda
+
     def can_be_managed(self, user):
         return True
 
diff --git a/lingo/pricing/forms.py b/lingo/pricing/forms.py
index ffb7b06..8f26b6c 100644
--- a/lingo/pricing/forms.py
+++ b/lingo/pricing/forms.py
@@ -22,6 +22,18 @@ from django.utils.translation import ugettext_lazy as _
 from lingo.pricing.models import AgendaPricing, Criteria, CriteriaCategory
 
 
+class ExportForm(forms.Form):
+    agendas = forms.BooleanField(label=_('Agendas'), required=False, initial=True)
+    pricing_categories = forms.BooleanField(
+        label=_('Pricing criteria categories'), required=False, initial=True
+    )
+    pricing_models = forms.BooleanField(label=_('Pricing models'), required=False, initial=True)
+
+
+class ImportForm(forms.Form):
+    config_json = forms.FileField(label=_('Export File'))
+
+
 class NewCriteriaForm(forms.ModelForm):
     class Meta:
         model = Criteria
diff --git a/lingo/pricing/management/__init__.py b/lingo/pricing/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lingo/pricing/management/commands/__init__.py b/lingo/pricing/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lingo/pricing/management/commands/export_site.py b/lingo/pricing/management/commands/export_site.py
new file mode 100644
index 0000000..21d4b8d
--- /dev/null
+++ b/lingo/pricing/management/commands/export_site.py
@@ -0,0 +1,39 @@
+# lingo - payment and billing system
+# Copyright (C) 2022  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# 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 json
+import sys
+
+from django.core.management.base import BaseCommand
+
+from lingo.pricing.utils import export_site
+
+
+class Command(BaseCommand):
+    help = 'Export the site'
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '--output', metavar='FILE', default=None, help='name of a file to write output to'
+        )
+
+    def handle(self, *args, **options):
+        if options['output']:
+            with open(options['output'], 'w') as output:
+                json.dump(export_site(), output, indent=4)
+        else:
+            output = sys.stdout
+            json.dump(export_site(), output, indent=4)
diff --git a/lingo/pricing/management/commands/import_site.py b/lingo/pricing/management/commands/import_site.py
new file mode 100644
index 0000000..b34cdd8
--- /dev/null
+++ b/lingo/pricing/management/commands/import_site.py
@@ -0,0 +1,54 @@
+# lingo - payment and billing system
+# Copyright (C) 2022  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# 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 json
+import sys
+
+from django.core.management.base import BaseCommand, CommandError
+
+from lingo.pricing.utils import import_site
+from lingo.utils.misc import AgendaImportError
+
+
+class Command(BaseCommand):
+    help = 'Import an exported site'
+
+    def add_arguments(self, parser):
+        parser.add_argument('filename', metavar='FILENAME', type=str, help='name of file to import')
+        parser.add_argument('--clean', action='store_true', default=False, help='Clean site before importing')
+        parser.add_argument(
+            '--if-empty', action='store_true', default=False, help='Import only if site is empty'
+        )
+        parser.add_argument('--overwrite', action='store_true', default=False, help='Overwrite existing data')
+
+    def handle(self, filename, **options):
+        def do_import(fd):
+            try:
+                import_site(
+                    json.load(fd),
+                    if_empty=options['if_empty'],
+                    clean=options['clean'],
+                    overwrite=options['overwrite'],
+                )
+            except AgendaImportError as exc:
+                raise CommandError('%s' % exc)
+
+        if filename == '-':
+            fd = sys.stdin
+            do_import(fd)
+        else:
+            with open(filename) as fd:
+                do_import(fd)
diff --git a/lingo/pricing/templates/lingo/pricing/export.html b/lingo/pricing/templates/lingo/pricing/export.html
new file mode 100644
index 0000000..80cee8d
--- /dev/null
+++ b/lingo/pricing/templates/lingo/pricing/export.html
@@ -0,0 +1,22 @@
+{% extends "lingo/pricing/manager_pricing_list.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'lingo-manager-config-export' %}">{% trans 'Export' %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans "Export" %}</h2>
+{% endblock %}
+
+{% block content %}
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Export" %}</button>
+    <a class="cancel" href="{% url 'lingo-manager-pricing-list' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/lingo/pricing/templates/lingo/pricing/import.html b/lingo/pricing/templates/lingo/pricing/import.html
new file mode 100644
index 0000000..c88b1a6
--- /dev/null
+++ b/lingo/pricing/templates/lingo/pricing/import.html
@@ -0,0 +1,22 @@
+{% extends "lingo/pricing/manager_pricing_list.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'lingo-manager-config-import' %}">{% trans 'Import' %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans "Import" %}</h2>
+{% 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 'lingo-manager-pricing-list' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/lingo/pricing/templates/lingo/pricing/manager_pricing_list.html b/lingo/pricing/templates/lingo/pricing/manager_pricing_list.html
index 5e9b544..fc1246d 100644
--- a/lingo/pricing/templates/lingo/pricing/manager_pricing_list.html
+++ b/lingo/pricing/templates/lingo/pricing/manager_pricing_list.html
@@ -9,8 +9,13 @@
 {% block appbar %}
 <h2>{% trans 'Pricing' context 'pricing' %}</h2>
 <span class="actions">
-<a href="{% url 'lingo-manager-pricing-criteria-list' %}">{% trans 'Criterias' %}</a>
-<a rel="popup" href="{% url 'lingo-manager-pricing-add' %}">{% trans 'New pricing model' %}</a>
+  <a class="extra-actions-menu-opener"></a>
+  <ul class="extra-actions-menu">
+    <li><a rel="popup" href="{% url 'lingo-manager-config-import' %}">{% trans 'Import' %}</a></li>
+    <li><a rel="popup" href="{% url 'lingo-manager-config-export' %}" data-autoclose-dialog="true">{% trans 'Export' %}</a></li>
+  </ul>
+  <a href="{% url 'lingo-manager-pricing-criteria-list' %}">{% trans 'Criterias' %}</a>
+  <a rel="popup" href="{% url 'lingo-manager-pricing-add' %}">{% trans 'New pricing model' %}</a>
 </span>
 {% endblock %}
 
diff --git a/lingo/pricing/urls.py b/lingo/pricing/urls.py
index 454d217..25d76db 100644
--- a/lingo/pricing/urls.py
+++ b/lingo/pricing/urls.py
@@ -20,6 +20,8 @@ from . import views
 
 urlpatterns = [
     url(r'^$', views.pricing_list, name='lingo-manager-pricing-list'),
+    url(r'^import/$', views.config_import, name='lingo-manager-config-import'),
+    url(r'^export/$', views.config_export, name='lingo-manager-config-export'),
     url(
         r'^add/$',
         views.pricing_add,
diff --git a/lingo/pricing/utils.py b/lingo/pricing/utils.py
new file mode 100644
index 0000000..a786d93
--- /dev/null
+++ b/lingo/pricing/utils.py
@@ -0,0 +1,75 @@
+# lingo - payment and billing system
+# Copyright (C) 2022  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# 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 collections
+
+from django.db import transaction
+
+from lingo.agendas.models import Agenda
+from lingo.pricing.models import AgendaPricing, CriteriaCategory, Pricing
+
+
+def export_site(
+    agendas=True,
+    pricing_categories=True,
+    pricing_models=True,
+):
+    '''Dump site objects to JSON-dumpable dictionnary'''
+    data = collections.OrderedDict()
+    if pricing_models:
+        data['pricing_models'] = [x.export_json() for x in Pricing.objects.all()]
+    if pricing_categories:
+        data['pricing_categories'] = [x.export_json() for x in CriteriaCategory.objects.all()]
+    if agendas:
+        data['agendas'] = [x.export_json() for x in Agenda.objects.all()]
+    return data
+
+
+def import_site(data, if_empty=False, clean=False, overwrite=False):
+    if if_empty and (
+        AgendaPricing.objects.exists() or CriteriaCategory.objects.exists() or Pricing.objects.exists()
+    ):
+        return
+
+    if clean:
+        AgendaPricing.objects.all().delete()
+        CriteriaCategory.objects.all().delete()
+        Pricing.objects.all().delete()
+
+    results = {
+        key: collections.defaultdict(list)
+        for key in [
+            'agendas',
+            'pricing_categories',
+            'pricing_models',
+        ]
+    }
+
+    with transaction.atomic():
+        for cls, key in (
+            (CriteriaCategory, 'pricing_categories'),
+            (Pricing, 'pricing_models'),
+            (Agenda, 'agendas'),
+        ):
+            objs = data.get(key, [])
+            for obj in objs:
+                created, obj = cls.import_json(obj, overwrite=overwrite)
+                results[key]['all'].append(obj)
+                if created:
+                    results[key]['created'].append(obj)
+                else:
+                    results[key]['updated'].append(obj)
+    return results
diff --git a/lingo/pricing/views.py b/lingo/pricing/views.py
index f1b4b30..40c39d6 100644
--- a/lingo/pricing/views.py
+++ b/lingo/pricing/views.py
@@ -20,11 +20,15 @@ from collections import defaultdict
 from operator import itemgetter
 
 from django import forms
+from django.contrib import messages
 from django.core.exceptions import PermissionDenied
 from django.db.models import Prefetch
 from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
 from django.shortcuts import get_object_or_404
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
+from django.utils.encoding import force_text
+from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext
 from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, UpdateView
 from django.views.generic.detail import SingleObjectMixin
 
@@ -33,6 +37,8 @@ from lingo.agendas.views import ManagedAgendaMixin, ViewableAgendaMixin
 from lingo.pricing.forms import (
     AgendaPricingForm,
     CriteriaForm,
+    ExportForm,
+    ImportForm,
     NewCriteriaForm,
     PricingCriteriaCategoryAddForm,
     PricingCriteriaCategoryEditForm,
@@ -41,6 +47,142 @@ from lingo.pricing.forms import (
     PricingVariableFormSet,
 )
 from lingo.pricing.models import AgendaPricing, Criteria, CriteriaCategory, Pricing, PricingCriteriaCategory
+from lingo.pricing.utils import export_site, import_site
+from lingo.utils.misc import AgendaImportError
+
+
+class ConfigExportView(FormView):
+    form_class = ExportForm
+    template_name = 'lingo/pricing/export.html'
+
+    def dispatch(self, request, *args, **kwargs):
+        if not request.user.is_staff:
+            raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_valid(self, form):
+        response = HttpResponse(content_type='application/json')
+        today = datetime.date.today()
+        response['Content-Disposition'] = 'attachment; filename="export_pricing_config_{}.json"'.format(
+            today.strftime('%Y%m%d')
+        )
+        json.dump(export_site(**form.cleaned_data), response, indent=2)
+        return response
+
+
+config_export = ConfigExportView.as_view()
+
+
+class ConfigImportView(FormView):
+    form_class = ImportForm
+    template_name = 'lingo/pricing/import.html'
+    success_url = reverse_lazy('lingo-manager-pricing-list')
+
+    def dispatch(self, request, *args, **kwargs):
+        if not request.user.is_staff:
+            raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_valid(self, form):
+        try:
+            config_json = json.loads(force_text(self.request.FILES['config_json'].read()))
+        except ValueError:
+            form.add_error('config_json', _('File is not in the expected JSON format.'))
+            return self.form_invalid(form)
+
+        try:
+            results = import_site(config_json, overwrite=False)
+        except AgendaImportError as exc:
+            form.add_error('config_json', '%s' % exc)
+            return self.form_invalid(form)
+        except KeyError as exc:
+            form.add_error('config_json', _('Key "%s" is missing.') % exc.args[0])
+            return self.form_invalid(form)
+
+        import_messages = {
+            'agendas': {
+                'update_noop': _('No agenda updated.'),
+                'update': lambda x: ungettext(
+                    'An agenda has been updated.',
+                    '%(count)d agendas have been updated.',
+                    x,
+                ),
+            },
+            'pricing_categories': {
+                'create_noop': _('No pricing criteria category created.'),
+                'create': lambda x: ungettext(
+                    'A pricing criteria category has been created.',
+                    '%(count)d pricing criteria categories have been created.',
+                    x,
+                ),
+                'update_noop': _('No pricing criteria category updated.'),
+                'update': lambda x: ungettext(
+                    'A pricing criteria category has been updated.',
+                    '%(count)d pricing criteria categories have been updated.',
+                    x,
+                ),
+            },
+            'pricing_models': {
+                'create_noop': _('No pricing model created.'),
+                'create': lambda x: ungettext(
+                    'A pricing model has been created.',
+                    '%(count)d pricing models have been created.',
+                    x,
+                ),
+                'update_noop': _('No pricing model updated.'),
+                'update': lambda x: ungettext(
+                    'A pricing model has been updated.',
+                    '%(count)d pricing models have been updated.',
+                    x,
+                ),
+            },
+        }
+
+        global_noop = True
+        for obj_name, obj_results in results.items():
+            if obj_results['all']:
+                global_noop = False
+                count = len(obj_results['created'])
+                if not count:
+                    message1 = import_messages[obj_name]['create_noop']
+                else:
+                    message1 = import_messages[obj_name]['create'](count) % {'count': count}
+
+                count = len(obj_results['updated'])
+                if not count:
+                    message2 = import_messages[obj_name]['update_noop']
+                else:
+                    message2 = import_messages[obj_name]['update'](count) % {'count': count}
+
+                obj_results['messages'] = "%s %s" % (message1, message2)
+
+        pc_count, pm_count = (
+            len(results['pricing_categories']['all']),
+            len(results['pricing_models']['all']),
+        )
+        if (pc_count, pm_count) == (1, 0):
+            # only one criteria category imported, redirect to criteria page
+            return HttpResponseRedirect(reverse('lingo-manager-pricing-criteria-list'))
+        if (pc_count, pm_count) == (0, 1):
+            # only one pricing imported, redirect to pricing page
+            return HttpResponseRedirect(
+                reverse(
+                    'lingo-manager-pricing-detail',
+                    kwargs={'pk': results['pricing_models']['all'][0].pk},
+                )
+            )
+
+        if global_noop:
+            messages.info(self.request, _('No data found.'))
+        else:
+            messages.info(self.request, results['agendas']['messages'])
+            messages.info(self.request, results['pricing_categories']['messages'])
+            messages.info(self.request, results['pricing_models']['messages'])
+
+        return super().form_valid(form)
+
+
+config_import = ConfigImportView.as_view()
 
 
 class PricingListView(ListView):
diff --git a/tests/pricing/test_import_export.py b/tests/pricing/test_import_export.py
new file mode 100644
index 0000000..74fadd8
--- /dev/null
+++ b/tests/pricing/test_import_export.py
@@ -0,0 +1,375 @@
+import copy
+import datetime
+import json
+import os
+import shutil
+import sys
+import tempfile
+from io import StringIO
+
+import pytest
+from django.core.management import call_command
+from django.utils.encoding import force_bytes
+
+from lingo.agendas.models import Agenda
+from lingo.pricing.models import AgendaPricing, Criteria, CriteriaCategory, Pricing, PricingCriteriaCategory
+from lingo.pricing.utils import import_site
+from lingo.utils.misc import AgendaImportError
+
+pytestmark = pytest.mark.django_db
+
+
+def get_output_of_command(command, *args, **kwargs):
+    old_stdout = sys.stdout
+    output = sys.stdout = StringIO()
+    call_command(command, *args, **kwargs)
+    sys.stdout = old_stdout
+    return output.getvalue()
+
+
+def test_import_export(app):
+    agenda = Agenda.objects.create(label='Foo Bar')
+    pricing = Pricing.objects.create(label='Foo')
+    AgendaPricing.objects.create(
+        agenda=agenda,
+        pricing=pricing,
+        date_start=datetime.date(year=2021, month=9, day=1),
+        date_end=datetime.date(year=2021, month=10, day=1),
+    )
+    CriteriaCategory.objects.create(label='Foo bar')
+
+    output = get_output_of_command('export_site')
+    assert len(json.loads(output)['agendas']) == 1
+    assert len(json.loads(output)['pricing_models']) == 1
+    assert len(json.loads(output)['pricing_categories']) == 1
+    import_site(data={}, clean=True)
+    empty_output = get_output_of_command('export_site')
+    assert len(json.loads(empty_output)['agendas']) == 1
+    assert len(json.loads(empty_output)['agendas'][0]['pricings']) == 0
+    assert len(json.loads(empty_output)['pricing_models']) == 0
+    assert len(json.loads(empty_output)['pricing_categories']) == 0
+
+    old_stdin = sys.stdin
+    sys.stdin = StringIO(json.dumps({}))
+    agenda = Agenda.objects.create(label='Foo Bar')
+    pricing = Pricing.objects.create(label='Foo')
+    AgendaPricing.objects.create(
+        agenda=agenda,
+        pricing=pricing,
+        date_start=datetime.date(year=2021, month=9, day=1),
+        date_end=datetime.date(year=2021, month=10, day=1),
+    )
+    CriteriaCategory.objects.create(label='Foo bar')
+    old_stdin = sys.stdin
+    sys.stdin = StringIO(json.dumps({}))
+    assert AgendaPricing.objects.count() == 1
+    assert Pricing.objects.count() == 1
+    assert CriteriaCategory.objects.count() == 1
+    try:
+        call_command('import_site', '-', clean=True)
+    finally:
+        sys.stdin = old_stdin
+    assert AgendaPricing.objects.count() == 0
+    assert Pricing.objects.count() == 0
+    assert CriteriaCategory.objects.count() == 0
+
+    with tempfile.NamedTemporaryFile() as f:
+        f.write(force_bytes(output))
+        f.flush()
+        call_command('import_site', f.name)
+    assert AgendaPricing.objects.count() == 1
+    assert Pricing.objects.count() == 1
+    assert CriteriaCategory.objects.count() == 1
+
+    import_site(data={}, if_empty=True)
+    assert AgendaPricing.objects.count() == 1
+    assert Pricing.objects.count() == 1
+    assert CriteriaCategory.objects.count() == 1
+
+    import_site(data={}, clean=True)
+    tempdir = tempfile.mkdtemp('lingo-test')
+    empty_output = get_output_of_command('export_site', output=os.path.join(tempdir, 't.json'))
+    assert os.path.exists(os.path.join(tempdir, 't.json'))
+    shutil.rmtree(tempdir)
+
+
+def test_import_export_agenda_with_pricing(app):
+    pricing = Pricing.objects.create(label='Foo')
+    agenda = Agenda.objects.create(label='Foo Bar')
+    agenda_pricing = AgendaPricing.objects.create(
+        agenda=agenda,
+        pricing=pricing,
+        date_start=datetime.date(year=2021, month=9, day=1),
+        date_end=datetime.date(year=2021, month=10, day=1),
+        pricing_data={
+            'foo': 'bar',
+        },
+    )
+    output = get_output_of_command('export_site')
+
+    import_site(data={}, clean=True)
+    assert Pricing.objects.count() == 0
+    data = json.loads(output)
+    del data['pricing_models']
+
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "foo" pricing model'
+
+    Pricing.objects.create(label='foobar')
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "foo" pricing model'
+
+    Agenda.objects.all().delete()
+    pricing = Pricing.objects.create(label='Foo')
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "foo-bar" agenda'
+
+    agenda = Agenda.objects.create(label='Foo Bar')
+    import_site(data, overwrite=True)
+    assert agenda.agendapricing_set.count() == 1
+    agenda_pricing = agenda.agendapricing_set.get()
+    assert agenda_pricing.agenda == agenda
+    assert agenda_pricing.pricing == pricing
+    assert agenda_pricing.date_start == datetime.date(year=2021, month=9, day=1)
+    assert agenda_pricing.date_end == datetime.date(year=2021, month=10, day=1)
+
+    # again
+    import_site(data)
+    assert agenda.agendapricing_set.count() == 1
+    agenda_pricing = AgendaPricing.objects.get(pk=agenda_pricing.pk)
+    assert agenda_pricing.agenda == agenda
+    assert agenda_pricing.pricing == pricing
+
+    data['agendas'][0]['pricings'].append(
+        {
+            'pricing': 'foo',
+            'date_start': '2022-09-01',
+            'date_end': '2022-10-01',
+            'pricing_data': {'foo': 'bar'},
+        }
+    )
+    import_site(data)
+    assert agenda.agendapricing_set.count() == 2
+    agenda_pricing = AgendaPricing.objects.latest('pk')
+    assert agenda_pricing.agenda == agenda
+    assert agenda_pricing.pricing == pricing
+    assert agenda_pricing.date_start == datetime.date(year=2022, month=9, day=1)
+    assert agenda_pricing.date_end == datetime.date(year=2022, month=10, day=1)
+
+
+def test_import_export_site_criteria_category(app):
+    output = get_output_of_command('export_site')
+    payload = json.loads(output)
+    assert len(payload['pricing_categories']) == 0
+
+    category = CriteriaCategory.objects.create(label='Foo bar')
+    Criteria.objects.create(label='Foo reason', category=category)
+    Criteria.objects.create(label='Baz', category=category)
+
+    output = get_output_of_command('export_site')
+    payload = json.loads(output)
+    assert len(payload['pricing_categories']) == 1
+
+    category.delete()
+    assert not CriteriaCategory.objects.exists()
+    assert not Criteria.objects.exists()
+
+    import_site(copy.deepcopy(payload))
+    assert CriteriaCategory.objects.count() == 1
+    category = CriteriaCategory.objects.first()
+    assert category.label == 'Foo bar'
+    assert category.slug == 'foo-bar'
+    assert category.criterias.count() == 2
+    assert Criteria.objects.get(category=category, label='Foo reason', slug='foo-reason')
+    assert Criteria.objects.get(category=category, label='Baz', slug='baz')
+
+    # update
+    update_payload = copy.deepcopy(payload)
+    update_payload['pricing_categories'][0]['label'] = 'Foo bar Updated'
+    import_site(update_payload)
+    category.refresh_from_db()
+    assert category.label == 'Foo bar Updated'
+
+    # insert another category
+    category.slug = 'foo-bar-updated'
+    category.save()
+    import_site(copy.deepcopy(payload))
+    assert CriteriaCategory.objects.count() == 2
+    category = CriteriaCategory.objects.latest('pk')
+    assert category.label == 'Foo bar'
+    assert category.slug == 'foo-bar'
+    assert category.criterias.count() == 2
+    assert Criteria.objects.get(category=category, label='Foo reason', slug='foo-reason')
+    assert Criteria.objects.get(category=category, label='Baz', slug='baz')
+
+    # with overwrite
+    Criteria.objects.create(category=category, label='Baz2')
+    import_site(copy.deepcopy(payload), overwrite=True)
+    assert CriteriaCategory.objects.count() == 2
+    category = CriteriaCategory.objects.latest('pk')
+    assert category.label == 'Foo bar'
+    assert category.slug == 'foo-bar'
+    assert category.criterias.count() == 2
+    assert Criteria.objects.get(category=category, label='Foo reason', slug='foo-reason')
+    assert Criteria.objects.get(category=category, label='Baz', slug='baz')
+
+
+def test_import_export_site(app):
+    output = get_output_of_command('export_site')
+    payload = json.loads(output)
+    assert len(payload['pricing_models']) == 0
+
+    pricing = Pricing.objects.create(label='Foo bar', extra_variables={'foo': 'bar'})
+
+    output = get_output_of_command('export_site')
+    payload = json.loads(output)
+    assert len(payload['pricing_models']) == 1
+
+    pricing.delete()
+    assert not Pricing.objects.exists()
+
+    import_site(copy.deepcopy(payload))
+    assert Pricing.objects.count() == 1
+    pricing = Pricing.objects.first()
+    assert pricing.label == 'Foo bar'
+    assert pricing.slug == 'foo-bar'
+    assert pricing.extra_variables == {'foo': 'bar'}
+
+    # update
+    update_payload = copy.deepcopy(payload)
+    update_payload['pricing_models'][0]['label'] = 'Foo bar Updated'
+    import_site(update_payload)
+    pricing.refresh_from_db()
+    assert pricing.label == 'Foo bar Updated'
+
+    # insert another pricing
+    pricing.slug = 'foo-bar-updated'
+    pricing.save()
+    import_site(copy.deepcopy(payload))
+    assert Pricing.objects.count() == 2
+    pricing = Pricing.objects.latest('pk')
+    assert pricing.label == 'Foo bar'
+    assert pricing.slug == 'foo-bar'
+    assert pricing.extra_variables == {'foo': 'bar'}
+
+
+def test_import_export_site_with_categories(app):
+    pricing = Pricing.objects.create(label='Foo bar')
+    category = CriteriaCategory.objects.create(label='Foo bar')
+    pricing.categories.add(category, through_defaults={'order': 42})
+
+    output = get_output_of_command('export_site')
+
+    import_site(data={}, clean=True)
+    assert Pricing.objects.count() == 0
+    assert CriteriaCategory.objects.count() == 0
+    data = json.loads(output)
+    del data['pricing_categories']
+
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "foo-bar" pricing category'
+
+    CriteriaCategory.objects.create(label='Foobar')
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "foo-bar" pricing category'
+
+    category = CriteriaCategory.objects.create(label='Foo bar')
+    import_site(data, overwrite=True)
+    pricing = Pricing.objects.get(slug=pricing.slug)
+    assert list(pricing.categories.all()) == [category]
+    assert PricingCriteriaCategory.objects.first().order == 42
+
+    category2 = CriteriaCategory.objects.create(label='Foo bar 2')
+    category3 = CriteriaCategory.objects.create(label='Foo bar 3')
+    pricing.categories.add(category2, through_defaults={'order': 1})
+    output = get_output_of_command('export_site')
+    data = json.loads(output)
+    del data['pricing_categories']
+    data['pricing_models'][0]['categories'] = [
+        {
+            'category': 'foo-bar-3',
+            'order': 1,
+            'criterias': [],
+        },
+        {
+            'category': 'foo-bar',
+            'order': 35,
+            'criterias': [],
+        },
+    ]
+    import_site(data, overwrite=True)
+    assert list(pricing.categories.all()) == [category, category3]
+    assert list(
+        PricingCriteriaCategory.objects.filter(pricing=pricing).values_list('category', flat=True)
+    ) == [category3.pk, category.pk]
+    assert list(PricingCriteriaCategory.objects.filter(pricing=pricing).values_list('order', flat=True)) == [
+        1,
+        35,
+    ]
+    assert list(pricing.criterias.all()) == []
+
+    criteria1 = Criteria.objects.create(label='Crit 1', category=category)
+    Criteria.objects.create(label='Crit 2', category=category)
+    criteria3 = Criteria.objects.create(label='Crit 3', category=category)
+
+    # unknown criteria
+    data['pricing_models'][0]['categories'] = [
+        {
+            'category': 'foo-bar-3',
+            'order': 1,
+            'criterias': ['unknown'],
+        },
+        {
+            'category': 'foo-bar',
+            'order': 35,
+            'criterias': [],
+        },
+    ]
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "unknown" pricing criteria for "foo-bar-3" category'
+
+    # wrong criteria (from another category)
+    data['pricing_models'][0]['categories'] = [
+        {
+            'category': 'foo-bar-3',
+            'order': 1,
+            'criterias': ['crit-1'],
+        },
+        {
+            'category': 'foo-bar',
+            'order': 35,
+            'criterias': [],
+        },
+    ]
+    with pytest.raises(AgendaImportError) as excinfo:
+        import_site(data, overwrite=True)
+    assert str(excinfo.value) == 'Missing "crit-1" pricing criteria for "foo-bar-3" category'
+
+    data['pricing_models'][0]['categories'] = [
+        {
+            'category': 'foo-bar-3',
+            'order': 1,
+            'criterias': [],
+        },
+        {
+            'category': 'foo-bar',
+            'order': 35,
+            'criterias': ['crit-1', 'crit-3'],
+        },
+    ]
+    import_site(data, overwrite=True)
+    assert list(pricing.categories.all()) == [category, category3]
+    assert list(
+        PricingCriteriaCategory.objects.filter(pricing=pricing).values_list('category', flat=True)
+    ) == [category3.pk, category.pk]
+    assert list(PricingCriteriaCategory.objects.filter(pricing=pricing).values_list('order', flat=True)) == [
+        1,
+        35,
+    ]
+    assert set(pricing.criterias.all()) == {criteria1, criteria3}
diff --git a/tests/pricing/test_manager.py b/tests/pricing/test_manager.py
index 33f1b9b..40d80f7 100644
--- a/tests/pricing/test_manager.py
+++ b/tests/pricing/test_manager.py
@@ -20,6 +20,42 @@ def agenda_with_restrictions(manager_user):
     return agenda
 
 
+def test_export_site(settings, freezer, app, admin_user):
+    freezer.move_to('2020-06-15')
+    login(app)
+    resp = app.get('/manage/pricing/')
+    resp = resp.click('Export')
+
+    resp = resp.form.submit()
+    assert resp.headers['content-type'] == 'application/json'
+    assert resp.headers['content-disposition'] == 'attachment; filename="export_pricing_config_20200615.json"'
+
+    site_json = json.loads(resp.text)
+    assert site_json == {
+        'agendas': [],
+        'pricing_categories': [],
+        'pricing_models': [],
+    }
+
+    Agenda.objects.create(label='Foo Bar')
+    resp = app.get('/manage/pricing/export/')
+    resp = resp.form.submit()
+
+    site_json = json.loads(resp.text)
+    assert len(site_json['agendas']) == 1
+
+    resp = app.get('/manage/pricing/export/')
+    resp.form['agendas'] = False
+    resp.form['pricing_categories'] = False
+    resp.form['pricing_models'] = False
+    resp = resp.form.submit()
+
+    site_json = json.loads(resp.text)
+    assert 'agendas' not in site_json
+    assert 'pricing_categories' not in site_json
+    assert 'pricing_models' not in site_json
+
+
 @pytest.mark.xfail(reason='/manage/ limited to admin')
 def test_list_pricings_as_manager(app, manager_user, agenda_with_restrictions):
     app = login(app, username='manager', password='manager')
@@ -144,7 +180,6 @@ def test_duplicate_pricing_as_manager(app, manager_user):
     app.get('/manage/pricing/%s/duplicate/' % pricing.pk, status=403)
 
 
-@pytest.mark.xfail(reason='import not yet implemented')
 @pytest.mark.freeze_time('2021-07-08')
 def test_import_pricing(app, admin_user):
     pricing = Pricing.objects.create(label='Model')
@@ -156,9 +191,9 @@ def test_import_pricing(app, admin_user):
     pricing_export = resp.text
 
     # existing pricing
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload('export.json', pricing_export.encode('utf-8'), 'application/json')
+    resp.form['config_json'] = Upload('export.json', pricing_export.encode('utf-8'), 'application/json')
     resp = resp.form.submit()
     assert resp.location.endswith('/manage/pricing/%s/' % pricing.pk)
     resp = resp.follow()
@@ -167,9 +202,9 @@ def test_import_pricing(app, admin_user):
 
     # new pricing
     Pricing.objects.all().delete()
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload('export.json', pricing_export.encode('utf-8'), 'application/json')
+    resp.form['config_json'] = Upload('export.json', pricing_export.encode('utf-8'), 'application/json')
     resp = resp.form.submit()
     pricing = Pricing.objects.latest('pk')
     assert resp.location.endswith('/manage/pricing/%s/' % pricing.pk)
@@ -186,23 +221,19 @@ def test_import_pricing(app, admin_user):
     pricings['pricing_models'][2]['label'] = 'Foo bar 3'
     pricings['pricing_models'][2]['slug'] = 'foo-bar-3'
 
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload(
-        'export.json', json.dumps(pricings).encode('utf-8'), 'application/json'
-    )
+    resp.form['config_json'] = Upload('export.json', json.dumps(pricings).encode('utf-8'), 'application/json')
     resp = resp.form.submit()
-    assert resp.location.endswith('/manage/')
+    assert resp.location.endswith('/manage/pricing/')
     resp = resp.follow()
     assert '2 pricing models have been created. A pricing model has been updated.' in resp.text
     assert Pricing.objects.count() == 3
 
     Pricing.objects.all().delete()
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload(
-        'export.json', json.dumps(pricings).encode('utf-8'), 'application/json'
-    )
+    resp.form['config_json'] = Upload('export.json', json.dumps(pricings).encode('utf-8'), 'application/json')
     resp = resp.form.submit().follow()
     assert '3 pricing models have been created. No pricing model updated.' in resp.text
     assert Pricing.objects.count() == 3
@@ -734,7 +765,6 @@ def test_reorder_criterias_as_manager(app, manager_user):
     app.get('/manage/pricing/criteria/category/%s/order/' % (category.pk), status=403)
 
 
-@pytest.mark.xfail(reason='import not yet implemented')
 @pytest.mark.freeze_time('2021-07-08')
 def test_import_criteria_category(app, admin_user):
     category = CriteriaCategory.objects.create(label='Foo bar')
@@ -751,9 +781,9 @@ def test_import_criteria_category(app, admin_user):
     category_export = resp.text
 
     # existing category
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload('export.json', category_export.encode('utf-8'), 'application/json')
+    resp.form['config_json'] = Upload('export.json', category_export.encode('utf-8'), 'application/json')
     resp = resp.form.submit()
     assert resp.location.endswith('/manage/pricing/criterias/')
     resp = resp.follow()
@@ -765,9 +795,9 @@ def test_import_criteria_category(app, admin_user):
 
     # new category
     CriteriaCategory.objects.all().delete()
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload('export.json', category_export.encode('utf-8'), 'application/json')
+    resp.form['config_json'] = Upload('export.json', category_export.encode('utf-8'), 'application/json')
     resp = resp.form.submit()
     assert resp.location.endswith('/manage/pricing/criterias/')
     resp = resp.follow()
@@ -786,13 +816,13 @@ def test_import_criteria_category(app, admin_user):
     categories['pricing_categories'][2]['label'] = 'Foo bar 3'
     categories['pricing_categories'][2]['slug'] = 'foo-bar-3'
 
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload(
+    resp.form['config_json'] = Upload(
         'export.json', json.dumps(categories).encode('utf-8'), 'application/json'
     )
     resp = resp.form.submit()
-    assert resp.location.endswith('/manage/')
+    assert resp.location.endswith('/manage/pricing/')
     resp = resp.follow()
     assert (
         '2 pricing criteria categories have been created. A pricing criteria category has been updated.'
@@ -802,9 +832,9 @@ def test_import_criteria_category(app, admin_user):
     assert Criteria.objects.count() == 6
 
     CriteriaCategory.objects.all().delete()
-    resp = app.get('/manage/', status=200)
+    resp = app.get('/manage/pricing/', status=200)
     resp = resp.click('Import')
-    resp.form['agendas_json'] = Upload(
+    resp.form['config_json'] = Upload(
         'export.json', json.dumps(categories).encode('utf-8'), 'application/json'
     )
     resp = resp.form.submit().follow()
-- 
2.35.1

