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

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 %}
{% block breadcrumb %}
{{ 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 %}
{% block appbar %}
<h2>{% trans "Import Regies" %}</h2>
<h2>{% trans "Import" %}</h2>
{% endblock %}
{% block content %}
@ -16,7 +16,7 @@
{{ 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>
<a class="cancel" href="{% url 'lingo-manager-invoicing-home' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -8,6 +8,13 @@
{% block appbar %}
<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 %}
{% block content %}

View File

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

View File

@ -9,15 +9,6 @@
{% 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 %}

View File

@ -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/<int:pk>/export/',
regie_views.regie_export,
name='lingo-manager-invoicing-regie-export',
),
path(
'regie/<int:regie_pk>/campaign/add/',
campaign_views.campaign_add,

View File

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

View File

@ -14,7 +14,20 @@
# 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.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()

View File

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

View File

@ -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 }}
<div class="buttons">
<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>
</form>
{% endblock %}

View File

@ -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 }}
<div class="buttons">
<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>
</form>
{% 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 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')