diff --git a/backend/workflows/admin_section_builders.py b/backend/workflows/admin_section_builders.py new file mode 100644 index 0000000..882f127 --- /dev/null +++ b/backend/workflows/admin_section_builders.py @@ -0,0 +1,109 @@ +from django.utils.translation import gettext as _ + + +def build_branding_sections(form, branding): + sections = [ + { + 'key': 'identity', + 'title': _('Identität'), + 'subtitle': _('Titel, Firmenname und zentrale Spracheinstellungen.'), + 'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'], + 'field_full': {'login_subtitle'}, + 'hint_map': { + 'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. workdock.de.'), + }, + }, + { + 'key': 'appearance', + 'title': _('Farben & Erscheinungsbild'), + 'subtitle': _('Zentrale visuelle Markenwerte und Browser-Icon.'), + 'fields': ['primary_color', 'secondary_color', 'logo_image', 'favicon_image'], + 'field_full': set(), + 'hint_map': { + 'logo_image': _('Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB.'), + 'favicon_image': _('Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB.'), + }, + }, + { + 'key': 'communication', + 'title': _('Kommunikation'), + 'subtitle': _('Absender, Support und PDF-Branding für ausgehende Kommunikation.'), + 'fields': ['support_email', 'sender_display_name', 'pdf_letterhead'], + 'field_full': {'pdf_letterhead'}, + 'hint_map': { + 'sender_display_name': _('Wird für ausgehende System-E-Mails als Anzeigename verwendet.'), + 'pdf_letterhead': _('Erlaubtes Format: PDF. Maximal 10 MB.'), + }, + }, + { + 'key': 'legal', + 'title': _('Footer & Rechtliches'), + 'subtitle': _('Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell.'), + 'fields': ['footer_text', 'legal_notice', 'footer_text_en', 'legal_notice_en'], + 'field_full': {'legal_notice', 'legal_notice_en'}, + 'hint_map': {}, + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + value = getattr(branding, field_name, '') or '' + is_file = bool(getattr(field.field.widget, 'input_type', '') == 'file') + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': value, + 'is_file': is_file, + 'is_full': field_name in section.get('field_full', set()), + 'hint': section.get('hint_map', {}).get(field_name, ''), + } + ) + section['rows'] = rows + return sections + + +def build_company_config_sections(form, company_config): + sections = [ + { + 'key': 'profile', + 'title': _('Firmenprofil'), + 'subtitle': _('Rechtlicher Name und zentrale Stammdaten der Firma.'), + 'fields': ['legal_company_name', 'phone_number', 'website_url', 'country'], + }, + { + 'key': 'address', + 'title': _('Adresse & Register'), + 'subtitle': _('Anschrift sowie optionale Register- und Steuerangaben.'), + 'fields': ['street_address', 'postal_code', 'city', 'registration_number', 'vat_id'], + }, + { + 'key': 'contacts', + 'title': _('Kontaktpunkte'), + 'subtitle': _('Zentrale Ansprechpartner für HR, IT und Operations.'), + 'fields': ['hr_contact_email', 'it_contact_email', 'operations_contact_email'], + }, + { + 'key': 'public', + 'title': _('Recht & Öffentlichkeit'), + 'subtitle': _('Öffentliche Links für Website, Impressum und Datenschutz.'), + 'fields': ['imprint_url', 'privacy_url'], + 'hint': _('Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden.'), + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': getattr(company_config, field_name, '') or '', + } + ) + section['rows'] = rows + return sections diff --git a/backend/workflows/admin_user_helpers.py b/backend/workflows/admin_user_helpers.py new file mode 100644 index 0000000..ab270a1 --- /dev/null +++ b/backend/workflows/admin_user_helpers.py @@ -0,0 +1,145 @@ +from django.contrib.auth import get_user_model +from django.shortcuts import render +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from django.utils.translation import gettext as _ +from django.urls import reverse +from django.contrib.auth.tokens import default_token_generator + +from .branding import get_branding_email_copy +from .forms import UserManagementCreateForm +from .models import AdminAuditLog +from .emailing import send_system_email +from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, get_user_role_key + + +def user_management_rows(*, display_user_name_fn): + user_model = get_user_model() + role_order = { + ROLE_PLATFORM_OWNER: 0, + ROLE_SUPER_ADMIN: 0, + 'admin': 1, + 'it_staff': 2, + 'staff': 3, + } + rows = [] + for user in user_model.objects.all().order_by('-is_active', 'username'): + role_key = get_user_role_key(user) + rows.append( + { + 'user': user, + 'role_key': role_key, + 'role_label': str(ROLE_LABELS[role_key]), + 'role_sort': role_order.get(role_key, 99), + 'display_name': display_user_name_fn(user), + } + ) + rows.sort(key=lambda item: (not item['user'].is_active, item['role_sort'], item['user'].username.lower())) + return rows + + +def render_user_management(request, *, create_form=None, status_code: int = 200, audit_action_label_fn, display_user_name_fn): + recent_user_events = list( + AdminAuditLog.objects.select_related('actor') + .filter(action__in=['user_created', 'user_updated', 'user_password_reset_sent', 'user_deleted']) + .order_by('-created_at', '-id')[:12] + ) + for row in recent_user_events: + row.action_label = audit_action_label_fn(row.action) + role_key = (row.details or {}).get('role') + row.role_label = str(ROLE_LABELS[role_key]) if role_key in ROLE_LABELS else role_key + include_product_owner = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER + return render( + request, + 'workflows/user_management.html', + { + 'create_form': create_form or UserManagementCreateForm(include_product_owner=include_product_owner), + 'rows': user_management_rows(display_user_name_fn=display_user_name_fn), + 'role_choices': [ + (key, str(ROLE_LABELS[key])) + for key in ROLE_GROUP_NAMES + if include_product_owner or key != ROLE_PLATFORM_OWNER + ], + 'include_product_owner': include_product_owner, + 'recent_user_events': recent_user_events, + }, + status=status_code, + ) + + +def platform_owner_user_count() -> int: + user_model = get_user_model() + return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_PLATFORM_OWNER and user.is_active) + + +def super_admin_user_count() -> int: + user_model = get_user_model() + return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active) + + +def would_remove_last_super_admin(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: + if get_user_role_key(user) != ROLE_SUPER_ADMIN or not user.is_active: + return False + if super_admin_user_count() > 1: + return False + if deleting: + return True + if new_role_key is not None and new_role_key != ROLE_SUPER_ADMIN: + return True + if new_is_active is not None and not new_is_active: + return True + return False + + +def would_remove_last_platform_owner(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: + if get_user_role_key(user) != ROLE_PLATFORM_OWNER or not user.is_active: + return False + if platform_owner_user_count() > 1: + return False + if deleting: + return True + if new_role_key is not None and new_role_key != ROLE_PLATFORM_OWNER: + return True + if new_is_active is not None and not new_is_active: + return True + return False + + +def send_user_access_email(request, target_user, *, invitation: bool, display_user_name_fn) -> None: + email = (target_user.email or '').strip() + if not email: + raise ValueError(_('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.')) + + uid = urlsafe_base64_encode(force_bytes(target_user.pk)) + token = default_token_generator.make_token(target_user) + reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) + reset_url = request.build_absolute_uri(reset_path) + branding_copy = get_branding_email_copy() + + if invitation: + subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username} + body = _( + 'Hallo %(name)s,\n\n' + 'für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n' + 'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n' + '%(url)s\n\n' + 'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.' + ) % { + 'name': display_user_name_fn(target_user), + 'portal_title': branding_copy['portal_title'], + 'url': reset_url, + } + else: + subject = _('Passwort zurücksetzen für %(username)s') % {'username': target_user.username} + body = _( + 'Hallo %(name)s,\n\n' + 'für Ihr Konto wurde ein Link zum Zurücksetzen des Passworts erstellt.\n' + 'Bitte öffnen Sie den folgenden Link:\n' + '%(url)s\n\n' + 'Wenn Sie diese Anfrage nicht erwartet haben, können Sie diese E-Mail ignorieren.' + ) % { + 'name': display_user_name_fn(target_user), + 'url': reset_url, + } + + send_system_email(subject=subject, body=body, to=[email]) diff --git a/backend/workflows/pdf_rendering.py b/backend/workflows/pdf_rendering.py new file mode 100644 index 0000000..78ef070 --- /dev/null +++ b/backend/workflows/pdf_rendering.py @@ -0,0 +1,810 @@ +from pathlib import Path +import base64 +import mimetypes +import re + +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_portal_letterhead_path +from .models import IntroChecklistItem, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, WorkflowConfig +from .forms import ACCESS_CHOICES, DEVICE_CHOICES, HARDWARE_EXTRA_CHOICES, OnboardingRequestForm, RESOURCE_CHOICES, SOFTWARE_CHOICES, SOFTWARE_EXTRA_CHOICES, WORKSPACE_GROUP_CHOICES +from .pdf_sections import build_pdf_sections + + +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 _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']) + +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 _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 _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 = ( + '' + ) + if '' in html_content: + html_content = html_content.replace('', f'{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 diff --git a/backend/workflows/views.py b/backend/workflows/views.py index d6ff995..ce31b61 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -34,6 +34,16 @@ from .backup_ops import ( ) from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired from . import account_views, admin_config_views, integrations_views, request_views +from .admin_section_builders import ( + build_branding_sections as _build_branding_sections, + build_company_config_sections as _build_company_config_sections, +) +from .admin_user_helpers import ( + render_user_management as _render_user_management, + send_user_access_email as _send_user_access_email, + would_remove_last_platform_owner as _would_remove_last_platform_owner, + would_remove_last_super_admin as _would_remove_last_super_admin, +) from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, @@ -196,141 +206,18 @@ def save_portal_app_registry(request): return admin_config_views.save_portal_app_registry_impl(request, audit_fn=_audit) -def _user_management_rows(): - user_model = get_user_model() - role_order = { - ROLE_PLATFORM_OWNER: 0, - ROLE_SUPER_ADMIN: 0, - 'admin': 1, - 'it_staff': 2, - 'staff': 3, - } - rows = [] - for user in user_model.objects.all().order_by('-is_active', 'username'): - role_key = get_user_role_key(user) - rows.append( - { - 'user': user, - 'role_key': role_key, - 'role_label': str(ROLE_LABELS[role_key]), - 'role_sort': role_order.get(role_key, 99), - 'display_name': _display_user_name(user), - } - ) - rows.sort(key=lambda item: (not item['user'].is_active, item['role_sort'], item['user'].username.lower())) - return rows - - -def _render_user_management(request, create_form=None, status_code: int = 200): - recent_user_events = list( - AdminAuditLog.objects.select_related('actor') - .filter(action__in=['user_created', 'user_updated', 'user_password_reset_sent', 'user_deleted']) - .order_by('-created_at', '-id')[:12] - ) - for row in recent_user_events: - row.action_label = _audit_action_label(row.action) - role_key = (row.details or {}).get('role') - row.role_label = str(ROLE_LABELS[role_key]) if role_key in ROLE_LABELS else role_key - include_product_owner = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER - return render( - request, - 'workflows/user_management.html', - { - 'create_form': create_form or UserManagementCreateForm(include_product_owner=include_product_owner), - 'rows': _user_management_rows(), - 'role_choices': [ - (key, str(ROLE_LABELS[key])) - for key in ROLE_GROUP_NAMES - if include_product_owner or key != ROLE_PLATFORM_OWNER - ], - 'include_product_owner': include_product_owner, - 'recent_user_events': recent_user_events, - }, - status=status_code, - ) - - -def _platform_owner_user_count() -> int: - user_model = get_user_model() - return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_PLATFORM_OWNER and user.is_active) - - -def _super_admin_user_count() -> int: - user_model = get_user_model() - return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active) - - -def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: - if get_user_role_key(user) != ROLE_SUPER_ADMIN or not user.is_active: - return False - if _super_admin_user_count() > 1: - return False - if deleting: - return True - if new_role_key is not None and new_role_key != ROLE_SUPER_ADMIN: - return True - if new_is_active is not None and not new_is_active: - return True - return False - - -def _would_remove_last_platform_owner(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: - if get_user_role_key(user) != ROLE_PLATFORM_OWNER or not user.is_active: - return False - if _platform_owner_user_count() > 1: - return False - if deleting: - return True - if new_role_key is not None and new_role_key != ROLE_PLATFORM_OWNER: - return True - if new_is_active is not None and not new_is_active: - return True - return False - - -def _send_user_access_email(request, target_user, *, invitation: bool) -> None: - email = (target_user.email or '').strip() - if not email: - raise ValueError(_('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.')) - - uid = urlsafe_base64_encode(force_bytes(target_user.pk)) - token = default_token_generator.make_token(target_user) - reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) - reset_url = request.build_absolute_uri(reset_path) - branding_copy = get_branding_email_copy() - - if invitation: - subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username} - body = _( - 'Hallo %(name)s,\n\n' - 'für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n' - 'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n' - '%(url)s\n\n' - 'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.' - ) % { - 'name': _display_user_name(target_user), - 'portal_title': branding_copy['portal_title'], - 'url': reset_url, - } - else: - subject = _('Passwort zurücksetzen für %(username)s') % {'username': target_user.username} - body = _( - 'Hallo %(name)s,\n\n' - 'für Ihr Konto wurde ein Link zum Zurücksetzen des Passworts erstellt.\n' - 'Bitte öffnen Sie den folgenden Link:\n' - '%(url)s\n\n' - 'Wenn Sie diese Anfrage nicht erwartet haben, können Sie diese E-Mail ignorieren.' - ) % { - 'name': _display_user_name(target_user), - 'url': reset_url, - } - - send_system_email(subject=subject, body=body, to=[email]) - - @_require_capability('manage_users') def user_management_page(request): - return admin_config_views.user_management_page_impl(request, render_user_management_fn=_render_user_management) + return admin_config_views.user_management_page_impl( + request, + render_user_management_fn=lambda req, create_form=None, status_code=200: _render_user_management( + req, + create_form=create_form, + status_code=status_code, + audit_action_label_fn=_audit_action_label, + display_user_name_fn=_display_user_name, + ), + ) @_require_capability('manage_product_branding') @@ -344,70 +231,6 @@ def save_portal_branding(request): return admin_config_views.save_portal_branding_impl(request, audit_fn=_audit, build_branding_sections_fn=_build_branding_sections) -def _build_branding_sections(form, branding): - sections = [ - { - 'key': 'identity', - 'title': _('Identität'), - 'subtitle': _('Titel, Firmenname und zentrale Spracheinstellungen.'), - 'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'], - 'field_full': {'login_subtitle'}, - 'hint_map': { - 'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. workdock.de.'), - }, - }, - { - 'key': 'appearance', - 'title': _('Farben & Erscheinungsbild'), - 'subtitle': _('Zentrale visuelle Markenwerte und Browser-Icon.'), - 'fields': ['primary_color', 'secondary_color', 'logo_image', 'favicon_image'], - 'field_full': set(), - 'hint_map': { - 'logo_image': _('Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB.'), - 'favicon_image': _('Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB.'), - }, - }, - { - 'key': 'communication', - 'title': _('Kommunikation'), - 'subtitle': _('Absender, Support und PDF-Branding für ausgehende Kommunikation.'), - 'fields': ['support_email', 'sender_display_name', 'pdf_letterhead'], - 'field_full': {'pdf_letterhead'}, - 'hint_map': { - 'sender_display_name': _('Wird für ausgehende System-E-Mails als Anzeigename verwendet.'), - 'pdf_letterhead': _('Erlaubtes Format: PDF. Maximal 10 MB.'), - }, - }, - { - 'key': 'legal', - 'title': _('Footer & Rechtliches'), - 'subtitle': _('Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell.'), - 'fields': ['footer_text', 'legal_notice', 'footer_text_en', 'legal_notice_en'], - 'field_full': {'legal_notice', 'legal_notice_en'}, - 'hint_map': {}, - }, - ] - for section in sections: - rows = [] - for field_name in section['fields']: - field = form[field_name] - value = getattr(branding, field_name, '') or '' - is_file = bool(getattr(field.field.widget, 'input_type', '') == 'file') - rows.append( - { - 'name': field_name, - 'bound_field': field, - 'label': field.label, - 'value': value, - 'is_file': is_file, - 'is_full': field_name in section.get('field_full', set()), - 'hint': section.get('hint_map', {}).get(field_name, ''), - } - ) - section['rows'] = rows - return sections - - @_require_capability('manage_company_config') def portal_company_config_page(request): return admin_config_views.portal_company_config_page_impl(request, build_company_config_sections_fn=_build_company_config_sections) @@ -419,50 +242,6 @@ def save_portal_company_config(request): return admin_config_views.save_portal_company_config_impl(request, audit_fn=_audit, build_company_config_sections_fn=_build_company_config_sections) -def _build_company_config_sections(form, company_config): - sections = [ - { - 'key': 'profile', - 'title': _('Firmenprofil'), - 'subtitle': _('Rechtlicher Name und zentrale Stammdaten der Firma.'), - 'fields': ['legal_company_name', 'phone_number', 'website_url', 'country'], - }, - { - 'key': 'address', - 'title': _('Adresse & Register'), - 'subtitle': _('Anschrift sowie optionale Register- und Steuerangaben.'), - 'fields': ['street_address', 'postal_code', 'city', 'registration_number', 'vat_id'], - }, - { - 'key': 'contacts', - 'title': _('Kontaktpunkte'), - 'subtitle': _('Zentrale Ansprechpartner für HR, IT und Operations.'), - 'fields': ['hr_contact_email', 'it_contact_email', 'operations_contact_email'], - }, - { - 'key': 'public', - 'title': _('Recht & Öffentlichkeit'), - 'subtitle': _('Öffentliche Links für Website, Impressum und Datenschutz.'), - 'fields': ['imprint_url', 'privacy_url'], - 'hint': _('Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden.'), - }, - ] - for section in sections: - rows = [] - for field_name in section['fields']: - field = form[field_name] - rows.append( - { - 'name': field_name, - 'bound_field': field, - 'label': field.label, - 'value': getattr(company_config, field_name, '') or '', - } - ) - section['rows'] = rows - return sections - - @_require_capability('manage_trial_lifecycle') def portal_trial_config_page(request): return admin_config_views.portal_trial_config_page_impl(request) @@ -479,8 +258,19 @@ def save_portal_trial_config(request): def create_user_from_admin(request): return admin_config_views.create_user_from_admin_impl( request, - render_user_management_fn=_render_user_management, - send_user_access_email_fn=_send_user_access_email, + render_user_management_fn=lambda req, create_form=None, status_code=200: _render_user_management( + req, + create_form=create_form, + status_code=status_code, + audit_action_label_fn=_audit_action_label, + display_user_name_fn=_display_user_name, + ), + send_user_access_email_fn=lambda req, target_user, invitation: _send_user_access_email( + req, + target_user, + invitation=invitation, + display_user_name_fn=_display_user_name, + ), audit_fn=_audit, display_user_name_fn=_display_user_name, ) @@ -505,7 +295,12 @@ def send_password_reset_from_admin(request, user_id: int): return admin_config_views.send_password_reset_from_admin_impl( request, user_id, - send_user_access_email_fn=_send_user_access_email, + send_user_access_email_fn=lambda req, target_user, invitation: _send_user_access_email( + req, + target_user, + invitation=invitation, + display_user_name_fn=_display_user_name, + ), audit_fn=_audit, display_user_name_fn=_display_user_name, )