from django import forms from pathlib import Path from datetime import timedelta from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] EMPLOYMENT_CHOICES = [('', 'Wählen Sie eine'), ('befristet', 'befristet'), ('unbefristet', 'unbefristet')] GENDER_CHOICES = [('', 'Wählen Sie eine'), ('herr', 'Herr'), ('frau', 'Frau'), ('divers', 'Divers')] DEPARTMENT_CHOICES = [ ('Buchhaltung/Personalverwaltung', 'Buchhaltung/Personalverwaltung'), ('IT-Service', 'IT-Service'), ('Azubi', 'Azubi'), ('Marketing/Kommunikation', 'Marketing/Kommunikation'), ('Messe', 'Messe'), ('Kongresse', 'Kongresse'), ('TNB/Studiengänge', 'TNB/Studiengänge'), ('TUB-Academy', 'TUB-Academy'), ('Projekte TUB', 'Projekte TUB'), ('TU Berlin Summer + Winter School', 'TU Berlin Summer + Winter School'), ] DEVICE_CHOICES = [ ('Laptop', 'Laptop'), ('Docking-Station', 'Docking-Station'), ('Tastatur und Maus', 'Tastatur und Maus'), ('Kopfhörer', 'Kopfhörer'), ('Tragetasche', 'Tragetasche'), ('Monitor', 'Monitor'), ('Schlüssel', 'Schlüssel'), ('Tischtelefon', 'Tischtelefon'), ] SOFTWARE_CHOICES = [ ('eM Client', 'eM Client'), ('KeepassXC', 'KeepassXC'), ('Nextcloud', 'Nextcloud'), ('7-Zip', '7-Zip'), ('PDF Reader', 'PDF Reader'), ('PDF-Editor (Flexi PDF)', 'PDF-Editor (Flexi PDF)'), ('Firefox', 'Firefox'), ('Chrome', 'Chrome'), ('Backup Client', 'Backup Client'), ('MS Office', 'MS Office'), ('Zoom', 'Zoom'), ('Cisco VPN Client', 'Cisco VPN Client'), ] ACCESS_CHOICES = [ ('TU Konto', 'TU Konto'), ('HR Works', 'HR Works'), ('Datev', 'Datev'), ('Odoo', 'Odoo'), ] WORKSPACE_GROUP_CHOICES = [ ('Group-Academy', 'Group-Academy'), ('Group-BuSu', 'Group-BuSu'), ('Group-EIT-Urban-Mobility', 'Group-EIT-Urban-Mobility'), ('Group-EL', 'Group-EL'), ('Group-EM', 'Group-EM'), ('Group-IT-Services', 'Group-IT-Services'), ('Group-Kongresse-Events', 'Group-Kongresse-Events'), ('Group-MaCo', 'Group-MaCo'), ('Group-Messe', 'Group-Messe'), ('Group-Messe-Kongresse', 'Group-Messe-Kongresse'), ('Group-MSE', 'Group-MSE'), ('Group-SuMo', 'Group-SuMo'), ('Group-TNB', 'Group-TNB'), ('Group-TUBS', 'Group-TUBS'), ('Group-Wima', 'Group-Wima'), ('Group-Leitungsrunde', 'Group-Leitungsrunde'), ('Campus-Euref', 'Campus-Euref'), ('Group-Lohnbuchhaltung', 'Group-Lohnbuchhaltung'), ('Group-SU-WU', 'Group-SU-WU'), ('NWM', 'NWM'), ] RESOURCE_CHOICES = [('Drucker HBS 5./6. OG', 'Drucker HBS 5./6. OG'), ('Drucker Euref', 'Drucker Euref')] PHONE_CHOICES = [ ('030 4472021 (0-9)', '030 4472021 (0-9)'), ('030 4472022 (0-9)', '030 4472022 (0-9)'), ('030 4472023 (0-9)', '030 4472023 (0-9)'), ('030 4472024 (0-9)', '030 4472024 (0-9)'), ('030 4472025 (0-9)', '030 4472025 (0-9)'), ('030 4472026 (0-9)', '030 4472026 (0-9)'), ('030 4472027 (0-9)', '030 4472027 (0-9)'), ('030 4472028 (0-9)', '030 4472028 (0-9)'), ] HARDWARE_EXTRA_CHOICES = [('Smartphone', 'Smartphone'), ('Anderes', 'Anderes')] SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)', 'Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'), ('Anderes', 'Anderes')] class AppAuthenticationForm(AuthenticationForm): username = forms.CharField(label=gettext_lazy('Benutzername')) password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'})) class AppPasswordResetForm(PasswordResetForm): email = forms.EmailField(label=gettext_lazy('E-Mail-Adresse')) class AppSetPasswordForm(SetPasswordForm): new_password1 = forms.CharField( label=gettext_lazy('Neues Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), help_text=password_validation.password_validators_help_text_html(), ) new_password2 = forms.CharField( label=gettext_lazy('Neues Passwort bestätigen'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), ) class UserManagementCreateForm(forms.Form): first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) username = forms.CharField(label=_('Benutzername'), max_length=150) email = forms.EmailField(label=_('E-Mail-Adresse')) role_key = forms.ChoiceField(label=_('Rolle')) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['role_key'].choices = [ (role_key, str(ROLE_LABELS[role_key])) for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF) ] def clean_username(self): username = (self.cleaned_data.get('username') or '').strip() user_model = get_user_model() if user_model.objects.filter(username=username).exists(): raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.')) return username def clean_email(self): return (self.cleaned_data.get('email') or '').strip().lower() def clean_role_key(self): role_key = (self.cleaned_data.get('role_key') or '').strip() if role_key not in ROLE_GROUP_NAMES: raise forms.ValidationError(_('Ungültige Rolle.')) return role_key def save(self): user_model = get_user_model() user = user_model.objects.create_user( username=self.cleaned_data['username'], email=self.cleaned_data['email'], password=None, first_name=self.cleaned_data.get('first_name', ''), last_name=self.cleaned_data.get('last_name', ''), is_active=True, ) assign_user_role(user, self.cleaned_data['role_key']) return user class OnboardingRequestForm(forms.ModelForm): first_name = forms.CharField(label='Vorname', required=False) last_name = forms.CharField(label='Nachname', required=False) full_name = forms.CharField(required=False, widget=forms.HiddenInput()) gender = forms.ChoiceField(label='Anrede', choices=GENDER_CHOICES, required=True) department = forms.ChoiceField(label='Abteilung', choices=DEPARTMENT_CHOICES, required=True) work_email = forms.EmailField( label='Gewünschte dienstliche E-Mail-Adresse', help_text='Bitte nutzen Sie das Format name@tub.co.', ) contract_start = forms.DateField(label='Vertragsbeginn', widget=forms.DateInput(attrs={'type': 'date'})) employment_type = forms.ChoiceField(label='Beschäftigungsverhältnis', choices=EMPLOYMENT_CHOICES, required=True) employment_end_date = forms.DateField( label='Enddatum (nur bei befristet)', required=False, widget=forms.DateInput(attrs={'type': 'date'}), ) handover_date = forms.DateField(label='Gewünschtes Übergabedatum der Geräte', required=False, widget=forms.DateInput(attrs={'type': 'date'})) group_mailboxes_required_choice = forms.ChoiceField(label='Gruppenpostfächer erforderlich?', choices=YES_NO_CHOICES, required=False) additional_software_needed_choice = forms.ChoiceField(label='Wird zusätzliche Software benötigt?', choices=YES_NO_CHOICES, required=False) additional_hardware_needed_choice = forms.ChoiceField(label='Darüber hinaus wird weitere Hardware benötigt?', choices=YES_NO_CHOICES, required=False) additional_access_needed_choice = forms.ChoiceField(label='Darüber hinaus werden weitere Zugänge benötigt?', choices=YES_NO_CHOICES, required=False) successor_required_choice = forms.ChoiceField(label='Neue Mitarbeitende ist Nachfolge von?', choices=YES_NO_CHOICES, required=False) inherit_phone_number_choice = forms.ChoiceField(label='Telefonnummer von Vorgängerperson übernehmen?', choices=YES_NO_CHOICES, required=False) order_business_cards = forms.BooleanField(label='Visitenkarten bestellen?', required=False) needed_devices_multi = forms.MultipleChoiceField( label='Benötigte Geräte und Gegenstände', choices=DEVICE_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) needed_software_multi = forms.MultipleChoiceField( label='Benötigte Software', choices=SOFTWARE_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) needed_accesses_multi = forms.MultipleChoiceField( label='Benötigte Zugänge', choices=ACCESS_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) needed_workspace_groups_multi = forms.MultipleChoiceField( label='Benötigte Gruppen im Workspace', choices=WORKSPACE_GROUP_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) needed_resources_multi = forms.MultipleChoiceField( label='Benötigte Ressourcen', choices=RESOURCE_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) phone_number_choice = forms.CharField( label='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)', required=False, widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}), ) additional_hardware_multi = forms.MultipleChoiceField( label='Zusätzliche Hardware', choices=HARDWARE_EXTRA_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) additional_software_multi = forms.MultipleChoiceField( label='Zusätzlich gewünschte Software', choices=SOFTWARE_EXTRA_CHOICES, required=False, widget=forms.CheckboxSelectMultiple, ) agreement_confirm = forms.BooleanField( label='Hiermit bestätige ich die Richtigkeit und Vollständigkeit meiner Angaben.', required=True, ) class Meta: model = OnboardingRequest fields = [ 'full_name', 'gender', 'job_title', 'department', 'work_email', 'contract_start', 'employment_type', 'employment_end_date', 'handover_date', 'order_business_cards', 'business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone', 'group_mailboxes', 'additional_software', 'additional_hardware_other', 'additional_access_text', 'needed_resources', 'successor_name', 'additional_notes', 'signature_image', ] widgets = { 'group_mailboxes': forms.Textarea(attrs={'rows': 2}), 'additional_software': forms.Textarea(attrs={'rows': 2}), 'additional_hardware_other': forms.Textarea(attrs={'rows': 2}), 'additional_access_text': forms.Textarea(attrs={'rows': 2}), 'successor_name': forms.TextInput(), 'additional_notes': forms.Textarea(attrs={'rows': 4}), } @staticmethod def _choices_from_options(category: str, fallback: list[tuple[str, str]]) -> list[tuple[str, str]]: options = FormOption.objects.filter(category=category, is_active=True).order_by('sort_order', 'label') if not options.exists(): return fallback language_code = get_language() return [(o.value or o.label, o.translated_label(language_code)) for o in options] def __init__(self, *args, **kwargs): self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower() super().__init__(*args, **kwargs) config = WorkflowConfig.objects.order_by('id').first() self.handover_lead_days = max(0, int(getattr(config, 'device_handover_lead_days', 5) or 5)) minimum_handover_date = timezone.localdate() + timedelta(days=self.handover_lead_days) self.fields['handover_date'].widget.attrs['min'] = minimum_handover_date.isoformat() self.fields['full_name'].label = 'Name' full_name_initial = (self.initial.get('full_name') or '').strip() if full_name_initial and not self.initial.get('first_name') and not self.initial.get('last_name'): name_parts = full_name_initial.split() if name_parts: self.fields['first_name'].initial = name_parts[0] self.fields['last_name'].initial = ' '.join(name_parts[1:]) if len(name_parts) > 1 else '' self.fields['department'].choices = self._choices_from_options('department', DEPARTMENT_CHOICES) self.fields['needed_devices_multi'].choices = self._choices_from_options('device', DEVICE_CHOICES) self.fields['needed_software_multi'].choices = self._choices_from_options('software', SOFTWARE_CHOICES) self.fields['needed_accesses_multi'].choices = self._choices_from_options('access', ACCESS_CHOICES) self.fields['needed_workspace_groups_multi'].choices = self._choices_from_options('workspace_group', WORKSPACE_GROUP_CHOICES) self.fields['needed_resources_multi'].choices = self._choices_from_options('resource', RESOURCE_CHOICES) self.fields['signature_image'].required = False apply_form_field_config('onboarding', self) def clean_work_email(self): value = (self.cleaned_data.get('work_email') or '').strip().lower() if not value: return value if not value.endswith('@tub.co'): raise forms.ValidationError('Bitte verwenden Sie eine @tub.co E-Mail-Adresse.') return value def clean_signature_image(self): image = self.cleaned_data.get('signature_image') if not image: return image max_size = 4 * 1024 * 1024 # 4 MB if image.size > max_size: raise forms.ValidationError('Die Signatur-Datei ist zu groß (max. 4 MB).') content_type = (getattr(image, 'content_type', '') or '').lower().strip() extension = Path(getattr(image, 'name', '')).suffix.lower() allowed_content_types = { 'image/png', 'image/x-png', 'image/jpeg', 'image/jpg', 'image/pjpeg', } allowed_extensions = {'.png', '.jpg', '.jpeg'} if content_type and not content_type.startswith('image/'): raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') if content_type and content_type not in allowed_content_types and extension not in allowed_extensions: raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') if not content_type and extension not in allowed_extensions: raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') try: header = image.read(16) image.seek(0) except Exception: raise forms.ValidationError('Die Signatur-Datei konnte nicht gelesen werden.') is_png = header.startswith(b'\x89PNG\r\n\x1a\n') is_jpeg = header.startswith(b'\xff\xd8\xff') if not (is_png or is_jpeg): raise forms.ValidationError('Die Signatur-Datei ist kein gültiges PNG/JPG-Bild.') return image def clean(self): cleaned = super().clean() has = self.fields.__contains__ first_name = (cleaned.get('first_name') or '').strip() last_name = (cleaned.get('last_name') or '').strip() full_name = (cleaned.get('full_name') or '').strip() if first_name and last_name: cleaned['full_name'] = f'{first_name} {last_name}'.strip() elif full_name: parts = full_name.split() cleaned['first_name'] = parts[0] if parts else '' cleaned['last_name'] = ' '.join(parts[1:]) if len(parts) > 1 else '' else: if not first_name: self.add_error('first_name', 'Bitte Vornamen eingeben.') if not last_name: self.add_error('last_name', 'Bitte Nachnamen eingeben.') if has('employment_end_date') and cleaned.get('employment_type') == 'befristet' and not cleaned.get('employment_end_date'): self.add_error('employment_end_date', 'Bei befristeter Beschäftigung ist ein Enddatum erforderlich.') if cleaned.get('order_business_cards'): for f in ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone']: if has(f) and not cleaned.get(f): self.add_error(f, 'Dieses Feld ist für die Visitenkartenbestellung erforderlich.') if has('group_mailboxes') and cleaned.get('group_mailboxes_required_choice') == 'ja' and not cleaned.get('group_mailboxes'): self.add_error('group_mailboxes', 'Bitte Gruppenpostfächer eintragen.') if has('additional_hardware_multi') and cleaned.get('additional_hardware_needed_choice') == 'ja' and not cleaned.get('additional_hardware_multi'): self.add_error('additional_hardware_multi', 'Bitte mindestens eine Hardware-Option wählen.') if has('additional_software_multi') and cleaned.get('additional_software_needed_choice') == 'ja' and not cleaned.get('additional_software_multi'): self.add_error('additional_software_multi', 'Bitte mindestens eine Software-Option wählen.') if has('additional_access_text') and cleaned.get('additional_access_needed_choice') == 'ja' and not cleaned.get('additional_access_text'): self.add_error('additional_access_text', 'Bitte zusätzliche Zugänge eintragen.') if has('successor_name') and cleaned.get('successor_required_choice') == 'ja' and not cleaned.get('successor_name'): self.add_error('successor_name', 'Bitte Name der Vorgängerperson eintragen.') handover_date = cleaned.get('handover_date') if has('handover_date') and handover_date: minimum_date = timezone.localdate() + timedelta(days=getattr(self, 'handover_lead_days', 5)) if handover_date < minimum_date: self.add_error( 'handover_date', _('Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen (frühestens %(date)s).') % { 'days': getattr(self, 'handover_lead_days', 5), 'date': minimum_date.strftime('%Y-%m-%d'), }, ) return cleaned def save(self, commit=True): instance = super().save(commit=False) if not (instance.preferred_language or '').strip(): instance.preferred_language = (get_language() or 'de').split('-')[0] instance.group_mailboxes_required = self.cleaned_data.get('group_mailboxes_required_choice') == 'ja' instance.additional_software_needed = self.cleaned_data.get('additional_software_needed_choice') == 'ja' instance.additional_hardware_needed = self.cleaned_data.get('additional_hardware_needed_choice') == 'ja' instance.additional_access_needed = self.cleaned_data.get('additional_access_needed_choice') == 'ja' instance.successor_required = self.cleaned_data.get('successor_required_choice') == 'ja' instance.inherit_phone_number = self.cleaned_data.get('inherit_phone_number_choice') == 'ja' instance.needed_devices = '\n'.join(self.cleaned_data.get('needed_devices_multi', [])) instance.needed_software = '\n'.join(self.cleaned_data.get('needed_software_multi', [])) instance.needed_accesses = '\n'.join(self.cleaned_data.get('needed_accesses_multi', [])) instance.needed_workspace_groups = '\n'.join(self.cleaned_data.get('needed_workspace_groups_multi', [])) instance.needed_resources = '\n'.join(self.cleaned_data.get('needed_resources_multi', [])) instance.additional_hardware = '\n'.join(self.cleaned_data.get('additional_hardware_multi', [])) selected_extra_software = self.cleaned_data.get('additional_software_multi', []) free_software = self.cleaned_data.get('additional_software', '').strip() all_extra_software = list(selected_extra_software) if free_software: all_extra_software.append(free_software) instance.additional_software = '\n'.join([s for s in all_extra_software if s]) if not instance.successor_required: instance.successor_name = '' instance.inherit_phone_number = False if instance.inherit_phone_number: instance.phone_number = '' else: instance.phone_number = self.cleaned_data.get('phone_number_choice', '') instance.agreement = 'accepted' if self.cleaned_data.get('agreement_confirm') else '' instance.onboarded_by_email = self.requester_email if commit: instance.save() return instance class OffboardingRequestForm(forms.ModelForm): search_query = forms.CharField( label='Mitarbeitende suchen (Name oder E-Mail)', required=False, help_text='Optional: Suche zur automatischen Vorbefüllung aus bereits onboardeten Personen.', ) class Meta: model = OffboardingRequest fields = [ 'full_name', 'work_email', 'department', 'job_title', 'last_working_day', 'notes', ] widgets = { 'last_working_day': forms.DateInput(attrs={'type': 'date'}), 'notes': forms.Textarea(attrs={'rows': 3}), } def __init__(self, *args, **kwargs): prefill_profile = kwargs.pop('prefill_profile', None) super().__init__(*args, **kwargs) self.fields['full_name'].label = 'Vorname und Nachname' self.fields['work_email'].help_text = '' if prefill_profile: self.fields['full_name'].initial = prefill_profile.full_name self.fields['work_email'].initial = prefill_profile.work_email self.fields['department'].initial = prefill_profile.department self.fields['job_title'].initial = prefill_profile.job_title apply_form_field_config('offboarding', self)