Files
workdock-platform/backend/workflows/forms.py
Md Bayazid Bostame b60d9eaeb7
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
fix: restore tubco user onboarding access
2026-04-08 13:38:30 +02:00

1005 lines
45 KiB
Python

from django import forms
from datetime import timedelta
from django.contrib.auth import authenticate, get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy
from .branding import get_company_email_domain
from .form_builder import add_custom_form_fields, apply_form_field_config, custom_field_key_from_name, hidden_custom_field_names, is_custom_field_name
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, 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, user_has_capability
from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token
from .upload_validation import (
validate_avatar_upload,
validate_favicon_upload,
validate_logo_upload,
validate_pdf_upload,
validate_signature_upload,
)
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 AppLoginForm(forms.Form):
username = forms.CharField(label=gettext_lazy('Benutzername'))
password = forms.CharField(
label=gettext_lazy('Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
error_messages = {
'invalid_login': gettext_lazy('Benutzername oder Passwort sind nicht korrekt.'),
'inactive': gettext_lazy('Dieses Konto ist deaktiviert.'),
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
username = cleaned_data.get('username')
password = cleaned_data.get('password')
if username and password:
login_value = (username or '').strip()
auth_username = login_value
user_model = get_user_model()
matched_user = user_model.objects.filter(email__iexact=login_value).first()
if matched_user:
auth_username = matched_user.username
self.user_cache = authenticate(self.request, username=auth_username, password=password)
if self.user_cache is None:
raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
if not self.user_cache.is_active:
raise ValidationError(self.error_messages['inactive'], code='inactive')
return cleaned_data
def get_user(self):
return self.user_cache
class AppTOTPChallengeForm(forms.Form):
otp_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
required=False,
max_length=12,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
recovery_code = forms.CharField(
label=gettext_lazy('Recovery-Code'),
required=False,
max_length=32,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
)
error_messages = {
'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'),
'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'),
}
def __init__(self, *args, profile=None, **kwargs):
self.profile = profile
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
profile = self.profile
if not profile or not profile.totp_enabled:
return cleaned_data
otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if recovery_code:
if not profile.consume_recovery_code(recovery_code):
raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp')
return cleaned_data
if not otp_code:
raise ValidationError(self.error_messages['missing_otp'], code='missing_otp')
if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())):
raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp')
return cleaned_data
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 AppPasswordChangeForm(PasswordChangeForm):
old_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
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 AccountAvatarForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['avatar_image']
labels = {
'avatar_image': gettext_lazy('Profilbild'),
}
widgets = {
'avatar_image': forms.ClearableFileInput(
attrs={
'accept': '.png,.jpg,.jpeg,.webp,.svg',
'onchange': 'this.form.submit()',
}
),
}
def clean_avatar_image(self):
avatar = self.cleaned_data.get('avatar_image')
validate_avatar_upload(avatar)
return avatar
class AccountDetailsForm(forms.Form):
first_name = forms.CharField(label=gettext_lazy('Vorname'), max_length=150, required=False)
last_name = forms.CharField(label=gettext_lazy('Nachname'), max_length=150, required=False)
email = forms.EmailField(label=gettext_lazy('E-Mail-Adresse'), required=False)
phone_number = forms.CharField(label=gettext_lazy('Telefon'), max_length=80, required=False)
mobile_number = forms.CharField(label=gettext_lazy('Mobil'), max_length=80, required=False)
job_title = forms.CharField(label=gettext_lazy('Position'), max_length=255, required=False)
department = forms.CharField(label=gettext_lazy('Abteilung'), max_length=255, required=False)
location = forms.CharField(label=gettext_lazy('Standort'), max_length=255, required=False)
contact_notes = forms.CharField(
label=gettext_lazy('Hinweise'),
max_length=255,
required=False,
widget=forms.Textarea(attrs={'rows': 3}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
self.user = user
self.profile = profile
initial = kwargs.setdefault('initial', {})
if user is not None and not args:
initial.setdefault('first_name', user.first_name)
initial.setdefault('last_name', user.last_name)
initial.setdefault('email', user.email)
if profile is not None and not args:
initial.setdefault('phone_number', profile.phone_number)
initial.setdefault('mobile_number', profile.mobile_number)
initial.setdefault('job_title', profile.job_title)
initial.setdefault('department', profile.department)
initial.setdefault('location', profile.location)
initial.setdefault('contact_notes', profile.contact_notes)
super().__init__(*args, **kwargs)
def clean_email(self):
return (self.cleaned_data.get('email') or '').strip().lower()
def save(self):
self.user.first_name = self.cleaned_data.get('first_name', '').strip()
self.user.last_name = self.cleaned_data.get('last_name', '').strip()
self.user.email = self.cleaned_data.get('email', '').strip()
self.user.save(update_fields=['first_name', 'last_name', 'email'])
self.profile.phone_number = self.cleaned_data.get('phone_number', '').strip()
self.profile.mobile_number = self.cleaned_data.get('mobile_number', '').strip()
self.profile.job_title = self.cleaned_data.get('job_title', '').strip()
self.profile.department = self.cleaned_data.get('department', '').strip()
self.profile.location = self.cleaned_data.get('location', '').strip()
self.profile.contact_notes = self.cleaned_data.get('contact_notes', '').strip()
self.profile.save(
update_fields=['phone_number', 'mobile_number', 'job_title', 'department', 'location', 'contact_notes', 'updated_at']
)
return self.user, self.profile
class AccountTOTPEnableForm(forms.Form):
current_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
def __init__(self, *args, user=None, secret: str = '', **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.secret = secret
def clean_current_password(self):
password = self.cleaned_data.get('current_password') or ''
if not self.user or not self.user.check_password(password):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password
def clean_verification_code(self):
code = normalize_totp_token(self.cleaned_data.get('verification_code'))
if not code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.'))
if not self.secret or not verify_totp_token(self.secret, code, for_time=int(timezone.now().timestamp())):
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return code
class AccountTOTPDisableForm(forms.Form):
current_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.profile = profile
def clean_current_password(self):
password = self.cleaned_data.get('current_password') or ''
if not self.user or not self.user.check_password(password):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password
def clean(self):
cleaned_data = super().clean()
if not self.profile or not self.profile.totp_enabled:
raise ValidationError(_('TOTP ist für dieses Konto nicht aktiv.'))
return cleaned_data
class AccountTOTPRegenerateRecoveryCodesForm(forms.Form):
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
recovery_code = forms.CharField(
label=gettext_lazy('Recovery-Code'),
max_length=32,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.profile = profile
def clean(self):
cleaned_data = super().clean()
code = normalize_totp_token(cleaned_data.get('verification_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if not code and not recovery_code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.'))
secret = getattr(self.profile, 'totp_secret', '') or ''
if code:
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return cleaned_data
if not self.profile.consume_recovery_code(recovery_code):
raise ValidationError(_('Der Recovery-Code ist ungültig.'))
return cleaned_data
class AccountNotificationPreferencesForm(forms.Form):
onboarding_success = forms.BooleanField(label=gettext_lazy('Onboarding erfolgreich'), required=False)
onboarding_failure = forms.BooleanField(label=gettext_lazy('Onboarding fehlgeschlagen'), required=False)
offboarding_success = forms.BooleanField(label=gettext_lazy('Offboarding erfolgreich'), required=False)
offboarding_failure = forms.BooleanField(label=gettext_lazy('Offboarding fehlgeschlagen'), required=False)
backup_success = forms.BooleanField(label=gettext_lazy('Backup erfolgreich'), required=False)
backup_failure = forms.BooleanField(label=gettext_lazy('Backup fehlgeschlagen'), required=False)
welcome_email_success = forms.BooleanField(label=gettext_lazy('Welcome E-Mail erfolgreich'), required=False)
welcome_email_failure = forms.BooleanField(label=gettext_lazy('Welcome E-Mail fehlgeschlagen'), required=False)
trial_alerts = forms.BooleanField(label=gettext_lazy('Trial-Hinweise'), required=False)
system_alerts = forms.BooleanField(label=gettext_lazy('System-Hinweise'), required=False)
FIELD_TO_EVENT = {
'onboarding_success': UserProfile.NOTIFICATION_ONBOARDING_SUCCESS,
'onboarding_failure': UserProfile.NOTIFICATION_ONBOARDING_FAILURE,
'offboarding_success': UserProfile.NOTIFICATION_OFFBOARDING_SUCCESS,
'offboarding_failure': UserProfile.NOTIFICATION_OFFBOARDING_FAILURE,
'backup_success': UserProfile.NOTIFICATION_BACKUP_SUCCESS,
'backup_failure': UserProfile.NOTIFICATION_BACKUP_FAILURE,
'welcome_email_success': UserProfile.NOTIFICATION_WELCOME_EMAIL_SUCCESS,
'welcome_email_failure': UserProfile.NOTIFICATION_WELCOME_EMAIL_FAILURE,
'trial_alerts': UserProfile.NOTIFICATION_TRIAL_ALERTS,
'system_alerts': UserProfile.NOTIFICATION_SYSTEM_ALERTS,
}
GROUPS = [
('workflow', gettext_lazy('Workflow'), ['onboarding_success', 'onboarding_failure', 'offboarding_success', 'offboarding_failure']),
('welcome', gettext_lazy('Welcome E-Mail'), ['welcome_email_success', 'welcome_email_failure']),
('operations', gettext_lazy('Operations'), ['backup_success', 'backup_failure', 'system_alerts']),
('platform', gettext_lazy('Platform'), ['trial_alerts']),
]
def __init__(self, *args, profile=None, user=None, **kwargs):
self.profile = profile
self.user = user
initial = kwargs.setdefault('initial', {})
if profile is not None and not args:
prefs = profile.get_notification_preferences()
for field_name, event_key in self.FIELD_TO_EVENT.items():
initial.setdefault(field_name, prefs.get(event_key, True))
super().__init__(*args, **kwargs)
self.visible_field_names = self._compute_visible_field_names()
for field_name in list(self.fields.keys()):
if field_name not in self.visible_field_names:
self.fields.pop(field_name)
def _compute_visible_field_names(self) -> list[str]:
visible = [
'onboarding_success',
'onboarding_failure',
'offboarding_success',
'offboarding_failure',
'welcome_email_success',
'welcome_email_failure',
]
if user_has_capability(self.user, 'manage_backups'):
visible.extend(['backup_success', 'backup_failure'])
if user_has_capability(self.user, 'manage_integrations'):
visible.append('system_alerts')
if user_has_capability(self.user, 'manage_trial_lifecycle'):
visible.append('trial_alerts')
return visible
def grouped_fields(self):
groups = []
for key, label, field_names in self.GROUPS:
rows = [self[name] for name in field_names if name in self.fields]
if rows:
groups.append({'key': key, 'label': label, 'fields': rows})
return groups
def save(self):
prefs = self.profile.get_notification_preferences()
for field_name in self.visible_field_names:
event_key = self.FIELD_TO_EVENT[field_name]
prefs[event_key] = bool(self.cleaned_data.get(field_name))
self.profile.notification_preferences = prefs
self.profile.save(update_fields=['notification_preferences', 'updated_at'])
return self.profile
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',
'company_domain',
'support_email',
'sender_display_name',
'login_subtitle',
'footer_text',
'footer_text_en',
'legal_notice',
'legal_notice_en',
'default_language',
'logo_image',
'pdf_letterhead',
'favicon_image',
'primary_color',
'secondary_color',
]
labels = {
'portal_title': gettext_lazy('Portal-Titel'),
'company_name': gettext_lazy('Firmenname'),
'company_domain': gettext_lazy('Firmen-Domain'),
'support_email': gettext_lazy('Support-E-Mail'),
'sender_display_name': gettext_lazy('Absender-Anzeigename'),
'login_subtitle': gettext_lazy('Login-Untertitel'),
'footer_text': gettext_lazy('Footer-Text DE'),
'footer_text_en': gettext_lazy('Footer-Text EN'),
'legal_notice': gettext_lazy('Rechtlicher Hinweis DE'),
'legal_notice_en': gettext_lazy('Rechtlicher Hinweis EN'),
'default_language': gettext_lazy('Standardsprache'),
'logo_image': gettext_lazy('Logo'),
'pdf_letterhead': gettext_lazy('PDF-Briefkopf'),
'favicon_image': gettext_lazy('Favicon'),
'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'}),
'favicon_image': forms.ClearableFileInput(attrs={'accept': '.ico,.png,.svg,.webp'}),
'legal_notice': forms.Textarea(attrs={'rows': 3}),
'legal_notice_en': forms.Textarea(attrs={'rows': 3}),
}
def clean_logo_image(self):
logo = self.cleaned_data.get('logo_image')
validate_logo_upload(logo)
return logo
def clean_pdf_letterhead(self):
letterhead = self.cleaned_data.get('pdf_letterhead')
validate_pdf_upload(letterhead)
return letterhead
def clean_favicon_image(self):
favicon = self.cleaned_data.get('favicon_image')
validate_favicon_upload(favicon)
return favicon
class PortalCompanyConfigForm(forms.ModelForm):
class Meta:
model = PortalCompanyConfig
fields = [
'legal_company_name',
'street_address',
'postal_code',
'city',
'country',
'website_url',
'imprint_url',
'privacy_url',
'hr_contact_email',
'it_contact_email',
'operations_contact_email',
'phone_number',
'vat_id',
'registration_number',
]
labels = {
'legal_company_name': gettext_lazy('Rechtlicher Firmenname'),
'street_address': gettext_lazy('Straße und Hausnummer'),
'postal_code': gettext_lazy('Postleitzahl'),
'city': gettext_lazy('Stadt'),
'country': gettext_lazy('Land'),
'website_url': gettext_lazy('Website'),
'imprint_url': gettext_lazy('Impressum-URL'),
'privacy_url': gettext_lazy('Datenschutz-URL'),
'hr_contact_email': gettext_lazy('HR-Kontakt'),
'it_contact_email': gettext_lazy('IT-Kontakt'),
'operations_contact_email': gettext_lazy('Operations-Kontakt'),
'phone_number': gettext_lazy('Zentrale Telefonnummer'),
'vat_id': gettext_lazy('USt-IdNr.'),
'registration_number': gettext_lazy('Register- oder Handelsnummer'),
}
class PortalTrialConfigForm(forms.ModelForm):
class Meta:
model = PortalTrialConfig
fields = [
'is_trial_mode',
'trial_started_at',
'trial_expires_at',
'restrict_production_integrations',
'auto_cleanup_enabled',
'trial_banner_text',
'trial_banner_text_en',
]
labels = {
'is_trial_mode': gettext_lazy('Trial-Modus aktiv'),
'trial_started_at': gettext_lazy('Trial-Beginn'),
'trial_expires_at': gettext_lazy('Trial-Ende'),
'restrict_production_integrations': gettext_lazy('Produktive Integrationen begrenzen'),
'auto_cleanup_enabled': gettext_lazy('Cleanup nach Ablauf zulassen'),
'trial_banner_text': gettext_lazy('Banner-Text DE'),
'trial_banner_text_en': gettext_lazy('Banner-Text EN'),
}
widgets = {
'trial_started_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
'trial_expires_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in ('trial_started_at', 'trial_expires_at'):
field = self.fields[field_name]
if self.instance and getattr(self.instance, field_name):
field.initial = timezone.localtime(getattr(self.instance, field_name)).strftime('%Y-%m-%dT%H:%M')
field.input_formats = ['%Y-%m-%dT%H:%M']
def clean(self):
cleaned = super().clean()
started = cleaned.get('trial_started_at')
expires = cleaned.get('trial_expires_at')
if cleaned.get('is_trial_mode') and not expires:
self.add_error('trial_expires_at', _('Bitte ein Trial-Ende festlegen.'))
if started and expires and expires <= started:
self.add_error('trial_expires_at', _('Das Trial-Ende muss nach dem Trial-Beginn liegen.'))
return cleaned
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='',
)
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='Telefon-Direktwahl',
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)
self.email_domain = get_company_email_domain()
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'
self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain}
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)
add_custom_form_fields('onboarding', self, getattr(self.instance, 'custom_field_values', None))
def clean_work_email(self):
value = (self.cleaned_data.get('work_email') or '').strip().lower()
if not value:
return value
expected_suffix = f'@{self.email_domain}'
if self.email_domain and not value.endswith(expected_suffix):
raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain})
return value
def clean_signature_image(self):
image = self.cleaned_data.get('signature_image')
validate_signature_upload(image)
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'),
},
)
hidden_custom = hidden_custom_field_names('onboarding', cleaned)
for field_name in hidden_custom:
cleaned[field_name] = False if self.fields.get(field_name) and self.fields[field_name].widget.input_type == 'checkbox' else ''
self._errors.pop(field_name, None)
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
instance.custom_field_values = {
custom_field_key_from_name(name): self.cleaned_data.get(name)
for name in self.fields.keys()
if is_custom_field_name(name)
}
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.email_domain = get_company_email_domain()
self.fields['full_name'].label = 'Vorname und Nachname'
self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain}
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)
add_custom_form_fields('offboarding', self, getattr(self.instance, 'custom_field_values', None))
def clean_work_email(self):
value = (self.cleaned_data.get('work_email') or '').strip().lower()
if not value:
return value
expected_suffix = f'@{self.email_domain}'
if self.email_domain and not value.endswith(expected_suffix):
raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain})
return value
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.custom_field_values = {
custom_field_key_from_name(name): self.cleaned_data.get(name)
for name in self.fields.keys()
if is_custom_field_name(name)
}
if commit:
instance.save()
return instance