diff --git a/backend/media/templates/offboarding_template.html b/backend/media/templates/offboarding_template.html
index 6b3ee37..39a1d1e 100644
--- a/backend/media/templates/offboarding_template.html
+++ b/backend/media/templates/offboarding_template.html
@@ -1,5 +1,5 @@
-
+
@@ -29,12 +29,6 @@
letter-spacing: 0.2px;
}
- .sub {
- margin: 2px 0 0 0;
- color: #6b7280;
- font-size: 9.5px;
- }
-
.section {
margin-top: 9px;
font-size: 11px;
@@ -54,6 +48,8 @@
border: 1px solid #f0e1e1;
padding: 4px 6px;
vertical-align: top;
+ overflow-wrap: anywhere;
+ word-break: break-word;
}
th {
@@ -141,17 +137,6 @@
font-size: 9.4px;
}
- .manual-title {
- margin: 9px 0 5px;
- font-size: 10px;
- font-weight: bold;
- color: #111827;
- }
-
- .manual-grid td {
- width: 50%;
- }
-
@@ -159,25 +144,26 @@
{{ T.offboarding_title }}
- {{ T.employee_info }}
-
-
- | {{ T.name }} |
- {{ FULL_NAME }} |
- {{ T.email }} |
- {{ EMAIL }} |
-
-
- | {{ T.department }} |
- {{ DEPARTMENT }} |
- {{ T.job_title }} |
- {{ JOB_TITLE }} |
-
-
- | {{ T.last_working_day }} |
- {{ LAST_WORKING_DAY }} |
-
-
+ {% for section in PDF_SECTIONS %}
+ {% if section.has_content %}
+ {{ section.title }}
+
+ {% if section.scalar_rows %}
+
+ {% for row in section.scalar_rows %}
+
+ | {{ row[0].label }} |
+ {{ row[0].display_value }} |
+ {% if row[1] %}
+ {{ row[1].label }} |
+ {{ row[1].display_value }} |
+ {% endif %}
+
+ {% endfor %}
+
+ {% endif %}
+ {% endif %}
+ {% endfor %}
{{ T.offboarding_requester }}
@@ -295,14 +281,6 @@
{{ T.return_complete }} □ {{ T.yes }} □ {{ T.no }}
- {{ T.notes }}
-
-
- | {{ T.notes }} |
- {{ NOTES }} |
-
-
-
{{ T.offboarding_note }}
diff --git a/backend/media/templates/onboarding_template.html b/backend/media/templates/onboarding_template.html
index 5807195..8b9055b 100644
--- a/backend/media/templates/onboarding_template.html
+++ b/backend/media/templates/onboarding_template.html
@@ -29,12 +29,6 @@
letter-spacing: 0.2px;
}
- .sub {
- margin: 2px 0 0 0;
- color: #475569;
- font-size: 9.5px;
- }
-
.section {
margin-top: 9px;
font-size: 11px;
@@ -101,11 +95,6 @@
word-break: break-word;
}
- .empty {
- color: #94a3b8;
- font-style: italic;
- }
-
.signature {
width: 150px;
height: 70px;
@@ -133,245 +122,41 @@
{{ T.onboarding_title }}
- {{ T.onboarding_staff_data }}
-
-
- | {{ T.name }} |
- {{ DISPLAY_NAME }} |
-
-
- | {{ T.department }} |
- {{ ABTEILUNG }} |
- {{ T.job_title }} |
- {{ BERUFSBEZEICHNUNG }} |
-
-
- | {{ T.work_email }} |
- {{ EMAIL }} |
- {{ T.employment_type }} |
- {{ BESCHAEFTIGUNG }} |
-
-
- | {{ T.contract_start }} |
- {{ VERTRAGSBEGINN }} |
- {{ T.contract_end }} |
- {{ VERTRAGSENDE }} |
-
-
- | {{ T.handover_date }} |
- {{ UEBERGABEDATUM }} |
-
-
+ {% for section in PDF_SECTIONS %}
+ {% if section.has_content %}
+ {{ section.title }}
- {{ T.equipment_access }}
+ {% if section.scalar_rows %}
+
+ {% for row in section.scalar_rows %}
+
+ | {{ row[0].label }} |
+ {{ row[0].display_value }} |
+ {% if row[1] %}
+ {{ row[1].label }} |
+ {{ row[1].display_value }} |
+ {% endif %}
+
+ {% endfor %}
+
+ {% endif %}
- {% if HAS_DEVICES %}
-
-
{{ T.devices }}
-
- {% for row in ARBEITSGERÄTE_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
+ {% for field in section.list_fields %}
+
+
{{ field.label }}
+
+ {% for row in field.display_value|batch(3, '') %}
+
+ {% for cell in row %}
+ | {% if cell %}• {{ cell }}{% endif %} |
+ {% endfor %}
+
+ {% endfor %}
+
+
{% endfor %}
-
-
- {% endif %}
-
- {% if HAS_GROUPS %}
-
-
{{ T.workspace_groups }}
-
- {% for row in ZUGAENGE_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if HAS_SOFTWARE %}
-
-
{{ T.software }}
-
- {% for row in SOFTWARE_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if HAS_ACCESSES %}
-
-
{{ T.accesses }}
-
- {% for row in ACCOUNT_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if HAS_RESOURCES %}
-
-
{{ T.resources }}
-
- {% for row in STANDARD_RESSOURCEN %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if GROUP_MAILBOXES_REQUIRED and HAS_GROUP_MAILBOXES %}
-
-
{{ T.group_mailboxes_required }}
-
- {% for row in GROUP_MAILBOXES_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if ADDITIONAL_HARDWARE_NEEDED and HAS_ADDITIONAL_HARDWARE %}
-
-
{{ T.additional_hardware_needed }}
-
- {% for row in ADDITIONAL_HARDWARE_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if ADDITIONAL_SOFTWARE_NEEDED and HAS_ADDITIONAL_SOFTWARE %}
-
-
{{ T.additional_software_needed }}
-
- {% for row in ADDITIONAL_SOFTWARE_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if ADDITIONAL_ACCESS_NEEDED and HAS_ADDITIONAL_ACCESS %}
-
-
{{ T.additional_access_needed }}
-
- {% for row in ADDITIONAL_ACCESS_LIST %}
-
- {% for cell in row %}| • {{ cell }} | {% endfor %}
- {% if row|length < 3 %}
- {% for _ in range(3 - row|length) %} | {% endfor %}
- {% endif %}
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if (VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN) or HAS_ADDITIONAL_HARDWARE_OTHER or HAS_SUCCESSOR_INFO or HAS_ADDITIONAL_NOTES %}
- {{ T.additional_details }}
- {% endif %}
-
- {% if VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN %}
-
-
{{ T.business_cards }}
-
-
- | {{ T.name }} |
- {{ VISITENKARTE_NAME }} |
- {{ T.job_title }} |
- {{ VISITENKARTE_TITEL }} |
-
-
- | {{ T.email }} |
- {{ VISITENKARTE_EMAIL }} |
- {{ T.phone }} |
- {{ VISITENKARTE_TELEFON }} |
-
-
-
- {% endif %}
-
- {% if HAS_ADDITIONAL_HARDWARE_OTHER %}
-
-
{{ T.additional_hardware_other }}
-
-
- | {{ ADDITIONAL_HARDWARE_OTHER }} |
-
-
-
- {% endif %}
-
- {% if HAS_SUCCESSOR_INFO %}
-
-
{{ T.successor_phone }}
-
-
- | {{ T.successor_of }} |
- {{ SUCCESSOR_NAME }} |
- {{ T.inherit_phone_number }} |
- {{ INHERIT_PHONE_NUMBER }} |
-
-
- | {{ T.direct_extension }} |
- {{ PHONE_NUMBER }} |
-
-
-
- {% endif %}
-
- {% if HAS_ADDITIONAL_NOTES %}
-
-
{{ T.notes }}
-
-
- | {{ ADDITIONAL_NOTES }} |
-
-
-
- {% endif %}
+ {% endif %}
+ {% endfor %}
{{ T.confirmation }}
diff --git a/backend/workflows/models.py b/backend/workflows/models.py
index 96c18a9..f524efa 100644
--- a/backend/workflows/models.py
+++ b/backend/workflows/models.py
@@ -477,6 +477,8 @@ class FormFieldConfig(models.Model):
('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')),
+ ('mitarbeitende', _('Mitarbeitende')),
+ ('austritt', _('Austritt')),
]
FORM_CHOICES = [
('onboarding', _('Onboarding')),
@@ -519,12 +521,15 @@ class FormFieldConfig(models.Model):
class FormSectionConfig(models.Model):
FORM_CHOICES = [
('onboarding', _('Onboarding')),
+ ('offboarding', _('Offboarding')),
]
SECTION_CHOICES = [
('stammdaten', _('Stammdaten')),
('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')),
+ ('mitarbeitende', _('Mitarbeitende')),
+ ('austritt', _('Austritt')),
]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES)
diff --git a/backend/workflows/pdf_sections.py b/backend/workflows/pdf_sections.py
new file mode 100644
index 0000000..948fc84
--- /dev/null
+++ b/backend/workflows/pdf_sections.py
@@ -0,0 +1,290 @@
+from __future__ import annotations
+
+from collections import OrderedDict
+from datetime import date, datetime
+
+from django.utils import formats
+from django.utils.translation import override
+
+from .form_builder import (
+ LOCKED_FIELD_RULES,
+ LOCKED_SECTION_RULES,
+ ensure_form_field_configs,
+ ensure_form_section_configs,
+ get_default_page_map,
+ get_section_labels,
+ get_section_order,
+)
+from .forms import OffboardingRequestForm, OnboardingRequestForm
+
+PDF_SECTION_TITLES = {
+ "onboarding": {
+ "stammdaten": "Stammdaten",
+ "vertrag": "Vertrag",
+ "itsetup": "IT-Setup",
+ "abschluss": "Abschluss",
+ },
+ "offboarding": {
+ "mitarbeitende": "Mitarbeitende",
+ "austritt": "Austritt",
+ "abschluss": "Abschluss",
+ },
+}
+
+PDF_FIELD_LABELS = {
+ "onboarding": {
+ "full_name": {"de": "Name", "en": "Name"},
+ },
+ "offboarding": {
+ "full_name": {"de": "Name", "en": "Name"},
+ },
+}
+
+PDF_EXCLUDED_FIELDS = {
+ "onboarding": {
+ "first_name",
+ "last_name",
+ "onboarded_by_email",
+ "agreement_confirm",
+ "signature_url",
+ "signature_image",
+ },
+ "offboarding": set(),
+}
+
+PDF_BOOLEAN_CONTROL_FIELDS = {
+ "onboarding": {
+ "order_business_cards",
+ "group_mailboxes_required_choice",
+ "additional_hardware_needed_choice",
+ "additional_software_needed_choice",
+ "additional_access_needed_choice",
+ "successor_required_choice",
+ "inherit_phone_number_choice",
+ },
+ "offboarding": set(),
+}
+
+
+def _normalized_lang(language_code: str | None) -> str:
+ return (language_code or "de").split("-")[0].lower() or "de"
+
+
+def _yes_no_text(language_code: str | None) -> tuple[str, str]:
+ lang = _normalized_lang(language_code)
+ if lang == "en":
+ return "Yes", "No"
+ return "Ja", "Nein"
+
+
+def _not_available_text(language_code: str | None) -> str:
+ return "Not provided" if _normalized_lang(language_code) == "en" else "Keine Angabe"
+
+
+def _split_name(full_name: str) -> tuple[str, str]:
+ parts = (full_name or "").split()
+ if not parts:
+ return "", ""
+ return parts[0], " ".join(parts[1:])
+
+
+def _split_multiline(value: str) -> list[str]:
+ return [line.strip() for line in (value or "").splitlines() if line.strip()]
+
+
+def _format_date(value, language_code: str | None) -> str:
+ if not value:
+ return ""
+ if isinstance(value, datetime):
+ with override(_normalized_lang(language_code)):
+ return formats.date_format(value, "DATETIME_FORMAT", use_l10n=True)
+ if isinstance(value, date):
+ with override(_normalized_lang(language_code)):
+ return formats.date_format(value, "DATE_FORMAT", use_l10n=True)
+ return str(value)
+
+
+def _coerce_text(value) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, (date, datetime)):
+ return str(value)
+ return str(value).strip()
+
+
+def _field_value_from_request(form_type: str, request_obj, field_name: str, language_code: str | None):
+ yes_text, no_text = _yes_no_text(language_code)
+ first_name, last_name = _split_name(getattr(request_obj, "full_name", ""))
+
+ onboarding_map = {
+ "first_name": first_name,
+ "last_name": last_name,
+ "full_name": getattr(request_obj, "full_name", ""),
+ "gender": getattr(request_obj, "get_gender_display", lambda: "")() or getattr(request_obj, "gender", ""),
+ "job_title": getattr(request_obj, "job_title", ""),
+ "department": getattr(request_obj, "department", ""),
+ "work_email": getattr(request_obj, "work_email", ""),
+ "order_business_cards": yes_text if getattr(request_obj, "order_business_cards", False) else no_text,
+ "business_card_name": getattr(request_obj, "business_card_name", ""),
+ "business_card_title": getattr(request_obj, "business_card_title", ""),
+ "business_card_email": getattr(request_obj, "business_card_email", ""),
+ "business_card_phone": getattr(request_obj, "business_card_phone", ""),
+ "contract_start": _format_date(getattr(request_obj, "contract_start", None), language_code),
+ "employment_type": getattr(request_obj, "get_employment_type_display", lambda: "")() or getattr(request_obj, "employment_type", ""),
+ "employment_end_date": _format_date(getattr(request_obj, "employment_end_date", None), language_code),
+ "handover_date": _format_date(getattr(request_obj, "handover_date", None), language_code),
+ "group_mailboxes_required_choice": yes_text if getattr(request_obj, "group_mailboxes_required", False) else no_text,
+ "group_mailboxes": _split_multiline(getattr(request_obj, "group_mailboxes", "")),
+ "needed_devices_multi": _split_multiline(getattr(request_obj, "needed_devices", "")),
+ "additional_hardware_needed_choice": yes_text if getattr(request_obj, "additional_hardware_needed", False) else no_text,
+ "additional_hardware_multi": _split_multiline(getattr(request_obj, "additional_hardware", "")),
+ "additional_hardware_other": getattr(request_obj, "additional_hardware_other", ""),
+ "needed_software_multi": _split_multiline(getattr(request_obj, "needed_software", "")),
+ "additional_software_needed_choice": yes_text if getattr(request_obj, "additional_software_needed", False) else no_text,
+ "additional_software_multi": _split_multiline(getattr(request_obj, "additional_software", "")),
+ "additional_software": getattr(request_obj, "additional_software", ""),
+ "needed_accesses_multi": _split_multiline(getattr(request_obj, "needed_accesses", "")),
+ "additional_access_needed_choice": yes_text if getattr(request_obj, "additional_access_needed", False) else no_text,
+ "additional_access_text": _split_multiline(getattr(request_obj, "additional_access_text", "")),
+ "needed_workspace_groups_multi": _split_multiline(getattr(request_obj, "needed_workspace_groups", "")),
+ "needed_resources_multi": _split_multiline(getattr(request_obj, "needed_resources", "")),
+ "successor_required_choice": yes_text if getattr(request_obj, "successor_required", False) else no_text,
+ "successor_name": getattr(request_obj, "successor_name", ""),
+ "inherit_phone_number_choice": yes_text if getattr(request_obj, "inherit_phone_number", False) else no_text,
+ "phone_number_choice": getattr(request_obj, "phone_number", ""),
+ "additional_notes": getattr(request_obj, "additional_notes", ""),
+ "signature_url": getattr(request_obj, "signature_url", ""),
+ "signature_image": getattr(getattr(request_obj, "signature_image", None), "name", "") or "",
+ "onboarded_by_email": getattr(request_obj, "onboarded_by_email", ""),
+ "agreement_confirm": yes_text if _coerce_text(getattr(request_obj, "agreement", "")) else no_text,
+ }
+ offboarding_map = {
+ "full_name": getattr(request_obj, "full_name", ""),
+ "work_email": getattr(request_obj, "work_email", ""),
+ "department": getattr(request_obj, "department", ""),
+ "job_title": getattr(request_obj, "job_title", ""),
+ "last_working_day": _format_date(getattr(request_obj, "last_working_day", None), language_code),
+ "notes": getattr(request_obj, "notes", ""),
+ }
+ value_map = onboarding_map if form_type == "onboarding" else offboarding_map
+ return value_map.get(field_name, "")
+
+
+def _field_kind(value) -> str:
+ if isinstance(value, list):
+ return "list"
+ return "text"
+
+
+def _is_empty_value(value) -> bool:
+ if isinstance(value, list):
+ return len(value) == 0
+ return _coerce_text(value) == ""
+
+
+def _field_meta(form_type: str, field_name: str, language_code: str | None) -> tuple[str, str]:
+ custom_label = PDF_FIELD_LABELS.get(form_type, {}).get(field_name, {})
+ if custom_label:
+ return custom_label.get(_normalized_lang(language_code), field_name), ""
+ form_class = OnboardingRequestForm if form_type == "onboarding" else OffboardingRequestForm
+ base_field = form_class.base_fields.get(field_name)
+ if not base_field:
+ return field_name, ""
+ with override(_normalized_lang(language_code)):
+ label = str(base_field.label or field_name)
+ help_text = str(base_field.help_text or "").strip()
+ return label, help_text
+
+
+def build_pdf_sections(form_type: str, request_obj, language_code: str | None = None) -> list[dict]:
+ language_code = _normalized_lang(language_code or getattr(request_obj, "preferred_language", None))
+ default_page_map = get_default_page_map(form_type)
+ section_order = get_section_order(form_type)
+ section_labels = get_section_labels(form_type)
+ field_names = list(default_page_map.keys())
+ configs = ensure_form_field_configs(form_type, field_names)
+ section_configs = ensure_form_section_configs(form_type)
+ locked_fields = LOCKED_FIELD_RULES.get(form_type, set())
+ locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
+
+ sections: OrderedDict[str, dict] = OrderedDict()
+ for key in section_order:
+ section_cfg = section_configs.get(key)
+ is_visible = True
+ if key not in locked_sections and section_cfg is not None:
+ is_visible = bool(section_cfg.is_visible)
+ if not is_visible:
+ continue
+ sections[key] = {
+ "key": key,
+ "title": PDF_SECTION_TITLES.get(form_type, {}).get(key, section_labels.get(key, key)),
+ "fields": [],
+ }
+
+ ordered_configs = sorted(
+ configs.values(),
+ key=lambda cfg: (cfg.sort_order, cfg.field_name),
+ )
+ for cfg in ordered_configs:
+ field_name = cfg.field_name
+ section_key = cfg.page_key or default_page_map.get(field_name, "")
+ if section_key not in sections:
+ continue
+ if field_name in PDF_EXCLUDED_FIELDS.get(form_type, set()):
+ continue
+ if field_name not in locked_fields and not cfg.is_visible:
+ continue
+
+ base_label, base_help_text = _field_meta(form_type, field_name, language_code)
+ label = cfg.translated_label_override(language_code) or base_label
+ help_text = cfg.translated_help_text_override(language_code) or base_help_text
+ raw_value = _field_value_from_request(form_type, request_obj, field_name, language_code)
+ sections[section_key]["fields"].append(
+ {
+ "name": field_name,
+ "label": label,
+ "help_text": help_text,
+ "kind": _field_kind(raw_value),
+ "value": raw_value,
+ "is_empty": _is_empty_value(raw_value),
+ "is_locked": field_name in locked_fields,
+ }
+ )
+
+ not_available = _not_available_text(language_code)
+ result = []
+ for section in sections.values():
+ visible_fields = []
+ for field in section["fields"]:
+ if (
+ field["name"] in PDF_BOOLEAN_CONTROL_FIELDS.get(form_type, set())
+ and _coerce_text(field["value"]).lower() in {"nein", "no"}
+ ):
+ continue
+ display_value = field["value"] if field["kind"] == "list" else (_coerce_text(field["value"]) or not_available)
+ visible_fields.append(
+ {
+ **field,
+ "display_value": display_value,
+ }
+ )
+ render_fields = [field for field in visible_fields if not field["is_empty"]]
+ scalar_fields = [field for field in render_fields if field["kind"] != "list"]
+ list_fields = [field for field in render_fields if field["kind"] == "list"]
+ scalar_rows = [scalar_fields[index:index + 2] for index in range(0, len(scalar_fields), 2)]
+ for row in scalar_rows:
+ if len(row) < 2:
+ row.append(None)
+ result.append(
+ {
+ "key": section["key"],
+ "title": section["title"],
+ "fields": visible_fields,
+ "render_fields": render_fields,
+ "scalar_fields": scalar_fields,
+ "list_fields": list_fields,
+ "scalar_rows": scalar_rows,
+ "has_content": bool(render_fields),
+ }
+ )
+ return result
diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py
index 1b9aa58..4ece6b1 100644
--- a/backend/workflows/tasks.py
+++ b/backend/workflows/tasks.py
@@ -29,6 +29,7 @@ from .forms import (
WORKSPACE_GROUP_CHOICES,
)
from .notifications import notify_user_by_email
+from .pdf_sections import build_pdf_sections
# These templates are the product-level defaults for fresh deployments.
# Runtime branding and company config can override the company-facing identity
@@ -965,6 +966,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
context = {
'T': t,
'PDF_LANG': lang,
+ 'PDF_SECTIONS': build_pdf_sections('onboarding', request_obj, lang),
'VORNAME': first_name,
'NACHNAME': last_name,
'DISPLAY_NAME': display_name or request_obj.full_name,
@@ -1209,6 +1211,8 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
context = {
'T': t,
+ 'PDF_LANG': lang,
+ 'PDF_SECTIONS': build_pdf_sections('offboarding', request_obj, lang),
'FULL_NAME': request_obj.full_name,
'EMAIL': request_obj.work_email,
'DEPARTMENT': request_obj.department or t['not_available_short'],
diff --git a/backend/workflows/tests/test_pdf_generation.py b/backend/workflows/tests/test_pdf_generation.py
new file mode 100644
index 0000000..83d0ef3
--- /dev/null
+++ b/backend/workflows/tests/test_pdf_generation.py
@@ -0,0 +1,75 @@
+from datetime import date
+from pathlib import Path
+
+from django.test import TestCase, override_settings
+from pypdf import PdfReader
+
+from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest
+from workflows.tasks import _generate_offboarding_pdf, _generate_onboarding_pdf
+
+
+@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs'))
+class PDFGenerationTests(TestCase):
+ def _extract_pdf_text(self, pdf_path: Path) -> str:
+ reader = PdfReader(str(pdf_path))
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
+
+ def test_onboarding_pdf_respects_hidden_section_and_field(self):
+ FormSectionConfig.objects.update_or_create(
+ form_type='onboarding',
+ section_key='itsetup',
+ defaults={'is_visible': False},
+ )
+ FormFieldConfig.objects.update_or_create(
+ form_type='onboarding',
+ field_name='job_title',
+ defaults={'is_visible': False},
+ )
+ request_obj = OnboardingRequest.objects.create(
+ full_name='Max Mustermann',
+ gender='herr',
+ job_title='Consultant',
+ department='IT-Service',
+ work_email='max.mustermann@workdock.de',
+ contract_start=date(2026, 11, 1),
+ employment_type='unbefristet',
+ needed_devices='Laptop\nMonitor',
+ onboarded_by_email='requester@workdock.de',
+ onboarded_by_name='Mia Beispiel',
+ agreement='accepted',
+ )
+
+ pdf_path = _generate_onboarding_pdf(request_obj)
+ text = self._extract_pdf_text(pdf_path)
+
+ self.assertIn('Max Mustermann', text)
+ self.assertIn('IT-Service', text)
+ self.assertIn('Stammdaten', text)
+ self.assertNotIn('Consultant', text)
+ self.assertNotIn('Laptop', text)
+ self.assertNotIn('IT-Setup', text)
+ self.assertNotIn('1. Stammdaten', text)
+ self.assertNotIn('Vorname', text)
+ self.assertNotIn('Nachname', text)
+ self.assertNotIn('onboarded_by_email', text)
+
+ def test_offboarding_pdf_uses_dynamic_sections(self):
+ request_obj = OffboardingRequest.objects.create(
+ full_name='Lara Beispiel',
+ work_email='lara.beispiel@workdock.de',
+ department='IT-Service',
+ job_title='Engineer',
+ last_working_day=date(2026, 12, 31),
+ notes='Bitte Accounts sperren.',
+ requested_by_email='admin@workdock.de',
+ requested_by_name='Nina Admin',
+ )
+
+ pdf_path = _generate_offboarding_pdf(request_obj)
+ text = self._extract_pdf_text(pdf_path)
+
+ self.assertIn('Lara Beispiel', text)
+ self.assertIn('Engineer', text)
+ self.assertIn('31. Dezember 2026', text)
+ self.assertIn('Bitte Accounts sperren.', text)
+ self.assertNotIn('1. Mitarbeitende', text)
diff --git a/backend/workflows/tests/test_pdf_sections.py b/backend/workflows/tests/test_pdf_sections.py
new file mode 100644
index 0000000..9d4321e
--- /dev/null
+++ b/backend/workflows/tests/test_pdf_sections.py
@@ -0,0 +1,96 @@
+from django.test import TestCase
+
+from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest
+from workflows.pdf_sections import build_pdf_sections
+
+
+class PDFSectionBuilderTests(TestCase):
+ def test_onboarding_builder_respects_hidden_section_and_hidden_field(self):
+ FormSectionConfig.objects.update_or_create(
+ form_type='onboarding',
+ section_key='itsetup',
+ defaults={'is_visible': False},
+ )
+ FormFieldConfig.objects.update_or_create(
+ form_type='onboarding',
+ field_name='job_title',
+ defaults={'is_visible': False},
+ )
+ request_obj = OnboardingRequest.objects.create(
+ full_name='Max Mustermann',
+ gender='herr',
+ job_title='Consultant',
+ department='IT-Service',
+ work_email='max.mustermann@workdock.de',
+ contract_start='2026-11-01',
+ employment_type='unbefristet',
+ agreement='accepted',
+ )
+
+ sections = build_pdf_sections('onboarding', request_obj, 'de')
+
+ self.assertEqual([section['key'] for section in sections], ['stammdaten', 'vertrag', 'abschluss'])
+ stammdaten = next(section for section in sections if section['key'] == 'stammdaten')
+ self.assertNotIn('job_title', [field['name'] for field in stammdaten['fields']])
+
+ def test_onboarding_builder_uses_field_order_and_overrides(self):
+ FormFieldConfig.objects.update_or_create(
+ form_type='onboarding',
+ field_name='department',
+ defaults={
+ 'sort_order': 1,
+ 'label_override': 'Team',
+ 'help_text_override': 'Interne Organisationseinheit',
+ },
+ )
+ FormFieldConfig.objects.update_or_create(
+ form_type='onboarding',
+ field_name='gender',
+ defaults={'sort_order': 5},
+ )
+ request_obj = OnboardingRequest.objects.create(
+ full_name='Max Mustermann',
+ gender='herr',
+ job_title='Consultant',
+ department='IT-Service',
+ work_email='max.mustermann@workdock.de',
+ contract_start='2026-11-01',
+ employment_type='unbefristet',
+ agreement='accepted',
+ )
+
+ sections = build_pdf_sections('onboarding', request_obj, 'de')
+ stammdaten = next(section for section in sections if section['key'] == 'stammdaten')
+ visible_names = [field['name'] for field in stammdaten['fields']]
+ department_field = next(field for field in stammdaten['fields'] if field['name'] == 'department')
+
+ self.assertLess(visible_names.index('department'), visible_names.index('gender'))
+ self.assertEqual(department_field['label'], 'Team')
+ self.assertEqual(department_field['help_text'], 'Interne Organisationseinheit')
+ self.assertEqual(department_field['display_value'], 'IT-Service')
+
+ def test_offboarding_builder_has_section_parity_and_formats_values(self):
+ FormSectionConfig.objects.update_or_create(
+ form_type='offboarding',
+ section_key='abschluss',
+ defaults={'is_visible': False},
+ )
+ request_obj = OffboardingRequest.objects.create(
+ full_name='Lara Beispiel',
+ work_email='lara.beispiel@workdock.de',
+ department='IT-Service',
+ job_title='Engineer',
+ last_working_day='2026-12-31',
+ notes='Bitte Accounts sperren.',
+ requested_by_email='admin@workdock.de',
+ )
+
+ sections = build_pdf_sections('offboarding', request_obj, 'de')
+
+ self.assertEqual([section['key'] for section in sections], ['mitarbeitende', 'austritt'])
+ mitarbeitende = next(section for section in sections if section['key'] == 'mitarbeitende')
+ austritt = next(section for section in sections if section['key'] == 'austritt')
+ self.assertIn('full_name', [field['name'] for field in mitarbeitende['fields']])
+ self.assertIn('last_working_day', [field['name'] for field in austritt['fields']])
+ date_field = next(field for field in austritt['fields'] if field['name'] == 'last_working_day')
+ self.assertTrue(date_field['display_value'])