From 67e37eabf167a109a8afbdc9d57ab279208ab50a Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Wed, 21 Sep 2022 11:52:51 +0200
Subject: [PATCH] invoicing: import/export regies (#69322)

---
 lingo/invoicing/models.py                     |  32 ++++++
 .../lingo/invoicing/manager_import.html       |  22 ++++
 .../lingo/invoicing/manager_regie_list.html   |   9 ++
 lingo/invoicing/urls.py                       |   2 +
 lingo/invoicing/views.py                      | 101 +++++++++++++++++-
 tests/invoicing/test_manager.py               |  25 +++++
 6 files changed, 187 insertions(+), 4 deletions(-)
 create mode 100644 lingo/invoicing/templates/lingo/invoicing/manager_import.html

diff --git a/lingo/invoicing/models.py b/lingo/invoicing/models.py
index a874230..7692aa8 100644
--- a/lingo/invoicing/models.py
+++ b/lingo/invoicing/models.py
@@ -14,6 +14,8 @@
 # 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.contrib.auth.models import Group
 from django.db import models
 from django.utils.text import slugify
@@ -22,6 +24,10 @@ from django.utils.translation import gettext_lazy as _
 from lingo.utils.misc import generate_slug
 
 
+class RegieImportError(Exception):
+    pass
+
+
 class Regie(models.Model):
     label = models.CharField(_('Label'), max_length=150)
     slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
@@ -52,3 +58,29 @@ class Regie(models.Model):
     @property
     def base_slug(self):
         return slugify(self.label)
+
+    def export_json(self):
+        return {
+            'label': self.label,
+            'slug': self.slug,
+            'description': self.description,
+            'permissions': {
+                'cashier': self.cashier_role.name if self.cashier_role else None,
+            },
+        }
+
+    @classmethod
+    def import_json(cls, data):
+        data = copy.deepcopy(data)
+        permissions = data.pop('permissions') or {}
+        role_name = permissions.get('cashier')
+        if role_name:
+            try:
+                data['cashier_role'] = Group.objects.get(name=role_name)
+            except Group.DoesNotExists:
+                raise RegieImportError('Missing role: %s' % role_name)
+            except Group.MultipleObjectsReturned:
+                raise RegieImportError('Multiple role exist with the name: %s' % role_name)
+
+        regie, created = cls.objects.update_or_create(slug=data['slug'], defaults=data)
+        return created, regie
diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_import.html b/lingo/invoicing/templates/lingo/invoicing/manager_import.html
new file mode 100644
index 0000000..89208c8
--- /dev/null
+++ b/lingo/invoicing/templates/lingo/invoicing/manager_import.html
@@ -0,0 +1,22 @@
+{% extends "lingo/invoicing/manager_regie_list.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'lingo-manager-invoicing-regie-import' %}">{% trans 'Import' %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans "Import Regies" %}</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-invoicing-regie-list' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html b/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html
index 8c01e85..163dcd7 100644
--- a/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html
+++ b/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html
@@ -4,6 +4,15 @@
 {% block appbar %}
 <h2>{% trans 'Regies' %}</h2>
 <span class="actions">
+  <a class="extra-actions-menu-opener"></a>
+  <ul class="extra-actions-menu">
+    <li>
+      <a href="{% url 'lingo-manager-invoicing-regie-import' %}">{% trans 'Import' %}</a>
+    </li>
+    <li>
+      <a href="{% url 'lingo-manager-invoicing-regie-export' %}">{% trans 'Export' %}</a>
+    </li>
+  </ul>
   <a rel="popup" href="{% url 'lingo-manager-invoicing-regie-add' %}">{% trans 'New regie' %}</a>
 </span>
 {% endblock %}
diff --git a/lingo/invoicing/urls.py b/lingo/invoicing/urls.py
index e2a6ac6..526954d 100644
--- a/lingo/invoicing/urls.py
+++ b/lingo/invoicing/urls.py
@@ -41,4 +41,6 @@ urlpatterns = [
         views.regie_delete,
         name='lingo-manager-invoicing-regie-delete',
     ),
+    path('regies/import/', views.regies_import, name='lingo-manager-invoicing-regie-import'),
+    path('regies/export/', views.regies_export, name='lingo-manager-invoicing-regie-export'),
 ]
diff --git a/lingo/invoicing/views.py b/lingo/invoicing/views.py
index 650abda..e12cc9b 100644
--- a/lingo/invoicing/views.py
+++ b/lingo/invoicing/views.py
@@ -14,10 +14,41 @@
 # 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/>.
 
-from django.urls import reverse
-from django.views.generic import CreateView, DeleteView, DetailView, ListView, TemplateView, UpdateView
-
-from lingo.invoicing.models import Regie
+import collections
+import datetime
+import json
+
+from django.contrib import messages
+from django.db import transaction
+from django.http import HttpResponse
+from django.urls import reverse, reverse_lazy
+from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext
+from django.views.generic import (
+    CreateView,
+    DeleteView,
+    DetailView,
+    FormView,
+    ListView,
+    TemplateView,
+    UpdateView,
+)
+
+from lingo.invoicing.models import Regie, RegieImportError
+from lingo.pricing.forms import ImportForm
+
+
+def import_regies(data):
+    results = collections.defaultdict(list)
+    with transaction.atomic():
+        regies = data.get('regies', [])
+        for regie in regies:
+            created, regie_obj = Regie.import_json(regie)
+            if created:
+                results['created'].append(regie_obj)
+            else:
+                results['updated'].append(regie_obj)
+    return results
 
 
 class HomeView(TemplateView):
@@ -81,3 +112,65 @@ class RegieDeleteView(DeleteView):
 
 
 regie_delete = RegieDeleteView.as_view()
+
+
+class RegiesExportView(ListView):
+    model = Regie
+
+    def get(self, request, *args, **kwargs):
+        response = HttpResponse(content_type='application/json')
+        today = datetime.date.today()
+        attachment = 'attachment; filename="export_regies_{}.json"'.format(today.strftime('%Y%m%d'))
+        response['Content-Disposition'] = attachment
+        json.dump({'regies': [regie.export_json() for regie in self.get_queryset()]}, response, indent=2)
+        return response
+
+
+regies_export = RegiesExportView.as_view()
+
+
+class RegiesImportView(FormView):
+    form_class = ImportForm
+    template_name = 'lingo/invoicing/manager_import.html'
+    success_url = reverse_lazy('lingo-manager-invoicing-regie-list')
+
+    def form_valid(self, form):
+        try:
+            config_json = json.loads(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_regies(config_json)
+        except RegieImportError as exc:
+            form.add_error('config_json', '%s' % exc)
+            return self.form_invalid(form)
+
+        import_messages = {
+            'create': lambda x: ungettext(
+                'A regie was created.',
+                '%(count)d regies were created.',
+                x,
+            ),
+            'update': lambda x: ungettext(
+                'A regie was updated.',
+                '%(count)d regie were updated.',
+                x,
+            ),
+        }
+        create_message = _('No regie created.')
+        update_message = _('No regie updated.')
+        created = len(results.get('created', []))
+        updated = len(results.get('updated', []))
+        if created:
+            create_message = import_messages.get('create')(created) % {'count': created}
+        if updated:
+            update_message = import_messages.get('update')(updated) % {'count': updated}
+        message = "%s %s" % (create_message, update_message)
+        messages.info(self.request, message)
+
+        return super().form_valid(form)
+
+
+regies_import = RegiesImportView.as_view()
diff --git a/tests/invoicing/test_manager.py b/tests/invoicing/test_manager.py
index 94b8688..62fbe8e 100644
--- a/tests/invoicing/test_manager.py
+++ b/tests/invoicing/test_manager.py
@@ -1,8 +1,10 @@
+import json
 from urllib.parse import urlparse
 
 import pytest
 from django.contrib.auth.models import Group
 from django.urls import reverse
+from webtest import Upload
 
 from lingo.invoicing.models import Regie
 from tests.utils import login
@@ -142,3 +144,26 @@ def test_manager_invoicing_regie_delete(app, admin_user):
     response = resp.form.submit().follow()
     assert Regie.objects.count() == 0
     assert urlparse(response.request.url).path == reverse('lingo-manager-invoicing-regie-list')
+
+
+def test_manager_invoicing_regie_import_export(app, admin_user, freezer):
+    freezer.move_to('2020-06-15')
+    app = login(app)
+    group = Group.objects.create(name='role-foo')
+    regie1 = Regie.objects.create(label='Foo', description='foo description', cashier_role=group)
+    regie2 = Regie.objects.create(label='Bar', description='bar description', cashier_role=group)
+    response = app.get(reverse('lingo-manager-invoicing-regie-export'))
+    assert response.headers['content-type'] == 'application/json'
+    assert response.headers['content-disposition'] == 'attachment; filename="export_regies_20200615.json"'
+    regies_export = response.text
+    regies_json = json.loads(regies_export)
+    assert len(regies_json['regies']) == 2
+
+    regie1.delete()
+    assert Regie.objects.count() == 1
+    response = app.get(reverse('lingo-manager-invoicing-regie-import'))
+    response.form['config_json'] = Upload('export.json', regies_export.encode('utf-8'), 'application/json')
+    response = response.form.submit().follow()
+    assert urlparse(response.request.url).path == reverse('lingo-manager-invoicing-regie-list')
+    assert 'A regie was created. A regie was updated.' in response.text
+    assert Regie.objects.count() == 2
-- 
2.30.2

