From 2b3f25985b53a5d6dd099c73832c5db6ef13ab26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Fri, 2 Jun 2023 11:04:23 +0200 Subject: [PATCH] invoicing: add a global export/import for invoicing config (#78125) --- lingo/invoicing/forms.py | 8 ++ .../templates/lingo/invoicing/export.html | 22 ++++ .../{manager_import.html => import.html} | 8 +- .../lingo/invoicing/manager_home.html | 7 ++ .../lingo/invoicing/manager_regie_detail.html | 12 +- .../lingo/invoicing/manager_regie_list.html | 9 -- lingo/invoicing/urls.py | 9 +- lingo/invoicing/utils.py | 40 ++++++- lingo/invoicing/views/home.py | 112 +++++++++++++++++- lingo/invoicing/views/regie.py | 76 ++---------- .../templates/lingo/pricing/export.html | 4 +- .../templates/lingo/pricing/import.html | 4 +- tests/invoicing/manager/test_import_export.py | 95 +++++++++++++++ tests/invoicing/manager/test_regie.py | 25 ---- 14 files changed, 314 insertions(+), 117 deletions(-) create mode 100644 lingo/invoicing/templates/lingo/invoicing/export.html rename lingo/invoicing/templates/lingo/invoicing/{manager_import.html => import.html} (55%) create mode 100644 tests/invoicing/manager/test_import_export.py diff --git a/lingo/invoicing/forms.py b/lingo/invoicing/forms.py index f0db4e5..bd43fdf 100644 --- a/lingo/invoicing/forms.py +++ b/lingo/invoicing/forms.py @@ -23,6 +23,14 @@ from gadjo.forms.widgets import MultiSelectWidget from lingo.invoicing.models import Campaign, DraftInvoice, DraftInvoiceLine, Invoice, InvoiceLine, Regie +class ExportForm(forms.Form): + regies = forms.BooleanField(label=_('Regies'), required=False, initial=True) + + +class ImportForm(forms.Form): + config_json = forms.FileField(label=_('Export File')) + + class RegieForm(forms.ModelForm): class Meta: model = Regie diff --git a/lingo/invoicing/templates/lingo/invoicing/export.html b/lingo/invoicing/templates/lingo/invoicing/export.html new file mode 100644 index 0000000..9605ac9 --- /dev/null +++ b/lingo/invoicing/templates/lingo/invoicing/export.html @@ -0,0 +1,22 @@ +{% extends "lingo/invoicing/manager_home.html" %} +{% load i18n %} + +{% block breadcrumb %} + {{ block.super }} + {% trans 'Export' %} +{% endblock %} + +{% block appbar %} +

{% trans "Export" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_import.html b/lingo/invoicing/templates/lingo/invoicing/import.html similarity index 55% rename from lingo/invoicing/templates/lingo/invoicing/manager_import.html rename to lingo/invoicing/templates/lingo/invoicing/import.html index 80e7920..69a6672 100644 --- a/lingo/invoicing/templates/lingo/invoicing/manager_import.html +++ b/lingo/invoicing/templates/lingo/invoicing/import.html @@ -1,13 +1,13 @@ -{% extends "lingo/invoicing/manager_regie_list.html" %} +{% extends "lingo/invoicing/manager_home.html" %} {% load i18n %} {% block breadcrumb %} {{ block.super }} - {% trans 'Import' %} + {% trans 'Import' %} {% endblock %} {% block appbar %} -

{% trans "Import Regies" %}

+

{% trans "Import" %}

{% endblock %} {% block content %} @@ -16,7 +16,7 @@ {{ form.as_p }}
- {% trans 'Cancel' %} + {% trans 'Cancel' %}
{% endblock %} diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_home.html b/lingo/invoicing/templates/lingo/invoicing/manager_home.html index b518c5f..8f8b143 100644 --- a/lingo/invoicing/templates/lingo/invoicing/manager_home.html +++ b/lingo/invoicing/templates/lingo/invoicing/manager_home.html @@ -8,6 +8,13 @@ {% block appbar %}

{% trans 'Invoicing' %}

+ + + + {% endblock %} {% block content %} diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_regie_detail.html b/lingo/invoicing/templates/lingo/invoicing/manager_regie_detail.html index cb1dffc..1f9ab94 100644 --- a/lingo/invoicing/templates/lingo/invoicing/manager_regie_detail.html +++ b/lingo/invoicing/templates/lingo/invoicing/manager_regie_detail.html @@ -9,10 +9,14 @@ {% block appbar %}

{% trans 'Regie' %} - {{ regie }}

- {% if not has_related_objects %} - {% trans "Delete" %} - {% endif %} - {% trans "Edit" %} + + {% trans 'Non invoiced lines' %} {% trans 'Invoices' %} diff --git a/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html b/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html index 24ee278..e4e47bf 100644 --- a/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html +++ b/lingo/invoicing/templates/lingo/invoicing/manager_regie_list.html @@ -9,15 +9,6 @@ {% block appbar %}

{% trans 'Regies' %}

- - {% trans 'New regie' %} {% endblock %} diff --git a/lingo/invoicing/urls.py b/lingo/invoicing/urls.py index af0f8d9..3c64a8a 100644 --- a/lingo/invoicing/urls.py +++ b/lingo/invoicing/urls.py @@ -23,6 +23,8 @@ from .views import regie as regie_views urlpatterns = [ path('', home_views.home, name='lingo-manager-invoicing-home'), + path('import/', home_views.config_import, name='lingo-manager-invoicing-config-import'), + path('export/', home_views.config_export, name='lingo-manager-invoicing-config-export'), path('regies/', regie_views.regies_list, name='lingo-manager-invoicing-regie-list'), path( 'regie/add/', @@ -44,8 +46,11 @@ urlpatterns = [ regie_views.regie_delete, name='lingo-manager-invoicing-regie-delete', ), - path('regies/import/', regie_views.regies_import, name='lingo-manager-invoicing-regie-import'), - path('regies/export/', regie_views.regies_export, name='lingo-manager-invoicing-regie-export'), + path( + 'regie//export/', + regie_views.regie_export, + name='lingo-manager-invoicing-regie-export', + ), path( 'regie//campaign/add/', campaign_views.campaign_add, diff --git a/lingo/invoicing/utils.py b/lingo/invoicing/utils.py index 5d259c5..5e27003 100644 --- a/lingo/invoicing/utils.py +++ b/lingo/invoicing/utils.py @@ -17,12 +17,13 @@ import collections import datetime +from django.db import transaction from django.test.client import RequestFactory from django.utils.translation import gettext_lazy as _ from lingo.agendas.chrono import get_check_status, get_subscriptions from lingo.agendas.models import Agenda -from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, InjectedLine +from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, InjectedLine, Regie from lingo.pricing.models import AgendaPricing, AgendaPricingNotFound, PricingError @@ -310,3 +311,40 @@ def generate_invoices_from_lines(all_lines, pool): invoices.append(invoice) return invoices + + +def export_site( + regies=True, +): + '''Dump site objects to JSON-dumpable dictionnary''' + data = collections.OrderedDict() + if regies: + data['regies'] = [x.export_json() for x in Regie.objects.all()] + return data + + +def import_site(data, if_empty=False, clean=False): + if if_empty and (Regie.objects.exists()): + return + + if clean: + Regie.objects.all().delete() + + results = { + key: collections.defaultdict(list) + for key in [ + 'regies', + ] + } + + with transaction.atomic(): + for cls, key in ((Regie, 'regies'),): + objs = data.get(key, []) + for obj in objs: + created, obj = cls.import_json(obj) + results[key]['all'].append(obj) + if created: + results[key]['created'].append(obj) + else: + results[key]['updated'].append(obj) + return results diff --git a/lingo/invoicing/views/home.py b/lingo/invoicing/views/home.py index 612d578..a24cc81 100644 --- a/lingo/invoicing/views/home.py +++ b/lingo/invoicing/views/home.py @@ -14,7 +14,20 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.views.generic import TemplateView +import datetime +import json + +from django.contrib import messages +from django.http import HttpResponse, HttpResponseRedirect +from django.urls import reverse, reverse_lazy +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext +from django.views.generic import FormView, TemplateView + +from lingo.invoicing.forms import ExportForm, ImportForm +from lingo.invoicing.utils import export_site, import_site +from lingo.utils.misc import LingoImportError class HomeView(TemplateView): @@ -22,3 +35,100 @@ class HomeView(TemplateView): home = HomeView.as_view() + + +class ConfigExportView(FormView): + form_class = ExportForm + template_name = 'lingo/invoicing/export.html' + + def form_valid(self, form): + response = HttpResponse(content_type='application/json') + today = datetime.date.today() + response['Content-Disposition'] = 'attachment; filename="export_invoicing_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/invoicing/import.html' + success_url = reverse_lazy('lingo-manager-invoicing-home') + + def form_valid(self, form): + try: + config_json = json.loads(force_str(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) + except LingoImportError 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 = { + 'regies': { + 'create_noop': _('No regie created.'), + 'create': lambda x: ngettext( + 'A regie has been created.', + '%(count)d regies have been created.', + x, + ), + 'update_noop': _('No regie updated.'), + 'update': lambda x: ngettext( + 'A regie has been updated.', + '%(count)d regies 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].get('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} + + if message1: + obj_results['messages'] = "%s %s" % (message1, message2) + else: + obj_results['messages'] = message2 + + (r_count,) = (len(results['regies']['all']),) + if (r_count,) == (1,): + # only one regie imported, redirect to regie page + return HttpResponseRedirect( + reverse( + 'lingo-manager-invoicing-regie-detail', + kwargs={'pk': results['regies']['all'][0].pk}, + ) + ) + + if global_noop: + messages.info(self.request, _('No data found.')) + else: + messages.info(self.request, results['regies']['messages']) + + return super().form_valid(form) + + +config_import = ConfigImportView.as_view() diff --git a/lingo/invoicing/views/regie.py b/lingo/invoicing/views/regie.py index 5d32083..76a5a1c 100644 --- a/lingo/invoicing/views/regie.py +++ b/lingo/invoicing/views/regie.py @@ -18,30 +18,17 @@ import collections import datetime import json -from django.contrib import messages from django.db import transaction from django.db.models import CharField, IntegerField, JSONField, Value from django.http import HttpResponse from django.shortcuts import get_object_or_404 -from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ -from django.utils.translation import ngettext -from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, UpdateView +from django.urls import reverse +from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from lingo.agendas.models import Agenda from lingo.invoicing.forms import RegieForm, RegieInvoiceFilterSet -from lingo.invoicing.models import ( - Counter, - InjectedLine, - Invoice, - InvoiceLine, - InvoicePayment, - Pool, - Regie, - RegieImportError, -) +from lingo.invoicing.models import Counter, InjectedLine, Invoice, InvoiceLine, InvoicePayment, Pool, Regie from lingo.invoicing.views.utils import PDFMixin -from lingo.pricing.forms import ImportForm def import_regies(data): @@ -128,66 +115,21 @@ class RegieDeleteView(DeleteView): regie_delete = RegieDeleteView.as_view() -class RegiesExportView(ListView): +class RegieExport(DetailView): 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')) + attachment = 'attachment; filename="export_regie_{}_{}.json"'.format( + self.get_object().slug, today.strftime('%Y%m%d') + ) response['Content-Disposition'] = attachment - json.dump({'regies': [regie.export_json() for regie in self.get_queryset()]}, response, indent=2) + json.dump({'regies': [self.get_object().export_json()]}, 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: ngettext( - 'A regie was created.', - '%(count)d regies were created.', - x, - ), - 'update': lambda x: ngettext( - '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() +regie_export = RegieExport.as_view() class NonInvoicedLineListView(ListView): diff --git a/lingo/pricing/templates/lingo/pricing/export.html b/lingo/pricing/templates/lingo/pricing/export.html index 9531773..8b9323d 100644 --- a/lingo/pricing/templates/lingo/pricing/export.html +++ b/lingo/pricing/templates/lingo/pricing/export.html @@ -1,4 +1,4 @@ -{% extends "lingo/pricing/manager_pricing_list.html" %} +{% extends "lingo/pricing/manager_home.html" %} {% load i18n %} {% block breadcrumb %} @@ -16,7 +16,7 @@ {{ form.as_p }}
- {% trans 'Cancel' %} + {% trans 'Cancel' %}
{% endblock %} diff --git a/lingo/pricing/templates/lingo/pricing/import.html b/lingo/pricing/templates/lingo/pricing/import.html index 96d754e..3b8771f 100644 --- a/lingo/pricing/templates/lingo/pricing/import.html +++ b/lingo/pricing/templates/lingo/pricing/import.html @@ -1,4 +1,4 @@ -{% extends "lingo/pricing/manager_pricing_list.html" %} +{% extends "lingo/pricing/manager_home.html" %} {% load i18n %} {% block breadcrumb %} @@ -16,7 +16,7 @@ {{ form.as_p }}
- {% trans 'Cancel' %} + {% trans 'Cancel' %}
{% endblock %} diff --git a/tests/invoicing/manager/test_import_export.py b/tests/invoicing/manager/test_import_export.py new file mode 100644 index 0000000..ce7e530 --- /dev/null +++ b/tests/invoicing/manager/test_import_export.py @@ -0,0 +1,95 @@ +import copy +import json + +import pytest +from webtest import Upload + +from lingo.invoicing.models import Regie +from tests.utils import login + +pytestmark = pytest.mark.django_db + + +def test_export_site(freezer, app, admin_user): + freezer.move_to('2020-06-15') + login(app) + resp = app.get('/manage/invoicing/') + resp = resp.click('Export') + + resp = resp.form.submit() + assert resp.headers['content-type'] == 'application/json' + assert ( + resp.headers['content-disposition'] == 'attachment; filename="export_invoicing_config_20200615.json"' + ) + + site_json = json.loads(resp.text) + assert site_json == { + 'regies': [], + } + + Regie.objects.create(label='Foo Bar') + resp = app.get('/manage/invoicing/export/') + resp = resp.form.submit() + + site_json = json.loads(resp.text) + assert len(site_json['regies']) == 1 + + +@pytest.mark.freeze_time('2023-06-02') +def test_import_regie(app, admin_user): + regie = Regie.objects.create(label='Foo bar') + + app = login(app) + resp = app.get('/manage/invoicing/regie/%s/' % regie.pk) + resp = resp.click(href='/manage/invoicing/regie/%s/export/' % regie.pk) + assert resp.headers['content-type'] == 'application/json' + assert resp.headers['content-disposition'] == 'attachment; filename="export_regie_foo-bar_20230602.json"' + regie_export = resp.text + + # existing regie + resp = app.get('/manage/invoicing/', status=200) + resp = resp.click('Import') + resp.form['config_json'] = Upload('export.json', regie_export.encode('utf-8'), 'application/json') + resp = resp.form.submit() + assert resp.location.endswith('/manage/invoicing/regie/%s/' % regie.pk) + resp = resp.follow() + assert 'No regie created. A regie has been updated.' not in resp.text + assert Regie.objects.count() == 1 + + # new regie + Regie.objects.all().delete() + resp = app.get('/manage/invoicing/', status=200) + resp = resp.click('Import') + resp.form['config_json'] = Upload('export.json', regie_export.encode('utf-8'), 'application/json') + resp = resp.form.submit() + regie = Regie.objects.latest('pk') + assert resp.location.endswith('/manage/invoicing/regie/%s/' % regie.pk) + resp = resp.follow() + assert 'A regie has been created. No regie updated.' not in resp.text + assert Regie.objects.count() == 1 + + # multiple regies + regies = json.loads(regie_export) + regies['regies'].append(copy.copy(regies['regies'][0])) + regies['regies'].append(copy.copy(regies['regies'][0])) + regies['regies'][1]['label'] = 'Foo bar 2' + regies['regies'][1]['slug'] = 'foo-bar-2' + regies['regies'][2]['label'] = 'Foo bar 3' + regies['regies'][2]['slug'] = 'foo-bar-3' + + resp = app.get('/manage/invoicing/', status=200) + resp = resp.click('Import') + resp.form['config_json'] = Upload('export.json', json.dumps(regies).encode('utf-8'), 'application/json') + resp = resp.form.submit() + assert resp.location.endswith('/manage/invoicing/') + resp = resp.follow() + assert '2 regies have been created. A regie has been updated.' in resp.text + assert Regie.objects.count() == 3 + + Regie.objects.all().delete() + resp = app.get('/manage/invoicing/', status=200) + resp = resp.click('Import') + resp.form['config_json'] = Upload('export.json', json.dumps(regies).encode('utf-8'), 'application/json') + resp = resp.form.submit().follow() + assert '3 regies have been created. No regie updated.' in resp.text + assert Regie.objects.count() == 3 diff --git a/tests/invoicing/manager/test_regie.py b/tests/invoicing/manager/test_regie.py index 376d740..dd3b5a9 100644 --- a/tests/invoicing/manager/test_regie.py +++ b/tests/invoicing/manager/test_regie.py @@ -1,5 +1,4 @@ import datetime -import json from urllib.parse import urlparse import pytest @@ -7,7 +6,6 @@ from django.contrib.auth.models import Group from django.urls import reverse from django.utils.formats import date_format from django.utils.timezone import localtime -from webtest import Upload from lingo.agendas.models import Agenda from lingo.invoicing.models import ( @@ -225,29 +223,6 @@ def test_manager_invoicing_regie_delete(app, admin_user): assert Counter.objects.count() == 0 -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) - 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 - - def test_non_invoiced_line_list(app, admin_user): regie = Regie.objects.create(label='Regie') other_regie = Regie.objects.create(label='Other Regie')