Files
workdock-platform/backend/workflows/tasks.py

1613 lines
72 KiB
Python

from pathlib import Path
from datetime import timedelta
import base64
import mimetypes
import re
from celery import current_task, shared_task
from django.contrib.auth import get_user_model
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext as _, get_language, override
from jinja2 import Template
from pypdf import PageObject, PdfReader, PdfWriter
from xhtml2pdf import pisa
from .branding import get_branding_email_copy, get_company_contact_copy, get_default_notification_templates, get_portal_letterhead_path
from .models import AsyncTaskLog, EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .emailing import send_system_email
from .services import upload_to_nextcloud
from .services import get_email_test_redirect, is_email_test_mode
from .forms import (
ACCESS_CHOICES,
DEVICE_CHOICES,
HARDWARE_EXTRA_CHOICES,
OnboardingRequestForm,
RESOURCE_CHOICES,
SOFTWARE_CHOICES,
SOFTWARE_EXTRA_CHOICES,
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
# without changing the workflow/task logic itself.
DEFAULT_NOTIFICATION_TEMPLATES = {
'onboarding_it': {
'subject': '[Onboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[Onboarding] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Neue Onboarding-Anfrage für {{ FULL_NAME }}.\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Vertragsbeginn: {{ CONTRACT_START }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
'Bitte IT-Setup vorbereiten.'
),
'body_en': (
'New onboarding request for {{ FULL_NAME }}.\n'
'Department: {{ DEPARTMENT }}\n'
'Contract start: {{ CONTRACT_START }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
'Please prepare the IT setup.'
),
},
'onboarding_general_info': {
'subject': '[Info Onboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[Onboarding Info] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Hallo,\n\n'
'{{ FULL_NAME }} wird onboarded.\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Vertragsbeginn: {{ CONTRACT_START }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'Hello,\n\n'
'{{ FULL_NAME }} is being onboarded.\n'
'Department: {{ DEPARTMENT }}\n'
'Contract start: {{ CONTRACT_START }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
'onboarding_business_card': {
'subject': '[Visitenkarte] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[Business Card] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Hallo,\n\n'
'bitte Visitenkarten erstellen:\n'
'Name: {{ BUSINESS_CARD_NAME }}\n'
'Titel: {{ BUSINESS_CARD_TITLE }}\n'
'E-Mail: {{ BUSINESS_CARD_EMAIL }}\n'
'Telefon: {{ BUSINESS_CARD_PHONE }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'Hello,\n\n'
'please create business cards:\n'
'Name: {{ BUSINESS_CARD_NAME }}\n'
'Title: {{ BUSINESS_CARD_TITLE }}\n'
'Email: {{ BUSINESS_CARD_EMAIL }}\n'
'Phone: {{ BUSINESS_CARD_PHONE }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
'onboarding_hr_works': {
'subject': '[HR Works] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[HR Works] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Hello Stefanie,\n\n'
'Es ist wieder soweit. Zuwachs!\n\n'
'Könntest du deshalb bitte ein HR Works Konto mit den folgenden Daten erstellen:\n\n'
'Name: {{ VORNAME }} {{ NACHNAME }}\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Vertragsbeginn: {{ CONTRACT_START }}\n'
'E-Mail-Adresse: {{ EMAIL }}\n\n'
'{% if PDF_LINK %}In 2 Minuten findest du alle Infos über den Mitarbeiter als PDF unter diesem Link: {{ PDF_LINK }}\n\n{% endif %}'
'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei {{ SUPPORT_EMAIL }} melden!\n\n'
'Vielen Dank und schöne Grüße,\n'
'Die IT.'
),
'body_en': (
'Hello Stefanie,\n\n'
'we have a new team member joining.\n\n'
'Could you please create an HR Works account with the following details:\n\n'
'Name: {{ VORNAME }} {{ NACHNAME }}\n'
'Department: {{ DEPARTMENT }}\n'
'Contract start: {{ CONTRACT_START }}\n'
'Email address: {{ EMAIL }}\n\n'
'{% if PDF_LINK %}You will find the employee PDF here in about 2 minutes: {{ PDF_LINK }}\n\n{% endif %}'
'If you need any other information, please contact {{ SUPPORT_EMAIL }}.\n\n'
'Thank you and best regards,\n'
'IT'
),
},
'onboarding_key': {
'subject': '[Schlüssel] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[Key] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Hallo,\n\n'
'bitte Schlüssel vorbereiten für:\n'
'Name: {{ FULL_NAME }}\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Vertragsbeginn: {{ CONTRACT_START }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'Hello,\n\n'
'please prepare keys for:\n'
'Name: {{ FULL_NAME }}\n'
'Department: {{ DEPARTMENT }}\n'
'Contract start: {{ CONTRACT_START }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
'onboarding_reference': {
'subject': '[Referenz Onboarding] {{ FULL_NAME }} | Ihre Anfrage',
'subject_en': '[Onboarding Reference] {{ FULL_NAME }} | Your Request',
'body': (
'Diese E-Mail dient als Referenz für Ihre Onboarding-Anfrage.\n'
'Name: {{ FULL_NAME }}\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Vertragsbeginn: {{ CONTRACT_START }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'This email is your reference copy for the onboarding request.\n'
'Name: {{ FULL_NAME }}\n'
'Department: {{ DEPARTMENT }}\n'
'Contract start: {{ CONTRACT_START }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
'onboarding_welcome': {
'subject': 'Willkommen bei {{ COMPANY_NAME }}, {{ VORNAME }}',
'subject_en': 'Welcome to {{ COMPANY_NAME }}, {{ VORNAME }}',
'body': (
'Hallo {{ FULL_NAME }},\n\n'
'herzlich willkommen bei {{ COMPANY_NAME }}.\n'
'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n'
'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n'
'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n'
'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n'
'Viele Grüße\n'
'{{ COMPANY_NAME }} IT'
),
'body_en': (
'Hello {{ FULL_NAME }},\n\n'
'welcome to {{ COMPANY_NAME }}.\n'
'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n'
'Your work email address is: {{ EMAIL }}.\n'
'You will find your onboarding documents attached as a PDF.\n\n'
'If you have any questions, feel free to contact us anytime.\n\n'
'Best regards,\n'
'{{ COMPANY_NAME }} IT'
),
},
'offboarding_it': {
'subject': '[Offboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[Offboarding] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Neue Offboarding-Anfrage für {{ FULL_NAME }}.\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Letzter Arbeitstag: {{ LAST_WORKING_DAY }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
'Bitte IT-Offboarding durchführen.'
),
'body_en': (
'New offboarding request for {{ FULL_NAME }}.\n'
'Department: {{ DEPARTMENT }}\n'
'Last working day: {{ LAST_WORKING_DAY }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
'Please complete the IT offboarding.'
),
},
'offboarding_general_info': {
'subject': '[Info Offboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[Offboarding Info] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Neue Offboarding-Anfrage für {{ FULL_NAME }}.\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Letzter Arbeitstag: {{ LAST_WORKING_DAY }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'New offboarding request for {{ FULL_NAME }}.\n'
'Department: {{ DEPARTMENT }}\n'
'Last working day: {{ LAST_WORKING_DAY }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
'offboarding_hr_works_disable': {
'subject': '[HR Works Deaktivierung] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}',
'subject_en': '[HR Works Disable] {{ FULL_NAME }} | Requested by {{ REQUESTED_BY }}',
'body': (
'Bitte HR Works Zugriff deaktivieren für {{ FULL_NAME }} ({{ EMAIL }}) zum {{ LAST_WORKING_DAY }}.\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'Please disable HR Works access for {{ FULL_NAME }} ({{ EMAIL }}) effective {{ LAST_WORKING_DAY }}.\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
'offboarding_reference': {
'subject': '[Referenz Offboarding] {{ FULL_NAME }} | Ihre Anfrage',
'subject_en': '[Offboarding Reference] {{ FULL_NAME }} | Your Request',
'body': (
'Diese E-Mail dient als Referenz für Ihre Offboarding-Anfrage.\n'
'Name: {{ FULL_NAME }}\n'
'Abteilung: {{ DEPARTMENT }}\n'
'Letzter Arbeitstag: {{ LAST_WORKING_DAY }}\n'
'Angefordert von: {{ REQUESTED_BY }}\n'
),
'body_en': (
'This email is your reference copy for the offboarding request.\n'
'Name: {{ FULL_NAME }}\n'
'Department: {{ DEPARTMENT }}\n'
'Last working day: {{ LAST_WORKING_DAY }}\n'
'Requested by: {{ REQUESTED_BY }}\n'
),
},
}
def _notify_request_result(*, recipient_email: str, title: str, body: str, level: str, event_key: str) -> None:
notify_user_by_email(
email=recipient_email,
title=title,
body=body,
level=level,
link_url='/requests/',
event_key=event_key,
)
def _notify_welcome_email_result(*, recipient_email: str, full_name: str, body: str, level: str, event_key: str) -> None:
notify_user_by_email(
email=recipient_email,
title=(
_('Welcome E-Mail gesendet: %(name)s') % {'name': full_name}
if event_key == 'welcome_email_success'
else _('Welcome E-Mail fehlgeschlagen: %(name)s') % {'name': full_name}
),
body=body,
level=level,
link_url='/admin-tools/welcome-emails/',
event_key=event_key,
)
def _start_task_log(task_name: str, *, target_type: str = '', target_id: int | None = None, target_label: str = '') -> AsyncTaskLog:
task_request = getattr(current_task, 'request', None)
return AsyncTaskLog.objects.create(
task_name=task_name,
task_id=getattr(task_request, 'id', '') or '',
target_type=target_type,
target_id=target_id,
target_label=target_label,
status='started',
)
def _finish_task_log(task_log: AsyncTaskLog | None, *, status: str, error_message: str = '') -> None:
if not task_log:
return
task_log.status = status
task_log.error_message = error_message
task_log.finished_at = timezone.now()
task_log.save(update_fields=['status', 'error_message', 'finished_at'])
def _split_name(full_name: str) -> tuple[str, str]:
parts = full_name.split()
if not parts:
return '', ''
return parts[0], ' '.join(parts[1:])
def _safe_filename_fragment(text: str, fallback: str = 'document') -> str:
value = re.sub(r'[^A-Za-z0-9._-]+', '_', (text or '').strip()).strip('._')
return value[:120] if value else fallback
def _resolve_user_display_name(email: str) -> str:
email = (email or '').strip().lower()
if not email:
return ''
user_model = get_user_model()
user = user_model.objects.filter(email__iexact=email).first()
if not user:
return ''
first_name = (getattr(user, 'first_name', '') or '').strip()
last_name = (getattr(user, 'last_name', '') or '').strip()
full_name = f'{first_name} {last_name}'.strip()
if full_name:
return full_name
return (getattr(user, 'username', '') or '').strip()
def _chunk_list(data_list: list[str], chunk_size: int = 3) -> list[list[str]]:
items = [i.strip() for i in data_list if i and i.strip()]
chunks = []
for i in range(0, len(items), chunk_size):
chunks.append(items[i : i + chunk_size])
return chunks
def _split_multiline(text: str) -> list[str]:
return [line.strip() for line in (text or '').split('\n') if line.strip()]
def _chunk_choice_labels(choices: list[tuple[str, str]], chunk_size: int = 3) -> list[list[str]]:
labels = [label for _, label in choices]
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',
[
'gender',
'first_name',
'last_name',
'job_title',
'department',
'work_email',
'order_business_cards',
'business_card_name',
'business_card_title',
'business_card_email',
'business_card_phone',
],
),
(
'Vertrag',
[
'contract_start',
'employment_type',
'employment_end_date',
'handover_date',
],
),
(
'IT-Setup',
[
'group_mailboxes_required_choice',
'group_mailboxes',
'additional_hardware_needed_choice',
'additional_hardware_other',
'additional_software_needed_choice',
'additional_software',
'additional_access_needed_choice',
'additional_access_text',
'successor_required_choice',
'successor_name',
'inherit_phone_number_choice',
'phone_number_choice',
],
),
(
'Abschluss',
[
'additional_notes',
'signature_image',
'agreement_confirm',
],
),
]
def _manual_onboarding_field_sections() -> list[dict]:
fields = OnboardingRequestForm.base_fields
sections = []
for title, field_names in MANUAL_ONBOARDING_FIELD_SECTIONS:
labels = [str(fields[name].label or name) for name in field_names if name in fields]
if not labels:
continue
sections.append({'title': title, 'rows': _chunk_list(labels, chunk_size=2)})
return sections
def _resolve_workflow_emails() -> tuple[str, str, str, str, str]:
config = WorkflowConfig.objects.order_by('id').first()
it_email = (config.it_onboarding_email if config and config.it_onboarding_email else settings.IT_ONBOARDING_NOTIFICATION_EMAIL)
general_info_email = (config.general_info_email if config and config.general_info_email else settings.GENERAL_INFO_NOTIFICATION_EMAIL)
business_card_email = (config.business_card_email if config and config.business_card_email else settings.BUSINESS_CARD_NOTIFICATION_EMAIL)
hr_works_email = (config.hr_works_email if config and config.hr_works_email else settings.HR_WORKS_NOTIFICATION_EMAIL)
key_email = (config.key_notification_email if config and config.key_notification_email else settings.KEY_NOTIFICATION_EMAIL)
return it_email, general_info_email, business_card_email, hr_works_email, key_email
def _matches_intro_condition(request_obj: OnboardingRequest, item: IntroChecklistItem) -> bool:
operator = (item.condition_operator or 'always').strip()
field_name = (item.condition_field or '').strip()
expected = (item.condition_value or '').strip()
if operator == 'always' or not field_name:
return True
raw_value = getattr(request_obj, field_name, '')
if raw_value is None:
raw_value = ''
if operator == 'is_true':
return bool(raw_value)
if operator == 'is_false':
return not bool(raw_value)
text_value = str(raw_value).strip()
if operator == 'equals':
return text_value.lower() == expected.lower()
if operator == 'contains':
return expected.lower() in text_value.lower()
return True
def _build_intro_sections_from_admin(request_obj: OnboardingRequest, language_code: str | None = None) -> dict[str, list[str]]:
items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label'))
if not items:
return {}
section_map = {key: [] for key, _label in IntroChecklistItem.SECTION_CHOICES}
for item in items:
if item.section not in section_map:
continue
if _matches_intro_condition(request_obj, item):
section_map[item.section].append(item.translated_label(language_code))
return {key: values for key, values in section_map.items() if values}
def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]:
lang = _normalized_lang(language_code or get_language())
with override(lang):
section_titles = {
'workplace': _('Geräte und Arbeitsplatz'),
'accounts': _('Konten und Berechtigungen'),
'software': _('Software und Tools'),
'process': _('Prozesse und Hinweise'),
}
devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software)
accesses = _split_multiline(request_obj.needed_accesses)
groups = _split_multiline(request_obj.needed_workspace_groups)
resources = _split_multiline(request_obj.needed_resources)
extra_hardware = _split_multiline(request_obj.additional_hardware)
extra_software = _split_multiline(request_obj.additional_software)
group_mailboxes = _split_multiline(request_obj.group_mailboxes)
workplace_items = []
for item in devices:
workplace_items.append(_('%(item)s übergeben und Grundfunktionen erklärt') % {'item': item})
for item in resources:
workplace_items.append(_('%(item)s gezeigt bzw. Nutzung erklärt') % {'item': item})
if request_obj.phone_number:
workplace_items.append(_('Telefonnummer / Direktwahl erklärt: %(value)s') % {'value': request_obj.phone_number})
if not workplace_items:
workplace_items.append(_('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen'))
account_items = [_('%(item)s Zugang erklärt') % {'item': item} for item in accesses]
account_items.extend([_('%(item)s Gruppe / Berechtigung erläutert') % {'item': item} for item in groups])
if request_obj.work_email:
account_items.insert(0, _('Dienstliche E-Mail-Adresse erläutert: %(value)s') % {'value': request_obj.work_email})
if group_mailboxes:
account_items.extend([_('Gruppenpostfach erklärt: %(item)s') % {'item': item} for item in group_mailboxes])
if not account_items:
account_items.append(_('Zugänge, Konten und Anmeldelogik besprochen'))
software_items = [_('%(item)s Einführung durchgeführt') % {'item': item} for item in software]
software_items.extend([_('%(item)s zusätzlich besprochen') % {'item': item} for item in extra_software])
if not software_items:
software_items.append(_('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'),
]
if extra_hardware:
process_items.extend([_('%(item)s als zusätzliche Ausstattung besprochen') % {'item': item} for item in extra_hardware])
if request_obj.additional_access_text:
process_items.extend([_('Zusätzlicher Zugang besprochen: %(item)s') % {'item': item} for item in _split_multiline(request_obj.additional_access_text)])
if request_obj.successor_name:
process_items.append(_('Übergabe-/Nachfolgekontext besprochen: %(value)s') % {'value': request_obj.successor_name})
custom_intro_items = _build_intro_sections_from_admin(request_obj, lang)
intro_sections_raw = [
('workplace', section_titles['workplace'], workplace_items),
('accounts', section_titles['accounts'], account_items),
('software', section_titles['software'], software_items),
('process', section_titles['process'], process_items),
]
sections = []
for key, title, default_items in intro_sections_raw:
merged_items = list(default_items)
merged_items.extend(custom_intro_items.get(key, []))
section_items = []
for idx, label in enumerate(merged_items, start=1):
section_items.append({'id': f'{key}_{idx}', 'label': label})
if section_items:
sections.append({'key': key, 'title': title, 'items': section_items})
return sections
def _send_workflow_email(
subject: str,
body: str,
to: list[str],
attachments: list[Path] | None = None,
from_email: str | None = None,
) -> None:
recipients = [r for r in to if r]
if not recipients:
return
effective_to = recipients
effective_body = body
if is_email_test_mode():
effective_to = [get_email_test_redirect()]
effective_body = (
"[TEST MODE] Diese E-Mail wurde umgeleitet.\n"
f"Originale Empfänger: {', '.join(recipients)}\n\n{body}"
)
send_system_email(
subject=subject,
body=effective_body,
to=effective_to,
attachments=[str(a) for a in (attachments or [])],
from_email=from_email,
)
def _render_notification_template(template_key: str, context: dict, language_code: str | None = None) -> tuple[str, str]:
lang = (language_code or 'de').split('-')[0]
db_template = NotificationTemplate.objects.filter(key=template_key, is_active=True).first()
if db_template:
subject_template = db_template.translated_subject_template(lang)
body_template = db_template.translated_body_template(lang)
else:
fallback = get_default_notification_templates()[template_key]
subject_template = fallback.get(f'subject_{lang}', '') or fallback['subject']
body_template = fallback.get(f'body_{lang}', '') or fallback['body']
subject = Template(subject_template).render(context).strip()
body = Template(body_template).render(context).strip()
return subject, body
def _parse_recipients(raw: str) -> list[str]:
if not raw:
return []
cleaned = raw.replace(';', ',').replace('\n', ',')
return [x.strip() for x in cleaned.split(',') if x.strip()]
def _as_bool(value) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
text = str(value).strip().lower()
return text in {'1', 'true', 'ja', 'yes', 'on', 'aktiv'}
def _rule_matches(rule: NotificationRule, request_obj) -> bool:
if rule.operator == 'always':
return True
raw_value = getattr(request_obj, rule.field_name, '')
actual = '' if raw_value is None else str(raw_value)
expected = (rule.expected_value or '').strip()
if rule.operator == 'contains':
return expected.lower() in actual.lower()
if rule.operator == 'equals':
return actual.strip().lower() == expected.lower()
if rule.operator == 'is_true':
return _as_bool(raw_value)
if rule.operator == 'is_false':
return not _as_bool(raw_value)
return False
def _apply_notification_rules(
event_type: str,
request_obj,
context: dict,
pdf_path: Path | None = None,
) -> None:
language_code = (getattr(request_obj, 'preferred_language', '') or 'de').split('-')[0]
rules = NotificationRule.objects.filter(event_type=event_type, is_active=True).order_by('sort_order', 'id')
for rule in rules:
if not _rule_matches(rule, request_obj):
continue
recipients = _parse_recipients(rule.recipients)
if not recipients:
continue
attachments = [pdf_path] if (pdf_path and rule.include_pdf_attachment) else None
template_key = (rule.template_key or '').strip()
known_keys = {k for k, _ in NotificationTemplate.TEMPLATE_CHOICES}
if template_key and template_key in known_keys:
_send_templated_email(
template_key=template_key,
context=context,
to=recipients,
attachments=attachments,
language_code=language_code,
)
continue
subject = rule.translated_custom_subject(language_code)
body = rule.translated_custom_body(language_code)
if not subject and not body:
continue
subject_rendered = Template(subject or f'[{event_type}] Regelmail').render(context).strip()
body_rendered = Template(body or '-').render(context).strip()
_send_workflow_email(
subject=subject_rendered,
body=body_rendered,
to=recipients,
attachments=attachments,
)
def _schedule_welcome_email(request_obj: OnboardingRequest) -> None:
recipient = (request_obj.work_email or '').strip().lower()
if not recipient:
return
config = WorkflowConfig.objects.order_by('id').first()
delay_days = 5
if config:
delay_days = max(0, int(config.welcome_email_delay_days or 5))
send_at = timezone.now() + timedelta(days=delay_days)
scheduled, _ = ScheduledWelcomeEmail.objects.update_or_create(
onboarding_request=request_obj,
defaults={
'recipient_email': recipient,
'send_at': send_at,
'status': 'scheduled',
'last_error': '',
'sent_at': None,
},
)
try:
async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=send_at)
scheduled.celery_task_id = async_result.id or ''
scheduled.save(update_fields=['celery_task_id', 'updated_at'])
except Exception as exc:
scheduled.status = 'failed'
scheduled.last_error = f'Scheduling failed: {exc}'
scheduled.save(update_fields=['status', 'last_error', 'updated_at'])
def _send_templated_email(
template_key: str,
to: list[str],
context: dict,
attachments: list[Path] | None = None,
from_email: str | None = None,
language_code: str | None = None,
) -> None:
subject, body = _render_notification_template(template_key, context, language_code=language_code)
_send_workflow_email(subject=subject, body=body, to=to, attachments=attachments, from_email=from_email)
def _render_html(template_path: Path, context: dict) -> str:
with template_path.open('r', encoding='utf-8') as handle:
template = Template(handle.read())
return template.render(context)
def _generate_content_pdf(html_content: str, output_pdf: Path) -> None:
page_style = (
'<style>'
'@page { size: A4; margin: 58mm 16mm 16mm 16mm; }'
'body { margin: 0; }'
'</style>'
)
if '<head>' in html_content:
html_content = html_content.replace('<head>', f'<head>{page_style}', 1)
else:
html_content = page_style + html_content
output_pdf.parent.mkdir(parents=True, exist_ok=True)
with output_pdf.open('wb') as fp:
result = pisa.CreatePDF(
src=html_content,
dest=fp,
encoding='utf-8',
)
if result.err:
raise RuntimeError(f'Failed to render PDF content for {output_pdf.name}')
def _overlay_with_letterhead(content_pdf: Path, letterhead_pdf: Path, output_pdf: Path) -> None:
letterhead_reader = PdfReader(str(letterhead_pdf))
content_reader = PdfReader(str(content_pdf))
writer = PdfWriter()
letterhead_page = letterhead_reader.pages[0]
for page in content_reader.pages:
merged = PageObject.create_blank_page(
width=letterhead_page.mediabox.width,
height=letterhead_page.mediabox.height,
)
merged.merge_page(letterhead_page)
merged.merge_page(page)
writer.add_page(merged)
with output_pdf.open('wb') as fp:
writer.write(fp)
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'
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_{safe_name}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_template.html'
letterhead_path = get_portal_letterhead_path()
company_contact = get_company_contact_copy()
devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software)
accesses = _split_multiline(request_obj.needed_accesses)
groups = _split_multiline(request_obj.needed_workspace_groups)
resources = _split_multiline(request_obj.needed_resources)
group_mailboxes_list = _split_multiline(request_obj.group_mailboxes or '')
additional_hardware_list = _split_multiline(request_obj.additional_hardware or '')
additional_software_list = _split_multiline(request_obj.additional_software or '')
additional_access_list = _split_multiline(request_obj.additional_access_text or '')
signature_src = ''
signature_note = t['not_available_short']
if getattr(request_obj, 'signature_image', None):
try:
signature_path = Path(request_obj.signature_image.path).resolve()
with signature_path.open('rb') as sig_fp:
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 = '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 t['not_available_short']
elif request_obj.signature_url:
signature_note = request_obj.signature_url
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()
additional_hardware = (request_obj.additional_hardware or '').strip()
additional_software = (request_obj.additional_software or '').strip()
additional_access_text = (request_obj.additional_access_text or '').strip()
successor_name = (request_obj.successor_name or '').strip()
additional_notes = (request_obj.additional_notes or '').strip()
phone_number = (request_obj.phone_number or '').strip()
display_name = f"{gender} {first_name} {last_name}".strip() if gender and gender != '-' else f"{first_name} {last_name}".strip()
pdf_sections = build_pdf_sections('onboarding', request_obj, lang)
pdf_section_map = {section['key']: section for section in pdf_sections if section.get('has_content')}
pdf_field_map = {}
for section in pdf_sections:
for field in section.get('render_fields', []):
pdf_field_map[field['name']] = field
def _field_text(name: str, fallback):
field = pdf_field_map.get(name)
if not field:
return fallback
value = field.get('display_value')
if isinstance(value, list):
return ' | '.join(value) if value else fallback
return value or fallback
def _field_list(name: str) -> list[str]:
field = pdf_field_map.get(name)
if not field:
return []
value = field.get('display_value')
if isinstance(value, list):
return value
text = str(value or '').strip()
return [text] if text else []
devices_visible = _field_list('needed_devices_multi')
groups_visible = _field_list('needed_workspace_groups_multi')
software_visible = _field_list('needed_software_multi')
accesses_visible = _field_list('needed_accesses_multi')
resources_visible = _field_list('needed_resources_multi')
group_mailboxes_visible = _field_list('group_mailboxes')
additional_hardware_visible = _field_list('additional_hardware_multi')
additional_software_visible = _field_list('additional_software_multi')
additional_access_visible = _field_list('additional_access_text')
job_title_visible = 'job_title' in pdf_field_map
itsetup_visible = 'itsetup' in pdf_section_map
context = {
'T': t,
'PDF_LANG': lang,
'PDF_SECTIONS': pdf_sections,
'VORNAME': first_name,
'NACHNAME': last_name,
'DISPLAY_NAME': display_name or request_obj.full_name,
'ANREDE': _field_text('gender', gender),
'JOB_TITLE_VISIBLE': job_title_visible,
'BERUFSBEZEICHNUNG': _field_text('job_title', t['not_available']),
'ABTEILUNG': _field_text('department', t['not_available']),
'EMAIL': _field_text('work_email', t['not_available']),
'VERTRAGSBEGINN': _field_text('contract_start', request_obj.contract_start),
'BESCHAEFTIGUNG': _field_text('employment_type', employment_type),
'VERTRAGSENDE': _field_text('employment_end_date', employment_end),
'UEBERGABEDATUM': _field_text('handover_date', request_obj.handover_date or t['not_available_short']),
'ARBEITSGERAETE_TEXT': ' | '.join(devices_visible) if devices_visible else t['not_available'],
'WORKSPACE_GROUPS_TEXT': ' | '.join(groups_visible) if groups_visible else t['not_available'],
'SOFTWARE_TEXT': ' | '.join(software_visible) if software_visible else t['not_available'],
'ZUGAENGE_TEXT': ' | '.join(accesses_visible) if accesses_visible else t['not_available'],
'RESSOURCEN_TEXT': ' | '.join(resources_visible) if resources_visible else t['not_available'],
'VISITENKARTE_BESTELLT': order_business_cards,
'HAS_VISITENKARTE_DATEN': order_business_cards and ('business_card_name' in pdf_field_map or 'business_card_title' in pdf_field_map or 'business_card_email' in pdf_field_map or 'business_card_phone' in pdf_field_map) and any(
[
_field_text('business_card_name', '').strip(),
_field_text('business_card_title', '').strip(),
_field_text('business_card_email', '').strip(),
_field_text('business_card_phone', '').strip(),
]
),
'VISITENKARTE_NAME': _field_text('business_card_name', t['not_available_short']),
'VISITENKARTE_TITEL': _field_text('business_card_title', t['not_available_short']),
'VISITENKARTE_EMAIL': _field_text('business_card_email', t['not_available_short']),
'VISITENKARTE_TELEFON': _field_text('business_card_phone', t['not_available_short']),
'GROUP_MAILBOXES': _field_text('group_mailboxes', group_mailboxes or t['not_available']),
'ADDITIONAL_HARDWARE_OTHER': _field_text('additional_hardware_other', additional_hardware_other or t['not_available']),
'ADDITIONAL_HARDWARE': _field_text('additional_hardware_other', additional_hardware or t['not_available']),
'ADDITIONAL_SOFTWARE': _field_text('additional_software', additional_software or t['not_available']),
'ADDITIONAL_ACCESS_TEXT': _field_text('additional_access_text', additional_access_text or t['not_available']),
'SUCCESSOR_NAME': _field_text('successor_name', successor_name or t['not_available']),
'PHONE_NUMBER': _field_text('phone_number_choice', phone_number or t['not_available_short']),
'INHERIT_PHONE_NUMBER': _field_text('inherit_phone_number_choice', t['yes'] if request_obj.inherit_phone_number else t['no']),
'ADDITIONAL_NOTES': _field_text('additional_notes', additional_notes or t['not_available']),
'GROUP_MAILBOXES_REQUIRED': 'group_mailboxes_required_choice' in pdf_field_map and bool(group_mailboxes_visible),
'ADDITIONAL_HARDWARE_NEEDED': 'additional_hardware_needed_choice' in pdf_field_map and bool(additional_hardware_visible),
'ADDITIONAL_SOFTWARE_NEEDED': 'additional_software_needed_choice' in pdf_field_map and bool(additional_software_visible),
'ADDITIONAL_ACCESS_NEEDED': 'additional_access_needed_choice' in pdf_field_map and bool(additional_access_visible),
'HAS_DEVICES': itsetup_visible and bool(devices_visible),
'HAS_GROUPS': itsetup_visible and bool(groups_visible),
'HAS_SOFTWARE': itsetup_visible and bool(software_visible),
'HAS_ACCESSES': itsetup_visible and bool(accesses_visible),
'HAS_RESOURCES': itsetup_visible and bool(resources_visible),
'HAS_GROUP_MAILBOXES': bool(group_mailboxes_visible),
'HAS_ADDITIONAL_HARDWARE': bool(additional_hardware_visible),
'HAS_ADDITIONAL_SOFTWARE': bool(additional_software_visible),
'HAS_ADDITIONAL_ACCESS': bool(additional_access_visible),
'HAS_ADDITIONAL_HARDWARE_OTHER': bool(_field_text('additional_hardware_other', '').strip()),
'HAS_SUCCESSOR_INFO': bool(_field_text('successor_name', '').strip()) or 'inherit_phone_number_choice' in pdf_field_map or bool(_field_text('phone_number_choice', '').strip()),
'HAS_ADDITIONAL_NOTES': bool(_field_text('additional_notes', '').strip()),
'GROUP_MAILBOXES_LIST': _chunk_list(group_mailboxes_visible),
'ADDITIONAL_HARDWARE_LIST': _chunk_list(additional_hardware_visible),
'ADDITIONAL_SOFTWARE_LIST': _chunk_list(additional_software_visible),
'ADDITIONAL_ACCESS_LIST': _chunk_list(additional_access_visible),
'ZUGAENGE_LIST': _chunk_list(groups_visible),
'ARBEITSGERÄTE_LIST': _chunk_list(devices_visible),
'SOFTWARE_LIST': _chunk_list(software_visible),
'ACCOUNT_LIST': _chunk_list(accesses_visible),
'STANDARD_RESSOURCEN': _chunk_list(resources_visible),
'UNTERSCHRIFT': signature_src,
'UNTERSCHRIFT_HINWEIS': signature_note,
'REQUESTED_BY_NAME': requester_name,
'REQUESTED_BY_EMAIL': requester_email,
'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'],
'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'],
'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'],
'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'],
'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'],
}
html = _render_html(template_path, context)
_generate_content_pdf(html, temp_pdf)
_overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf)
if temp_pdf.exists():
temp_pdf.unlink(missing_ok=True)
return output_pdf
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'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html'
letterhead_path = get_portal_letterhead_path()
company_contact = get_company_contact_copy()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
intro_sections = [
{
'title': section['title'],
'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2),
}
for section in build_intro_sections_for_request(request_obj, language_code=language_code)
]
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 '-'
context = {
'T': t,
'DISPLAY_NAME': display_name,
'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 t['not_available_short'],
'REQUESTED_BY_NAME': requester_name,
'REQUESTED_BY_EMAIL': requester_email,
'INTRO_SECTIONS': intro_sections,
'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'],
'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'],
'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'],
'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'],
'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'],
}
html = _render_html(template_path, context)
_generate_content_pdf(html, temp_pdf)
_overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf)
if temp_pdf.exists():
temp_pdf.unlink(missing_ok=True)
return output_pdf
def _generate_onboarding_intro_session_pdf(
session: OnboardingIntroductionSession,
admin_signature_name: str = '-',
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'
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html'
letterhead_path = get_portal_letterhead_path()
company_contact = get_company_contact_copy()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
raw_sections = build_intro_sections_for_request(request_obj, language_code=language_code)
checked_map = session.checklist_state or {}
exported_sections = []
checked_count = 0
total_count = 0
for section in raw_sections:
checked_items = []
for item in section['items']:
checked = bool(checked_map.get(item['id']))
total_count += 1
if checked:
checked_count += 1
checked_items.append({'label': item['label']})
if checked_items:
exported_sections.append({
'title': section['title'],
'rows': [checked_items[i:i + 2] for i in range(0, len(checked_items), 2)],
})
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 '-'
context = {
'T': t,
'DISPLAY_NAME': display_name,
'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 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 t['not_available_short'],
'INTRO_SECTIONS': exported_sections,
'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'],
'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'],
'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'],
'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'],
'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'],
}
html = _render_html(template_path, context)
_generate_content_pdf(html, temp_pdf)
_overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf)
if temp_pdf.exists():
temp_pdf.unlink(missing_ok=True)
return output_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'
template_path = settings.PDF_TEMPLATES_DIR / 'offboarding_template.html'
letterhead_path = get_portal_letterhead_path()
company_contact = get_company_contact_copy()
latest_onboarding = (
OnboardingRequest.objects.filter(work_email=request_obj.work_email)
.order_by('-created_at')
.first()
)
has_onboarding_data = latest_onboarding is not None
onboarding_hardware = _split_multiline(latest_onboarding.needed_devices) if latest_onboarding else []
selected_set = {item.lower() for item in onboarding_hardware}
hardware_catalog = [
'Laptop',
'Docking-Station',
'Tastatur und Maus',
'Kopfhörer',
'Tragetasche',
'Monitor',
'Schlüssel',
'Tischtelefon',
]
checklist = [{'label': item, 'selected': item.lower() in selected_set} for item in hardware_catalog]
extra_selected = [item for item in onboarding_hardware if item.lower() not in {x.lower() for x in hardware_catalog}]
for item in extra_selected:
checklist.append({'label': item, 'selected': True})
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,
'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'],
'JOB_TITLE': request_obj.job_title or t['not_available_short'],
'LAST_WORKING_DAY': request_obj.last_working_day,
'NOTES': request_obj.notes or t['not_available_short'],
'REQUESTED_BY': requester_email,
'REQUESTED_BY_NAME': requester_name,
'HAS_ONBOARDING_DATA': has_onboarding_data,
'ONBOARDING_HARDWARE': onboarding_hardware,
'HARDWARE_CHECKLIST': checklist,
'MANUAL_FIELD_SECTIONS': _manual_onboarding_field_sections(),
'MANUAL_DEVICES': _chunk_choice_labels(DEVICE_CHOICES),
'MANUAL_SOFTWARE': _chunk_choice_labels(SOFTWARE_CHOICES),
'MANUAL_ACCESSES': _chunk_choice_labels(ACCESS_CHOICES),
'MANUAL_WORKSPACE_GROUPS': _chunk_choice_labels(WORKSPACE_GROUP_CHOICES),
'MANUAL_RESOURCES': _chunk_choice_labels(RESOURCE_CHOICES),
'MANUAL_EXTRA_HARDWARE': _chunk_choice_labels(HARDWARE_EXTRA_CHOICES),
'MANUAL_EXTRA_SOFTWARE': _chunk_choice_labels(SOFTWARE_EXTRA_CHOICES),
'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'],
'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'],
'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'],
'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'],
'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'],
}
html = _render_html(template_path, context)
_generate_content_pdf(html, temp_pdf)
_overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf)
if temp_pdf.exists():
temp_pdf.unlink(missing_ok=True)
return output_pdf
@shared_task
def process_onboarding_request(onboarding_request_id: int) -> None:
request_obj = OnboardingRequest.objects.get(id=onboarding_request_id)
task_log = _start_task_log(
'process_onboarding_request',
target_type='onboarding_request',
target_id=request_obj.id,
target_label=request_obj.full_name,
)
request_obj.processing_status = 'processing'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
try:
branding_copy = get_branding_email_copy()
company_contact = get_company_contact_copy()
it_email, general_info_email, business_card_email, hr_works_email, key_email = _resolve_workflow_emails()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip()
first_name, last_name = _split_name(request_obj.full_name)
EmployeeProfile.objects.update_or_create(
work_email=request_obj.work_email,
defaults={
'full_name': request_obj.full_name,
'first_name': first_name,
'last_name': last_name,
'department': request_obj.department,
'job_title': request_obj.job_title,
},
)
pdf_path = _generate_onboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
request_obj.save(update_fields=['generated_pdf_path'])
email_context = {
'FULL_NAME': display_name,
'VORNAME': first_name,
'NACHNAME': last_name,
'DEPARTMENT': request_obj.department or '-',
'CONTRACT_START': request_obj.contract_start,
'EMAIL': request_obj.work_email,
'REQUESTED_BY': request_obj.onboarded_by_email or '-',
'SUPPORT_EMAIL': company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}",
'IT_CONTACT_EMAIL': company_contact['it_contact_email'],
'HR_CONTACT_EMAIL': company_contact['hr_contact_email'],
'OPERATIONS_CONTACT_EMAIL': company_contact['operations_contact_email'],
'BUSINESS_CARD_NAME': request_obj.business_card_name or display_name,
'BUSINESS_CARD_TITLE': request_obj.business_card_title or '-',
'BUSINESS_CARD_EMAIL': request_obj.business_card_email or request_obj.work_email,
'BUSINESS_CARD_PHONE': request_obj.business_card_phone or '-',
'PDF_LINK': settings.ONBOARDING_SHARED_PDF_LINK,
}
_send_templated_email(
template_key='onboarding_it',
context=email_context,
to=[it_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='onboarding_general_info',
context=email_context,
to=[general_info_email],
language_code=request_obj.preferred_language,
)
if request_obj.order_business_cards:
_send_templated_email(
template_key='onboarding_business_card',
context=email_context,
to=[business_card_email],
language_code=request_obj.preferred_language,
)
if 'HR Works' in request_obj.needed_accesses:
_send_templated_email(
template_key='onboarding_hr_works',
context=email_context,
to=[hr_works_email],
language_code=request_obj.preferred_language,
)
if 'Schlüssel' in request_obj.needed_devices:
_send_templated_email(
template_key='onboarding_key',
context=email_context,
to=[key_email],
language_code=request_obj.preferred_language,
)
if request_obj.onboarded_by_email:
_send_templated_email(
template_key='onboarding_reference',
context=email_context,
to=[request_obj.onboarded_by_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_apply_notification_rules(
event_type='onboarding',
request_obj=request_obj,
context=email_context,
pdf_path=pdf_path,
)
_schedule_welcome_email(request_obj)
upload_to_nextcloud(pdf_path, Path(pdf_path).name)
request_obj.processing_status = 'completed'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.onboarded_by_email,
title=_('Onboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name},
body=_('Die Onboarding-Anfrage wurde erfolgreich verarbeitet.'),
level='success',
event_key='onboarding_success',
)
_finish_task_log(task_log, status='succeeded')
except Exception as exc:
request_obj.processing_status = 'failed'
request_obj.last_error = str(exc)
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.onboarded_by_email,
title=_('Onboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name},
body=str(exc),
level='error',
event_key='onboarding_failure',
)
_finish_task_log(task_log, status='failed', error_message=str(exc))
raise
@shared_task
def process_offboarding_request(offboarding_request_id: int) -> None:
request_obj = OffboardingRequest.objects.get(id=offboarding_request_id)
task_log = _start_task_log(
'process_offboarding_request',
target_type='offboarding_request',
target_id=request_obj.id,
target_label=request_obj.full_name,
)
request_obj.processing_status = 'processing'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
try:
branding_copy = get_branding_email_copy()
company_contact = get_company_contact_copy()
it_email, general_info_email, business_card_email_unused, hr_works_email, key_email_unused = _resolve_workflow_emails()
pdf_path = _generate_offboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
request_obj.save(update_fields=['generated_pdf_path'])
email_context = {
'FULL_NAME': request_obj.full_name,
'DEPARTMENT': request_obj.department or '-',
'LAST_WORKING_DAY': request_obj.last_working_day,
'REQUESTED_BY': request_obj.requested_by_email,
'EMAIL': request_obj.work_email,
'SUPPORT_EMAIL': company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}",
'IT_CONTACT_EMAIL': company_contact['it_contact_email'],
'HR_CONTACT_EMAIL': company_contact['hr_contact_email'],
'OPERATIONS_CONTACT_EMAIL': company_contact['operations_contact_email'],
}
_send_templated_email(
template_key='offboarding_it',
context=email_context,
to=[it_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='offboarding_general_info',
context=email_context,
to=[general_info_email],
language_code=request_obj.preferred_language,
)
had_hr_works = OnboardingRequest.objects.filter(
work_email=request_obj.work_email,
needed_accesses__icontains='HR Works',
).exists()
if had_hr_works:
_send_templated_email(
template_key='offboarding_hr_works_disable',
context=email_context,
to=[hr_works_email],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='offboarding_reference',
context=email_context,
to=[request_obj.requested_by_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_apply_notification_rules(
event_type='offboarding',
request_obj=request_obj,
context=email_context,
pdf_path=pdf_path,
)
upload_to_nextcloud(pdf_path, Path(pdf_path).name)
request_obj.processing_status = 'completed'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.requested_by_email,
title=_('Offboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name},
body=_('Die Offboarding-Anfrage wurde erfolgreich verarbeitet.'),
level='success',
event_key='offboarding_success',
)
_finish_task_log(task_log, status='succeeded')
except Exception as exc:
request_obj.processing_status = 'failed'
request_obj.last_error = str(exc)
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.requested_by_email,
title=_('Offboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name},
body=str(exc),
level='error',
event_key='offboarding_failure',
)
_finish_task_log(task_log, status='failed', error_message=str(exc))
raise
@shared_task
def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = False) -> None:
scheduled = ScheduledWelcomeEmail.objects.select_related('onboarding_request').filter(id=scheduled_email_id).first()
if not scheduled:
return
task_log = _start_task_log(
'send_scheduled_welcome_email',
target_type='scheduled_welcome_email',
target_id=scheduled.id,
target_label=scheduled.recipient_email,
)
if scheduled.status in {'sent', 'cancelled'} and not force_now:
_finish_task_log(task_log, status='succeeded')
return
if scheduled.status == 'paused' and not force_now:
_finish_task_log(task_log, status='succeeded')
return
if not force_now and timezone.now() < scheduled.send_at:
async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=scheduled.send_at)
scheduled.celery_task_id = async_result.id or scheduled.celery_task_id
scheduled.save(update_fields=['celery_task_id', 'updated_at'])
_finish_task_log(task_log, status='succeeded')
return
request_obj = scheduled.onboarding_request
first_name, last_name = _split_name(request_obj.full_name)
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip()
email_context = {
'FULL_NAME': display_name,
'VORNAME': first_name,
'NACHNAME': last_name,
'DEPARTMENT': request_obj.department or '-',
'CONTRACT_START': request_obj.contract_start,
'EMAIL': request_obj.work_email,
'REQUESTED_BY': request_obj.onboarded_by_email or '-',
}
config = WorkflowConfig.objects.order_by('id').first()
include_pdf = True if not config else bool(config.welcome_include_pdf)
from_email = ''
if config:
from_email = (config.welcome_sender_email or config.email_account or '').strip()
attachments = []
if include_pdf and request_obj.generated_pdf_path:
pdf_path = Path(request_obj.generated_pdf_path)
if pdf_path.exists():
attachments = [pdf_path]
try:
_send_templated_email(
template_key='onboarding_welcome',
context=email_context,
to=[scheduled.recipient_email],
attachments=attachments,
from_email=from_email or None,
language_code=request_obj.preferred_language,
)
scheduled.status = 'sent'
scheduled.sent_at = timezone.now()
scheduled.last_error = ''
_notify_welcome_email_result(
recipient_email=request_obj.onboarded_by_email,
full_name=request_obj.full_name,
body=_('Die geplante Welcome E-Mail wurde erfolgreich versendet.'),
level='success',
event_key='welcome_email_success',
)
_finish_task_log(task_log, status='succeeded')
except Exception as exc:
scheduled.status = 'failed'
scheduled.last_error = str(exc)
_notify_welcome_email_result(
recipient_email=request_obj.onboarded_by_email,
full_name=request_obj.full_name,
body=str(exc),
level='error',
event_key='welcome_email_failure',
)
_finish_task_log(task_log, status='failed', error_message=str(exc))
raise
finally:
scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at'])