Files
workdock-platform/backend/workflows/forms.py
2026-03-26 11:43:54 +01:00

542 lines
24 KiB
Python

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, PortalBranding, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, 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, include_product_owner: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.include_product_owner = include_product_owner
role_order = [ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF]
if include_product_owner:
role_order = [ROLE_PLATFORM_OWNER] + role_order
self.fields['role_key'].choices = [(role_key, str(ROLE_LABELS[role_key])) for role_key in role_order]
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.'))
if role_key == ROLE_PLATFORM_OWNER and not self.include_product_owner:
raise forms.ValidationError(_('Nur Platform Owner dürfen diese Rolle vergeben.'))
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 PortalBrandingForm(forms.ModelForm):
class Meta:
model = PortalBranding
fields = [
'portal_title',
'company_name',
'support_email',
'default_language',
'logo_image',
'pdf_letterhead',
'primary_color',
'secondary_color',
]
labels = {
'portal_title': gettext_lazy('Portal-Titel'),
'company_name': gettext_lazy('Firmenname'),
'support_email': gettext_lazy('Support-E-Mail'),
'default_language': gettext_lazy('Standardsprache'),
'logo_image': gettext_lazy('Logo'),
'pdf_letterhead': gettext_lazy('PDF-Briefkopf'),
'primary_color': gettext_lazy('Primärfarbe'),
'secondary_color': gettext_lazy('Sekundärfarbe'),
}
widgets = {
'primary_color': forms.TextInput(attrs={'type': 'color'}),
'secondary_color': forms.TextInput(attrs={'type': 'color'}),
'logo_image': forms.ClearableFileInput(attrs={'accept': '.svg,.png,.jpg,.jpeg,.webp'}),
'pdf_letterhead': forms.ClearableFileInput(attrs={'accept': '.pdf'}),
}
def clean_logo_image(self):
logo = self.cleaned_data.get('logo_image')
if not logo:
return logo
if getattr(logo, 'size', 0) > 5 * 1024 * 1024:
raise forms.ValidationError(_('Das Logo darf maximal 5 MB groß sein.'))
return logo
def clean_pdf_letterhead(self):
letterhead = self.cleaned_data.get('pdf_letterhead')
if not letterhead:
return letterhead
if getattr(letterhead, 'size', 0) > 10 * 1024 * 1024:
raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.'))
return letterhead
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)