Skip to content

Commit dbdf3f4

Browse files
Add multiple files (zip) support for odt2pdf converter.
GitHub issue #42
1 parent 2c20f0f commit dbdf3f4

9 files changed

Lines changed: 349 additions & 28 deletions

File tree

django/cohiva/tests/test_views.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import io
2+
import os
3+
import zipfile
4+
5+
from django.core.files.uploadedfile import SimpleUploadedFile
6+
from django.http import FileResponse, HttpResponseNotFound
7+
from django.test import TestCase
8+
9+
from cohiva.views.generic import UploadedFileProcessorMixin
10+
11+
12+
class UploadedFileProcessorMixinTestCase(TestCase):
13+
def test_no_input_files(self):
14+
cls = UploadedFileProcessorMixin()
15+
input_files = [f for f in cls.get_input_files()]
16+
self.assertEqual(len(input_files), 0)
17+
18+
def test_no_output_files(self):
19+
cls = UploadedFileProcessorMixin()
20+
response = cls.get_response()
21+
self.assertTrue(isinstance(response, HttpResponseNotFound))
22+
23+
def test_single_file(self):
24+
test_upload = SimpleUploadedFile(
25+
"test.txt",
26+
b"Testing",
27+
content_type="text/plain",
28+
)
29+
cls = UploadedFileProcessorMixin()
30+
cls.set_uploaded_file(test_upload)
31+
input_files = [f for f in cls.get_input_files()]
32+
self.assertEqual(len(input_files), 1)
33+
self.assertEqual(input_files[0], test_upload)
34+
35+
response_file = "/tmp/response.pdf"
36+
response_content = b"%PDF-1.4\n%Dummy PDF"
37+
with open(response_file, "wb") as f:
38+
f.write(response_content)
39+
cls.add_output_file(response_file, "test.pdf", "application/pdf")
40+
response = cls.get_response()
41+
self.assertTrue(isinstance(response, FileResponse))
42+
self.assertEqual(response.headers["Content-Type"], "application/pdf")
43+
self.assertEqual(
44+
response.headers["Content-Disposition"], 'attachment; filename="test.pdf"'
45+
)
46+
self.assertEqual(response.getvalue(), response_content)
47+
response.close()
48+
# Make sure the file was not deleted
49+
self.assertTrue(os.path.isfile(response_file))
50+
os.unlink(response_file)
51+
52+
def test_multiple_files(self):
53+
file_like_object = io.BytesIO()
54+
with zipfile.ZipFile(file_like_object, "w") as archive:
55+
archive.writestr("testA.txt", b"Testing A")
56+
archive.writestr("testB.txt", b"Testing B")
57+
file_like_object.seek(0)
58+
test_upload = SimpleUploadedFile(
59+
"test_files.zip",
60+
file_like_object.getvalue(),
61+
content_type="application/zip",
62+
)
63+
cls = UploadedFileProcessorMixin()
64+
cls.set_uploaded_file(test_upload)
65+
input_file = [f for f in cls.get_input_files()]
66+
self.assertEqual(len(input_file), 2)
67+
68+
response = cls.get_response()
69+
self.assertTrue(isinstance(response, HttpResponseNotFound))
70+
71+
response_file_a = "/tmp/responseA.pdf"
72+
response_content_a = b"%PDF-1.4\n%Dummy PDF A"
73+
with open(response_file_a, "wb") as f:
74+
f.write(response_content_a)
75+
response_file_b = "/tmp/responseB.pdf"
76+
response_content_b = b"%PDF-1.4\n%Dummy PDF B"
77+
with open(response_file_b, "wb") as f:
78+
f.write(response_content_b)
79+
cls.add_output_file(response_file_a, "testA.pdf", "application/pdf")
80+
cls.add_output_file(response_file_b, "testB.pdf", "application/pdf")
81+
response = cls.get_response()
82+
self.assertTrue(isinstance(response, FileResponse))
83+
self.assertEqual(response.headers["Content-Type"], "application/zip")
84+
self.assertEqual(
85+
response.headers["Content-Disposition"],
86+
'attachment; filename="test_files_resultat.zip"',
87+
)
88+
with zipfile.ZipFile(io.BytesIO(response.getvalue())) as zip_file:
89+
self.assertEqual(zip_file.read("testA.pdf"), response_content_a)
90+
self.assertEqual(zip_file.read("testB.pdf"), response_content_b)
91+
# Make sure the files were not deleted
92+
self.assertTrue(os.path.isfile(response_file_a))
93+
self.assertTrue(os.path.isfile(response_file_b))
94+
os.unlink(response_file_a)
95+
os.unlink(response_file_b)
96+
97+
def test_delete_after_single_file(self):
98+
cls = UploadedFileProcessorMixin()
99+
response_file = "/tmp/response.pdf"
100+
response_content = b"%PDF-1.4\n%Dummy PDF"
101+
with open(response_file, "wb") as f:
102+
f.write(response_content)
103+
cls.add_output_file(response_file, "test.pdf", "application/pdf", delete_after=True)
104+
response = cls.get_response()
105+
self.assertTrue(isinstance(response, FileResponse))
106+
response.close()
107+
# Make sure the file was deleted
108+
self.assertFalse(os.path.isfile(response_file))
109+
110+
def test_delete_after_multiple_files(self):
111+
cls = UploadedFileProcessorMixin()
112+
response_file_a = "/tmp/responseA.pdf"
113+
response_content_a = b"%PDF-1.4\n%Dummy PDF A"
114+
with open(response_file_a, "wb") as f:
115+
f.write(response_content_a)
116+
response_file_b = "/tmp/responseB.pdf"
117+
response_content_b = b"%PDF-1.4\n%Dummy PDF B"
118+
with open(response_file_b, "wb") as f:
119+
f.write(response_content_b)
120+
cls.add_output_file(response_file_a, "testA.pdf", "application/pdf", delete_after=True)
121+
cls.add_output_file(response_file_b, "testB.pdf", "application/pdf", delete_after=True)
122+
response = cls.get_response()
123+
self.assertTrue(isinstance(response, FileResponse))
124+
# Make sure the files were deleted
125+
self.assertFalse(os.path.isfile(response_file_a))
126+
self.assertFalse(os.path.isfile(response_file_b))
127+
128+
def test_custom_archive_filename(self):
129+
cls = UploadedFileProcessorMixin()
130+
response_file_a = "/tmp/responseA.pdf"
131+
response_content_a = b"%PDF-1.4\n%Dummy PDF A"
132+
with open(response_file_a, "wb") as f:
133+
f.write(response_content_a)
134+
response_file_b = "/tmp/responseB.pdf"
135+
response_content_b = b"%PDF-1.4\n%Dummy PDF B"
136+
with open(response_file_b, "wb") as f:
137+
f.write(response_content_b)
138+
cls.add_output_file(response_file_a, "testA.pdf", "application/pdf")
139+
cls.add_output_file(response_file_b, "testB.pdf", "application/pdf")
140+
response = cls.get_response(archive_file_name="custom.zip")
141+
self.assertEqual(
142+
response.headers["Content-Disposition"], 'attachment; filename="custom.zip"'
143+
)
144+
os.unlink(response_file_a)
145+
os.unlink(response_file_b)
146+
147+
def test_no_custom_archive_filename(self):
148+
cls = UploadedFileProcessorMixin()
149+
response_file_a = "/tmp/responseA.pdf"
150+
response_content_a = b"%PDF-1.4\n%Dummy PDF A"
151+
with open(response_file_a, "wb") as f:
152+
f.write(response_content_a)
153+
response_file_b = "/tmp/responseB.pdf"
154+
response_content_b = b"%PDF-1.4\n%Dummy PDF B"
155+
with open(response_file_b, "wb") as f:
156+
f.write(response_content_b)
157+
cls.add_output_file(response_file_a, "testA.pdf", "application/pdf")
158+
cls.add_output_file(response_file_b, "testB.pdf", "application/pdf")
159+
response = cls.get_response()
160+
self.assertEqual(
161+
response.headers["Content-Disposition"], 'attachment; filename="resultat.zip"'
162+
)
163+
os.unlink(response_file_a)
164+
os.unlink(response_file_b)
165+
166+
def test_custom_archive_filename_single_file(self):
167+
cls = UploadedFileProcessorMixin()
168+
response_file_a = "/tmp/responseA.pdf"
169+
response_content_a = b"%PDF-1.4\n%Dummy PDF A"
170+
with open(response_file_a, "wb") as f:
171+
f.write(response_content_a)
172+
cls.add_output_file(response_file_a, "testA.pdf", "application/pdf")
173+
response = cls.get_response(archive_file_name="custom.zip")
174+
self.assertEqual(
175+
response.headers["Content-Disposition"], 'attachment; filename="testA.pdf"'
176+
)
177+
response.close()
178+
os.unlink(response_file_a)

django/cohiva/views/generic.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,83 @@
11
import io
2+
import os
3+
import tempfile
4+
import zipfile
25
from zipfile import ZipFile
36

47
from django.core.exceptions import ImproperlyConfigured
58
from django.http import FileResponse, HttpResponseNotFound
69
from django.views.generic.base import View
710

811

12+
class UploadedFileProcessorMixin:
13+
def __init__(self, *args, **kwargs):
14+
self.uploaded_file = None
15+
self.output_files = []
16+
17+
def set_uploaded_file(self, uploaded_file):
18+
self.uploaded_file = uploaded_file
19+
20+
def get_input_files(self):
21+
"""If uploaded_file is a zip file, return the unpacked files, otherwise return the uploaded file."""
22+
if not self.uploaded_file:
23+
return
24+
if self.uploaded_file.name.endswith(".zip"):
25+
with zipfile.ZipFile(io.BytesIO(self.uploaded_file.read())) as zip_file:
26+
for zipinfo in zip_file.infolist():
27+
if not zipinfo.is_dir():
28+
file_like_object = io.BytesIO(zip_file.read(zipinfo))
29+
file_like_object.name = zipinfo.filename
30+
yield file_like_object
31+
else:
32+
yield self.uploaded_file
33+
34+
def add_output_file(self, filepath, filename, content_type=None, delete_after=False):
35+
self.output_files.append(
36+
{
37+
"filepath": filepath,
38+
"filename": filename,
39+
"content_type": content_type,
40+
"delete_after": delete_after,
41+
}
42+
)
43+
44+
def get_response(self, archive_file_name=None):
45+
if len(self.output_files) == 1:
46+
if self.output_files[0]["delete_after"]:
47+
# Make a temporary copy of the file so we can delete it before sending the response.
48+
output_file = tempfile.TemporaryFile()
49+
with open(self.output_files[0]["filepath"], "rb") as source_file:
50+
for chunk in iter(lambda: source_file.read(8192), b""):
51+
output_file.write(chunk)
52+
output_file.seek(0)
53+
else:
54+
output_file = open(self.output_files[0]["filepath"], "rb")
55+
resp = FileResponse(
56+
output_file,
57+
as_attachment=True,
58+
filename=self.output_files[0]["filename"],
59+
content_type=self.output_files[0]["content_type"],
60+
)
61+
elif len(self.output_files) > 1:
62+
file_like_object = io.BytesIO()
63+
with ZipFile(file_like_object, "w") as archive:
64+
for f in self.output_files:
65+
archive.write(f["filepath"], f["filename"])
66+
file_like_object.seek(0)
67+
if not archive_file_name:
68+
if self.uploaded_file:
69+
archive_file_name = f"{self.uploaded_file.name[0:-4]}_resultat.zip"
70+
else:
71+
archive_file_name = "resultat.zip"
72+
resp = FileResponse(file_like_object, as_attachment=True, filename=archive_file_name)
73+
else:
74+
resp = HttpResponseNotFound("Keine Dateien gefunden.")
75+
for f in self.output_files:
76+
if f["delete_after"]:
77+
os.unlink(f["filepath"])
78+
return resp
79+
80+
981
class ZipDownloadView(View):
1082
zipfile_name = "download.zip"
1183

django/geno/forms.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,13 +1052,17 @@ class TransactionUploadFileForm(forms.Form):
10521052
class Odt2PdfForm(forms.Form):
10531053
"""Form for uploading ODT files to convert to PDF."""
10541054

1055+
form_id = "odt2pdf-form"
1056+
10551057
file = forms.FileField(
1056-
label=_("ODT-Datei"),
1058+
label=_("ODT- oder ZIP-Datei"),
10571059
required=True,
1058-
help_text=_("Wählen Sie eine LibreOffice-Datei (.odt) zum Hochladen aus."),
1060+
help_text=_(
1061+
"Wähle eine LibreOffice-Datei (.odt) oder eine ZIP-Datei (.odt) zum Hochladen aus."
1062+
),
10591063
widget=UnfoldAdminFileFieldWidget(
10601064
attrs={
1061-
"accept": ".odt",
1065+
"accept": ".odt,.zip",
10621066
}
10631067
),
10641068
)
@@ -1073,6 +1077,10 @@ def __init__(self, *args, **kwargs):
10731077
Div("file", css_class="mb-4"),
10741078
)
10751079

1080+
@property
1081+
def button_attrs(self):
1082+
return {"form": self.form_id}
1083+
10761084

10771085
class TransactionUploadProcessForm(forms.Form):
10781086
# Transaction types are loaded from centralized module

django/geno/templates/geno/odt2pdf.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@
1010
<div class="flex items-start gap-3">
1111
<span class="material-symbols-outlined text-base-400 dark:text-base-600 text-[20px] flex-shrink-0 mt-0.5">info</span>
1212
<div class="text-sm text-font-light dark:text-font-dark">
13-
<p>{% trans "Laden Sie eine LibreOffice-Datei (.odt) hoch, um sie in ein PDF umzuwandeln." %}</p>
13+
<p>{% trans "Lade eine LibreOffice-Datei (.odt) hoch, um sie in ein PDF umzuwandeln." %}</p>
14+
<p class="mt-2">
15+
{% trans "Die Datei wird auf dem Server verarbeitet und als PDF-Datei zum Download bereitgestellt." %}
16+
</p>
1417
<p class="mt-2">
15-
{% trans "Die Datei wird auf dem Server verarbeitet und als PDF-Datei zum Download bereitgestellt." %}
18+
{% trans "Du kannst auch mehrere LibreOffice-Dateien in einer ZIP-Datei (.zip) zusammengefasst hochladen. Dann erhältst du wiederum eine ZIP-Datei mit den PDF-Dateien zurück." %}
1619
</p>
1720
</div>
1821
</div>
1922
</div>
2023

2124
{# Form with custom layout #}
22-
<form method="post" enctype="multipart/form-data" id="odt2pdf-form">
25+
<form method="post" enctype="multipart/form-data" id="{{ form.form_id }}">
2326
{% csrf_token %}
2427

2528
{% component "unfold/components/card.html" %}
@@ -35,7 +38,7 @@
3538
<div class="backdrop-blur-xs bg-white/80 rounded-b-default pb-4 px-4 dark:bg-base-900/80 lg:border-t lg:border-base-200 relative lg:scrollable-top lg:py-0 dark:border-base-800">
3639
<div class="flex flex-col-reverse gap-3 items-center mx-auto lg:flex-row-reverse container lg:h-[64px]">
3740
{# Primary action - Submit form using form attribute #}
38-
{% component "unfold/components/button.html" with submit=1 attrs="form='odt2pdf-form'" %}
41+
{% component "unfold/components/button.html" with submit=1 attrs=form.button_attrs %}
3942
{% trans "In PDF umwandeln" %}
4043
{% endcomponent %}
4144
</div>

django/geno/tests/test_views.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import datetime
2+
import io
3+
import zipfile
24
from unittest.mock import DEFAULT, patch
35

46
from django.apps import apps as django_apps
57
from django.conf import settings
8+
from django.http import FileResponse
9+
from django.test import tag
610
from django.urls import reverse
711

812
import geno.tests.data as geno_testdata
@@ -223,3 +227,31 @@ def test_invoice_manual_process_creation_error(self, mock_create_qrbill):
223227
raw=True,
224228
)
225229
self.assertEqual(Invoice.objects.count(), 0)
230+
231+
232+
class Odt2PdfViewTest(GenoAdminTestCase):
233+
@tag("slow-test")
234+
def test_odt2pdf_view_singlefile(self):
235+
self.client.login(username="superuser", password="secret")
236+
with open("geno/tests/template_test.odt", "rb") as dummy_file:
237+
response = self.client.post(reverse("geno:odt2pdf"), {"file": dummy_file})
238+
self.assertEqual(response.status_code, 200)
239+
self.assertTrue(isinstance(response, FileResponse))
240+
self.assertEqual(response.headers["Content-Type"], "application/pdf")
241+
self.assertInPDF(response.getvalue(), "Template-Test")
242+
243+
@tag("slow-test")
244+
def test_odt2pdf_view_multifile(self):
245+
self.client.login(username="superuser", password="secret")
246+
with open("/tmp/test.zip", "wb") as zipfile_object:
247+
with zipfile.ZipFile(zipfile_object, "w") as archive:
248+
archive.write("geno/tests/template_test.odt", "testA.odt")
249+
archive.write("geno/tests/template_test.odt", "testB.odt")
250+
with open("/tmp/test.zip", "rb") as inputfile:
251+
response = self.client.post(reverse("geno:odt2pdf"), {"file": inputfile})
252+
self.assertEqual(response.status_code, 200)
253+
self.assertTrue(isinstance(response, FileResponse))
254+
self.assertEqual(response.headers["Content-Type"], "application/zip")
255+
with zipfile.ZipFile(io.BytesIO(response.getvalue())) as zip_file:
256+
self.assertInPDF(zip_file.read("testA.pdf"), "Template-Test")
257+
self.assertInPDF(zip_file.read("testB.pdf"), "Template-Test")

django/geno/utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ def odt2pdf(odtfile, instance_tag="default"):
5151
p = Popen(cmd, stdout=PIPE, stderr=PIPE)
5252
output, err = p.communicate()
5353
if p.returncode or len(err):
54-
raise Exception("odt2pdf failed: %s - %s (2. attempt)" % (output, err))
54+
raise Exception(
55+
"odt2pdf for %s failed: %s - %s (2. attempt)" % (odtfile, output, err)
56+
)
5557
else:
56-
raise Exception("odt2pdf failed: %s - %s (1. attempt)" % (output, err))
58+
raise Exception("odt2pdf for %s failed: %s - %s (1. attempt)" % (odtfile, output, err))
5759

5860
pdf_file = "%s/%s.pdf" % (path, outfile[0])
5961
if not os.path.isfile(pdf_file):

0 commit comments

Comments
 (0)