snapshot: preserve dynamic pdf parity and quality pass

This commit is contained in:
Md Bayazid Bostame
2026-03-27 12:41:32 +01:00
parent e929e7509b
commit b9441f2503
7 changed files with 525 additions and 292 deletions

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ T.lang or 'de' }}"> <html lang="{{ PDF_LANG or T.lang or 'de' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -29,12 +29,6 @@
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.sub {
margin: 2px 0 0 0;
color: #6b7280;
font-size: 9.5px;
}
.section { .section {
margin-top: 9px; margin-top: 9px;
font-size: 11px; font-size: 11px;
@@ -54,6 +48,8 @@
border: 1px solid #f0e1e1; border: 1px solid #f0e1e1;
padding: 4px 6px; padding: 4px 6px;
vertical-align: top; vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
} }
th { th {
@@ -141,17 +137,6 @@
font-size: 9.4px; font-size: 9.4px;
} }
.manual-title {
margin: 9px 0 5px;
font-size: 10px;
font-weight: bold;
color: #111827;
}
.manual-grid td {
width: 50%;
}
</style> </style>
</head> </head>
<body> <body>
@@ -159,25 +144,26 @@
<h1 class="title">{{ T.offboarding_title }}</h1> <h1 class="title">{{ T.offboarding_title }}</h1>
</div> </div>
<div class="section">{{ T.employee_info }}</div> {% for section in PDF_SECTIONS %}
<table> {% if section.has_content %}
<tr> <div class="section">{{ section.title }}</div>
<th>{{ T.name }}</th>
<td class="mono">{{ FULL_NAME }}</td> {% if section.scalar_rows %}
<th>{{ T.email }}</th> <table>
<td>{{ EMAIL }}</td> {% for row in section.scalar_rows %}
</tr> <tr>
<tr> <th>{{ row[0].label }}</th>
<th>{{ T.department }}</th> <td{% if not row[1] %} colspan="3"{% endif %}{% if row[0].name in ['full_name', 'work_email'] %} class="mono"{% endif %}>{{ row[0].display_value }}</td>
<td>{{ DEPARTMENT }}</td> {% if row[1] %}
<th>{{ T.job_title }}</th> <th>{{ row[1].label }}</th>
<td>{{ JOB_TITLE }}</td> <td>{{ row[1].display_value }}</td>
</tr> {% endif %}
<tr> </tr>
<th>{{ T.last_working_day }}</th> {% endfor %}
<td colspan="3">{{ LAST_WORKING_DAY }}</td> </table>
</tr> {% endif %}
</table> {% endif %}
{% endfor %}
<div class="section">{{ T.offboarding_requester }}</div> <div class="section">{{ T.offboarding_requester }}</div>
<table> <table>
@@ -295,14 +281,6 @@
<div class="sigline">{{ T.return_complete }} <span class="cb">&#9633;</span>&#160;{{ T.yes }} &#160;&#160;&#160; <span class="cb">&#9633;</span>&#160;{{ T.no }}</div> <div class="sigline">{{ T.return_complete }} <span class="cb">&#9633;</span>&#160;{{ T.yes }} &#160;&#160;&#160; <span class="cb">&#9633;</span>&#160;{{ T.no }}</div>
</div> </div>
<div class="section">{{ T.notes }}</div>
<table>
<tr>
<th>{{ T.notes }}</th>
<td>{{ NOTES }}</td>
</tr>
</table>
<p class="small">{{ T.offboarding_note }}</p> <p class="small">{{ T.offboarding_note }}</p>
</body> </body>
</html> </html>

View File

@@ -29,12 +29,6 @@
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.sub {
margin: 2px 0 0 0;
color: #475569;
font-size: 9.5px;
}
.section { .section {
margin-top: 9px; margin-top: 9px;
font-size: 11px; font-size: 11px;
@@ -101,11 +95,6 @@
word-break: break-word; word-break: break-word;
} }
.empty {
color: #94a3b8;
font-style: italic;
}
.signature { .signature {
width: 150px; width: 150px;
height: 70px; height: 70px;
@@ -133,245 +122,41 @@
<h1 class="title">{{ T.onboarding_title }}</h1> <h1 class="title">{{ T.onboarding_title }}</h1>
</div> </div>
<div class="section">{{ T.onboarding_staff_data }}</div> {% for section in PDF_SECTIONS %}
<table> {% if section.has_content %}
<tr> <div class="section">{{ section.title }}</div>
<th>{{ T.name }}</th>
<td class="mono" colspan="3">{{ DISPLAY_NAME }}</td>
</tr>
<tr>
<th>{{ T.department }}</th>
<td>{{ ABTEILUNG }}</td>
<th>{{ T.job_title }}</th>
<td>{{ BERUFSBEZEICHNUNG }}</td>
</tr>
<tr>
<th>{{ T.work_email }}</th>
<td>{{ EMAIL }}</td>
<th>{{ T.employment_type }}</th>
<td>{{ BESCHAEFTIGUNG }}</td>
</tr>
<tr>
<th>{{ T.contract_start }}</th>
<td>{{ VERTRAGSBEGINN }}</td>
<th>{{ T.contract_end }}</th>
<td>{{ VERTRAGSENDE }}</td>
</tr>
<tr>
<th>{{ T.handover_date }}</th>
<td colspan="3">{{ UEBERGABEDATUM }}</td>
</tr>
</table>
<div class="section">{{ T.equipment_access }}</div> {% if section.scalar_rows %}
<table>
{% for row in section.scalar_rows %}
<tr>
<th>{{ row[0].label }}</th>
<td{% if not row[1] %} colspan="3"{% endif %}{% if row[0].name in ['full_name', 'work_email'] %} class="mono"{% endif %}>{{ row[0].display_value }}</td>
{% if row[1] %}
<th>{{ row[1].label }}</th>
<td>{{ row[1].display_value }}</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% endif %}
{% if HAS_DEVICES %} {% for field in section.list_fields %}
<div class="opt-card"> <div class="opt-card">
<div class="opt-title">{{ T.devices }}</div> <div class="opt-title">{{ field.label }}</div>
<table class="opt-grid"> <table class="opt-grid">
{% for row in ARBEITSGERÄTE_LIST %} {% for row in field.display_value|batch(3, '') %}
<tr> <tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %} {% for cell in row %}
{% if row|length < 3 %} <td>{% if cell %}• {{ cell }}{% endif %}</td>
{% for _ in range(3 - row|length) %}<td></td>{% endfor %} {% endfor %}
{% endif %} </tr>
</tr> {% endfor %}
</table>
</div>
{% endfor %} {% endfor %}
</table> {% endif %}
</div> {% endfor %}
{% endif %}
{% if HAS_GROUPS %}
<div class="opt-card">
<div class="opt-title">{{ T.workspace_groups }}</div>
<table class="opt-grid">
{% for row in ZUGAENGE_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if HAS_SOFTWARE %}
<div class="opt-card">
<div class="opt-title">{{ T.software }}</div>
<table class="opt-grid">
{% for row in SOFTWARE_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if HAS_ACCESSES %}
<div class="opt-card">
<div class="opt-title">{{ T.accesses }}</div>
<table class="opt-grid">
{% for row in ACCOUNT_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if HAS_RESOURCES %}
<div class="opt-card">
<div class="opt-title">{{ T.resources }}</div>
<table class="opt-grid">
{% for row in STANDARD_RESSOURCEN %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if GROUP_MAILBOXES_REQUIRED and HAS_GROUP_MAILBOXES %}
<div class="opt-card">
<div class="opt-title">{{ T.group_mailboxes_required }}</div>
<table class="opt-grid">
{% for row in GROUP_MAILBOXES_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if ADDITIONAL_HARDWARE_NEEDED and HAS_ADDITIONAL_HARDWARE %}
<div class="opt-card">
<div class="opt-title">{{ T.additional_hardware_needed }}</div>
<table class="opt-grid">
{% for row in ADDITIONAL_HARDWARE_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if ADDITIONAL_SOFTWARE_NEEDED and HAS_ADDITIONAL_SOFTWARE %}
<div class="opt-card">
<div class="opt-title">{{ T.additional_software_needed }}</div>
<table class="opt-grid">
{% for row in ADDITIONAL_SOFTWARE_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if ADDITIONAL_ACCESS_NEEDED and HAS_ADDITIONAL_ACCESS %}
<div class="opt-card">
<div class="opt-title">{{ T.additional_access_needed }}</div>
<table class="opt-grid">
{% for row in ADDITIONAL_ACCESS_LIST %}
<tr>
{% for cell in row %}<td>• {{ cell }}</td>{% endfor %}
{% if row|length < 3 %}
{% for _ in range(3 - row|length) %}<td></td>{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if (VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN) or HAS_ADDITIONAL_HARDWARE_OTHER or HAS_SUCCESSOR_INFO or HAS_ADDITIONAL_NOTES %}
<div class="section">{{ T.additional_details }}</div>
{% endif %}
{% if VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN %}
<div class="opt-card">
<div class="opt-title">{{ T.business_cards }}</div>
<table>
<tr>
<th>{{ T.name }}</th>
<td>{{ VISITENKARTE_NAME }}</td>
<th>{{ T.job_title }}</th>
<td>{{ VISITENKARTE_TITEL }}</td>
</tr>
<tr>
<th>{{ T.email }}</th>
<td>{{ VISITENKARTE_EMAIL }}</td>
<th>{{ T.phone }}</th>
<td>{{ VISITENKARTE_TELEFON }}</td>
</tr>
</table>
</div>
{% endif %}
{% if HAS_ADDITIONAL_HARDWARE_OTHER %}
<div class="opt-card">
<div class="opt-title">{{ T.additional_hardware_other }}</div>
<table>
<tr>
<td>{{ ADDITIONAL_HARDWARE_OTHER }}</td>
</tr>
</table>
</div>
{% endif %}
{% if HAS_SUCCESSOR_INFO %}
<div class="opt-card">
<div class="opt-title">{{ T.successor_phone }}</div>
<table>
<tr>
<th>{{ T.successor_of }}</th>
<td>{{ SUCCESSOR_NAME }}</td>
<th>{{ T.inherit_phone_number }}</th>
<td>{{ INHERIT_PHONE_NUMBER }}</td>
</tr>
<tr>
<th>{{ T.direct_extension }}</th>
<td colspan="3">{{ PHONE_NUMBER }}</td>
</tr>
</table>
</div>
{% endif %}
{% if HAS_ADDITIONAL_NOTES %}
<div class="opt-card">
<div class="opt-title">{{ T.notes }}</div>
<table>
<tr>
<td>{{ ADDITIONAL_NOTES }}</td>
</tr>
</table>
</div>
{% endif %}
<div class="section">{{ T.confirmation }}</div> <div class="section">{{ T.confirmation }}</div>
<table> <table>

View File

@@ -477,6 +477,8 @@ class FormFieldConfig(models.Model):
('vertrag', _('Vertrag')), ('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')), ('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')), ('abschluss', _('Abschluss')),
('mitarbeitende', _('Mitarbeitende')),
('austritt', _('Austritt')),
] ]
FORM_CHOICES = [ FORM_CHOICES = [
('onboarding', _('Onboarding')), ('onboarding', _('Onboarding')),
@@ -519,12 +521,15 @@ class FormFieldConfig(models.Model):
class FormSectionConfig(models.Model): class FormSectionConfig(models.Model):
FORM_CHOICES = [ FORM_CHOICES = [
('onboarding', _('Onboarding')), ('onboarding', _('Onboarding')),
('offboarding', _('Offboarding')),
] ]
SECTION_CHOICES = [ SECTION_CHOICES = [
('stammdaten', _('Stammdaten')), ('stammdaten', _('Stammdaten')),
('vertrag', _('Vertrag')), ('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')), ('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')), ('abschluss', _('Abschluss')),
('mitarbeitende', _('Mitarbeitende')),
('austritt', _('Austritt')),
] ]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES) form_type = models.CharField(max_length=20, choices=FORM_CHOICES)

View File

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

View File

@@ -29,6 +29,7 @@ from .forms import (
WORKSPACE_GROUP_CHOICES, WORKSPACE_GROUP_CHOICES,
) )
from .notifications import notify_user_by_email from .notifications import notify_user_by_email
from .pdf_sections import build_pdf_sections
# These templates are the product-level defaults for fresh deployments. # These templates are the product-level defaults for fresh deployments.
# Runtime branding and company config can override the company-facing identity # Runtime branding and company config can override the company-facing identity
@@ -965,6 +966,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
context = { context = {
'T': t, 'T': t,
'PDF_LANG': lang, 'PDF_LANG': lang,
'PDF_SECTIONS': build_pdf_sections('onboarding', request_obj, lang),
'VORNAME': first_name, 'VORNAME': first_name,
'NACHNAME': last_name, 'NACHNAME': last_name,
'DISPLAY_NAME': display_name or request_obj.full_name, 'DISPLAY_NAME': display_name or request_obj.full_name,
@@ -1209,6 +1211,8 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
context = { context = {
'T': t, 'T': t,
'PDF_LANG': lang,
'PDF_SECTIONS': build_pdf_sections('offboarding', request_obj, lang),
'FULL_NAME': request_obj.full_name, 'FULL_NAME': request_obj.full_name,
'EMAIL': request_obj.work_email, 'EMAIL': request_obj.work_email,
'DEPARTMENT': request_obj.department or t['not_available_short'], 'DEPARTMENT': request_obj.department or t['not_available_short'],

View File

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

View File

@@ -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'])