Skip to content

Commit 56342c1

Browse files
Fixed tests and reduced docker image size.
1 parent 2f36d8c commit 56342c1

9 files changed

Lines changed: 768 additions & 48 deletions

File tree

django/Dockerfile

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,50 @@
1-
# we use the default debian package
2-
FROM python:3.13-bookworm AS cohiva-platform-os
1+
# Build stage
2+
FROM python:3.13-bookworm AS builder
33

44
ENV PYTHONDONTWRITEBYTECODE=1 \
55
PYTHONUNBUFFERED=1
6-
RUN apt-get update && apt-get install -y build-essential libmariadb-dev libfreetype-dev libjpeg-dev libffi-dev poppler-utils xmlsec1 libreoffice-writer mariadb-client #mariadb-client-compat
7-
RUN apt-get install -y locales && sed -i '/^#.*de_CH.UTF-8/s/^# *//' /etc/locale.gen && locale-gen de_CH.UTF-8 && update-locale LANG=de_CH.UTF-8
86

9-
# set the container-wide default
7+
# Install build dependencies
8+
RUN apt-get update && \
9+
apt-get install -y --no-install-recommends build-essential libmariadb-dev libfreetype-dev libjpeg-dev libffi-dev
10+
11+
# Install python dependencies in /usr/local/
12+
WORKDIR /cohiva
13+
COPY . .
14+
RUN cp /cohiva/requirements-docker.txt /cohiva/requirements.txt # Use requirements without dev packages
15+
RUN chmod +x ./install_dependencies.sh
16+
RUN ./install_dependencies.sh -e docker
17+
18+
# Remove unnecessary stuff from /usr/local/
19+
RUN find /usr/local/lib -type d -name '__pycache__' -exec rm -rf {} + && \
20+
find /usr/local/lib -type f -name '*.dist-info' -exec rm -rf {} +
21+
22+
FROM python:3.13-slim-bookworm AS cohiva-platform-os
23+
24+
# Set environment variables to ensure LibreOffice runs in headless mode
25+
ENV DEBIAN_FRONTEND=noninteractive
26+
ENV LIBREOFFICE_HEADLESS=1
27+
28+
ENV PYTHONDONTWRITEBYTECODE=1 \
29+
PYTHONUNBUFFERED=1
30+
31+
# Install runtime dependencies
32+
# mariadb-client-compat
33+
RUN apt-get update && \
34+
apt-get install -y --no-install-recommends xmlsec1 libreoffice-writer-nogui mariadb-client locales && \
35+
apt-get clean && \
36+
rm -rf /var/lib/apt/lists/*
37+
38+
# Set locale to de_CH.UTF-8
39+
RUN sed -i '/^#.*de_CH.UTF-8/s/^# *//' /etc/locale.gen && locale-gen de_CH.UTF-8 && update-locale LANG=de_CH.UTF-8
1040
ENV LANG=de_CH.UTF-8 \
1141
LC_ALL=de_CH.UTF-8
1242

1343
FROM cohiva-platform-os AS cohiva-platform-django
1444

1545
WORKDIR /cohiva
46+
COPY --from=builder /usr/local /usr/local
1647
COPY . .
17-
RUN chmod +x ./install_dependencies.sh
18-
RUN ./install_dependencies.sh -e docker
1948
# optional gunicorn tuning vars
2049
ENV GUNICORN_WORKERS=2
2150
ENV GUNICORN_BIND=0.0.0.0:8000

django/geno/tests/base.py

Lines changed: 235 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import zoneinfo
33
from io import BytesIO
4+
from unittest.mock import patch
45
from zipfile import ZipFile
56

67
from django.conf import settings
@@ -172,11 +173,19 @@ def assertNotInPDF(self, pdffile_or_bytes, expected_text_or_list, on_page=None):
172173
else:
173174
raise AssertionError(f"Text found in PDF: {expected_text_or_list}")
174175

175-
def assertPDFPages(self, pdffile_or_bytes, npages_expected):
176+
def assertPDFPages(
177+
self, pdffile_or_bytes, npages_expected, accept_more_pages_than_expected=False
178+
):
176179
pdf = PdfReader(self.get_filelike(pdffile_or_bytes))
177180
npages = len(pdf.pages)
178-
if npages != npages_expected:
179-
raise AssertionError(f"PDF has {npages} pages insted of {npages_expected}")
181+
if accept_more_pages_than_expected:
182+
if npages < npages_expected:
183+
raise AssertionError(
184+
f"PDF has {npages} pages, which is less than the minimum of {npages_expected}"
185+
)
186+
else:
187+
if npages != npages_expected:
188+
raise AssertionError(f"PDF has {npages} pages instead of {npages_expected}")
180189

181190
def assertInZIP(self, zipfile_or_bytes, num_files=None, contents=None):
182191
if contents is None:
@@ -246,3 +255,226 @@ def setUpTestData(cls):
246255

247256
def setUp(self):
248257
self.client.login(username="superuser", password="secret")
258+
259+
260+
def fill_template_effect(template, context, output_format="odt"):
261+
odt_path = "/tmp/mock_odtfile.odt"
262+
if not os.path.exists(odt_path):
263+
with open(odt_path, "wb") as f:
264+
f.write(b"ODT mock\n")
265+
return odt_path
266+
267+
268+
def odt2pdf_effect(odtfile, instance_tag="default"):
269+
pdf_path = "/tmp/mock_pdffile.pdf"
270+
if not os.path.exists(pdf_path):
271+
with open(pdf_path, "wb") as f:
272+
f.write(b"%PDF-1.1\n%mock\n")
273+
return pdf_path
274+
275+
276+
class DocumentCreationMockMixin:
277+
"""
278+
Mixin that patches `fill_template_pod` and `odt2pdf` and provides helper assertions.
279+
"""
280+
281+
patch_target_fill_template = "geno.utils.fill_template_pod"
282+
patch_target_odt2pdf = "geno.utils.odt2pdf"
283+
284+
def setUp(self):
285+
super().setUp()
286+
fill_template_patcher = patch(self.patch_target_fill_template)
287+
self.mock_fill_template = fill_template_patcher.start()
288+
self.mock_fill_template.side_effect = fill_template_effect
289+
self.addCleanup(fill_template_patcher.stop)
290+
291+
odt2pdf_patcher = patch(self.patch_target_odt2pdf)
292+
self.mock_odt2pdf = odt2pdf_patcher.start()
293+
self.mock_odt2pdf.side_effect = odt2pdf_effect
294+
self.addCleanup(odt2pdf_patcher.stop)
295+
296+
def reset_mocks(self):
297+
self.mock_fill_template.reset_mock()
298+
self.mock_odt2pdf.reset_mock()
299+
300+
def assertMocksCallCount(self, expected_count):
301+
self.assertEqual(self.mock_fill_template.call_count, expected_count)
302+
self.assertEqual(self.mock_odt2pdf.call_count, expected_count)
303+
304+
def assertFillTemplateCalledOnce(self):
305+
self.mock_fill_template.assert_called_once()
306+
307+
def assertFillTemplateCalledWith(
308+
self, template=None, expected_context_items=None, call_index=None
309+
):
310+
if template:
311+
self.assertFillTemplateCalledWithTemplate(template, call_index)
312+
if expected_context_items:
313+
self.assertFillTemplateContextContains(expected_context_items, call_index)
314+
if not template and not expected_context_items:
315+
raise AssertionError("assertFillTemplateCalledWith called without arguments.")
316+
317+
def assertFillTemplateCalledWithTemplate(self, template, call_index=None):
318+
"""
319+
Assert that fill_template_pod was called with the given template.
320+
321+
- If call_index is None: check that *any* call used this template.
322+
- If call_index is an integer: check only that specific call.
323+
"""
324+
325+
if not self.mock_fill_template.called:
326+
raise AssertionError("fill_template_pod was never called.")
327+
328+
calls = self.mock_fill_template.call_args_list
329+
330+
# Helper to extract template argument
331+
def extract_template(call):
332+
args, kwargs = call
333+
return kwargs.get("template") or args[0]
334+
335+
# Check a specific call
336+
if call_index is not None:
337+
try:
338+
call = calls[call_index]
339+
except IndexError:
340+
raise AssertionError(
341+
f"fill_template_pod was called {len(calls)} times, "
342+
f"but call_index {call_index} was requested."
343+
)
344+
345+
actual_template = extract_template(call)
346+
if actual_template != template:
347+
raise AssertionError(
348+
f"In call {call_index}, expected template {template!r}, "
349+
f"but got {actual_template!r}."
350+
)
351+
return # success
352+
353+
# No call_index → check ANY call
354+
for call in calls:
355+
if extract_template(call) == template:
356+
return # success
357+
358+
# If we reach here → no call matched
359+
formatted_calls = "\n".join(
360+
f"call[{i}].template = {extract_template(call)!r}" for i, call in enumerate(calls)
361+
)
362+
raise AssertionError(
363+
f"No call to fill_template_pod used template {template!r}.\n\n"
364+
f"Templates used in calls:\n{formatted_calls}"
365+
)
366+
367+
def assertFillTemplateContextContains(self, expected_items: dict, call_index=None):
368+
"""
369+
Assert that the context passed to fill_template_pod contains expected_items.
370+
371+
- If call_index is None: check that *any* call contains the items.
372+
- If call_index is an integer: check that specific call.
373+
"""
374+
375+
calls = self.mock_fill_template.call_args_list
376+
377+
def extract_context(call):
378+
args, kwargs = call
379+
return kwargs.get("context") or args[1]
380+
381+
# Check a specific call
382+
if call_index is not None:
383+
try:
384+
call = calls[call_index]
385+
except IndexError:
386+
raise AssertionError(
387+
f"fill_template_pod was called {len(calls)} times, "
388+
f"but call_index {call_index} was requested."
389+
)
390+
391+
context = extract_context(call)
392+
for key, value in expected_items.items():
393+
if key not in context:
394+
if value is None:
395+
# None means that the key can or should be missing
396+
continue
397+
print(context)
398+
raise AssertionError(f"In call {call_index}, context missing key {key!r}.")
399+
if value is None:
400+
if context[key] not in (None, ""):
401+
raise AssertionError(
402+
f"In call {call_index}, expected key {key!r} to be missing, "
403+
f"but got {context[key]}."
404+
)
405+
elif context[key] != value and str(context[key]) != str(value):
406+
raise AssertionError(
407+
f"In call {call_index}, key {key!r}: "
408+
f"expected {value!r}, got {context[key]!r}."
409+
)
410+
return # success
411+
412+
# No call_index → check ANY call
413+
for call in calls:
414+
context = extract_context(call)
415+
416+
if all(key in context and context[key] == val for key, val in expected_items.items()):
417+
return # success: at least one call matched
418+
419+
# If we reach here → no calls matched
420+
formatted_calls = "\n".join(
421+
f"call[{i}].context = {extract_context(call)}" for i, call in enumerate(calls)
422+
)
423+
raise AssertionError(
424+
"No call to fill_template_pod had a context containing the expected items.\n\n"
425+
f"Expected items: {expected_items}\n\n"
426+
f"All contexts:\n{formatted_calls}"
427+
)
428+
429+
def assertOdt2PdfCalledOnce(self):
430+
self.mock_odt2pdf.assert_called_once()
431+
432+
def assertOdt2PdfCalledWithFile(self, odtfile, call_index=None):
433+
"""
434+
Assert that odt2pdf was called with the given odtfile.
435+
436+
- If call_index is None: check that *any* call used this odtfile.
437+
- If call_index is an integer: check only that specific call.
438+
"""
439+
440+
if not self.mock_odt2pdf.called:
441+
raise AssertionError("odt2pdf was never called.")
442+
443+
calls = self.mock_odt2pdf.call_args_list
444+
445+
# Helper to extract odtfile argument
446+
def extract_odtfile(call):
447+
args, kwargs = call
448+
return kwargs.get("odtfile") or args[0]
449+
450+
# Check a specific call
451+
if call_index is not None:
452+
try:
453+
call = calls[call_index]
454+
except IndexError:
455+
raise AssertionError(
456+
f"odt2pdf was called {len(calls)} times, "
457+
f"but call_index {call_index} was requested."
458+
)
459+
460+
actual_odtfile = extract_odtfile(call)
461+
if actual_odtfile != odtfile:
462+
raise AssertionError(
463+
f"In call {call_index}, expected odtfile {odtfile!r}, "
464+
f"but got {actual_odtfile!r}."
465+
)
466+
return # success
467+
468+
# No call_index → check ANY call
469+
for call in calls:
470+
if extract_odtfile(call) == odtfile:
471+
return # success
472+
473+
# If we reach here → no call matched
474+
formatted_calls = "\n".join(
475+
f"call[{i}].odtfile = {extract_odtfile(call)!r}" for i, call in enumerate(calls)
476+
)
477+
raise AssertionError(
478+
f"No call to odt2pdf used odtfile {odtfile!r}.\n\n"
479+
f"Templates used in calls:\n{formatted_calls}"
480+
)

0 commit comments

Comments
 (0)