snapshot: extract remaining workflow view helper clusters

This commit is contained in:
Md Bayazid Bostame
2026-03-28 09:13:37 +01:00
parent e80a68d6f8
commit 473b0a577c
4 changed files with 1103 additions and 244 deletions

View File

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

View File

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

View File

@@ -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 = (
'<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

View File

@@ -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 .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 . 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 .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import ( from .form_builder import (
DEFAULT_FIELD_ORDER, 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) 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') @_require_capability('manage_users')
def user_management_page(request): 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') @_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) 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') @_require_capability('manage_company_config')
def portal_company_config_page(request): 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) 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) 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') @_require_capability('manage_trial_lifecycle')
def portal_trial_config_page(request): def portal_trial_config_page(request):
return admin_config_views.portal_trial_config_page_impl(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): def create_user_from_admin(request):
return admin_config_views.create_user_from_admin_impl( return admin_config_views.create_user_from_admin_impl(
request, request,
render_user_management_fn=_render_user_management, render_user_management_fn=lambda req, create_form=None, status_code=200: _render_user_management(
send_user_access_email_fn=_send_user_access_email, 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, audit_fn=_audit,
display_user_name_fn=_display_user_name, 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( return admin_config_views.send_password_reset_from_admin_impl(
request, request,
user_id, 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, audit_fn=_audit,
display_user_name_fn=_display_user_name, display_user_name_fn=_display_user_name,
) )