diff --git a/README.md b/README.md
index a8e1cfe..a61c262 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,12 @@ Notes:
- Language stability hardening is in place:
- onboarding/offboarding request records normalize `preferred_language` at model-save time
- both request tables now have a database default of `de`
-- Several generated PDF business text blocks are still not fully bilingual yet.
+- Generated PDF fixed text is now bilingual for:
+ - onboarding PDF
+ - offboarding PDF
+ - intro PDF
+ - live introduction protocol PDF
+- Remaining bilingual gap is mostly long-form handbook/wiki copy and a few secondary admin/help texts.
- CI now validates that translation catalogs compile successfully on push and pull request.
## Current implemented scope
diff --git a/backend/media/templates/offboarding_template.html b/backend/media/templates/offboarding_template.html
new file mode 100644
index 0000000..af585a6
--- /dev/null
+++ b/backend/media/templates/offboarding_template.html
@@ -0,0 +1,307 @@
+
+
+
+
+
+ {{ T.offboarding_title }}
+
+
+
+
+
{{ 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 }} |
+
+
+
+ {{ T.offboarding_requester }}
+
+
+ | {{ T.requested_by_name }} |
+ {{ REQUESTED_BY_NAME }} |
+ {{ T.requested_by_email }} |
+ {{ REQUESTED_BY }} |
+
+
+
+ {% if HAS_ONBOARDING_DATA %}
+ {{ T.it_hardware_status }}
+
+
{{ T.hardware_check }}
+
+ {% if HARDWARE_CHECKLIST %}
+ {% for item in HARDWARE_CHECKLIST %}
+ {% if loop.index0 % 3 == 0 %}{% endif %}
+ | □ {{ item.label }} |
+ {% if loop.index0 % 3 == 2 or loop.last %}
{% endif %}
+ {% endfor %}
+ {% else %}
+ | {{ T.no_onboarding_hardware }} |
+ {% endif %}
+
+
+ {% else %}
+ {{ T.manual_return_overview }}
+ {{ T.manual_return_note }}
+
+
+
{{ T.returned_devices }}
+
+ {% for row in MANUAL_DEVICES %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 3 %}
+ {% for _ in range(3 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
{{ T.returned_software }}
+
+ {% for row in MANUAL_SOFTWARE %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 3 %}
+ {% for _ in range(3 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
{{ T.removed_workspace_groups }}
+
+ {% for row in MANUAL_WORKSPACE_GROUPS %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 3 %}
+ {% for _ in range(3 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
{{ T.removed_accesses }}
+
+ {% for row in MANUAL_ACCESSES %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 3 %}
+ {% for _ in range(3 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
{{ T.returned_extra_it }}
+
+ {% for row in MANUAL_EXTRA_HARDWARE %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 3 %}
+ {% for _ in range(3 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+ {% for row in MANUAL_EXTRA_SOFTWARE %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 3 %}
+ {% for _ in range(3 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {{ T.it_signatures }}
+
+
{{ T.it_checked_by }}
+
{{ T.it_signature }}
+
{{ T.return_complete }} □ {{ T.yes }} □ {{ T.no }}
+
+
+ {{ T.notes }}
+
+
+ | {{ T.notes }} |
+ {{ NOTES }} |
+
+
+
+ {{ T.offboarding_note }}
+
+
diff --git a/backend/media/templates/onboarding_intro_session_pdf.html b/backend/media/templates/onboarding_intro_session_pdf.html
new file mode 100644
index 0000000..30165e0
--- /dev/null
+++ b/backend/media/templates/onboarding_intro_session_pdf.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ {{ T.live_intro_title }}
+
+
+
+
+
{{ T.live_intro_title }}
+
{{ T.live_intro_sub }}
+
+
+ {{ T.base_data }}
+
+
+ | {{ T.name }} |
+ {{ DISPLAY_NAME }} |
+ {{ T.department }} |
+ {{ ABTEILUNG }} |
+
+
+ | {{ T.job_title }} |
+ {{ BERUFSBEZEICHNUNG }} |
+ {{ T.employment_start }} |
+ {{ VERTRAGSBEGINN }} |
+
+
+ | {{ T.work_email }} |
+ {{ EMAIL }} |
+ {{ T.introduced_by }} |
+ {{ REQUESTED_BY_NAME }} |
+
+
+
+ {% for section in INTRO_SECTIONS %}
+
+
{{ section.title }}
+
+ {% for row in section.rows %}
+
+ {% for cell in row %}| ✓{{ cell.label }} | {% endfor %}
+ {% if row|length < 2 %} | {% endif %}
+
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {{ T.notes }}
+ {{ SESSION_NOTES }}
+
+ {{ T.employee_signature_block }}
+
+
+
diff --git a/backend/media/templates/onboarding_intro_template.html b/backend/media/templates/onboarding_intro_template.html
new file mode 100644
index 0000000..8be677a
--- /dev/null
+++ b/backend/media/templates/onboarding_intro_template.html
@@ -0,0 +1,193 @@
+
+
+
+
+
+ {{ T.intro_title }}
+
+
+
+
+
{{ T.intro_title }}
+
{{ T.intro_sub }}
+
+
+ {{ T.base_data }}
+
+
+ | {{ T.name }} |
+ {{ DISPLAY_NAME }} |
+ {{ T.department }} |
+ {{ ABTEILUNG }} |
+
+
+ | {{ T.job_title }} |
+ {{ BERUFSBEZEICHNUNG }} |
+ {{ T.start_date }} |
+ {{ VERTRAGSBEGINN }} |
+
+
+ | {{ T.work_email }} |
+ {{ EMAIL }} |
+ {{ T.introduced_by }} |
+ {{ REQUESTED_BY_NAME }} |
+
+
+
+ {{ T.intro_note }}
+
+ {% for section in INTRO_SECTIONS %}
+
+
{{ section.title }}
+
+ {% for row in section.rows %}
+
+ {% for cell in row %}| □ {{ cell }} | {% endfor %}
+ {% if row|length < 2 %}
+ {% for _ in range(2 - row|length) %} | {% endfor %}
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {{ T.confirmation }}
+
+
{{ T.intro_completed_at }}
+
{{ T.employee_signature }}
+
{{ T.trainer_signature }}
+
{{ T.open_questions }}
+
+
+
diff --git a/backend/media/templates/onboarding_template.html b/backend/media/templates/onboarding_template.html
new file mode 100644
index 0000000..273619e
--- /dev/null
+++ b/backend/media/templates/onboarding_template.html
@@ -0,0 +1,395 @@
+
+
+
+
+
+ {{ T.onboarding_title }}
+
+
+
+
+
{{ 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 }} |
+
+
+
+ {{ T.equipment_access }}
+
+ {% 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 %}
+
+ {% 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 %}
+
+ {{ T.confirmation }}
+
+
+ | {{ T.requested_by_name }} |
+ {{ REQUESTED_BY_NAME }} |
+ {{ T.requested_by_email }} |
+ {{ REQUESTED_BY_EMAIL }} |
+
+
+ | {{ T.signature }} |
+ {% if UNTERSCHRIFT %}
+  |
+ {% else %}
+ {{ UNTERSCHRIFT_HINWEIS }} |
+ {% endif %}
+
+
+
+ {{ T.onboarding_note }}
+
+
diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py
index 8bc0ffe..e8e0a78 100644
--- a/backend/workflows/tasks.py
+++ b/backend/workflows/tasks.py
@@ -291,6 +291,169 @@ def _chunk_choice_labels(choices: list[tuple[str, str]], chunk_size: int = 3) ->
return _chunk_list(labels, chunk_size=chunk_size)
+def _normalized_lang(language_code: str | None) -> str:
+ return (language_code or 'de').split('-')[0].lower() or 'de'
+
+
+def _pdf_texts(language_code: str | None = None) -> dict[str, str]:
+ lang = _normalized_lang(language_code)
+ texts = {
+ 'de': {
+ 'lang': 'de',
+ 'not_available': 'Keine Angabe',
+ 'not_available_short': '-',
+ 'yes': 'Ja',
+ 'no': 'Nein',
+ 'onboarding_title': 'Onboarding-Unterlagen',
+ 'onboarding_staff_data': 'Personaldaten',
+ 'name': 'Name',
+ 'department': 'Abteilung',
+ 'job_title': 'Berufsbezeichnung',
+ 'work_email': 'Dienstliche E-Mail',
+ 'employment_type': 'Beschäftigungsverhältnis',
+ 'contract_start': 'Vertragsbeginn',
+ 'contract_end': 'Vertragsende',
+ 'handover_date': 'Übergabedatum',
+ 'equipment_access': 'Ausstattung und Zugänge',
+ 'devices': 'Benötigte Geräte und Gegenstände',
+ 'workspace_groups': 'Benötigte Gruppen im Workspace',
+ 'software': 'Benötigte Software',
+ 'accesses': 'Benötigte Zugänge',
+ 'resources': 'Benötigte Ressourcen',
+ 'group_mailboxes_required': 'Gruppenpostfächer erforderlich',
+ 'additional_hardware_needed': 'Darüber hinaus wird weitere Hardware benötigt',
+ 'additional_software_needed': 'Wird zusätzliche Software benötigt',
+ 'additional_access_needed': 'Darüber hinaus werden weitere Zugänge benötigt',
+ 'additional_details': 'Zusätzliche Angaben',
+ 'business_cards': 'Visitenkarten',
+ 'email': 'E-Mail',
+ 'phone': 'Telefon',
+ 'additional_hardware_other': 'Weitere Hardware (Freitext)',
+ 'successor_phone': 'Nachfolge und Telefon',
+ 'successor_of': 'Nachfolge von',
+ 'inherit_phone_number': 'Telefon von Vorgänger übernehmen',
+ 'direct_extension': 'Direktwahl',
+ 'notes': 'Notizen',
+ 'confirmation': 'Bestätigung',
+ 'requested_by_name': 'Angefordert von (Name)',
+ 'requested_by_email': 'Angefordert von (E-Mail)',
+ 'signature': 'Unterschrift',
+ 'signature_alt': 'Unterschrift',
+ 'onboarding_note': 'Hinweis: Dieses Formular dient als interne Prozessgrundlage für das Onboarding.',
+ 'offboarding_title': 'Offboarding-Unterlagen',
+ 'employee_info': 'Mitarbeitenden-Informationen',
+ 'last_working_day': 'Letzter Arbeitstag',
+ 'offboarding_requester': 'Offboarding-Anfordernde Person',
+ 'it_hardware_status': 'IT-Hardware-Status (aus Onboarding)',
+ 'hardware_check': 'Hardware-Check',
+ 'no_onboarding_hardware': 'Keine Onboarding-Hardwaredaten gefunden.',
+ 'manual_return_overview': 'Manuelle Rückgabeübersicht',
+ 'manual_return_note': 'Es wurden keine gespeicherten Onboarding-Daten zu dieser Person gefunden. Die folgenden Listen dienen als manuelle Rückgabe- und Prüfübersicht.',
+ 'returned_devices': 'Zurückgegebene Geräte und Artikel',
+ 'returned_software': 'Zurückgegebene bzw. deaktivierte Software',
+ 'removed_workspace_groups': 'Entfernte Gruppen im Workspace',
+ 'removed_accesses': 'Entfernte Zugänge',
+ 'returned_extra_it': 'Zurückgegebene zusätzliche Hardware / Software',
+ 'it_signatures': 'IT-Section: Signaturen',
+ 'it_checked_by': 'IT geprüft am durch:',
+ 'it_signature': 'IT-Unterschrift:',
+ 'return_complete': 'Rückgabe vollständig:',
+ 'offboarding_note': 'Hinweis: Dieses Formular dient als interne Prozessgrundlage für das Offboarding.',
+ 'intro_title': 'Einweisungs- und Übergabeprotokoll',
+ 'intro_sub': 'Gesprächsleitfaden für die persönliche Einführung neuer Mitarbeitender.',
+ 'base_data': 'Basisdaten',
+ 'start_date': 'Startdatum',
+ 'introduced_by': 'Einweisung durch',
+ 'intro_note': 'Dieses Dokument dient als Gesprächsleitfaden für die persönliche Einweisung. Die Felder können während des Termins manuell abgehakt und anschließend unterschrieben werden.',
+ 'employee_signature': 'Unterschrift Mitarbeitende Person:',
+ 'trainer_signature': 'Unterschrift Einweisende Person:',
+ 'intro_completed_at': 'Einweisung durchgeführt am:',
+ 'open_questions': 'Rückfragen offen / Nacharbeit erforderlich:',
+ 'live_intro_title': 'Einweisungsprotokoll',
+ 'live_intro_sub': 'Export des aktuellen Live-Status aus der webbasierten Einweisung.',
+ 'employment_start': 'Vertragsbeginn',
+ 'employee_signature_block': 'Unterschrift Mitarbeitende Person',
+ },
+ 'en': {
+ 'lang': 'en',
+ 'not_available': 'Not provided',
+ 'not_available_short': '-',
+ 'yes': 'Yes',
+ 'no': 'No',
+ 'onboarding_title': 'Onboarding Documents',
+ 'onboarding_staff_data': 'Employee Details',
+ 'name': 'Name',
+ 'department': 'Department',
+ 'job_title': 'Job title',
+ 'work_email': 'Work email',
+ 'employment_type': 'Employment type',
+ 'contract_start': 'Contract start',
+ 'contract_end': 'Contract end',
+ 'handover_date': 'Handover date',
+ 'equipment_access': 'Equipment and access',
+ 'devices': 'Required devices and items',
+ 'workspace_groups': 'Required workspace groups',
+ 'software': 'Required software',
+ 'accesses': 'Required accesses',
+ 'resources': 'Required resources',
+ 'group_mailboxes_required': 'Group mailboxes required',
+ 'additional_hardware_needed': 'Additional hardware required',
+ 'additional_software_needed': 'Additional software required',
+ 'additional_access_needed': 'Additional accesses required',
+ 'additional_details': 'Additional details',
+ 'business_cards': 'Business cards',
+ 'email': 'Email',
+ 'phone': 'Phone',
+ 'additional_hardware_other': 'Additional hardware (free text)',
+ 'successor_phone': 'Successor and phone',
+ 'successor_of': 'Successor to',
+ 'inherit_phone_number': 'Take over predecessor phone number',
+ 'direct_extension': 'Direct extension',
+ 'notes': 'Notes',
+ 'confirmation': 'Confirmation',
+ 'requested_by_name': 'Requested by (name)',
+ 'requested_by_email': 'Requested by (email)',
+ 'signature': 'Signature',
+ 'signature_alt': 'Signature',
+ 'onboarding_note': 'Note: This form serves as the internal process basis for onboarding.',
+ 'offboarding_title': 'Offboarding Documents',
+ 'employee_info': 'Employee information',
+ 'last_working_day': 'Last working day',
+ 'offboarding_requester': 'Offboarding requester',
+ 'it_hardware_status': 'IT hardware status (from onboarding)',
+ 'hardware_check': 'Hardware check',
+ 'no_onboarding_hardware': 'No onboarding hardware data found.',
+ 'manual_return_overview': 'Manual return overview',
+ 'manual_return_note': 'No stored onboarding data was found for this person. The following lists serve as a manual return and review overview.',
+ 'returned_devices': 'Returned devices and items',
+ 'returned_software': 'Returned or disabled software',
+ 'removed_workspace_groups': 'Removed workspace groups',
+ 'removed_accesses': 'Removed accesses',
+ 'returned_extra_it': 'Returned additional hardware / software',
+ 'it_signatures': 'IT section: signatures',
+ 'it_checked_by': 'Checked by IT on:',
+ 'it_signature': 'IT signature:',
+ 'return_complete': 'Return complete:',
+ 'offboarding_note': 'Note: This form serves as the internal process basis for offboarding.',
+ 'intro_title': 'Introduction and Handover Protocol',
+ 'intro_sub': 'Conversation guide for the personal introduction of new employees.',
+ 'base_data': 'Basic data',
+ 'start_date': 'Start date',
+ 'introduced_by': 'Introduction by',
+ 'intro_note': 'This document serves as a conversation guide for the personal introduction. The fields can be checked manually during the meeting and signed afterwards.',
+ 'employee_signature': 'Employee signature:',
+ 'trainer_signature': 'Trainer signature:',
+ 'intro_completed_at': 'Introduction completed on:',
+ 'open_questions': 'Open questions / follow-up required:',
+ 'live_intro_title': 'Introduction Protocol',
+ 'live_intro_sub': 'Export of the current live status from the web-based introduction.',
+ 'employment_start': 'Contract start',
+ 'employee_signature_block': 'Employee signature',
+ },
+ }
+ return texts.get(lang, texts['de'])
+
+
MANUAL_ONBOARDING_FIELD_SECTIONS = [
(
'Stammdaten',
@@ -406,7 +569,7 @@ def _build_intro_sections_from_admin(request_obj: OnboardingRequest, language_co
def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]:
- lang = (language_code or get_language() or 'de').split('-')[0]
+ lang = _normalized_lang(language_code or get_language())
section_titles = {
'de': {
'workplace': 'Geräte und Arbeitsplatz',
@@ -432,39 +595,56 @@ def build_intro_sections_for_request(request_obj: OnboardingRequest, language_co
workplace_items = []
for item in devices:
- workplace_items.append(f'{item} übergeben und Grundfunktionen erklärt')
+ if lang == 'en':
+ workplace_items.append(f'{item} handed over and basic functions explained')
+ else:
+ workplace_items.append(f'{item} übergeben und Grundfunktionen erklärt')
for item in resources:
- workplace_items.append(f'{item} gezeigt bzw. Nutzung erklärt')
+ if lang == 'en':
+ workplace_items.append(f'{item} shown or usage explained')
+ else:
+ workplace_items.append(f'{item} gezeigt bzw. Nutzung erklärt')
if request_obj.phone_number:
- workplace_items.append(f'Telefonnummer / Direktwahl erklärt: {request_obj.phone_number}')
+ if lang == 'en':
+ workplace_items.append(f'Phone number / direct extension explained: {request_obj.phone_number}')
+ else:
+ workplace_items.append(f'Telefonnummer / Direktwahl erklärt: {request_obj.phone_number}')
if not workplace_items:
- workplace_items.append('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen')
+ workplace_items.append('Workplace, devices, and general usage reviewed' if lang == 'en' else 'Arbeitsplatz, Geräte und allgemeine Nutzung besprochen')
- account_items = [f'{item} Zugang erklärt' for item in accesses]
- account_items.extend([f'{item} Gruppe / Berechtigung erläutert' for item in groups])
+ account_items = [f'{item} access explained' if lang == 'en' else f'{item} Zugang erklärt' for item in accesses]
+ account_items.extend([f'{item} group / permission explained' if lang == 'en' else f'{item} Gruppe / Berechtigung erläutert' for item in groups])
if request_obj.work_email:
- account_items.insert(0, f'Dienstliche E-Mail-Adresse erläutert: {request_obj.work_email}')
+ account_items.insert(0, f'Work email address explained: {request_obj.work_email}' if lang == 'en' else f'Dienstliche E-Mail-Adresse erläutert: {request_obj.work_email}')
if group_mailboxes:
- account_items.extend([f'Gruppenpostfach erklärt: {item}' for item in group_mailboxes])
+ account_items.extend([f'Group mailbox explained: {item}' if lang == 'en' else f'Gruppenpostfach erklärt: {item}' for item in group_mailboxes])
if not account_items:
- account_items.append('Zugänge, Konten und Anmeldelogik besprochen')
+ account_items.append('Accesses, accounts, and login logic reviewed' if lang == 'en' else 'Zugänge, Konten und Anmeldelogik besprochen')
- software_items = [f'{item} Einführung durchgeführt' for item in software]
- software_items.extend([f'{item} zusätzlich besprochen' for item in extra_software])
+ software_items = [f'{item} introduction completed' if lang == 'en' else f'{item} Einführung durchgeführt' for item in software]
+ software_items.extend([f'{item} discussed additionally' if lang == 'en' else f'{item} zusätzlich besprochen' for item in extra_software])
if not software_items:
- software_items.append('Benötigte Standardsoftware und tägliche Nutzung erklärt')
+ software_items.append('Required standard software and daily usage explained' if lang == 'en' else 'Benötigte Standardsoftware und tägliche Nutzung erklärt')
- process_items = [
- 'Passwortregeln und sicherer Umgang besprochen',
- 'Dateiablage, Nextcloud und Freigaben erklärt',
- 'Kommunikationswege und Support-Prozess erklärt',
- ]
+ process_items = (
+ [
+ 'Password rules and secure handling reviewed',
+ 'File storage, Nextcloud, and sharing explained',
+ 'Communication channels and support process explained',
+ ]
+ if lang == 'en'
+ else [
+ 'Passwortregeln und sicherer Umgang besprochen',
+ 'Dateiablage, Nextcloud und Freigaben erklärt',
+ 'Kommunikationswege und Support-Prozess erklärt',
+ ]
+ )
if extra_hardware:
- process_items.extend([f'{item} als zusätzliche Ausstattung besprochen' for item in extra_hardware])
+ process_items.extend([f'{item} discussed as additional equipment' if lang == 'en' else f'{item} als zusätzliche Ausstattung besprochen' for item in extra_hardware])
if request_obj.additional_access_text:
- process_items.extend([f'Zusätzlicher Zugang besprochen: {item}' for item in _split_multiline(request_obj.additional_access_text)])
+ process_items.extend([f'Additional access discussed: {item}' if lang == 'en' else f'Zusätzlicher Zugang besprochen: {item}' for item in _split_multiline(request_obj.additional_access_text)])
if request_obj.successor_name:
- process_items.append(f'Übergabe-/Nachfolgekontext besprochen: {request_obj.successor_name}')
+ process_items.append(f'Handover / successor context reviewed: {request_obj.successor_name}' if lang == 'en' else f'Übergabe-/Nachfolgekontext besprochen: {request_obj.successor_name}')
custom_intro_items = _build_intro_sections_from_admin(request_obj, lang)
intro_sections_raw = [
@@ -701,6 +881,8 @@ def _overlay_with_letterhead(content_pdf: Path, letterhead_pdf: Path, output_pdf
def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
+ lang = _normalized_lang(request_obj.preferred_language)
+ t = _pdf_texts(lang)
first_name, last_name = _split_name(request_obj.full_name)
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_{request_obj.id}')
output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_letter_{safe_name}.pdf'
@@ -720,7 +902,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
additional_access_list = _split_multiline(request_obj.additional_access_text or '')
signature_src = ''
- signature_note = '-'
+ signature_note = t['not_available_short']
if getattr(request_obj, 'signature_image', None):
try:
signature_path = Path(request_obj.signature_image.path).resolve()
@@ -728,18 +910,18 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
encoded = base64.b64encode(sig_fp.read()).decode('ascii')
mime_type = mimetypes.guess_type(signature_path.name)[0] or 'image/png'
signature_src = f"data:{mime_type};base64,{encoded}"
- signature_note = 'Digitale Signatur als Bilddatei hinterlegt.'
+ signature_note = 'Digital signature stored as image file.' if lang == 'en' else 'Digitale Signatur als Bilddatei hinterlegt.'
except Exception:
signature_src = ''
- signature_note = request_obj.signature_url or '-'
+ signature_note = request_obj.signature_url or t['not_available_short']
elif request_obj.signature_url:
signature_note = request_obj.signature_url
- requester_email = request_obj.onboarded_by_email or '-'
- requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-'
- gender = (request_obj.get_gender_display() or '-').strip() or '-'
- employment_type = (request_obj.employment_type or '-').strip() or '-'
- employment_end = request_obj.employment_end_date or '-'
+ requester_email = request_obj.onboarded_by_email or t['not_available_short']
+ requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or t['not_available_short']
+ gender = (request_obj.get_gender_display() or t['not_available_short']).strip() or t['not_available_short']
+ employment_type = (request_obj.employment_type or t['not_available_short']).strip() or t['not_available_short']
+ employment_end = request_obj.employment_end_date or t['not_available_short']
order_business_cards = bool(request_obj.order_business_cards)
group_mailboxes = (request_obj.group_mailboxes or '').strip()
additional_hardware_other = (request_obj.additional_hardware_other or '').strip()
@@ -752,22 +934,24 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
display_name = f"{gender} {first_name} {last_name}".strip() if gender and gender != '-' else f"{first_name} {last_name}".strip()
context = {
+ 'T': t,
+ 'PDF_LANG': lang,
'VORNAME': first_name,
'NACHNAME': last_name,
'DISPLAY_NAME': display_name or request_obj.full_name,
'ANREDE': gender,
- 'BERUFSBEZEICHNUNG': request_obj.job_title or 'N/A',
- 'ABTEILUNG': request_obj.department or 'N/A',
- 'EMAIL': request_obj.work_email or 'N/A',
+ 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available'],
+ 'ABTEILUNG': request_obj.department or t['not_available'],
+ 'EMAIL': request_obj.work_email or t['not_available'],
'VERTRAGSBEGINN': request_obj.contract_start,
'BESCHAEFTIGUNG': employment_type,
'VERTRAGSENDE': employment_end,
- 'UEBERGABEDATUM': request_obj.handover_date or '-',
- 'ARBEITSGERAETE_TEXT': ' | '.join(devices) if devices else 'Keine Angabe',
- 'WORKSPACE_GROUPS_TEXT': ' | '.join(groups) if groups else 'Keine Angabe',
- 'SOFTWARE_TEXT': ' | '.join(software) if software else 'Keine Angabe',
- 'ZUGAENGE_TEXT': ' | '.join(accesses) if accesses else 'Keine Angabe',
- 'RESSOURCEN_TEXT': ' | '.join(resources) if resources else 'Keine Angabe',
+ 'UEBERGABEDATUM': request_obj.handover_date or t['not_available_short'],
+ 'ARBEITSGERAETE_TEXT': ' | '.join(devices) if devices else t['not_available'],
+ 'WORKSPACE_GROUPS_TEXT': ' | '.join(groups) if groups else t['not_available'],
+ 'SOFTWARE_TEXT': ' | '.join(software) if software else t['not_available'],
+ 'ZUGAENGE_TEXT': ' | '.join(accesses) if accesses else t['not_available'],
+ 'RESSOURCEN_TEXT': ' | '.join(resources) if resources else t['not_available'],
'VISITENKARTE_BESTELLT': order_business_cards,
'HAS_VISITENKARTE_DATEN': order_business_cards and any(
[
@@ -777,19 +961,19 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
(request_obj.business_card_phone or '').strip(),
]
),
- 'VISITENKARTE_NAME': request_obj.business_card_name or '-',
- 'VISITENKARTE_TITEL': request_obj.business_card_title or '-',
- 'VISITENKARTE_EMAIL': request_obj.business_card_email or '-',
- 'VISITENKARTE_TELEFON': request_obj.business_card_phone or '-',
- 'GROUP_MAILBOXES': group_mailboxes or 'Keine Angabe',
- 'ADDITIONAL_HARDWARE_OTHER': additional_hardware_other or 'Keine Angabe',
- 'ADDITIONAL_HARDWARE': additional_hardware or 'Keine Angabe',
- 'ADDITIONAL_SOFTWARE': additional_software or 'Keine Angabe',
- 'ADDITIONAL_ACCESS_TEXT': additional_access_text or 'Keine Angabe',
- 'SUCCESSOR_NAME': successor_name or 'Keine Angabe',
- 'PHONE_NUMBER': phone_number or '-',
- 'INHERIT_PHONE_NUMBER': 'Ja' if request_obj.inherit_phone_number else 'Nein',
- 'ADDITIONAL_NOTES': additional_notes or 'Keine Angabe',
+ 'VISITENKARTE_NAME': request_obj.business_card_name or t['not_available_short'],
+ 'VISITENKARTE_TITEL': request_obj.business_card_title or t['not_available_short'],
+ 'VISITENKARTE_EMAIL': request_obj.business_card_email or t['not_available_short'],
+ 'VISITENKARTE_TELEFON': request_obj.business_card_phone or t['not_available_short'],
+ 'GROUP_MAILBOXES': group_mailboxes or t['not_available'],
+ 'ADDITIONAL_HARDWARE_OTHER': additional_hardware_other or t['not_available'],
+ 'ADDITIONAL_HARDWARE': additional_hardware or t['not_available'],
+ 'ADDITIONAL_SOFTWARE': additional_software or t['not_available'],
+ 'ADDITIONAL_ACCESS_TEXT': additional_access_text or t['not_available'],
+ 'SUCCESSOR_NAME': successor_name or t['not_available'],
+ 'PHONE_NUMBER': phone_number or t['not_available_short'],
+ 'INHERIT_PHONE_NUMBER': t['yes'] if request_obj.inherit_phone_number else t['no'],
+ 'ADDITIONAL_NOTES': additional_notes or t['not_available'],
'GROUP_MAILBOXES_REQUIRED': bool(request_obj.group_mailboxes_required),
'ADDITIONAL_HARDWARE_NEEDED': bool(request_obj.additional_hardware_needed),
'ADDITIONAL_SOFTWARE_NEEDED': bool(request_obj.additional_software_needed),
@@ -831,6 +1015,8 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code: str | None = None) -> Path:
+ lang = _normalized_lang(language_code or request_obj.preferred_language)
+ t = _pdf_texts(lang)
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_{request_obj.id}')
output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_{safe_name}.pdf'
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf'
@@ -852,11 +1038,12 @@ def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code
requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-'
context = {
+ 'T': t,
'DISPLAY_NAME': display_name,
- 'ABTEILUNG': request_obj.department or '-',
- 'BERUFSBEZEICHNUNG': request_obj.job_title or '-',
+ 'ABTEILUNG': request_obj.department or t['not_available_short'],
+ 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available_short'],
'VERTRAGSBEGINN': request_obj.contract_start,
- 'EMAIL': request_obj.work_email or '-',
+ 'EMAIL': request_obj.work_email or t['not_available_short'],
'REQUESTED_BY_NAME': requester_name,
'REQUESTED_BY_EMAIL': requester_email,
'INTRO_SECTIONS': intro_sections,
@@ -877,6 +1064,8 @@ def _generate_onboarding_intro_session_pdf(
language_code: str | None = None,
) -> Path:
request_obj = session.onboarding_request
+ lang = _normalized_lang(language_code or request_obj.preferred_language)
+ t = _pdf_texts(lang)
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_session_{request_obj.id}')
version = timezone.now().strftime('%Y%m%d%H%M%S')
output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_session_{safe_name}_{version}.pdf'
@@ -911,18 +1100,19 @@ def _generate_onboarding_intro_session_pdf(
requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-'
context = {
+ 'T': t,
'DISPLAY_NAME': display_name,
- 'ABTEILUNG': request_obj.department or '-',
- 'BERUFSBEZEICHNUNG': request_obj.job_title or '-',
+ 'ABTEILUNG': request_obj.department or t['not_available_short'],
+ 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available_short'],
'VERTRAGSBEGINN': request_obj.contract_start,
- 'EMAIL': request_obj.work_email or '-',
+ 'EMAIL': request_obj.work_email or t['not_available_short'],
'REQUESTED_BY_NAME': requester_name,
'REQUESTED_BY_EMAIL': requester_email,
'SESSION_STATUS': session.get_status_display(),
'SESSION_COMPLETED_BY': session.completed_by_name or '-',
'SESSION_COMPLETED_AT': session.completed_at or '-',
'SESSION_UPDATED_AT': session.updated_at,
- 'SESSION_NOTES': session.notes or '-',
+ 'SESSION_NOTES': session.notes or t['not_available_short'],
'INTRO_SECTIONS': exported_sections,
}
@@ -936,6 +1126,8 @@ def _generate_onboarding_intro_session_pdf(
def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
+ lang = _normalized_lang(request_obj.preferred_language)
+ t = _pdf_texts(lang)
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'offboarding_{request_obj.id}')
output_pdf = settings.PDF_OUTPUT_DIR / f'offboarding_letter_{safe_name}.pdf'
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_offboarding_{safe_name}.pdf'
@@ -965,16 +1157,17 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
for item in extra_selected:
checklist.append({'label': item, 'selected': True})
- requester_email = request_obj.requested_by_email or '-'
- requester_name = request_obj.requested_by_name or _resolve_user_display_name(request_obj.requested_by_email) or '-'
+ requester_email = request_obj.requested_by_email or t['not_available_short']
+ requester_name = request_obj.requested_by_name or _resolve_user_display_name(request_obj.requested_by_email) or t['not_available_short']
context = {
+ 'T': t,
'FULL_NAME': request_obj.full_name,
'EMAIL': request_obj.work_email,
- 'DEPARTMENT': request_obj.department or '-',
- 'JOB_TITLE': request_obj.job_title or '-',
+ 'DEPARTMENT': request_obj.department or t['not_available_short'],
+ 'JOB_TITLE': request_obj.job_title or t['not_available_short'],
'LAST_WORKING_DAY': request_obj.last_working_day,
- 'NOTES': request_obj.notes or '-',
+ 'NOTES': request_obj.notes or t['not_available_short'],
'REQUESTED_BY': requester_email,
'REQUESTED_BY_NAME': requester_name,
'HAS_ONBOARDING_DATA': has_onboarding_data,
diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html
index 1662ec8..b59335d 100644
--- a/backend/workflows/templates/workflows/developer_handbook.html
+++ b/backend/workflows/templates/workflows/developer_handbook.html
@@ -136,6 +136,8 @@ docker compose exec -T web django-admin compilemessages
PDF generation is HTML-to-PDF using xhtml2pdf.
Letterhead overlay is applied from templates.pdf.
Main logic lives in backend/workflows/tasks.py.
+ Fixed PDF labels/headings are rendered from task-level DE/EN text dictionaries, not hard-coded directly in request processing logic.
+ PDF language follows the normalized request preferred_language, with German fallback.
Key templates:
onboarding_template.html
diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html
index 74e902f..6c42aa2 100644
--- a/backend/workflows/templates/workflows/project_wiki.html
+++ b/backend/workflows/templates/workflows/project_wiki.html
@@ -139,6 +139,7 @@
- Additional onboarding intro template:
/backend/media/templates/onboarding_intro_template.html.
- Letterhead:
/backend/media/templates/templates.pdf.
- Output folder:
/backend/media/pdfs/.
+ - Fixed PDF labels and notes are rendered through task-level DE/EN text maps and follow the request language with German fallback.
- Signature images are embedded for compatibility with xhtml2pdf rendering.
- Conditional sections are hidden if no data is provided.
- Offboarding fallback behavior: if no onboarding record is available, the PDF renders a manual onboarding review layout grouped into
Stammdaten, Vertrag, IT-Setup, and Abschluss, plus checkbox grids for devices, software, accesses, workspace groups, resources, and additional IT items.
@@ -169,8 +170,9 @@
- Email phase added: admin-managed notification template subject/body content, notification rule custom subject/body content, and welcome email subject/body content now support explicit DE/EN values.
- Runtime selection: onboarding and offboarding requests store the UI language at submission time, and notification delivery uses that language with German fallback.
- Stability hardening: request models normalize
preferred_language on save, and the database schema also enforces a default of de. This prevents null-language inserts outside the main form/view path.
+ - PDF phase added: fixed PDF headings, labels, notes, and confirmation text now render in German or English based on the request language, with German as the fallback.
- Editing path: these DE/EN values are maintained directly in the frontend builder pages, not only in Django admin.
- - Not fully bilingual yet: several generated PDF/business text blocks still remain primarily single-language.
+ - Not fully bilingual yet: the main remaining gaps are long-form handbook/wiki copy and a few secondary admin/help texts.
- Implementation: Django i18n with locale middleware, translation catalogs, and a DE/EN language switch in the main UI.