snapshot: preserve dynamic pdf parity and quality pass
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ T.lang or 'de' }}">
|
||||
<html lang="{{ PDF_LANG or T.lang or 'de' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -159,25 +144,26 @@
|
||||
<h1 class="title">{{ T.offboarding_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="section">{{ T.employee_info }}</div>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ T.name }}</th>
|
||||
<td class="mono">{{ FULL_NAME }}</td>
|
||||
<th>{{ T.email }}</th>
|
||||
<td>{{ EMAIL }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.department }}</th>
|
||||
<td>{{ DEPARTMENT }}</td>
|
||||
<th>{{ T.job_title }}</th>
|
||||
<td>{{ JOB_TITLE }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.last_working_day }}</th>
|
||||
<td colspan="3">{{ LAST_WORKING_DAY }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% for section in PDF_SECTIONS %}
|
||||
{% if section.has_content %}
|
||||
<div class="section">{{ section.title }}</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 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="section">{{ T.offboarding_requester }}</div>
|
||||
<table>
|
||||
@@ -295,14 +281,6 @@
|
||||
<div class="sigline">{{ T.return_complete }} <span class="cb">□</span> {{ T.yes }}     <span class="cb">□</span> {{ T.no }}</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>
|
||||
</body>
|
||||
</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 @@
|
||||
<h1 class="title">{{ T.onboarding_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="section">{{ T.onboarding_staff_data }}</div>
|
||||
<table>
|
||||
<tr>
|
||||
<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>
|
||||
{% for section in PDF_SECTIONS %}
|
||||
{% if section.has_content %}
|
||||
<div class="section">{{ section.title }}</div>
|
||||
|
||||
<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 %}
|
||||
<div class="opt-card">
|
||||
<div class="opt-title">{{ T.devices }}</div>
|
||||
<table class="opt-grid">
|
||||
{% for row in ARBEITSGERÄTE_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>
|
||||
{% for field in section.list_fields %}
|
||||
<div class="opt-card">
|
||||
<div class="opt-title">{{ field.label }}</div>
|
||||
<table class="opt-grid">
|
||||
{% for row in field.display_value|batch(3, '') %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{% if cell %}• {{ cell }}{% endif %}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="section">{{ T.confirmation }}</div>
|
||||
<table>
|
||||
|
||||
@@ -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)
|
||||
|
||||
290
backend/workflows/pdf_sections.py
Normal file
290
backend/workflows/pdf_sections.py
Normal 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
|
||||
@@ -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'],
|
||||
|
||||
75
backend/workflows/tests/test_pdf_generation.py
Normal file
75
backend/workflows/tests/test_pdf_generation.py
Normal 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)
|
||||
96
backend/workflows/tests/test_pdf_sections.py
Normal file
96
backend/workflows/tests/test_pdf_sections.py
Normal 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'])
|
||||
Reference in New Issue
Block a user