invoicing: add a global export/import for invoicing config (#78125)

This commit is contained in:
Lauréline Guérin 2023-06-02 11:04:23 +02:00
parent 1813a9c415
commit 2b3f25985b
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
14 changed files with 314 additions and 117 deletions

View File

@ -23,6 +23,14 @@ from gadjo.forms.widgets import MultiSelectWidget
from lingo.invoicing.models import Campaign, DraftInvoice, DraftInvoiceLine, Invoice, InvoiceLine, Regie 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 RegieForm(forms.ModelForm):
class Meta: class Meta:
model = Regie model = Regie

View File

@ -0,0 +1,22 @@
{% extends "lingo/invoicing/manager_home.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'lingo-manager-invoicing-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-invoicing-home' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -1,13 +1,13 @@
{% extends "lingo/invoicing/manager_regie_list.html" %} {% extends "lingo/invoicing/manager_home.html" %}
{% load i18n %} {% load i18n %}
{% block breadcrumb %} {% block breadcrumb %}
{{ block.super }} {{ block.super }}
<a href="{% url 'lingo-manager-invoicing-regie-import' %}">{% trans 'Import' %}</a> <a href="{% url 'lingo-manager-invoicing-config-import' %}">{% trans 'Import' %}</a>
{% endblock %} {% endblock %}
{% block appbar %} {% block appbar %}
<h2>{% trans "Import Regies" %}</h2> <h2>{% trans "Import" %}</h2>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -16,7 +16,7 @@
{{ form.as_p }} {{ form.as_p }}
<div class="buttons"> <div class="buttons">
<button class="submit-button">{% trans "Import" %}</button> <button class="submit-button">{% trans "Import" %}</button>
<a class="cancel" href="{% url 'lingo-manager-invoicing-regie-list' %}">{% trans 'Cancel' %}</a> <a class="cancel" href="{% url 'lingo-manager-invoicing-home' %}">{% trans 'Cancel' %}</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -8,6 +8,13 @@
{% block appbar %} {% block appbar %}
<h2>{% trans 'Invoicing' %}</h2> <h2>{% trans 'Invoicing' %}</h2>
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'lingo-manager-invoicing-config-import' %}">{% trans 'Import' %}</a></li>
<li><a rel="popup" href="{% url 'lingo-manager-invoicing-config-export' %}" data-autoclose-dialog="true">{% trans 'Export' %}</a></li>
</ul>
</span>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -9,10 +9,14 @@
{% block appbar %} {% block appbar %}
<h2>{% trans 'Regie' %} - {{ regie }}</h2> <h2>{% trans 'Regie' %} - {{ regie }}</h2>
<span class="actions"> <span class="actions">
{% if not has_related_objects %} <a class="extra-actions-menu-opener"></a>
<a href="{% url 'lingo-manager-invoicing-regie-delete' pk=regie.pk %}" rel="popup">{% trans "Delete" %}</a> <ul class="extra-actions-menu">
{% endif %} <li><a href="{% url 'lingo-manager-invoicing-regie-edit' pk=regie.pk %}" rel="popup">{% trans "Edit" %}</a></li>
<a href="{% url 'lingo-manager-invoicing-regie-edit' pk=regie.pk %}" rel="popup">{% trans "Edit" %}</a> <li><a href="{% url 'lingo-manager-invoicing-regie-export' pk=regie.pk %}">{% trans 'Export' %}</a></li>
{% if not has_related_objects %}
<li><a href="{% url 'lingo-manager-invoicing-regie-delete' pk=regie.pk %}" rel="popup">{% trans "Delete" %}</a></li>
{% endif %}
</ul>
<a href="{% url 'lingo-manager-invoicing-non-invoiced-line-list' regie_pk=regie.pk %}">{% trans 'Non invoiced lines' %}</a> <a href="{% url 'lingo-manager-invoicing-non-invoiced-line-list' regie_pk=regie.pk %}">{% trans 'Non invoiced lines' %}</a>
<a href="{% url 'lingo-manager-invoicing-regie-invoice-list' regie_pk=regie.pk %}">{% trans 'Invoices' %}</a> <a href="{% url 'lingo-manager-invoicing-regie-invoice-list' regie_pk=regie.pk %}">{% trans 'Invoices' %}</a>
</span> </span>

View File

@ -9,15 +9,6 @@
{% block appbar %} {% block appbar %}
<h2>{% trans 'Regies' %}</h2> <h2>{% trans 'Regies' %}</h2>
<span class="actions"> <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> <a rel="popup" href="{% url 'lingo-manager-invoicing-regie-add' %}">{% trans 'New regie' %}</a>
</span> </span>
{% endblock %} {% endblock %}

View File

@ -23,6 +23,8 @@ from .views import regie as regie_views
urlpatterns = [ urlpatterns = [
path('', home_views.home, name='lingo-manager-invoicing-home'), 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('regies/', regie_views.regies_list, name='lingo-manager-invoicing-regie-list'),
path( path(
'regie/add/', 'regie/add/',
@ -44,8 +46,11 @@ urlpatterns = [
regie_views.regie_delete, regie_views.regie_delete,
name='lingo-manager-invoicing-regie-delete', name='lingo-manager-invoicing-regie-delete',
), ),
path('regies/import/', regie_views.regies_import, name='lingo-manager-invoicing-regie-import'), path(
path('regies/export/', regie_views.regies_export, name='lingo-manager-invoicing-regie-export'), 'regie/<int:pk>/export/',
regie_views.regie_export,
name='lingo-manager-invoicing-regie-export',
),
path( path(
'regie/<int:regie_pk>/campaign/add/', 'regie/<int:regie_pk>/campaign/add/',
campaign_views.campaign_add, campaign_views.campaign_add,

View File

@ -17,12 +17,13 @@
import collections import collections
import datetime import datetime
from django.db import transaction
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from lingo.agendas.chrono import get_check_status, get_subscriptions from lingo.agendas.chrono import get_check_status, get_subscriptions
from lingo.agendas.models import Agenda 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 from lingo.pricing.models import AgendaPricing, AgendaPricingNotFound, PricingError
@ -310,3 +311,40 @@ def generate_invoices_from_lines(all_lines, pool):
invoices.append(invoice) invoices.append(invoice)
return invoices 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

View File

@ -14,7 +14,20 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
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): class HomeView(TemplateView):
@ -22,3 +35,100 @@ class HomeView(TemplateView):
home = HomeView.as_view() 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()

View File

@ -18,30 +18,17 @@ import collections
import datetime import datetime
import json import json
from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import CharField, IntegerField, JSONField, Value from django.db.models import CharField, IntegerField, JSONField, Value
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
from django.utils.translation import ngettext
from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, UpdateView
from lingo.agendas.models import Agenda from lingo.agendas.models import Agenda
from lingo.invoicing.forms import RegieForm, RegieInvoiceFilterSet from lingo.invoicing.forms import RegieForm, RegieInvoiceFilterSet
from lingo.invoicing.models import ( from lingo.invoicing.models import Counter, InjectedLine, Invoice, InvoiceLine, InvoicePayment, Pool, Regie
Counter,
InjectedLine,
Invoice,
InvoiceLine,
InvoicePayment,
Pool,
Regie,
RegieImportError,
)
from lingo.invoicing.views.utils import PDFMixin from lingo.invoicing.views.utils import PDFMixin
from lingo.pricing.forms import ImportForm
def import_regies(data): def import_regies(data):
@ -128,66 +115,21 @@ class RegieDeleteView(DeleteView):
regie_delete = RegieDeleteView.as_view() regie_delete = RegieDeleteView.as_view()
class RegiesExportView(ListView): class RegieExport(DetailView):
model = Regie model = Regie
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
response = HttpResponse(content_type='application/json') response = HttpResponse(content_type='application/json')
today = datetime.date.today() 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 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 return response
regies_export = RegiesExportView.as_view() regie_export = RegieExport.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()
class NonInvoicedLineListView(ListView): class NonInvoicedLineListView(ListView):

View File

@ -1,4 +1,4 @@
{% extends "lingo/pricing/manager_pricing_list.html" %} {% extends "lingo/pricing/manager_home.html" %}
{% load i18n %} {% load i18n %}
{% block breadcrumb %} {% block breadcrumb %}
@ -16,7 +16,7 @@
{{ form.as_p }} {{ form.as_p }}
<div class="buttons"> <div class="buttons">
<button class="submit-button">{% trans "Export" %}</button> <button class="submit-button">{% trans "Export" %}</button>
<a class="cancel" href="{% url 'lingo-manager-pricing-list' %}">{% trans 'Cancel' %}</a> <a class="cancel" href="{% url 'lingo-manager-pricing-home' %}">{% trans 'Cancel' %}</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "lingo/pricing/manager_pricing_list.html" %} {% extends "lingo/pricing/manager_home.html" %}
{% load i18n %} {% load i18n %}
{% block breadcrumb %} {% block breadcrumb %}
@ -16,7 +16,7 @@
{{ form.as_p }} {{ form.as_p }}
<div class="buttons"> <div class="buttons">
<button class="submit-button">{% trans "Import" %}</button> <button class="submit-button">{% trans "Import" %}</button>
<a class="cancel" href="{% url 'lingo-manager-pricing-list' %}">{% trans 'Cancel' %}</a> <a class="cancel" href="{% url 'lingo-manager-pricing-home' %}">{% trans 'Cancel' %}</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -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

View File

@ -1,5 +1,4 @@
import datetime import datetime
import json
from urllib.parse import urlparse from urllib.parse import urlparse
import pytest import pytest
@ -7,7 +6,6 @@ from django.contrib.auth.models import Group
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.timezone import localtime from django.utils.timezone import localtime
from webtest import Upload
from lingo.agendas.models import Agenda from lingo.agendas.models import Agenda
from lingo.invoicing.models import ( from lingo.invoicing.models import (
@ -225,29 +223,6 @@ def test_manager_invoicing_regie_delete(app, admin_user):
assert Counter.objects.count() == 0 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): def test_non_invoiced_line_list(app, admin_user):
regie = Regie.objects.create(label='Regie') regie = Regie.objects.create(label='Regie')
other_regie = Regie.objects.create(label='Other Regie') other_regie = Regie.objects.create(label='Other Regie')