Skip to content

Commit 0d45af3

Browse files
committed
expose the ZGW import/export functionality through the admin page
1 parent 8c58a8c commit 0d45af3

File tree

4 files changed

+283
-2
lines changed

4 files changed

+283
-2
lines changed

src/open_inwoner/openzaak/admin.py

+113-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
1+
import datetime
2+
import logging
3+
14
from django.contrib import admin, messages
25
from django.core.exceptions import ValidationError
36
from django.db.models import BooleanField, Count, ExpressionWrapper, Q
4-
from django.forms import ModelForm, Textarea
7+
from django.forms import Form, ModelForm, Textarea
58
from django.forms.models import BaseInlineFormSet
9+
from django.http import HttpResponseRedirect, StreamingHttpResponse
10+
from django.template.defaultfilters import filesizeformat
11+
from django.template.response import TemplateResponse
12+
from django.urls import path, reverse
613
from django.utils.translation import gettext_lazy as _, ngettext
714

815
from import_export.admin import ImportExportMixin
16+
from privates.storages import PrivateMediaFileSystemStorage
917
from solo.admin import SingletonModelAdmin
1018

1119
from open_inwoner.ckeditor5.widgets import CKEditorWidget
20+
from open_inwoner.openzaak.import_export import (
21+
CatalogusConfigExport,
22+
CatalogusConfigImport,
23+
)
24+
from open_inwoner.utils.forms import LimitedUploadFileField
1225

1326
from .models import (
1427
CatalogusConfig,
@@ -22,6 +35,8 @@
2235
ZGWApiGroupConfig,
2336
)
2437

38+
logger = logging.getLogger(__name__)
39+
2540

2641
class ZGWApiGroupConfig(admin.StackedInline):
2742
model = ZGWApiGroupConfig
@@ -67,8 +82,26 @@ class OpenZaakConfigAdmin(SingletonModelAdmin):
6782
)
6883

6984

85+
class ImportZGWDumpForm(Form):
86+
MAX_UPLOAD_SIZE = 1024**2 * 25 # 25MB
87+
zgw_export_file = LimitedUploadFileField(
88+
label=_("ZGW export bestand"),
89+
help_text=_(
90+
"Upload a file generated by the export function on the 'Catalogus Config' administration page. The maximum size of this file is %(file_size)s"
91+
% {"file_size": filesizeformat(MAX_UPLOAD_SIZE)}
92+
),
93+
allow_empty_file=False,
94+
required=True,
95+
max_upload_size=MAX_UPLOAD_SIZE,
96+
min_upload_size=1,
97+
allowed_mime_types=["application/json", "text/plain"],
98+
)
99+
100+
70101
@admin.register(CatalogusConfig)
71102
class CatalogusConfigAdmin(admin.ModelAdmin):
103+
change_list_template = "admin/catalogusconfig_change_list.html"
104+
72105
list_display = [
73106
"domein",
74107
"rsin",
@@ -90,6 +123,85 @@ class CatalogusConfigAdmin(admin.ModelAdmin):
90123
ordering = ("domein", "rsin")
91124
list_filter = ("service",)
92125

126+
actions = ["export_catalogus_configs"]
127+
128+
def get_urls(self):
129+
urls = super().get_urls()
130+
custom_urls = [
131+
path(
132+
"import-catalogus-dump/",
133+
self.admin_site.admin_view(self.process_file_view),
134+
name="upload_zgw_import_file",
135+
),
136+
]
137+
return custom_urls + urls
138+
139+
@admin.action(description=_("Exporteer naar json export"))
140+
def export_catalogus_configs(modeladmin, request, queryset):
141+
export = CatalogusConfigExport.from_catalogus_configs(queryset)
142+
response = StreamingHttpResponse(
143+
export.as_jsonl_iter(),
144+
content_type="application/json",
145+
)
146+
response[
147+
"Content-Disposition"
148+
] = 'attachment; filename="zgw-catalogi-export.json"'
149+
return response
150+
151+
def process_file_view(self, request):
152+
form = None
153+
154+
match request.method:
155+
case "POST":
156+
form = ImportZGWDumpForm(request.POST, request.FILES)
157+
if form.is_valid():
158+
storage = PrivateMediaFileSystemStorage()
159+
uploaded_file = request.FILES["zgw_export_file"]
160+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
161+
target_file_name = f"zgw_import_dump_{timestamp}.jsonl"
162+
storage.save(target_file_name, uploaded_file)
163+
164+
try:
165+
import_result = CatalogusConfigImport.import_from_jsonl_file_in_django_storage(
166+
target_file_name,
167+
storage,
168+
)
169+
self.message_user(
170+
request,
171+
_(
172+
"Successfully processed %(num_rows)d items"
173+
% {"num_rows": import_result.total_rows_processed}
174+
),
175+
messages.SUCCESS,
176+
)
177+
178+
return HttpResponseRedirect(
179+
reverse(
180+
"admin:openzaak_catalogusconfig_changelist",
181+
)
182+
)
183+
except Exception as e:
184+
logger.exception("Unable to process ZGW import")
185+
self.message_user(
186+
request,
187+
_(
188+
"We were unable to process your upload. Please regenerate the file and try again."
189+
),
190+
messages.ERROR,
191+
)
192+
finally:
193+
storage.delete(target_file_name)
194+
else:
195+
self.message_user(
196+
request, _("No file was uploaded"), messages.WARNING
197+
)
198+
case _:
199+
form = ImportZGWDumpForm()
200+
201+
return TemplateResponse(
202+
request, "admin/import_zgw_dump_form.html", {"form": form}
203+
)
204+
93205

94206
class HasDocNotifyListFilter(admin.SimpleListFilter):
95207
title = _("notify document attachment")

src/open_inwoner/openzaak/tests/test_admin.py

+147-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1+
import json
2+
from unittest import mock
3+
4+
from django.test import override_settings
15
from django.urls import reverse
26
from django.utils.translation import gettext_lazy as _
37

8+
import freezegun
49
from django_webtest import WebTest
510
from maykin_2fa.test import disable_admin_mfa
11+
from privates.storages import PrivateMediaFileSystemStorage
12+
from privates.test import temp_private_root
13+
from webtest import Upload
614

715
from open_inwoner.accounts.tests.factories import UserFactory
816

9-
from .factories import ZaakTypeConfigFactory, ZaakTypeInformatieObjectTypeConfigFactory
17+
from .factories import (
18+
CatalogusConfigFactory,
19+
ServiceFactory,
20+
ZaakTypeConfigFactory,
21+
ZaakTypeInformatieObjectTypeConfigFactory,
22+
)
1023

1124

1225
@disable_admin_mfa()
@@ -109,3 +122,136 @@ def test_both_can_be_disabled(self):
109122
)
110123
self.assertFalse(self.ztc.document_upload_enabled)
111124
self.assertFalse(self.ztiotc.document_upload_enabled)
125+
126+
127+
@disable_admin_mfa()
128+
@temp_private_root()
129+
@freezegun.freeze_time("2024-08-14 17:50:01")
130+
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
131+
class TestCatalogusConfigExportAdmin(WebTest):
132+
csrf_checks = False
133+
134+
def setUp(self):
135+
self.user = UserFactory(is_superuser=True, is_staff=True)
136+
self.service = ServiceFactory(slug="service-1")
137+
self.catalogus = CatalogusConfigFactory(
138+
url="https://foo.maykinmedia.nl",
139+
domein="DM",
140+
rsin="123456789",
141+
service=self.service,
142+
)
143+
self.mock_file = Upload("dump.json", b"foobar", "application/json")
144+
145+
def test_export_action_returns_correct_export(self):
146+
response = self.app.post(
147+
reverse(
148+
"admin:openzaak_catalogusconfig_changelist",
149+
),
150+
{
151+
"action": "export_catalogus_configs",
152+
"_selected_action": [self.catalogus.id],
153+
},
154+
user=self.user,
155+
)
156+
157+
self.assertEqual(
158+
response.content_disposition,
159+
'attachment; filename="zgw-catalogi-export.json"',
160+
)
161+
self.assertEqual(response.status_code, 200)
162+
self.assertEqual(
163+
[json.loads(row) for row in response.text.split("\n")[:-1]],
164+
[
165+
{
166+
"model": "openzaak.catalogusconfig",
167+
"fields": {
168+
"url": "https://foo.maykinmedia.nl",
169+
"domein": "DM",
170+
"rsin": "123456789",
171+
"service": ["service-1"],
172+
},
173+
},
174+
],
175+
msg="Response should be valid JSONL matching the input object",
176+
)
177+
self.assertFalse(
178+
PrivateMediaFileSystemStorage().exists(
179+
"zgw_import_dump_2024-08-14-17-50-01.jsonl"
180+
),
181+
msg="File should always deleted regardless of success or failure",
182+
)
183+
184+
@mock.patch(
185+
"open_inwoner.openzaak.import_export.CatalogusConfigImport.import_from_jsonl_file_in_django_storage"
186+
)
187+
def test_import_flow_reports_success(self, m) -> None:
188+
form = self.app.get(
189+
reverse(
190+
"admin:upload_zgw_import_file",
191+
),
192+
user=self.user,
193+
).form
194+
form["zgw_export_file"] = self.mock_file
195+
196+
response = form.submit().follow()
197+
198+
self.assertEqual(m.call_count, 1)
199+
self.assertEqual(m.call_args[0][0], "zgw_import_dump_2024-08-14-17-50-01.jsonl")
200+
self.assertTrue(isinstance(m.call_args[0][1], PrivateMediaFileSystemStorage))
201+
202+
messages = [str(msg) for msg in response.context["messages"]]
203+
self.assertEqual(
204+
messages,
205+
["Successfully processed 1 items"],
206+
)
207+
self.assertFalse(
208+
PrivateMediaFileSystemStorage().exists(
209+
"zgw_import_dump_2024-08-14-17-50-01.jsonl"
210+
),
211+
msg="File should always deleted regardless of success or failure",
212+
)
213+
self.assertEqual(
214+
response.request.path,
215+
reverse(
216+
"admin:openzaak_catalogusconfig_changelist",
217+
),
218+
)
219+
220+
@mock.patch(
221+
"open_inwoner.openzaak.import_export.CatalogusConfigImport.import_from_jsonl_file_in_django_storage"
222+
)
223+
def test_import_flow_errors_reports_failure_to_user(self, m) -> None:
224+
m.side_effect = Exception("something went wrong")
225+
form = self.app.get(
226+
reverse(
227+
"admin:upload_zgw_import_file",
228+
),
229+
user=self.user,
230+
).form
231+
form["zgw_export_file"] = self.mock_file
232+
233+
response = form.submit()
234+
235+
self.assertEqual(m.call_count, 1)
236+
self.assertEqual(m.call_args[0][0], "zgw_import_dump_2024-08-14-17-50-01.jsonl")
237+
self.assertTrue(isinstance(m.call_args[0][1], PrivateMediaFileSystemStorage))
238+
239+
messages = [str(msg) for msg in response.context["messages"]]
240+
self.assertEqual(
241+
messages,
242+
[
243+
"We were unable to process your upload. Please regenerate the file and try again."
244+
],
245+
)
246+
self.assertFalse(
247+
PrivateMediaFileSystemStorage().exists(
248+
"zgw_import_dump_2024-08-14-17-50-01.jsonl"
249+
),
250+
msg="File should always deleted regardless of success or failure",
251+
)
252+
self.assertEqual(
253+
response.request.path,
254+
reverse(
255+
"admin:upload_zgw_import_file",
256+
),
257+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% extends "admin/change_list.html" %}
2+
{% load i18n admin_urls %}
3+
4+
{% block object-tools-items %}
5+
{{ block.super }}
6+
<li>
7+
<a href="{% url 'admin:upload_zgw_import_file' %}" class="addlink">
8+
{% trans "Create from json export" %}
9+
</a>
10+
</li>
11+
{% endblock %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load i18n admin_urls %}
3+
4+
{% block content %}
5+
<div id="main" class="main">
6+
<form method="post" enctype="multipart/form-data">
7+
{% csrf_token %}
8+
{{ form.as_div }}
9+
<input type="submit" value="Submit">
10+
</form>
11+
</div>
12+
{% endblock %}

0 commit comments

Comments
 (0)