snapshot: modularize workflow views by domain

This commit is contained in:
Md Bayazid Bostame
2026-03-28 08:56:43 +01:00
parent b2686522c7
commit ee323106e9
9 changed files with 3385 additions and 2940 deletions

View File

@@ -0,0 +1,211 @@
from io import BytesIO
from django.contrib import messages
from django.contrib.auth import get_user_model, login as auth_login
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from .branding import get_branding_email_copy
from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm
from .models import UserProfile
from .roles import get_user_role_label
from .totp import build_otpauth_uri, generate_recovery_codes, generate_totp_secret
def login_page_impl(request):
if getattr(request.user, 'is_authenticated', False):
return redirect('home')
next_target = (request.POST.get('next') or request.GET.get('next') or '').strip()
form = AppLoginForm(request=request, data=request.POST or None)
if request.method == 'POST' and form.is_valid():
user = form.get_user()
profile, _ = UserProfile.objects.get_or_create(user=user)
safe_next = next_target if next_target.startswith('/') else ''
if profile.totp_enabled:
request.session['login_pending_user_id'] = user.pk
request.session['login_pending_backend'] = getattr(user, 'backend', '')
request.session['login_pending_next'] = safe_next
return redirect('login_totp')
auth_login(request, user, backend=getattr(user, 'backend', None))
now_ts = int(timezone.now().timestamp())
request.session['auth_fresh_ts'] = now_ts
request.session['last_activity_ts'] = now_ts
return redirect(safe_next or reverse('home'))
request.session.pop('login_pending_user_id', None)
request.session.pop('login_pending_backend', None)
request.session.pop('login_pending_next', None)
return render(
request,
'workflows/auth/login.html',
{
'form': form,
'next': next_target,
'login_step': 'password',
},
)
def login_totp_page_impl(request):
if getattr(request.user, 'is_authenticated', False):
return redirect('home')
pending_user_id = request.session.get('login_pending_user_id')
backend_path = (request.session.get('login_pending_backend') or '').strip()
next_target = (request.session.get('login_pending_next') or '').strip()
if not pending_user_id:
return redirect('login')
user = get_object_or_404(get_user_model(), pk=pending_user_id)
profile, _ = UserProfile.objects.get_or_create(user=user)
if not profile.totp_enabled:
request.session.pop('login_pending_user_id', None)
request.session.pop('login_pending_backend', None)
request.session.pop('login_pending_next', None)
return redirect('login')
show_recovery = request.method == 'POST' and bool((request.POST.get('recovery_code') or '').strip())
form = AppTOTPChallengeForm(data=request.POST or None, profile=profile)
if request.method == 'POST' and form.is_valid():
auth_login(request, user, backend=backend_path or 'django.contrib.auth.backends.ModelBackend')
request.session.pop('login_pending_user_id', None)
request.session.pop('login_pending_backend', None)
request.session.pop('login_pending_next', None)
now_ts = int(timezone.now().timestamp())
request.session['auth_fresh_ts'] = now_ts
request.session['last_activity_ts'] = now_ts
return redirect(next_target if next_target.startswith('/') else reverse('home'))
return render(
request,
'workflows/auth/login.html',
{
'form': form,
'next': next_target,
'login_step': 'totp',
'login_totp_user': user,
'show_recovery_code': show_recovery,
},
)
def account_profile_page_impl(request):
session_secret_key = 'account_totp_pending_secret'
session_codes_key = 'account_totp_recovery_codes'
profile, created = UserProfile.objects.get_or_create(user=request.user)
recovery_codes = request.session.pop(session_codes_key, [])
pending_totp_secret = request.session.get(session_secret_key) or ''
if profile.totp_enabled:
pending_totp_secret = ''
request.session.pop(session_secret_key, None)
elif not pending_totp_secret:
pending_totp_secret = generate_totp_secret()
request.session[session_secret_key] = pending_totp_secret
avatar_form = AccountAvatarForm(instance=profile)
details_form = AccountDetailsForm(user=request.user, profile=profile)
notification_preferences_form = AccountNotificationPreferencesForm(profile=profile, user=request.user)
totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret)
totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile)
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(user=request.user, profile=profile)
account_edit_open = False
notifications_edit_open = False
totp_edit_open = False
if request.method == 'POST':
form_kind = (request.POST.get('account_form') or '').strip()
if form_kind == 'avatar':
avatar_form = AccountAvatarForm(request.POST, request.FILES, instance=profile)
if avatar_form.is_valid():
avatar_form.save()
messages.success(request, _('Profilbild gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Profilbild konnte nicht gespeichert werden.'))
elif form_kind == 'details':
account_edit_open = True
details_form = AccountDetailsForm(request.POST, user=request.user, profile=profile)
if details_form.is_valid():
details_form.save()
messages.success(request, _('Profildaten gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Profildaten konnten nicht gespeichert werden.'))
elif form_kind == 'notification_preferences':
notifications_edit_open = True
notification_preferences_form = AccountNotificationPreferencesForm(request.POST, profile=profile, user=request.user)
if notification_preferences_form.is_valid():
notification_preferences_form.save()
messages.success(request, _('Benachrichtigungseinstellungen gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Benachrichtigungseinstellungen konnten nicht gespeichert werden.'))
elif form_kind == 'totp_enable':
totp_edit_open = True
totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret)
if totp_enable_form.is_valid():
recovery_codes = generate_recovery_codes()
profile.enable_totp(pending_totp_secret, recovery_codes)
request.session[session_codes_key] = recovery_codes
request.session.pop(session_secret_key, None)
messages.success(request, _('TOTP wurde aktiviert.'))
return redirect('account_profile_page')
messages.error(request, _('TOTP konnte nicht aktiviert werden.'))
elif form_kind == 'totp_disable':
totp_edit_open = True
totp_disable_form = AccountTOTPDisableForm(request.POST, user=request.user, profile=profile)
if totp_disable_form.is_valid():
profile.disable_totp()
request.session.pop(session_secret_key, None)
messages.success(request, _('TOTP wurde deaktiviert.'))
return redirect('account_profile_page')
messages.error(request, _('TOTP konnte nicht deaktiviert werden.'))
elif form_kind == 'totp_regenerate_codes':
totp_edit_open = True
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(request.POST, user=request.user, profile=profile)
if totp_regenerate_form.is_valid():
recovery_codes = generate_recovery_codes()
profile.set_recovery_codes(recovery_codes)
profile.save(update_fields=['totp_recovery_codes', 'updated_at'])
request.session[session_codes_key] = recovery_codes
messages.success(request, _('Recovery-Codes wurden neu erzeugt.'))
return redirect('account_profile_page')
messages.error(request, _('Recovery-Codes konnten nicht neu erzeugt werden.'))
branding_context = get_branding_email_copy()
totp_account_name = (request.user.email or request.user.username or '').strip()
totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip()
totp_otpauth_uri = '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer)
totp_qr_svg = ''
if totp_otpauth_uri:
try:
import qrcode
import qrcode.image.svg
qr_image = qrcode.make(totp_otpauth_uri, image_factory=qrcode.image.svg.SvgPathImage)
stream = BytesIO()
qr_image.save(stream)
totp_qr_svg = stream.getvalue().decode('utf-8')
except Exception:
totp_qr_svg = ''
return render(
request,
'workflows/account_profile.html',
{
'account_user': request.user,
'account_user_profile': profile,
'avatar_form': avatar_form,
'details_form': details_form,
'notification_preferences_form': notification_preferences_form,
'notification_preference_groups': notification_preferences_form.grouped_fields(),
'totp_enable_form': totp_enable_form,
'totp_disable_form': totp_disable_form,
'totp_regenerate_form': totp_regenerate_form,
'account_edit_open': account_edit_open,
'notifications_edit_open': notifications_edit_open,
'totp_edit_open': totp_edit_open,
'role_label': get_user_role_label(request.user),
'totp_pending_secret': pending_totp_secret,
'totp_otpauth_uri': totp_otpauth_uri,
'totp_qr_svg': totp_qr_svg,
'totp_recovery_codes': recovery_codes,
},
)

View File

@@ -0,0 +1,410 @@
from datetime import timedelta
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.translation import gettext as _
from .app_registry import get_portal_app_registry_rows, normalize_portal_app_sort_orders
from .branding import get_portal_trial_config, is_trial_expired
from .forms import PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile
from .notifications import notify_user
from .roles import ROLE_GROUP_NAMES, ROLE_PLATFORM_OWNER, get_user_role_key
def portal_app_registry_page_impl(request, *, translate_choice_list):
return render(
request,
'workflows/app_registry.html',
{
'rows': get_portal_app_registry_rows(),
'section_choices': translate_choice_list(PortalAppConfig.SECTION_CHOICES),
},
)
def save_portal_app_registry_impl(request, *, audit_fn):
rows = get_portal_app_registry_rows()
updated_configs = []
for row in rows:
config = row['config']
key = config.key
config.section = (request.POST.get(f'section__{key}') or config.section).strip()
if config.section not in dict(PortalAppConfig.SECTION_CHOICES):
config.section = row['default_section']
config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on'
config.visible_to_super_admin = request.POST.get(f'visible_to_super_admin__{key}') == 'on'
config.visible_to_admin = request.POST.get(f'visible_to_admin__{key}') == 'on'
config.visible_to_it_staff = request.POST.get(f'visible_to_it_staff__{key}') == 'on'
config.visible_to_staff = request.POST.get(f'visible_to_staff__{key}') == 'on'
try:
config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order'])
except ValueError:
config.sort_order = row['default_sort_order']
config.title_override = (request.POST.get(f'title_override__{key}') or '').strip()
config.title_override_en = (request.POST.get(f'title_override_en__{key}') or '').strip()
config.description_override = (request.POST.get(f'description_override__{key}') or '').strip()
config.description_override_en = (request.POST.get(f'description_override_en__{key}') or '').strip()
config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip()
config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip()
config.save()
updated_configs.append(config)
normalize_portal_app_sort_orders()
audit_fn(
request,
'portal_app_registry_saved',
target_type='portal_app_registry',
target_label='Portal App Registry',
details={'updated_apps': len(rows)},
)
messages.success(request, _('App-Registry gespeichert.'))
return redirect('portal_app_registry_page')
def user_management_page_impl(request, *, render_user_management_fn):
return render_user_management_fn(request)
def portal_branding_page_impl(request, *, build_branding_sections_fn):
branding, created = PortalBranding.objects.get_or_create(name='Default')
form = PortalBrandingForm(instance=branding)
return render(
request,
'workflows/branding_settings.html',
{
'form': form,
'branding': branding,
'branding_sections': build_branding_sections_fn(form, branding),
'editing_branding_section': '',
},
)
def save_portal_branding_impl(request, *, audit_fn, build_branding_sections_fn):
branding, created = PortalBranding.objects.get_or_create(name='Default')
section_key = (request.POST.get('section_key') or '').strip()
data = request.POST.copy()
for field_name in PortalBrandingForm.Meta.fields:
if field_name not in data:
field = PortalBranding._meta.get_field(field_name)
if getattr(field, 'many_to_many', False):
continue
if getattr(field, 'null', False) and getattr(branding, field_name, None) is None:
data[field_name] = ''
else:
data[field_name] = getattr(branding, field_name, '') or ''
form = PortalBrandingForm(data, request.FILES, instance=branding)
if not form.is_valid():
messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render(
request,
'workflows/branding_settings.html',
{
'form': form,
'branding': branding,
'branding_sections': build_branding_sections_fn(form, branding),
'editing_branding_section': section_key,
},
status=400,
)
branding = form.save()
audit_fn(
request,
'portal_branding_saved',
target_type='portal_branding',
target_id=branding.id,
target_label=branding.portal_title,
details={
'company_name': branding.company_name,
'support_email': branding.support_email,
'default_language': branding.default_language,
'has_custom_logo': bool(branding.logo_image),
'has_custom_letterhead': bool(branding.pdf_letterhead),
},
)
messages.success(request, _('Portal-Branding wurde gespeichert.'))
return render(
request,
'workflows/branding_settings.html',
{
'form': PortalBrandingForm(instance=branding),
'branding': branding,
'branding_sections': build_branding_sections_fn(PortalBrandingForm(instance=branding), branding),
'editing_branding_section': '',
},
)
def portal_company_config_page_impl(request, *, build_company_config_sections_fn):
company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default')
form = PortalCompanyConfigForm(instance=company_config)
return render(
request,
'workflows/company_config.html',
{
'form': form,
'company_config': company_config,
'company_config_sections': build_company_config_sections_fn(form, company_config),
'editing_company_section': '',
},
)
def save_portal_company_config_impl(request, *, audit_fn, build_company_config_sections_fn):
company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default')
section_key = (request.POST.get('section_key') or '').strip()
data = request.POST.copy()
for field_name in PortalCompanyConfigForm.Meta.fields:
if field_name not in data:
data[field_name] = getattr(company_config, field_name, '') or ''
form = PortalCompanyConfigForm(data, instance=company_config)
if not form.is_valid():
messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render(
request,
'workflows/company_config.html',
{
'form': form,
'company_config': company_config,
'company_config_sections': build_company_config_sections_fn(form, company_config),
'editing_company_section': section_key,
},
status=400,
)
company_config = form.save()
audit_fn(
request,
'portal_company_config_saved',
target_type='portal_company_config',
target_id=company_config.id,
target_label=company_config.legal_company_name or 'Default',
details={
'website_url': company_config.website_url,
'imprint_url': company_config.imprint_url,
'privacy_url': company_config.privacy_url,
'hr_contact_email': company_config.hr_contact_email,
'it_contact_email': company_config.it_contact_email,
'operations_contact_email': company_config.operations_contact_email,
},
)
messages.success(request, _('Firmenkonfiguration wurde gespeichert.'))
return render(
request,
'workflows/company_config.html',
{
'form': PortalCompanyConfigForm(instance=company_config),
'company_config': company_config,
'company_config_sections': build_company_config_sections_fn(PortalCompanyConfigForm(instance=company_config), company_config),
'editing_company_section': '',
},
)
def portal_trial_config_page_impl(request):
trial_config = get_portal_trial_config()
form = PortalTrialConfigForm(instance=trial_config)
return render(
request,
'workflows/trial_management.html',
{
'form': form,
'trial_config': trial_config,
'trial_is_expired': is_trial_expired(),
},
)
def save_portal_trial_config_impl(request, *, audit_fn):
trial_config = get_portal_trial_config()
form = PortalTrialConfigForm(request.POST, instance=trial_config)
if not form.is_valid():
messages.error(request, _('Trial-Konfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render(
request,
'workflows/trial_management.html',
{
'form': form,
'trial_config': trial_config,
'trial_is_expired': is_trial_expired(),
},
status=400,
)
trial_config = form.save()
audit_fn(
request,
'portal_trial_config_saved',
target_type='portal_trial_config',
target_id=trial_config.id,
target_label='Default',
details={
'is_trial_mode': trial_config.is_trial_mode,
'trial_started_at': trial_config.trial_started_at.isoformat() if trial_config.trial_started_at else '',
'trial_expires_at': trial_config.trial_expires_at.isoformat() if trial_config.trial_expires_at else '',
'restrict_production_integrations': trial_config.restrict_production_integrations,
'auto_cleanup_enabled': trial_config.auto_cleanup_enabled,
},
)
if trial_config.is_trial_mode and trial_config.trial_expires_at:
remaining = trial_config.trial_expires_at - timezone.now()
if remaining.total_seconds() <= 0:
notify_user(
user=request.user,
title=_('Trial ist abgelaufen'),
body=_('Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt blockiert.'),
level=UserNotification.LEVEL_WARNING,
link_url='/admin-tools/trial/',
event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS,
)
elif remaining <= timedelta(days=7):
notify_user(
user=request.user,
title=_('Trial läuft bald ab'),
body=_('Der Trial endet am %(date)s.') % {'date': timezone.localtime(trial_config.trial_expires_at).strftime('%d.%m.%Y %H:%M')},
level=UserNotification.LEVEL_WARNING,
link_url='/admin-tools/trial/',
event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS,
)
elif not trial_config.is_trial_mode:
notify_user(
user=request.user,
title=_('Trial-Modus deaktiviert'),
body=_('Der Trial-Modus wurde ausgeschaltet.'),
level=UserNotification.LEVEL_INFO,
link_url='/admin-tools/trial/',
event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS,
)
messages.success(request, _('Trial-Konfiguration wurde gespeichert.'))
return render(
request,
'workflows/trial_management.html',
{
'form': PortalTrialConfigForm(instance=trial_config),
'trial_config': trial_config,
'trial_is_expired': is_trial_expired(),
},
)
def create_user_from_admin_impl(request, *, render_user_management_fn, send_user_access_email_fn, audit_fn, display_user_name_fn):
form = UserManagementCreateForm(request.POST, include_product_owner=(get_user_role_key(request.user) == ROLE_PLATFORM_OWNER))
if not form.is_valid():
messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.'))
return render_user_management_fn(request, create_form=form, status_code=400)
user = form.save()
send_user_access_email_fn(request, user, invitation=True)
audit_fn(
request,
'user_created',
target_type='user',
target_id=user.id,
target_label=display_user_name_fn(user),
details={'username': user.username, 'role': get_user_role_key(user), 'invitation_sent': True},
)
messages.success(request, _('Benutzer wurde erstellt und eingeladen: %(username)s') % {'username': user.username})
return redirect('user_management_page')
def update_user_from_admin_impl(request, user_id: int, *, would_remove_last_platform_owner_fn, would_remove_last_super_admin_fn, audit_fn, display_user_name_fn):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
role_key = (request.POST.get('role_key') or '').strip()
is_active = request.POST.get('is_active') == 'on'
new_password = (request.POST.get('new_password') or '').strip()
if role_key not in ROLE_GROUP_NAMES:
messages.error(request, _('Ungültige Rolle.'))
return redirect('user_management_page')
if role_key == ROLE_PLATFORM_OWNER and get_user_role_key(request.user) != ROLE_PLATFORM_OWNER:
messages.error(request, _('Nur Platform Owner dürfen diese Rolle vergeben.'))
return redirect('user_management_page')
current_role = get_user_role_key(request.user)
if target_user == request.user and current_role == ROLE_PLATFORM_OWNER and (role_key != ROLE_PLATFORM_OWNER or not is_active):
messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst sperren oder herabstufen.'))
return redirect('user_management_page')
if target_user == request.user and current_role == ROLE_SUPER_ADMIN and (role_key != ROLE_SUPER_ADMIN or not is_active):
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.'))
return redirect('user_management_page')
if would_remove_last_platform_owner_fn(target_user, new_role_key=role_key, new_is_active=is_active):
messages.error(request, _('Der letzte aktive Platform Owner kann nicht deaktiviert oder herabgestuft werden.'))
return redirect('user_management_page')
if would_remove_last_super_admin_fn(target_user, new_role_key=role_key, new_is_active=is_active):
messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.'))
return redirect('user_management_page')
assign_user_role(target_user, role_key)
target_user.is_active = is_active
if new_password:
target_user.set_password(new_password)
target_user.save()
audit_fn(
request,
'user_updated',
target_type='user',
target_id=target_user.id,
target_label=display_user_name_fn(target_user),
details={'username': target_user.username, 'role': role_key, 'is_active': is_active, 'password_changed': bool(new_password)},
)
messages.success(request, _('Benutzer wurde aktualisiert: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')
def send_password_reset_from_admin_impl(request, user_id: int, *, send_user_access_email_fn, audit_fn, display_user_name_fn):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
try:
send_user_access_email_fn(request, target_user, invitation=False)
except ValueError as exc:
messages.error(request, str(exc))
return redirect('user_management_page')
audit_fn(
request,
'user_password_reset_sent',
target_type='user',
target_id=target_user.id,
target_label=display_user_name_fn(target_user),
details={'username': target_user.username, 'email': target_user.email},
)
messages.success(request, _('Passwort-Reset-Link wurde versendet: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')
def delete_user_from_admin_impl(request, user_id: int, *, would_remove_last_platform_owner_fn, would_remove_last_super_admin_fn, audit_fn, display_user_name_fn):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
current_role = get_user_role_key(request.user)
if target_user == request.user and current_role == ROLE_PLATFORM_OWNER:
messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst löschen.'))
return redirect('user_management_page')
if target_user == request.user:
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.'))
return redirect('user_management_page')
if would_remove_last_platform_owner_fn(target_user, deleting=True):
messages.error(request, _('Der letzte aktive Platform Owner kann nicht gelöscht werden.'))
return redirect('user_management_page')
if would_remove_last_super_admin_fn(target_user, deleting=True):
messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.'))
return redirect('user_management_page')
target_label = display_user_name_fn(target_user)
username = target_user.username
target_user.delete()
audit_fn(
request,
'user_deleted',
target_type='user',
target_label=target_label,
details={'username': username},
)
messages.success(request, _('Benutzer wurde gelöscht: %(username)s') % {'username': username})
return redirect('user_management_page')
def handbook_page_impl(request):
return render(request, 'workflows/handbook.html')
def project_wiki_page_impl(request):
return render(request, 'workflows/project_wiki.html')
def developer_handbook_page_impl(request):
return render(request, 'workflows/developer_handbook.html')
def release_checklist_page_impl(request):
return render(request, 'workflows/release_checklist.html')

View File

@@ -0,0 +1,893 @@
from django.contrib import messages
from django.db import IntegrityError
from django.shortcuts import redirect, render
from django.utils.translation import get_language, gettext as _
from .forms import OffboardingRequestForm, OnboardingRequestForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
FORM_PRESETS,
LOCKED_FIELD_RULES,
LOCKED_SECTION_RULES,
ONBOARDING_DEFAULT_PAGE,
apply_form_preset,
build_custom_field_key,
ensure_form_conditional_rule_configs,
ensure_form_field_configs,
ensure_form_section_configs,
get_custom_section_configs,
get_default_page_map,
get_section_definitions,
get_section_order,
)
from .models import (
FormConditionalRuleConfig,
FormCustomFieldConfig,
FormCustomSectionConfig,
FormFieldConfig,
FormOption,
)
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
import re
def form_builder_page_impl(
request,
*,
audit_fn,
translate_choice_list,
form_field_labels_fn,
field_rule_summary_fn,
conditional_rule_summary_fn,
onboarding_groups,
conditional_rule_operator_choices,
):
_audit = audit_fn
_translate_choice_list = translate_choice_list
_form_field_labels = form_field_labels_fn
_field_rule_summary = field_rule_summary_fn
_conditional_rule_summary = conditional_rule_summary_fn
ONBOARDING_GROUPS = onboarding_groups
CONDITIONAL_RULE_OPERATOR_CHOICES = conditional_rule_operator_choices
language_code = get_language()
form_type = request.GET.get('form_type', 'onboarding')
can_override_locked_builder_rules = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER
anchor = (request.GET.get('anchor') or '').strip()
active_panel = (request.GET.get('panel') or '').strip()
active_subpanel = (request.GET.get('subpanel') or '').strip()
active_rules_panel = (request.GET.get('rules_panel') or '').strip()
active_module = (request.GET.get('module') or '').strip()
active_structure_section = (request.GET.get('structure_section') or '').strip()
active_field_rules_section = ((request.POST.get('field_rules_section') if request.method == 'POST' else '') or request.GET.get('field_rules_section') or '').strip()
active_field_texts_section = ((request.POST.get('field_texts_section') if request.method == 'POST' else '') or request.GET.get('field_texts_section') or '').strip()
active_custom_fields_section = ((request.POST.get('custom_fields_section') if request.method == 'POST' else '') or request.GET.get('custom_fields_section') or '').strip()
active_section_rules_section = ((request.POST.get('section_rules_section') if request.method == 'POST' else '') or request.GET.get('section_rules_section') or '').strip()
active_conditional_target = ((request.POST.get('conditional_target') if request.method == 'POST' else '') or request.GET.get('conditional_target') or '').strip()
if form_type not in DEFAULT_FIELD_ORDER:
form_type = 'onboarding'
option_category = request.GET.get('option_category', 'department')
option_categories = [c[0] for c in FormOption.CATEGORY_CHOICES]
if option_category not in option_categories:
option_category = option_categories[0]
valid_modules = {
'structure',
'section-rules',
'field-rules',
'conditional-rules',
'options',
'field-texts',
'custom-sections',
'custom-fields',
'preview',
}
if not active_module:
if active_panel == 'builder-structure':
active_module = 'structure'
elif active_panel == 'builder-rules':
active_module = active_rules_panel or 'section-rules'
elif active_panel == 'builder-content':
active_module = active_subpanel or 'options'
else:
active_module = 'structure'
if active_module not in valid_modules:
active_module = 'structure'
if form_type != 'onboarding' and active_module == 'custom-sections':
active_module = 'options'
if form_type != 'onboarding' and active_module == 'conditional-rules':
active_module = 'field-rules'
if request.method == 'POST':
delete_option_id = request.POST.get('delete_option_id', '').strip()
delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip()
delete_custom_section_id = request.POST.get('delete_custom_section_id', '').strip()
if delete_option_id:
option = FormOption.objects.filter(id=delete_option_id).first()
if not option:
messages.error(request, _('Option nicht gefunden.'))
else:
option_category = option.category
deleted_label = option.label
deleted_id = option.id
option.delete()
_audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label)
messages.success(request, _('Option wurde gelöscht.'))
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=options")
if delete_custom_field_id:
custom_field = FormCustomFieldConfig.objects.filter(id=delete_custom_field_id, form_type=form_type).first()
if not custom_field:
messages.error(request, _('Benutzerdefiniertes Feld nicht gefunden.'))
else:
deleted_label = custom_field.label
deleted_id = custom_field.id
custom_field.delete()
_audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label)
messages.success(request, _('Benutzerdefiniertes Feld wurde gelöscht.'))
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-fields")
if delete_custom_section_id:
custom_section = FormCustomSectionConfig.objects.filter(id=delete_custom_section_id, form_type=form_type).first()
if not custom_section:
messages.error(request, _('Benutzerdefinierter Abschnitt nicht gefunden.'))
else:
deleted_label = custom_section.title
deleted_id = custom_section.id
section_key = custom_section.section_key
custom_fields = list(FormCustomFieldConfig.objects.filter(form_type=form_type, section_key=section_key))
deleted_field_count = len(custom_fields)
if custom_fields:
field_keys = [item.field_key for item in custom_fields]
FormConditionalRuleConfig.objects.filter(
form_type=form_type,
target_key__in=[f'custom__{field_key}' for field_key in field_keys],
).delete()
FormCustomFieldConfig.objects.filter(id__in=[item.id for item in custom_fields]).delete()
custom_section.delete()
_audit(
request,
'form_custom_section_deleted',
target_type='form_custom_section',
target_id=deleted_id,
target_label=deleted_label,
details={'section_key': section_key, 'deleted_field_count': deleted_field_count},
)
messages.success(request, _('Benutzerdefinierter Abschnitt wurde gelöscht.'))
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-sections")
action = request.POST.get('builder_action', '')
if action == 'add_option':
category = request.POST.get('category', '').strip()
label = request.POST.get('label', '').strip()
label_en = request.POST.get('label_en', '').strip()
value = request.POST.get('value', '').strip()
if category not in option_categories:
messages.error(request, _('Ungültige Kategorie.'))
elif not label:
messages.error(request, _('Bitte einen Namen für die Option angeben.'))
else:
next_sort = (
FormOption.objects.filter(category=category).order_by('-sort_order').values_list('sort_order', flat=True).first()
)
FormOption.objects.create(
# Global form option catalog entry
category=category,
label=label,
label_en=label_en,
value=value or label,
sort_order=(next_sort + 1) if next_sort is not None else 0,
is_active=True,
)
_audit(
request,
'form_option_added',
target_type='form_option',
target_label=label,
details={'category': category, 'label_en': label_en, 'value': value or label},
)
messages.success(request, _('Option wurde hinzugefügt.'))
option_category = category
elif action == 'save_options':
option_ids = request.POST.getlist('option_ids')
for pos, raw_id in enumerate(option_ids):
option = FormOption.objects.filter(id=raw_id).first()
if not option:
continue
next_label = request.POST.get(f'label_{option.id}', '').strip() or option.label
option.label = next_label
option.label_en = request.POST.get(f'label_en_{option.id}', '').strip()
option.value = request.POST.get(f'value_{option.id}', '').strip() or next_label
option.is_active = request.POST.get(f'active_{option.id}') == 'on'
option.sort_order = pos
try:
option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order'])
except IntegrityError:
messages.error(request, _('Doppelte Bezeichnung in Kategorie: %(label)s') % {'label': next_label})
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}&module=options")
option_category = option.category
_audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)})
messages.success(request, _('Optionen wurden gespeichert.'))
elif action == 'save_field_texts':
field_ids = request.POST.getlist('field_ids')
for raw_id in field_ids:
cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first()
if not cfg:
continue
cfg.label_override = (request.POST.get(f'label_override_{cfg.id}') or '').strip()
cfg.label_override_en = (request.POST.get(f'label_override_en_{cfg.id}') or '').strip()
cfg.help_text_override = (request.POST.get(f'help_text_override_{cfg.id}') or '').strip()
cfg.help_text_override_en = (request.POST.get(f'help_text_override_en_{cfg.id}') or '').strip()
cfg.save(update_fields=['label_override', 'label_override_en', 'help_text_override', 'help_text_override_en'])
_audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)})
messages.success(request, _('Feldtexte wurden gespeichert.'))
elif action == 'add_custom_section' and form_type == 'onboarding':
title = (request.POST.get('custom_section_title') or '').strip()
title_en = (request.POST.get('custom_section_title_en') or '').strip()
sort_order_raw = (request.POST.get('custom_section_sort_order') or '').strip()
if not title:
messages.error(request, _('Bitte einen Titel für den benutzerdefinierten Abschnitt angeben.'))
else:
section_key_base = build_custom_field_key(title)
section_key = section_key_base
suffix = 2
while FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).exists():
section_key = f'{section_key_base}_{suffix}'
suffix += 1
try:
sort_order = int(sort_order_raw or 0)
except ValueError:
sort_order = 0
FormCustomSectionConfig.objects.create(
form_type=form_type,
section_key=section_key,
sort_order=max(0, sort_order),
title=title,
title_en=title_en,
is_active=True,
)
_audit(request, 'form_custom_section_added', target_type='form_custom_section', target_label=title, details={'form_type': form_type, 'section_key': section_key})
messages.success(request, _('Benutzerdefinierter Abschnitt wurde hinzugefügt.'))
elif action == 'save_custom_sections' and form_type == 'onboarding':
section_ids = request.POST.getlist('custom_section_ids')
updated = 0
for raw_id in section_ids:
cfg = FormCustomSectionConfig.objects.filter(id=raw_id, form_type=form_type).first()
if not cfg:
continue
try:
sort_order = int((request.POST.get(f'custom_section_sort_order_{cfg.id}') or '').strip() or cfg.sort_order)
except ValueError:
sort_order = cfg.sort_order
cfg.title = (request.POST.get(f'custom_section_title_{cfg.id}') or '').strip() or cfg.title
cfg.title_en = (request.POST.get(f'custom_section_title_en_{cfg.id}') or '').strip()
cfg.is_active = request.POST.get(f'custom_section_is_active_{cfg.id}') == 'on'
cfg.sort_order = max(0, sort_order)
cfg.save(update_fields=['title', 'title_en', 'is_active', 'sort_order'])
updated += 1
_audit(request, 'form_custom_sections_saved', target_type='form_custom_section', target_label=form_type, details={'count': updated})
messages.success(request, _('Benutzerdefinierte Abschnitte wurden gespeichert.'))
elif action == 'add_custom_field':
label = (request.POST.get('custom_label') or '').strip()
label_en = (request.POST.get('custom_label_en') or '').strip()
section_key = (request.POST.get('custom_section_key') or '').strip()
field_type = (request.POST.get('custom_field_type') or '').strip()
sort_order_raw = (request.POST.get('custom_sort_order') or '').strip()
help_text = (request.POST.get('custom_help_text') or '').strip()
help_text_en = (request.POST.get('custom_help_text_en') or '').strip()
select_options = (request.POST.get('custom_select_options') or '').strip()
select_options_en = (request.POST.get('custom_select_options_en') or '').strip()
section_choices = {key for key in get_section_order(form_type)}
field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES}
if not label:
messages.error(request, _('Bitte eine Bezeichnung für das benutzerdefinierte Feld angeben.'))
elif section_key not in section_choices:
messages.error(request, _('Ungültiger Abschnitt für das benutzerdefinierte Feld.'))
elif field_type not in field_type_choices:
messages.error(request, _('Ungültiger Feldtyp.'))
elif field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not select_options:
messages.error(request, _('Auswahlfelder benötigen mindestens eine Option.'))
else:
field_key_base = build_custom_field_key(label)
field_key = field_key_base
suffix = 2
while FormCustomFieldConfig.objects.filter(form_type=form_type, field_key=field_key).exists():
field_key = f'{field_key_base}_{suffix}'
suffix += 1
try:
sort_order = int(sort_order_raw or 0)
except ValueError:
sort_order = 0
FormCustomFieldConfig.objects.create(
form_type=form_type,
field_key=field_key,
section_key=section_key,
sort_order=max(0, sort_order),
field_type=field_type,
is_active=True,
is_required=request.POST.get('custom_is_required') == 'on',
label=label,
label_en=label_en,
help_text=help_text,
help_text_en=help_text_en,
select_options=select_options,
select_options_en=select_options_en,
)
_audit(request, 'form_custom_field_added', target_type='form_custom_field', target_label=label, details={'form_type': form_type, 'field_type': field_type, 'section_key': section_key})
messages.success(request, _('Benutzerdefiniertes Feld wurde hinzugefügt.'))
elif action == 'save_custom_fields':
custom_ids = request.POST.getlist('custom_field_ids')
updated = 0
section_choices = {key for key in get_section_order(form_type)}
field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES}
for raw_id in custom_ids:
cfg = FormCustomFieldConfig.objects.filter(id=raw_id, form_type=form_type).first()
if not cfg:
continue
field_type = (request.POST.get(f'custom_field_type_{cfg.id}') or '').strip()
section_key = (request.POST.get(f'custom_section_key_{cfg.id}') or '').strip()
try:
sort_order = int((request.POST.get(f'custom_sort_order_{cfg.id}') or '').strip() or cfg.sort_order)
except ValueError:
sort_order = cfg.sort_order
cfg.label = (request.POST.get(f'custom_label_{cfg.id}') or '').strip() or cfg.label
cfg.label_en = (request.POST.get(f'custom_label_en_{cfg.id}') or '').strip()
cfg.help_text = (request.POST.get(f'custom_help_text_{cfg.id}') or '').strip()
cfg.help_text_en = (request.POST.get(f'custom_help_text_en_{cfg.id}') or '').strip()
cfg.is_required = request.POST.get(f'custom_is_required_{cfg.id}') == 'on'
cfg.is_active = request.POST.get(f'custom_is_active_{cfg.id}') == 'on'
if field_type in field_type_choices:
cfg.field_type = field_type
if section_key in section_choices:
cfg.section_key = section_key
cfg.sort_order = max(0, sort_order)
cfg.select_options = (request.POST.get(f'custom_select_options_{cfg.id}') or '').strip()
cfg.select_options_en = (request.POST.get(f'custom_select_options_en_{cfg.id}') or '').strip()
if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not cfg.select_options:
messages.error(request, _('Auswahlfeld "%(label)s" benötigt mindestens eine Option.') % {'label': cfg.label})
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-fields")
cfg.save()
updated += 1
_audit(request, 'form_custom_fields_saved', target_type='form_custom_field', target_label=form_type, details={'count': updated})
messages.success(request, _('Benutzerdefinierte Felder wurden gespeichert.'))
elif action == 'save_field_rules':
field_ids = request.POST.getlist('field_rule_ids')
locked_fields = LOCKED_FIELD_RULES.get(form_type, set())
updated = 0
for raw_id in field_ids:
cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first()
if not cfg:
continue
if cfg.field_name in locked_fields and not can_override_locked_builder_rules:
cfg.is_visible = True
cfg.is_required = None
else:
cfg.is_visible = request.POST.get(f'is_visible_{cfg.id}') == 'on'
required_mode = (request.POST.get(f'is_required_{cfg.id}') or '').strip()
cfg.is_required = True if required_mode == 'required' else False if required_mode == 'optional' else None
cfg.save(update_fields=['is_visible', 'is_required'])
updated += 1
_audit(request, 'form_field_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated})
messages.success(request, _('Feldregeln wurden gespeichert.'))
elif action == 'save_section_rules' and form_type in {'onboarding', 'offboarding'}:
section_configs = ensure_form_section_configs(form_type)
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
posted_order = request.POST.getlist('section_order')
next_sort_order = 0
updated = 0
for section_key in posted_order:
cfg = section_configs.get(section_key)
if cfg is not None:
if cfg.sort_order != next_sort_order:
cfg.sort_order = next_sort_order
cfg.save(update_fields=['sort_order'])
updated += 1
next_sort_order += 1
continue
if form_type == 'onboarding':
custom_cfg = FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).first()
if custom_cfg and custom_cfg.sort_order != next_sort_order:
custom_cfg.sort_order = next_sort_order
custom_cfg.save(update_fields=['sort_order'])
updated += 1
next_sort_order += 1
for section_key, cfg in section_configs.items():
if section_key in locked_sections and not can_override_locked_builder_rules:
if not cfg.is_visible:
cfg.is_visible = True
cfg.save(update_fields=['is_visible'])
continue
cfg.is_visible = request.POST.get(f'section_visible_{section_key}') == 'on'
cfg.save(update_fields=['is_visible'])
updated += 1
if form_type == 'onboarding':
for cfg in FormCustomSectionConfig.objects.filter(form_type=form_type):
cfg.is_active = request.POST.get(f'section_visible_{cfg.section_key}') == 'on'
cfg.save(update_fields=['is_active'])
updated += 1
_audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated})
messages.success(request, _('Abschnittsregeln wurden gespeichert.'))
elif action == 'save_conditional_rules' and form_type == 'onboarding':
rule_configs = ensure_form_conditional_rule_configs(form_type)
updated = 0
for target_key, cfg in rule_configs.items():
cfg.is_active = request.POST.get(f'conditional_active_{target_key}') == 'on'
clauses = []
clause_total = 2
for index in range(clause_total):
field_name = (request.POST.get(f'conditional_field_{target_key}_{index}') or '').strip()
operator = (request.POST.get(f'conditional_operator_{target_key}_{index}') or '').strip()
value = (request.POST.get(f'conditional_value_{target_key}_{index}') or '').strip()
if not field_name or not operator:
continue
parsed_value = True if operator == 'checked' else value
clauses.append({'field': field_name, 'operator': operator, 'value': parsed_value})
cfg.clauses = clauses
cfg.save(update_fields=['is_active', 'clauses'])
updated += 1
_audit(request, 'form_conditional_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated})
messages.success(request, _('Bedingte Logik wurde gespeichert.'))
elif action == 'apply_preset':
preset_key = (request.POST.get('preset_key') or '').strip()
if apply_form_preset(form_type, preset_key):
active_module = 'preview'
_audit(request, 'form_preset_applied', target_type='form_config', target_label=form_type, details={'preset': preset_key})
messages.success(request, _('Preset wurde angewendet.'))
else:
messages.error(request, _('Preset konnte nicht angewendet werden.'))
if action in {'add_option', 'save_options'}:
active_module = 'options'
elif action == 'save_field_texts':
active_module = 'field-texts'
elif action in {'add_custom_field', 'save_custom_fields'}:
active_module = 'custom-fields'
elif action in {'add_custom_section', 'save_custom_sections'}:
active_module = 'custom-sections'
elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}:
active_module = 'section-rules'
if action == 'save_section_rules':
active_module = 'section-rules'
elif action == 'save_field_rules':
active_module = 'field-rules'
elif action == 'save_conditional_rules':
active_module = 'conditional-rules'
redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}"
if active_module:
redirect_target += f"&module={active_module}"
if active_structure_section:
redirect_target += f"&structure_section={active_structure_section}"
if active_section_rules_section and active_module == 'section-rules':
redirect_target += f"&section_rules_section={active_section_rules_section}"
if active_field_rules_section and active_module == 'field-rules':
redirect_target += f"&field_rules_section={active_field_rules_section}"
if active_conditional_target and active_module == 'conditional-rules':
redirect_target += f"&conditional_target={active_conditional_target}"
if active_field_texts_section and active_module == 'field-texts':
redirect_target += f"&field_texts_section={active_field_texts_section}"
if active_custom_fields_section and active_module == 'custom-fields':
redirect_target += f"&custom_fields_section={active_custom_fields_section}"
return redirect(redirect_target)
default_names = list(DEFAULT_FIELD_ORDER.get(form_type, []))
existing_names = list(
OnboardingRequestForm.base_fields.keys()
if form_type == 'onboarding'
else OffboardingRequestForm.base_fields.keys()
)
for name in existing_names:
if name not in default_names:
default_names.append(name)
ensure_form_field_configs(form_type, default_names)
section_configs = ensure_form_section_configs(form_type)
conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {}
section_definitions = get_section_definitions(form_type, include_inactive_custom=True)
section_order = [item['key'] for item in section_definitions]
section_labels = {item['key']: item['title'] for item in section_definitions}
default_page_map = get_default_page_map(form_type)
configs = list(
FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name')
)
labels = _form_field_labels(form_type)
locked = LOCKED_FIELD_RULES.get(form_type, set())
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key'))
custom_section_configs = get_custom_section_configs(form_type, include_inactive=True)
if form_type == 'onboarding':
columns = [
{
'key': key,
'title': section_labels.get(key, key),
'items': [],
}
for key in section_order
]
column_by_key = {c['key']: c for c in columns}
fallback = 'abschluss'
for cfg in configs:
page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(cfg.field_name, fallback)
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': cfg.field_name,
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
'label_en': cfg.label_override_en,
'is_visible': cfg.is_visible,
'is_required': cfg.is_required,
'locked': cfg.field_name in locked,
'page_key': page_key,
'is_custom': False,
'sort_order': cfg.sort_order,
}
)
for cfg in custom_field_configs:
page_key = cfg.section_key or fallback
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': f'custom__{cfg.field_key}',
'label': cfg.translated_label(language_code),
'label_de': cfg.label,
'label_en': cfg.label_en,
'is_visible': cfg.is_active,
'is_required': cfg.is_required,
'locked': False,
'page_key': page_key,
'is_custom': True,
'sort_order': cfg.sort_order,
}
)
for column in columns:
column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name']))
else:
columns = [
{
'key': key,
'title': section_labels.get(key, key),
'items': [],
}
for key in section_order
]
column_by_key = {c['key']: c for c in columns}
fallback = section_order[-1] if section_order else 'all'
for cfg in configs:
page_key = cfg.page_key or default_page_map.get(cfg.field_name, fallback)
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': cfg.field_name,
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
'label_en': cfg.label_override_en,
'is_visible': cfg.is_visible,
'is_required': cfg.is_required,
'locked': cfg.field_name in locked,
'page_key': page_key,
'is_custom': False,
'sort_order': cfg.sort_order,
}
)
for cfg in custom_field_configs:
page_key = cfg.section_key or fallback
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': f'custom__{cfg.field_key}',
'label': cfg.translated_label(language_code),
'label_de': cfg.label,
'label_en': cfg.label_en,
'is_visible': cfg.is_active,
'is_required': cfg.is_required,
'locked': False,
'page_key': page_key,
'is_custom': True,
'sort_order': cfg.sort_order,
}
)
for column in columns:
column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name']))
section_rule_items = []
if section_order:
if active_structure_section not in section_order:
active_structure_section = section_order[0]
fallback_section = section_order[-1] if section_order else ''
custom_section_map = {cfg.section_key: cfg for cfg in custom_section_configs}
for key in section_order:
cfg = section_configs.get(key)
custom_cfg = custom_section_map.get(key)
is_custom = custom_cfg is not None
raw_title = str(section_labels.get(key, key))
display_title = re.sub(r'^\d+\.\s*', '', raw_title) if not is_custom else raw_title
section_rule_items.append(
{
'key': key,
'title': raw_title,
'display_title': display_title,
'is_visible': bool(custom_cfg.is_active) if is_custom else (True if not cfg else cfg.is_visible),
'locked': False if is_custom else (key in locked_sections and not can_override_locked_builder_rules),
'is_custom': is_custom,
'sort_order': custom_cfg.sort_order if is_custom else (cfg.sort_order if cfg else 0),
'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]) + len([c for c in custom_field_configs if c.section_key == key]),
}
)
section_rule_keys = [item['key'] for item in section_rule_items]
if section_rule_keys and active_section_rules_section not in section_rule_keys:
active_section_rules_section = section_rule_keys[0]
field_rule_items = []
for cfg in configs:
page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '')
field_rule_items.append(
{
'id': cfg.id,
'field_name': cfg.field_name,
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
'page_key': page_key,
'page_label': section_labels.get(page_key, page_key) if section_order else '',
'is_visible': cfg.is_visible,
'is_required': cfg.is_required,
'locked': cfg.field_name in locked and not can_override_locked_builder_rules,
'summary': _field_rule_summary(
is_visible=cfg.is_visible,
is_required=cfg.is_required,
locked=cfg.field_name in locked and not can_override_locked_builder_rules,
),
}
)
custom_field_groups = []
if section_order:
grouped_custom = {key: [] for key in section_order}
for cfg in custom_field_configs:
grouped_custom.setdefault(cfg.section_key, []).append(cfg)
for key in section_order:
custom_field_groups.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': grouped_custom.get(key, []),
}
)
custom_section_field_counts: dict[str, int] = {}
for cfg in custom_field_configs:
custom_section_field_counts[cfg.section_key] = custom_section_field_counts.get(cfg.section_key, 0) + 1
for cfg in custom_section_configs:
cfg.custom_field_count = custom_section_field_counts.get(cfg.section_key, 0)
field_rule_groups = []
if section_order:
grouped_rules = {key: [] for key in section_order}
for item in field_rule_items:
grouped_rules.setdefault(item['page_key'], []).append(item)
for key in section_order:
field_rule_groups.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': grouped_rules.get(key, []),
}
)
field_rule_group_keys = [group['key'] for group in field_rule_groups]
if field_rule_group_keys and active_field_rules_section not in field_rule_group_keys:
active_field_rules_section = field_rule_group_keys[0]
field_text_groups = []
if section_order:
grouped_texts = {key: [] for key in section_order}
for cfg in configs:
page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '')
grouped_texts.setdefault(page_key, []).append(cfg)
for key in section_order:
field_text_groups.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': grouped_texts.get(key, []),
}
)
field_text_group_keys = [group['key'] for group in field_text_groups]
if field_text_group_keys and active_field_texts_section not in field_text_group_keys:
active_field_texts_section = field_text_group_keys[0]
custom_field_group_keys = [group['key'] for group in custom_field_groups]
if custom_field_group_keys and active_custom_fields_section not in custom_field_group_keys:
active_custom_fields_section = custom_field_group_keys[0]
conditional_rule_items = []
if form_type == 'onboarding':
conditional_field_choices = []
for field_name in [
'order_business_cards',
'employment_type',
'group_mailboxes_required_choice',
'additional_hardware_needed_choice',
'additional_software_needed_choice',
'additional_access_needed_choice',
'successor_required_choice',
'inherit_phone_number_choice',
]:
conditional_field_choices.append((field_name, labels.get(field_name, field_name)))
for cfg in custom_field_configs:
conditional_field_choices.append((f'custom__{cfg.field_key}', cfg.translated_label(language_code)))
conditional_field_label_map = {value: label for value, label in conditional_field_choices}
conditional_target_titles = {
'business-card-box': _('Visitenkarten-Details'),
'employment-end-box': _('Vertragsende'),
'group-mailboxes-box': _('Gruppenpostfächer'),
'extra-hardware-box': _('Zusätzliche Hardware'),
'extra-software-box': _('Zusätzliche Software'),
'extra-access-box': _('Zusätzliche Zugänge'),
'successor-box': _('Nachfolge'),
}
conditional_target_descriptions = {
'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'),
'employment-end-box': _('Steuert das Enddatum bei befristeter Beschäftigung.'),
'group-mailboxes-box': _('Steuert das Freitextfeld für Gruppenpostfächer.'),
'extra-hardware-box': _('Steuert zusätzliche Hardware-Felder.'),
'extra-software-box': _('Steuert zusätzliche Software-Felder.'),
'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'),
'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'),
}
for target_key, cfg in conditional_rule_configs.items():
clauses = list(cfg.clauses or [])
while len(clauses) < 2:
clauses.append({'field': '', 'operator': 'equals', 'value': ''})
if target_key.startswith('custom__'):
custom_field_key = target_key.replace('custom__', '', 1)
custom_field = next((item for item in custom_field_configs if item.field_key == custom_field_key), None)
target_title = custom_field.translated_label(language_code) if custom_field else target_key
target_description = _('Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes.')
target_fields = [target_title]
else:
target_title = conditional_target_titles.get(target_key, target_key)
target_description = conditional_target_descriptions.get(target_key, '')
target_fields = [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])]
conditional_rule_items.append(
{
'target_key': target_key,
'title': target_title,
'description': target_description,
'is_active': cfg.is_active,
'clauses': clauses[:2],
'summary': _conditional_rule_summary(clauses[:2], conditional_field_label_map),
'field_choices': conditional_field_choices,
'operator_choices': CONDITIONAL_RULE_OPERATOR_CHOICES,
'target_fields': target_fields,
}
)
conditional_rule_keys = [item['target_key'] for item in conditional_rule_items]
if conditional_rule_keys and active_conditional_target not in conditional_rule_keys:
active_conditional_target = conditional_rule_keys[0]
preview_sections = []
if section_order:
field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups}
for key in section_order:
section_cfg = section_configs.get(key)
section_locked = key in locked_sections
custom_section = next((cfg for cfg in custom_section_configs if cfg.section_key == key), None)
section_visible = bool(custom_section.is_active) if custom_section else (True if section_locked or not section_cfg else section_cfg.is_visible)
visible_items = [
item for item in field_rule_group_map.get(key, [])
if item['locked'] or item['is_visible']
]
visible_items.extend(
[
{
'label': cfg.translated_label(language_code),
'locked': False,
}
for cfg in custom_field_configs
if cfg.section_key == key and cfg.is_active
]
)
if section_visible:
preview_sections.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': visible_items,
}
)
locked_field_count = len([item for item in field_rule_items if item['locked']])
hidden_field_count = len([item for item in field_rule_items if not item['is_visible']])
configurable_field_count = len(field_rule_items) - locked_field_count
hidden_section_count = len([item for item in section_rule_items if not item['is_visible']]) if section_rule_items else 0
builder_summary = {
'locked_field_count': locked_field_count,
'configurable_field_count': configurable_field_count,
'hidden_field_count': hidden_field_count,
'hidden_section_count': hidden_section_count,
'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]),
'custom_section_count': len([cfg for cfg in custom_section_configs if cfg.is_active]),
}
module_labels = {
'structure': _('Struktur & Reihenfolge'),
'section-rules': _('Abschnitte'),
'field-rules': _('Feldregeln'),
'conditional-rules': _('Bedingte Logik'),
'options': _('Optionen'),
'field-texts': _('Feldtexte'),
'custom-sections': _('Eigene Abschnitte'),
'custom-fields': _('Eigene Felder'),
'preview': _('Vorschau'),
}
option_category_labels = dict(_translate_choice_list(FormOption.CATEGORY_CHOICES))
form_type_labels = {
'onboarding': _('Onboarding'),
'offboarding': _('Offboarding'),
}
active_focus_label = ''
if active_module == 'structure' and active_structure_section:
active_focus_label = section_labels.get(active_structure_section, active_structure_section)
elif active_module == 'section-rules' and section_rule_items:
active_focus_label = _('Alle Abschnitte')
elif active_module == 'field-rules' and active_field_rules_section:
active_focus_label = section_labels.get(active_field_rules_section, active_field_rules_section)
elif active_module == 'conditional-rules' and active_conditional_target:
active_focus_label = next((item['title'] for item in conditional_rule_items if item['target_key'] == active_conditional_target), active_conditional_target)
elif active_module == 'options':
active_focus_label = option_category_labels.get(option_category, option_category)
elif active_module == 'field-texts' and active_field_texts_section:
active_focus_label = section_labels.get(active_field_texts_section, active_field_texts_section)
elif active_module == 'custom-sections':
active_focus_label = _('Onboarding')
elif active_module == 'custom-fields' and active_custom_fields_section:
active_focus_label = section_labels.get(active_custom_fields_section, active_custom_fields_section)
elif active_module == 'preview':
active_focus_label = _('Live-Vorschau')
return render(
request,
'workflows/form_builder.html',
{
'form_type': form_type,
'columns': columns,
'form_types': [('onboarding', _('Onboarding')), ('offboarding', _('Offboarding'))],
'option_categories': _translate_choice_list(FormOption.CATEGORY_CHOICES),
'selected_option_category': option_category,
'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'),
'field_text_items': configs,
'field_rule_items': field_rule_items,
'field_rule_groups': field_rule_groups,
'field_text_groups': field_text_groups,
'preview_sections': preview_sections,
'section_rule_items': section_rule_items,
'builder_summary': builder_summary,
'conditional_rule_items': conditional_rule_items,
'custom_field_groups': custom_field_groups,
'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES),
'custom_section_items': custom_section_configs,
'active_panel': active_panel,
'active_subpanel': active_subpanel,
'active_rules_panel': active_rules_panel,
'active_module': active_module,
'active_form_type_label': form_type_labels.get(form_type, form_type),
'active_module_label': module_labels.get(active_module, active_module),
'active_focus_label': active_focus_label,
'active_structure_section': active_structure_section,
'active_field_rules_section': active_field_rules_section,
'active_field_texts_section': active_field_texts_section,
'active_custom_fields_section': active_custom_fields_section,
'active_section_rules_section': active_section_rules_section,
'active_conditional_target': active_conditional_target,
'available_presets': FORM_PRESETS.get(form_type, {}),
'can_override_locked_builder_rules': can_override_locked_builder_rules,
},
)

View File

@@ -0,0 +1,783 @@
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
from celery import current_app
from django.conf import settings
from django.contrib import messages
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from django.utils.translation import gettext as _
from .branding import get_default_notification_templates
from .form_builder import DEFAULT_FIELD_ORDER, get_default_page_map, get_section_order
from .models import FormCustomFieldConfig, FormFieldConfig, NotificationRule, NotificationTemplate, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig
from .notifications import notify_user
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import send_scheduled_welcome_email
from .emailing import send_system_email
def integrations_setup_page_impl(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
kind = (request.GET.get('kind') or 'nextcloud').strip().lower()
if kind not in {'nextcloud', 'mail', 'emails', 'rules', 'backup'}:
kind = 'nextcloud'
templates = list(NotificationTemplate.objects.all().order_by('key'))
system_email_config = (
SystemEmailConfig.objects.filter(is_active=True).order_by('-updated_at').first()
or SystemEmailConfig.objects.filter(name='Default SMTP').first()
)
return render(
request,
'workflows/integrations_setup.html',
{
'workflow_config': config,
'system_email_config': system_email_config,
'nextcloud_enabled': is_nextcloud_enabled(),
'email_test_mode': is_email_test_mode(),
'kind': kind,
'templates': templates,
'notification_rules': NotificationRule.objects.all().order_by('event_type', 'sort_order', 'id'),
'rule_event_choices': NotificationRule.EVENT_CHOICES,
'rule_operator_choices': NotificationRule.OPERATOR_CHOICES,
'template_choices': NotificationTemplate.TEMPLATE_CHOICES,
'remote_backup_target_choices': WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES,
},
)
def welcome_emails_page_impl(request):
rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200]
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
welcome_template = NotificationTemplate.objects.filter(key='onboarding_welcome').first()
default_welcome = get_default_notification_templates().get('onboarding_welcome', {})
default_subject = (default_welcome.get('subject') or '').strip()
default_body = (default_welcome.get('body') or '').strip()
default_subject_en = (default_welcome.get('subject_en') or '').strip()
default_body_en = (default_welcome.get('body_en') or '').strip()
subject_value = (welcome_template.subject_template if welcome_template else '').strip() or default_subject
body_value = (welcome_template.body_template if welcome_template else '').strip() or default_body
subject_value_en = (welcome_template.subject_template_en if welcome_template else '').strip() or default_subject_en
body_value_en = (welcome_template.body_template_en if welcome_template else '').strip() or default_body_en
return render(
request,
'workflows/welcome_emails.html',
{
'rows': rows,
'workflow_config': config,
'welcome_template': welcome_template,
'welcome_subject_value': subject_value,
'welcome_body_value': body_value,
'welcome_subject_value_en': subject_value_en,
'welcome_body_value_en': body_value_en,
'welcome_keywords': ['{{ FULL_NAME }}', '{{ VORNAME }}', '{{ NACHNAME }}', '{{ DEPARTMENT }}', '{{ CONTRACT_START }}', '{{ EMAIL }}', '{{ REQUESTED_BY }}'],
},
)
def trigger_welcome_email_now_impl(request, schedule_id: int, *, audit_fn):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
if not scheduled:
messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.')
return redirect('welcome_emails_page')
if scheduled.status == 'cancelled':
messages.error(request, f'Welcome E-Mail #{schedule_id} ist abgebrochen und kann nicht gesendet werden.')
return redirect('welcome_emails_page')
async_result = send_scheduled_welcome_email.delay(scheduled.id, True)
scheduled.celery_task_id = async_result.id or scheduled.celery_task_id
scheduled.status = 'scheduled'
scheduled.last_error = ''
scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at'])
audit_fn(request, 'welcome_email_triggered_now', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde sofort angestoßen.')
return redirect('welcome_emails_page')
def save_welcome_email_settings_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
try:
delay_days = int(request.POST.get('welcome_email_delay_days', config.welcome_email_delay_days or 5))
except ValueError:
messages.error(request, 'Ungültige Zahl bei der Welcome-Verzögerung.')
return redirect('welcome_emails_page')
config.welcome_email_delay_days = max(0, delay_days)
config.welcome_sender_email = request.POST.get('welcome_sender_email', '').strip()
config.welcome_include_pdf = request.POST.get('welcome_include_pdf') == 'on'
config.save(update_fields=['welcome_email_delay_days', 'welcome_sender_email', 'welcome_include_pdf'])
subject = request.POST.get('welcome_subject')
body = request.POST.get('welcome_body')
subject_en = request.POST.get('welcome_subject_en')
body_en = request.POST.get('welcome_body_en')
if subject is not None or body is not None or subject_en is not None or body_en is not None:
default_welcome = get_default_notification_templates().get('onboarding_welcome', {})
default_subject = (default_welcome.get('subject') or '').strip()
default_body = (default_welcome.get('body') or '').strip()
default_subject_en = (default_welcome.get('subject_en') or '').strip()
default_body_en = (default_welcome.get('body_en') or '').strip()
subject_clean = (subject or '').strip() or default_subject
body_clean = (body or '').strip() or default_body
subject_clean_en = (subject_en or '').strip() or default_subject_en
body_clean_en = (body_en or '').strip() or default_body_en
template, _ = NotificationTemplate.objects.get_or_create(
key='onboarding_welcome',
defaults={
'subject_template': subject_clean,
'body_template': body_clean,
'subject_template_en': subject_clean_en,
'body_template_en': body_clean_en,
'is_active': True,
},
)
changes = []
if template.subject_template != subject_clean:
template.subject_template = subject_clean
changes.append('subject_template')
if template.body_template != body_clean:
template.body_template = body_clean
changes.append('body_template')
if template.subject_template_en != subject_clean_en:
template.subject_template_en = subject_clean_en
changes.append('subject_template_en')
if template.body_template_en != body_clean_en:
template.body_template_en = body_clean_en
changes.append('body_template_en')
if not template.is_active:
template.is_active = True
changes.append('is_active')
if changes:
template.save(update_fields=changes)
audit_fn(
request,
'welcome_email_settings_saved',
target_type='welcome_email_settings',
target_label='onboarding_welcome',
details={
'delay_days': config.welcome_email_delay_days,
'sender_email': config.welcome_sender_email,
'include_pdf': config.welcome_include_pdf,
},
)
messages.success(request, 'Welcome-E-Mail Einstellungen wurden gespeichert.')
return redirect('welcome_emails_page')
def _revoke_celery_task(task_id: str) -> None:
if not task_id:
return
try:
current_app.control.revoke(task_id, terminate=False)
except Exception:
return
def _parse_selected_schedule_ids(raw: str) -> list[int]:
if not raw:
return []
parsed: list[int] = []
seen: set[int] = set()
for token in raw.split(','):
token = token.strip()
if not token:
continue
try:
schedule_id = int(token)
except ValueError:
continue
if schedule_id in seen:
continue
seen.add(schedule_id)
parsed.append(schedule_id)
return parsed
def bulk_welcome_email_action_impl(request, *, audit_fn):
action = (request.POST.get('bulk_action') or '').strip().lower()
selected_ids = _parse_selected_schedule_ids(request.POST.get('selected_ids', ''))
if action not in {'pause', 'send_now', 'delete'}:
messages.error(request, 'Ungültige Bulk-Aktion.')
return redirect('welcome_emails_page')
if not selected_ids:
messages.warning(request, 'Keine Welcome-Einträge ausgewählt.')
return redirect('welcome_emails_page')
rows = list(ScheduledWelcomeEmail.objects.filter(id__in=selected_ids).order_by('id'))
if not rows:
messages.warning(request, 'Keine passenden Welcome-Einträge gefunden.')
return redirect('welcome_emails_page')
success_count = 0
skipped_count = 0
for scheduled in rows:
if action == 'pause':
if scheduled.status in {'sent', 'cancelled'}:
skipped_count += 1
continue
_revoke_celery_task(scheduled.celery_task_id)
scheduled.status = 'paused'
scheduled.save(update_fields=['status', 'updated_at'])
success_count += 1
continue
if action == 'send_now':
if scheduled.status == 'cancelled':
skipped_count += 1
continue
async_result = send_scheduled_welcome_email.delay(scheduled.id, True)
scheduled.celery_task_id = async_result.id or scheduled.celery_task_id
scheduled.status = 'scheduled'
scheduled.last_error = ''
scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at'])
success_count += 1
continue
if action == 'delete':
if scheduled.status == 'scheduled':
_revoke_celery_task(scheduled.celery_task_id)
scheduled.delete()
success_count += 1
action_label = {
'pause': 'pausiert',
'send_now': 'sofort angestoßen',
'delete': 'gelöscht',
}[action]
if success_count:
audit_fn(
request,
'welcome_email_bulk_action',
target_type='welcome_email',
target_label=action,
details={'selected_ids': selected_ids, 'success_count': success_count, 'skipped_count': skipped_count},
)
messages.success(request, f'{success_count} Welcome-Eintrag/Einträge {action_label}.')
if skipped_count:
messages.warning(request, f'{skipped_count} Eintrag/Einträge wurden übersprungen (Status nicht geeignet).')
return redirect('welcome_emails_page')
def pause_welcome_email_impl(request, schedule_id: int, *, audit_fn):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
if not scheduled:
messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.')
return redirect('welcome_emails_page')
if scheduled.status in {'sent', 'cancelled'}:
messages.error(request, f'Welcome E-Mail #{schedule_id} kann nicht pausiert werden.')
return redirect('welcome_emails_page')
_revoke_celery_task(scheduled.celery_task_id)
scheduled.status = 'paused'
scheduled.save(update_fields=['status', 'updated_at'])
audit_fn(request, 'welcome_email_paused', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde pausiert.')
return redirect('welcome_emails_page')
def resume_welcome_email_impl(request, schedule_id: int, *, audit_fn):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
if not scheduled:
messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.')
return redirect('welcome_emails_page')
if scheduled.status != 'paused':
messages.error(request, f'Welcome E-Mail #{schedule_id} ist nicht pausiert.')
return redirect('welcome_emails_page')
eta = scheduled.send_at if timezone.now() < scheduled.send_at else None
async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=eta)
scheduled.celery_task_id = async_result.id or scheduled.celery_task_id
scheduled.status = 'scheduled'
scheduled.last_error = ''
scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at'])
audit_fn(request, 'welcome_email_resumed', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde fortgesetzt.')
return redirect('welcome_emails_page')
def cancel_welcome_email_impl(request, schedule_id: int, *, audit_fn):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
if not scheduled:
messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.')
return redirect('welcome_emails_page')
if scheduled.status == 'sent':
messages.error(request, f'Welcome E-Mail #{schedule_id} wurde bereits gesendet.')
return redirect('welcome_emails_page')
_revoke_celery_task(scheduled.celery_task_id)
scheduled.status = 'cancelled'
scheduled.last_error = ''
scheduled.save(update_fields=['status', 'last_error', 'updated_at'])
audit_fn(request, 'welcome_email_cancelled', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email)
messages.success(request, f'Welcome E-Mail #{schedule_id} wurde abgebrochen.')
return redirect('welcome_emails_page')
def form_builder_save_order_impl(request, *, audit_fn):
try:
payload = json.loads(request.body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({'ok': False, 'error': _('Ungültige JSON-Daten.')}, status=400)
form_type = payload.get('form_type')
if form_type not in DEFAULT_FIELD_ORDER:
return JsonResponse({'ok': False, 'error': _('Ungültiger Formulartyp.')}, status=400)
default_page_map = get_default_page_map(form_type)
columns = payload.get('columns')
if not isinstance(columns, dict):
return JsonResponse({'ok': False, 'error': _('Spalten-Daten fehlen.')}, status=400)
configs = list(FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name'))
custom_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_key'))
allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs}
seen = set()
allowed_columns = get_section_order(form_type)
fallback_section = allowed_columns[-1] if allowed_columns else ''
name_to_cfg = {cfg.field_name: cfg for cfg in configs}
custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs}
sort_order = 0
for column_key in allowed_columns:
names = columns.get(column_key, [])
if not isinstance(names, list):
return JsonResponse({'ok': False, 'error': _('Ungültige Spalte: %(column)s') % {'column': column_key}}, status=400)
for name in names:
if not isinstance(name, str):
continue
if name not in allowed_names or name in seen:
continue
seen.add(name)
if name in name_to_cfg:
cfg = name_to_cfg[name]
cfg.sort_order = sort_order
cfg.page_key = column_key
else:
cfg = custom_name_to_cfg[name]
cfg.sort_order = sort_order
cfg.section_key = column_key
sort_order += 1
missing = [cfg.field_name for cfg in configs if cfg.field_name not in seen]
for name in missing:
cfg = name_to_cfg[name]
cfg.sort_order = sort_order
sort_order += 1
cfg.page_key = cfg.page_key or default_page_map.get(name, fallback_section)
missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen]
for name in missing_custom:
cfg = custom_name_to_cfg[name]
cfg.sort_order = sort_order
sort_order += 1
cfg.section_key = cfg.section_key or fallback_section
FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key'])
if custom_configs:
FormCustomFieldConfig.objects.bulk_update(custom_configs, ['sort_order', 'section_key'])
saved_count = len(configs) + len(custom_configs)
audit_fn(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': saved_count})
return JsonResponse({'ok': True, 'saved_count': saved_count})
def send_test_email_impl(request, *, audit_fn, redirect_back_fn):
mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF'
redirect_email = get_email_test_redirect()
try:
send_system_email(
subject=f'SMTP test from onboarding/offboarding v2 ({mode})',
body=(
'This is a test email. If you see this, SMTP is configured correctly.\n'
f'EMAIL_TEST_MODE={is_email_test_mode()}\n'
f'EMAIL_TEST_REDIRECT={redirect_email}\n'
),
to=[settings.TEST_NOTIFICATION_EMAIL],
)
audit_fn(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()})
notify_user(
user=request.user,
title=_('SMTP-Test erfolgreich'),
body=_('Die SMTP-Testmail wurde erfolgreich gesendet.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).')
except Exception as exc:
notify_user(
user=request.user,
title=_('SMTP-Test fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.error(request, _('SMTP-Testmail konnte nicht gesendet werden: %(error)s') % {'error': exc})
return redirect_back_fn(request, 'home')
def nextcloud_test_upload_impl(request, *, audit_fn, redirect_back_fn):
filename = f"nextcloud_test_{timezone.now().strftime('%Y%m%d_%H%M%S')}.txt"
content = (
"Nextcloud test upload from onboarding/offboarding system.\n"
f"Time: {timezone.now().isoformat()}\n"
f"User: {request.user.username}\n"
)
temp_path = None
try:
with NamedTemporaryFile('w', suffix='.txt', delete=False, encoding='utf-8') as tf:
tf.write(content)
temp_path = Path(tf.name)
ok = upload_to_nextcloud(temp_path, filename)
if ok:
audit_fn(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'success'})
notify_user(
user=request.user,
title=_('Nextcloud-Test erfolgreich'),
body=_('Der Testupload nach Nextcloud war erfolgreich.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}')
else:
audit_fn(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'error'})
notify_user(
user=request.user,
title=_('Nextcloud-Test fehlgeschlagen'),
body=_('Der Testupload nach Nextcloud ist fehlgeschlagen.'),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.')
except Exception as exc:
notify_user(
user=request.user,
title=_('Nextcloud-Test fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}')
finally:
if temp_path and temp_path.exists():
temp_path.unlink(missing_ok=True)
return redirect_back_fn(request, 'home')
def toggle_nextcloud_enabled_impl(request, *, audit_fn, redirect_back_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
currently_enabled = is_nextcloud_enabled()
config.nextcloud_enabled_override = not currently_enabled
config.save(update_fields=['nextcloud_enabled_override'])
audit_fn(request, 'nextcloud_mode_toggled', target_type='workflow_config', target_label='nextcloud', details={'enabled': config.nextcloud_enabled_override})
state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert'
messages.success(request, f'Nextcloud Upload wurde {state}.')
return redirect_back_fn(request, 'home')
def toggle_email_mode_impl(request, *, audit_fn, redirect_back_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
currently_test_mode = is_email_test_mode()
config.email_test_mode_override = not currently_test_mode
config.save(update_fields=['email_test_mode_override'])
audit_fn(request, 'email_mode_toggled', target_type='workflow_config', target_label='email_mode', details={'test_mode': config.email_test_mode_override})
state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus'
messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.')
return redirect_back_fn(request, 'home')
def save_integrations_settings_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
try:
sync_interval = int(request.POST.get('sync_interval_seconds', config.sync_interval_seconds or 60))
smtp_port = int(request.POST.get('smtp_port', config.smtp_port or 465))
except ValueError:
messages.error(request, 'Ungültige Zahl bei Sync-Intervall oder SMTP Port.')
return redirect('home')
config.nextcloud_base_url_override = request.POST.get('nextcloud_base_url_override', '').strip()
config.nextcloud_username_override = request.POST.get('nextcloud_username_override', '').strip()
config.nextcloud_directory_override = request.POST.get('nextcloud_directory_override', '').strip()
config.sync_interval_seconds = max(10, sync_interval)
config.imap_server = request.POST.get('imap_server', '').strip()
config.mailbox = request.POST.get('mailbox', '').strip() or 'INBOX'
config.smtp_server = request.POST.get('smtp_server', '').strip()
config.smtp_port = max(1, smtp_port)
config.email_account = request.POST.get('email_account', '').strip()
config.smtp_use_ssl = request.POST.get('smtp_use_ssl') == 'on'
config.smtp_use_tls = request.POST.get('smtp_use_tls') == 'on'
nextcloud_password = request.POST.get('nextcloud_password_override', '').strip()
if nextcloud_password:
config.nextcloud_password_override = nextcloud_password
email_password = request.POST.get('email_password', '').strip()
if email_password:
config.email_password = email_password
config.save()
audit_fn(request, 'integrations_saved', target_type='workflow_config', target_label='all_integrations')
messages.success(request, 'Integrations-Einstellungen wurden gespeichert.')
return redirect('home')
def save_nextcloud_settings_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
try:
sync_interval = int(request.POST.get('sync_interval_seconds', config.sync_interval_seconds or 60))
except ValueError:
messages.error(request, 'Ungültige Zahl beim Sync-Intervall.')
return redirect('home')
config.nextcloud_base_url_override = request.POST.get('nextcloud_base_url_override', '').strip()
config.nextcloud_username_override = request.POST.get('nextcloud_username_override', '').strip()
config.nextcloud_directory_override = request.POST.get('nextcloud_directory_override', '').strip()
config.sync_interval_seconds = max(10, sync_interval)
nextcloud_password = request.POST.get('nextcloud_password_override', '').strip()
if nextcloud_password:
config.nextcloud_password_override = nextcloud_password
config.save()
audit_fn(request, 'nextcloud_settings_saved', target_type='workflow_config', target_label='nextcloud')
messages.success(request, 'Nextcloud-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=nextcloud')
def save_workflow_rules_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
try:
handover_lead_days = int(
request.POST.get(
'device_handover_lead_days',
config.device_handover_lead_days or 5,
)
)
except ValueError:
messages.error(request, 'Ungültige Zahl beim Hardware-Vorlauf.')
return redirect('/admin-tools/integrations/?kind=rules')
config.device_handover_lead_days = max(0, handover_lead_days)
config.save(update_fields=['device_handover_lead_days'])
audit_fn(
request,
'workflow_rules_saved',
target_type='workflow_config',
target_label='workflow_rules',
details={
'device_handover_lead_days': config.device_handover_lead_days,
},
)
messages.success(request, 'Workflow-Regeln wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=rules')
def save_backup_settings_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
target_type = (request.POST.get('remote_backup_target_type') or config.remote_backup_target_type or 'nextcloud').strip().lower()
if target_type not in {choice for choice, _ in WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES}:
target_type = 'nextcloud'
remote_backup_enabled = request.POST.get('remote_backup_enabled') == 'on'
remote_backup_nextcloud_directory = request.POST.get('remote_backup_nextcloud_directory', '').strip()
primary_nextcloud_directory = (
(config.nextcloud_directory_override or '').strip()
or settings.NEXTCLOUD_DIRECTORY.strip()
).strip('/')
if remote_backup_enabled and target_type == 'nextcloud':
if not remote_backup_nextcloud_directory:
messages.error(request, 'Bitte ein separates Nextcloud Backup-Verzeichnis angeben.')
return redirect('/admin-tools/integrations/?kind=backup')
if remote_backup_nextcloud_directory.strip('/') == primary_nextcloud_directory:
messages.error(request, 'Das Backup-Verzeichnis muss vom normalen Nextcloud Dokumentenordner getrennt sein.')
return redirect('/admin-tools/integrations/?kind=backup')
config.remote_backup_enabled = remote_backup_enabled
config.remote_backup_target_type = target_type
config.remote_backup_nextcloud_directory = remote_backup_nextcloud_directory
config.remote_backup_s3_bucket = request.POST.get('remote_backup_s3_bucket', '').strip()
config.remote_backup_nfs_path = request.POST.get('remote_backup_nfs_path', '').strip()
config.save(
update_fields=[
'device_handover_lead_days',
'remote_backup_enabled',
'remote_backup_target_type',
'remote_backup_nextcloud_directory',
'remote_backup_s3_bucket',
'remote_backup_nfs_path',
]
)
audit_fn(
request,
'backup_settings_saved',
target_type='workflow_config',
target_label='backup_settings',
details={
'remote_backup_enabled': config.remote_backup_enabled,
'remote_backup_target_type': config.remote_backup_target_type,
'remote_backup_nextcloud_directory': config.remote_backup_nextcloud_directory,
},
)
messages.success(request, 'Backup-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=backup')
def save_mail_settings_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
try:
smtp_port = int(request.POST.get('smtp_port', config.smtp_port or 465))
except ValueError:
messages.error(request, 'Ungültige Zahl beim SMTP Port.')
return redirect('home')
config.imap_server = request.POST.get('imap_server', '').strip()
config.mailbox = request.POST.get('mailbox', '').strip() or 'INBOX'
config.smtp_server = request.POST.get('smtp_server', '').strip()
config.smtp_port = max(1, smtp_port)
config.email_account = request.POST.get('email_account', '').strip()
config.smtp_use_ssl = request.POST.get('smtp_use_ssl') == 'on'
config.smtp_use_tls = request.POST.get('smtp_use_tls') == 'on'
email_password = request.POST.get('email_password', '').strip()
if email_password:
config.email_password = email_password
config.save()
smtp_cfg, _ = SystemEmailConfig.objects.get_or_create(name='Default SMTP')
SystemEmailConfig.objects.exclude(id=smtp_cfg.id).update(is_active=False)
smtp_cfg.is_active = True
smtp_cfg.host = config.smtp_server
smtp_cfg.port = config.smtp_port
smtp_cfg.username = config.email_account
if email_password:
smtp_cfg.password = email_password
smtp_cfg.use_ssl = config.smtp_use_ssl
smtp_cfg.use_tls = config.smtp_use_tls
smtp_cfg.from_email = request.POST.get('from_email', '').strip()
smtp_cfg.save()
audit_fn(request, 'mail_settings_saved', target_type='workflow_config', target_label='mail')
messages.success(request, 'Mail-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=mail')
def save_email_routing_settings_impl(request, *, audit_fn):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
config.it_onboarding_email = request.POST.get('it_onboarding_email', '').strip()
config.general_info_email = request.POST.get('general_info_email', '').strip()
config.business_card_email = request.POST.get('business_card_email', '').strip()
config.hr_works_email = request.POST.get('hr_works_email', '').strip()
config.key_notification_email = request.POST.get('key_notification_email', '').strip()
config.save(
update_fields=[
'it_onboarding_email',
'general_info_email',
'business_card_email',
'hr_works_email',
'key_notification_email',
]
)
known_keys = {k for k, _ in NotificationTemplate.TEMPLATE_CHOICES}
for key in known_keys:
subject = request.POST.get(f'subject_{key}')
body = request.POST.get(f'body_{key}')
subject_en = request.POST.get(f'subject_en_{key}')
body_en = request.POST.get(f'body_en_{key}')
if subject is None and body is None and subject_en is None and body_en is None:
continue
subject = (subject or '').strip()
body = (body or '').strip()
subject_en = (subject_en or '').strip()
body_en = (body_en or '').strip()
if not subject and not body and not subject_en and not body_en:
continue
obj, _ = NotificationTemplate.objects.get_or_create(
key=key,
defaults={
'subject_template': subject or f'[{key}]',
'body_template': body or '-',
'subject_template_en': subject_en,
'body_template_en': body_en,
'is_active': True,
},
)
changed = []
if subject and obj.subject_template != subject:
obj.subject_template = subject
changed.append('subject_template')
if body and obj.body_template != body:
obj.body_template = body
changed.append('body_template')
if obj.subject_template_en != subject_en:
obj.subject_template_en = subject_en
changed.append('subject_template_en')
if obj.body_template_en != body_en:
obj.body_template_en = body_en
changed.append('body_template_en')
if not obj.is_active:
obj.is_active = True
changed.append('is_active')
if changed:
obj.save(update_fields=changed)
audit_fn(request, 'email_routing_saved', target_type='workflow_config', target_label='email_routing')
messages.success(request, 'E-Mail Routing und Vorlagen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=emails')
def save_notification_rules_impl(request, *, audit_fn):
rule_ids = request.POST.getlist('rule_ids')
for position, raw_id in enumerate(rule_ids):
rule = NotificationRule.objects.filter(id=raw_id).first()
if not rule:
continue
if request.POST.get(f'delete_{rule.id}') == 'on':
rule.delete()
continue
rule.name = request.POST.get(f'name_{rule.id}', '').strip() or rule.name
event_type = request.POST.get(f'event_type_{rule.id}', '').strip()
if event_type in {'onboarding', 'offboarding'}:
rule.event_type = event_type
operator = request.POST.get(f'operator_{rule.id}', '').strip()
if operator in {x[0] for x in NotificationRule.OPERATOR_CHOICES}:
rule.operator = operator
rule.field_name = request.POST.get(f'field_name_{rule.id}', '').strip()
rule.expected_value = request.POST.get(f'expected_value_{rule.id}', '').strip()
rule.recipients = request.POST.get(f'recipients_{rule.id}', '').strip()
rule.template_key = request.POST.get(f'template_key_{rule.id}', '').strip()
rule.custom_subject = request.POST.get(f'custom_subject_{rule.id}', '').strip()
rule.custom_body = request.POST.get(f'custom_body_{rule.id}', '').strip()
rule.custom_subject_en = request.POST.get(f'custom_subject_en_{rule.id}', '').strip()
rule.custom_body_en = request.POST.get(f'custom_body_en_{rule.id}', '').strip()
rule.include_pdf_attachment = request.POST.get(f'include_pdf_{rule.id}') == 'on'
rule.is_active = request.POST.get(f'active_{rule.id}') == 'on'
rule.sort_order = position
rule.save()
new_name = request.POST.get('new_name', '').strip()
new_recipients = request.POST.get('new_recipients', '').strip()
if new_name and new_recipients:
new_event = request.POST.get('new_event_type', 'onboarding').strip()
if new_event not in {'onboarding', 'offboarding'}:
new_event = 'onboarding'
new_operator = request.POST.get('new_operator', 'always').strip()
if new_operator not in {x[0] for x in NotificationRule.OPERATOR_CHOICES}:
new_operator = 'always'
NotificationRule.objects.create(
name=new_name,
event_type=new_event,
field_name=request.POST.get('new_field_name', '').strip(),
operator=new_operator,
expected_value=request.POST.get('new_expected_value', '').strip(),
recipients=new_recipients,
template_key=request.POST.get('new_template_key', '').strip(),
custom_subject=request.POST.get('new_custom_subject', '').strip(),
custom_body=request.POST.get('new_custom_body', '').strip(),
custom_subject_en=request.POST.get('new_custom_subject_en', '').strip(),
custom_body_en=request.POST.get('new_custom_body_en', '').strip(),
include_pdf_attachment=request.POST.get('new_include_pdf') == 'on',
is_active=True,
sort_order=NotificationRule.objects.filter(event_type=new_event).count() + 1,
)
audit_fn(request, 'notification_rules_saved', target_type='notification_rule')
messages.success(request, 'Benachrichtigungsregeln wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=emails')

View File

@@ -0,0 +1,134 @@
from django.contrib import messages
from django.shortcuts import redirect, render
from .models import IntroChecklistItem
def intro_builder_page_impl(request, *, audit_fn, translate_choice_list):
if request.method == 'POST':
delete_id = (request.POST.get('delete_item_id') or '').strip()
if delete_id:
item = IntroChecklistItem.objects.filter(id=delete_id).first()
if item:
deleted_label = item.label
deleted_id_int = item.id
item.delete()
audit_fn(request, 'intro_checklist_item_deleted', target_type='intro_checklist_item', target_id=deleted_id_int, target_label=deleted_label)
messages.success(request, 'Checklistenpunkt wurde gelöscht.')
else:
messages.error(request, 'Checklistenpunkt nicht gefunden.')
return redirect('intro_builder_page')
action = (request.POST.get('builder_action') or '').strip()
if action == 'add_item':
section = (request.POST.get('section') or '').strip()
label = (request.POST.get('label') or '').strip()
label_en = (request.POST.get('label_en') or '').strip()
if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}:
messages.error(request, 'Ungültiger Abschnitt.')
return redirect('intro_builder_page')
if not label:
messages.error(request, 'Bitte eine Bezeichnung für den Checklistenpunkt angeben.')
return redirect('intro_builder_page')
next_sort = (
IntroChecklistItem.objects.filter(section=section).order_by('-sort_order').values_list('sort_order', flat=True).first()
)
IntroChecklistItem.objects.create(
section=section,
label=label,
label_en=label_en,
sort_order=(next_sort + 1) if next_sort is not None else 0,
is_active=True,
condition_operator='always',
)
audit_fn(request, 'intro_checklist_item_added', target_type='intro_checklist_item', target_label=label, details={'section': section, 'label_en': label_en})
messages.success(request, 'Checklistenpunkt wurde hinzugefügt.')
return redirect('intro_builder_page')
if action == 'save_items':
item_ids = request.POST.getlist('item_ids')
valid_sections = {k for k, _ in IntroChecklistItem.SECTION_CHOICES}
valid_ops = {k for k, _ in IntroChecklistItem.OPERATOR_CHOICES}
for pos, raw_id in enumerate(item_ids):
item = IntroChecklistItem.objects.filter(id=raw_id).first()
if not item:
continue
section = (request.POST.get(f'section_{item.id}') or item.section).strip()
if section not in valid_sections:
section = item.section
operator = (request.POST.get(f'operator_{item.id}') or item.condition_operator).strip()
if operator not in valid_ops:
operator = 'always'
item.section = section
item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label
item.label_en = (request.POST.get(f'label_en_{item.id}') or '').strip()
item.is_active = request.POST.get(f'active_{item.id}') == 'on'
item.condition_field = (request.POST.get(f'field_{item.id}') or '').strip()
item.condition_operator = operator
item.condition_value = (request.POST.get(f'value_{item.id}') or '').strip()
item.sort_order = pos
item.save(
update_fields=[
'section',
'label',
'label_en',
'is_active',
'condition_field',
'condition_operator',
'condition_value',
'sort_order',
]
)
audit_fn(request, 'intro_checklist_saved', target_type='intro_checklist_item', details={'count': len(item_ids)})
messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.')
return redirect('intro_builder_page')
condition_field_choices = [
('', 'Keine Bedingung'),
('needed_devices', 'Benötigte Geräte und Gegenstände'),
('needed_software', 'Benötigte Software'),
('needed_accesses', 'Benötigte Zugänge'),
('needed_workspace_groups', 'Benötigte Gruppen im Workspace'),
('needed_resources', 'Benötigte Ressourcen'),
('additional_hardware', 'Zusätzliche Hardware'),
('additional_software', 'Zusätzliche Software'),
('additional_access_text', 'Weitere Zugänge (Freitext)'),
('group_mailboxes_required', 'Gruppenpostfächer erforderlich'),
('order_business_cards', 'Visitenkarten bestellt'),
('phone_number', 'Direktwahl vorhanden'),
('successor_name', 'Nachfolge vorhanden'),
('department', 'Abteilung'),
]
items = list(IntroChecklistItem.objects.all().order_by('section', 'sort_order', 'label'))
section_label_map = dict(translate_choice_list(IntroChecklistItem.SECTION_CHOICES))
grouped_items = []
for value, _label in IntroChecklistItem.SECTION_CHOICES:
section_items = [item for item in items if item.section == value]
grouped_items.append(
{
'key': value,
'label': section_label_map.get(value, value),
'items': section_items,
'count': len(section_items),
'active_count': len([item for item in section_items if item.is_active]),
}
)
return render(
request,
'workflows/intro_builder.html',
{
'items': items,
'grouped_items': grouped_items,
'intro_summary': {
'total_items': len(items),
'active_items': len([item for item in items if item.is_active]),
'conditional_items': len([item for item in items if item.condition_operator != 'always']),
'section_count': len([group for group in grouped_items if group['count']]),
},
'section_choices': translate_choice_list(IntroChecklistItem.SECTION_CHOICES),
'operator_choices': translate_choice_list(IntroChecklistItem.OPERATOR_CHOICES),
'condition_field_choices': condition_field_choices,
},
)

View File

@@ -0,0 +1,160 @@
from datetime import timedelta
from django.contrib import messages
from django.db.models import Count, Q
from django.shortcuts import redirect, render
from django.utils import timezone
from django.utils.translation import gettext as _
from .backup_ops import create_backup_bundle, latest_backup_health_snapshot, list_backup_bundles, verify_backup_bundle
from .models import AdminAuditLog, AsyncTaskLog, UserNotification, UserProfile
from .notifications import notify_user
from .roles import user_has_capability
def job_monitor_page_impl(request):
status_filter = (request.GET.get('status') or '').strip()
task_filter = (request.GET.get('task') or '').strip()
logs = AsyncTaskLog.objects.all()
if status_filter:
logs = logs.filter(status=status_filter)
if task_filter:
logs = logs.filter(task_name=task_filter)
logs = logs.order_by('-started_at', '-id')[:200]
task_names = list(AsyncTaskLog.objects.order_by('task_name').values_list('task_name', flat=True).distinct())
since = timezone.now() - timedelta(hours=24)
recent_logs = AsyncTaskLog.objects.filter(started_at__gte=since)
counts = {row['status']: row['count'] for row in recent_logs.values('status').annotate(count=Count('id'))}
recent_failed = list(AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5])
can_manage_backups = user_has_capability(request.user, 'manage_backups')
return render(
request,
'workflows/job_monitor.html',
{
'logs': logs,
'status_filter': status_filter,
'task_filter': task_filter,
'task_names': task_names,
'status_choices': [('started', _('Gestartet')), ('succeeded', _('Erfolgreich')), ('failed', _('Fehlgeschlagen'))],
'job_summary': {
'started_count_24h': counts.get('started', 0),
'success_count_24h': counts.get('succeeded', 0),
'failed_count_24h': counts.get('failed', 0),
'recent_failed': recent_failed,
'can_manage_backups': can_manage_backups,
'backup_health': latest_backup_health_snapshot() if can_manage_backups else None,
},
},
)
def audit_log_page_impl(request):
action = (request.GET.get('action') or '').strip()
user_query = (request.GET.get('user') or '').strip()
date_from = (request.GET.get('date_from') or '').strip()
date_to = (request.GET.get('date_to') or '').strip()
rows_qs = AdminAuditLog.objects.select_related('actor').all()
if action:
rows_qs = rows_qs.filter(action=action)
if user_query:
rows_qs = rows_qs.filter(
Q(actor_display__icontains=user_query)
| Q(actor__username__icontains=user_query)
| Q(actor__email__icontains=user_query)
)
if date_from:
rows_qs = rows_qs.filter(created_at__date__gte=date_from)
if date_to:
rows_qs = rows_qs.filter(created_at__date__lte=date_to)
rows = list(rows_qs[:300])
action_choices = AdminAuditLog.objects.order_by('action').values_list('action', flat=True).distinct()
return render(
request,
'workflows/audit_log.html',
{
'rows': rows,
'action_choices': action_choices,
'selected_action': action,
'user_query': user_query,
'date_from': date_from,
'date_to': date_to,
},
)
def backup_recovery_page_impl(request):
rows = list_backup_bundles()
return render(
request,
'workflows/backup_recovery.html',
{
'rows': rows,
'backup_health': latest_backup_health_snapshot(),
},
)
def create_backup_from_admin_impl(request, *, audit_fn):
try:
result = create_backup_bundle()
audit_fn(
request,
'backup_created',
target_type='backup_bundle',
target_label=result['name'],
details={'path': result['path']},
)
notify_user(
user=request.user,
title=_('Backup erstellt: %(name)s') % {'name': result['name']},
body=_('Das Backup-Bundle wurde erfolgreich erstellt.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_SUCCESS,
)
messages.success(request, _('Backup wurde erstellt: %(name)s') % {'name': result['name']})
except Exception as exc:
notify_user(
user=request.user,
title=_('Backup fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_FAILURE,
)
messages.error(request, _('Backup konnte nicht erstellt werden: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')
def verify_backup_from_admin_impl(request, backup_name: str, *, audit_fn):
try:
result = verify_backup_bundle(backup_name)
audit_fn(
request,
'backup_verified',
target_type='backup_bundle',
target_label=backup_name,
details={'summary': result['summary']},
)
notify_user(
user=request.user,
title=_('Backup verifiziert: %(name)s') % {'name': result['name']},
body=result.get('summary') or _('Das Backup wurde erfolgreich verifiziert.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_SUCCESS,
)
messages.success(request, _('Backup wurde verifiziert: %(name)s') % {'name': result['name']})
except Exception as exc:
notify_user(
user=request.user,
title=_('Backup-Verifikation fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_FAILURE,
)
messages.error(request, _('Backup-Verifikation fehlgeschlagen: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')

View File

@@ -0,0 +1,650 @@
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.contrib import messages
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.translation import get_language, gettext as _
from .branding import get_company_email_domain
from .form_builder import LOCKED_SECTION_RULES, OFFBOARDING_PAGE_ORDER, ensure_form_section_configs, get_section_definitions
from .forms import OffboardingRequestForm, OnboardingRequestForm
from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .roles import user_has_capability
from .tasks import _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, build_intro_sections_for_request, process_offboarding_request, process_onboarding_request
def request_timeline_page_impl(request, kind: str, request_id: int, *, request_target_label_fn, request_custom_field_details_fn, audit_action_label_fn):
if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id)
elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id)
else:
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')
request_label = request_target_label_fn(obj, kind)
custom_field_details = request_custom_field_details_fn(obj, kind, getattr(request, 'LANGUAGE_CODE', None))
audit_rows = list(
AdminAuditLog.objects.select_related('actor')
.filter(target_type__in=[kind, 'request'])
.filter(Q(target_id=request_id) | Q(target_label__icontains=(obj.full_name or '').strip()))
.order_by('-created_at', '-id')[:200]
)
timeline_rows = [
{
'created_at': obj.created_at,
'kind': 'system',
'title': _('Anfrage erstellt'),
'summary': request_label,
'meta': _('Status: %(status)s') % {'status': obj.get_processing_status_display()},
'details': {item['label']: item['value'] for item in custom_field_details},
}
]
contract_start = getattr(obj, 'contract_start', None)
if contract_start:
timeline_rows.append(
{
'created_at': timezone.make_aware(timezone.datetime.combine(contract_start, timezone.datetime.min.time())),
'kind': 'milestone',
'title': _('Vertragsbeginn'),
'summary': str(contract_start),
'meta': _('Geplanter Start'),
}
)
handover_date = getattr(obj, 'handover_date', None)
if handover_date:
timeline_rows.append(
{
'created_at': timezone.make_aware(timezone.datetime.combine(handover_date, timezone.datetime.min.time())),
'kind': 'milestone',
'title': _('Geräteübergabe / Hardware-Abholung'),
'summary': str(handover_date),
'meta': _('Geplanter Hardware-Termin'),
}
)
if getattr(obj, 'generated_pdf_path', ''):
timeline_rows.append(
{
'created_at': obj.created_at,
'kind': 'document',
'title': _('PDF verfügbar'),
'summary': Path(obj.generated_pdf_path).name,
'meta': '',
'url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}",
}
)
for row in audit_rows:
timeline_rows.append(
{
'created_at': row.created_at,
'kind': 'audit',
'title': audit_action_label_fn(row.action),
'summary': row.target_label or row.target_type or '-',
'meta': row.actor_display or '-',
'details': row.details,
}
)
if kind == 'onboarding':
intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first()
if intro_session:
timeline_rows.append(
{
'created_at': intro_session.updated_at,
'kind': 'session',
'title': _('Einweisungssitzung'),
'summary': intro_session.get_status_display(),
'meta': intro_session.completed_by_name or '-',
'url': (f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" if intro_session.exported_pdf_path else ''),
}
)
welcome_email = ScheduledWelcomeEmail.objects.filter(onboarding_request=obj).first()
if welcome_email:
timeline_rows.append(
{
'created_at': welcome_email.updated_at,
'kind': 'email',
'title': _('Welcome E-Mail'),
'summary': welcome_email.get_status_display(),
'meta': welcome_email.recipient_email,
}
)
timeline_rows.sort(key=lambda item: item['created_at'])
return render(
request,
'workflows/request_timeline.html',
{
'request_kind': kind,
'request_obj': obj,
'request_label': request_label,
'timeline_rows': timeline_rows,
'custom_field_details': custom_field_details,
'contract_start': getattr(obj, 'contract_start', None),
'handover_date': getattr(obj, 'handover_date', None),
},
)
def requests_dashboard_impl(request, *, audit_fn, request_target_label_fn, request_status_label_fn):
if not user_has_capability(request.user, 'access_requests_dashboard'):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('home')
if request.method == 'POST':
if not user_has_capability(request.user, 'delete_requests'):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('requests_dashboard')
selected = request.POST.getlist('selected_requests')
single_delete = (request.POST.get('single_delete') or '').strip()
if single_delete:
selected = [single_delete]
if not selected:
messages.warning(request, _('Keine Einträge ausgewählt.'))
return redirect('requests_dashboard')
deleted_count = 0
invalid_count = 0
deleted_labels = []
for token in selected:
try:
kind, raw_id = token.split(':', 1)
request_id = int(raw_id)
except (ValueError, TypeError):
invalid_count += 1
continue
model = None
if kind == 'onboarding':
model = OnboardingRequest
elif kind == 'offboarding':
model = OffboardingRequest
else:
invalid_count += 1
continue
obj = model.objects.filter(id=request_id).first()
if not obj:
continue
deleted_labels.append(request_target_label_fn(obj, kind))
obj.delete()
deleted_count += 1
if deleted_count:
audit_fn(
request,
'requests_deleted',
target_type='request',
target_label='Dashboard bulk/single delete',
details={
'deleted_count': deleted_count,
'invalid_count': invalid_count,
'selected': selected,
'request_labels': deleted_labels,
},
)
messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count})
if invalid_count:
messages.warning(request, _('%(count)s Auswahl(en) konnten nicht verarbeitet werden.') % {'count': invalid_count})
if not deleted_count and not invalid_count:
messages.info(request, _('Keine passenden Einträge gefunden.'))
return redirect('requests_dashboard')
search_query = request.GET.get('q', '').strip()
type_filter = (request.GET.get('type') or '').strip().lower()
status_filter = (request.GET.get('status') or '').strip().lower()
department_filter = (request.GET.get('department') or '').strip()
date_from = (request.GET.get('date_from') or '').strip()
date_to = (request.GET.get('date_to') or '').strip()
onboarding_qs = OnboardingRequest.objects.order_by('-created_at')
offboarding_qs = OffboardingRequest.objects.order_by('-created_at')
all_onboarding = OnboardingRequest.objects.all()
all_offboarding = OffboardingRequest.objects.all()
if search_query:
onboarding_qs = onboarding_qs.filter(Q(full_name__icontains=search_query) | Q(work_email__icontains=search_query))
offboarding_qs = offboarding_qs.filter(Q(full_name__icontains=search_query) | Q(work_email__icontains=search_query))
if status_filter in {'submitted', 'processing', 'completed', 'failed'}:
onboarding_qs = onboarding_qs.filter(processing_status=status_filter)
offboarding_qs = offboarding_qs.filter(processing_status=status_filter)
if department_filter:
onboarding_qs = onboarding_qs.filter(department=department_filter)
offboarding_qs = offboarding_qs.filter(department=department_filter)
if date_from:
onboarding_qs = onboarding_qs.filter(created_at__date__gte=date_from)
offboarding_qs = offboarding_qs.filter(created_at__date__gte=date_from)
if date_to:
onboarding_qs = onboarding_qs.filter(created_at__date__lte=date_to)
offboarding_qs = offboarding_qs.filter(created_at__date__lte=date_to)
if type_filter == 'onboarding':
offboarding_qs = offboarding_qs.none()
elif type_filter == 'offboarding':
onboarding_qs = onboarding_qs.none()
onboarding_items = onboarding_qs[:50]
offboarding_items = offboarding_qs[:50]
language_code = (
request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
or getattr(request, 'LANGUAGE_CODE', '')
or get_language()
or 'de'
).split('-')[0].lower()
rows = []
for obj in onboarding_items:
intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first()
if intro_session and intro_session.exported_pdf_path:
intro_session.exported_pdf_url = f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}"
rows.append(
{
'id': obj.id,
'kind': 'Onboarding',
'kind_slug': 'onboarding',
'name': obj.full_name,
'work_email': obj.work_email,
'created_at': obj.created_at,
'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': f"/media/pdfs/{Path(obj.intro_pdf_path).name}" if obj.intro_pdf_path else None,
'intro_session': intro_session,
'status': request_status_label_fn(obj.processing_status, language_code),
'status_key': obj.processing_status,
'last_error': obj.last_error,
}
)
for obj in offboarding_items:
rows.append(
{
'id': obj.id,
'kind': 'Offboarding',
'kind_slug': 'offboarding',
'name': obj.full_name,
'work_email': obj.work_email,
'created_at': obj.created_at,
'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': None,
'intro_session': None,
'status': request_status_label_fn(obj.processing_status, language_code),
'status_key': obj.processing_status,
'last_error': obj.last_error,
}
)
rows.sort(key=lambda x: x['created_at'], reverse=True)
today = timezone.localdate()
start_date = today - timedelta(days=13)
onboarding_daily = {}
offboarding_daily = {}
for i in range(14):
day = start_date + timedelta(days=i)
onboarding_daily[day] = 0
offboarding_daily[day] = 0
for dt in onboarding_qs.filter(created_at__date__gte=start_date).values_list('created_at', flat=True):
onboarding_daily[timezone.localtime(dt).date()] += 1
for dt in offboarding_qs.filter(created_at__date__gte=start_date).values_list('created_at', flat=True):
offboarding_daily[timezone.localtime(dt).date()] += 1
chart_points = []
max_total = 1
for i in range(14):
day = start_date + timedelta(days=i)
on_count = onboarding_daily[day]
off_count = offboarding_daily[day]
total = on_count + off_count
max_total = max(max_total, total)
chart_points.append(
{
'label': day.strftime('%d.%m'),
'onboarding': on_count,
'offboarding': off_count,
'total': total,
}
)
for point in chart_points:
point['height'] = max(8, int((point['total'] / max_total) * 84))
onboarding_total = onboarding_qs.count()
offboarding_total = offboarding_qs.count()
departments = sorted(
{
value.strip()
for value in list(all_onboarding.exclude(department='').values_list('department', flat=True))
+ list(all_offboarding.exclude(department='').values_list('department', flat=True))
if value and value.strip()
},
key=str.lower,
)
status_choices = [
{'value': 'submitted', 'label': request_status_label_fn('submitted', language_code)},
{'value': 'processing', 'label': request_status_label_fn('processing', language_code)},
{'value': 'completed', 'label': request_status_label_fn('completed', language_code)},
{'value': 'failed', 'label': request_status_label_fn('failed', language_code)},
]
has_filters = any([search_query, type_filter, status_filter, department_filter, date_from, date_to])
column_count = 4
if user_has_capability(request.user, 'delete_requests'):
column_count += 1
if user_has_capability(request.user, 'run_intro_session') or user_has_capability(request.user, 'generate_intro_pdfs'):
column_count += 1
if user_has_capability(request.user, 'access_requests_dashboard'):
column_count += 1
return render(
request,
'workflows/requests_dashboard.html',
{
'rows': rows[:60],
'search_query': search_query,
'selected_type': type_filter,
'selected_status': status_filter,
'selected_department': department_filter,
'date_from': date_from,
'date_to': date_to,
'departments': departments,
'status_choices': status_choices,
'has_filters': has_filters,
'column_count': column_count,
'onboarding_total': onboarding_total,
'offboarding_total': offboarding_total,
'combined_total': onboarding_total + offboarding_total,
'chart_points': chart_points,
},
)
def onboarding_create_impl(
request,
*,
build_onboarding_layout_fn,
build_onboarding_sections_fn,
normalized_conditional_rule_payload_fn,
display_user_name_fn,
onboarding_inline_checks,
onboarding_checkbox_lists,
):
config = WorkflowConfig.objects.order_by('id').first()
legal_text = (
config.legal_text
if config and config.legal_text
else 'Eine Ausrüstungsvereinbarung erlaubt es einem Mitarbeitenden, die Ausrüstung des Unternehmens im Außendienst oder zu Hause zu nutzen und mitzunehmen.'
)
if request.method == 'POST':
form = OnboardingRequestForm(request.POST, request.FILES, requester_email=request.user.email)
if form.is_valid():
obj = form.save()
obj.onboarded_by_name = display_user_name_fn(request.user)
obj.preferred_language = ((getattr(request, 'LANGUAGE_CODE', '') or get_language() or 'de').split('-')[0])
obj.save(update_fields=['onboarded_by_name', 'preferred_language'])
process_onboarding_request.delay(obj.id)
return redirect(f"/onboarding/new/?saved=1&id={obj.id}")
else:
form = OnboardingRequestForm(requester_email=request.user.email)
onboarding_blocks = build_onboarding_layout_fn(form)
field_pages = getattr(form, '_field_page_keys', {})
section_configs = ensure_form_section_configs('onboarding')
visible_section_keys = set()
for section in get_section_definitions('onboarding'):
key = section['key']
if section.get('is_custom'):
if section.get('is_active', True):
visible_section_keys.add(key)
elif key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible:
visible_section_keys.add(key)
onboarding_sections = build_onboarding_sections_fn(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys)
onboarding_conditional_rules = normalized_conditional_rule_payload_fn('onboarding')
return render(
request,
'workflows/onboarding_form.html',
{
'form': form,
'onboarding_blocks': onboarding_blocks,
'onboarding_sections': onboarding_sections,
'onboarding_inline_checks': onboarding_inline_checks,
'onboarding_checkbox_lists': onboarding_checkbox_lists,
'onboarding_conditional_rules': onboarding_conditional_rules,
'legal_text': legal_text,
'saved': request.GET.get('saved') == '1',
'saved_request_id': request.GET.get('id', ''),
'portal_email_domain': get_company_email_domain(),
},
)
def onboarding_success_impl(request, request_id: int):
obj = get_object_or_404(OnboardingRequest, id=request_id)
pdf_url = None
if obj.generated_pdf_path:
pdf_url = f"/media/pdfs/{Path(obj.generated_pdf_path).name}"
return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
def generate_onboarding_intro_pdf_impl(request, request_id: int, *, audit_fn):
obj = get_object_or_404(OnboardingRequest, id=request_id)
pdf_path = _generate_onboarding_intro_pdf(obj, language_code=get_language())
obj.intro_pdf_path = str(pdf_path)
obj.save(update_fields=['intro_pdf_path'])
audit_fn(request, 'intro_pdf_generated', target_type='onboarding', target_id=obj.id, target_label=obj.full_name)
messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.'))
return redirect('requests_dashboard')
def generate_onboarding_intro_session_pdf_impl(request, request_id: int, *, audit_fn, display_user_name_fn):
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
pdf_path = _generate_onboarding_intro_session_pdf(
session,
admin_signature_name=display_user_name_fn(request.user),
language_code=get_language(),
)
session.exported_pdf_path = str(pdf_path)
session.save(update_fields=['exported_pdf_path'])
audit_fn(request, 'intro_live_pdf_generated', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name)
messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.'))
return redirect('onboarding_intro_session_page', request_id=request_id)
def onboarding_intro_session_page_impl(request, request_id: int, *, audit_fn, display_user_name_fn):
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
sections = build_intro_sections_for_request(onboarding, language_code=get_language())
if request.method == 'POST':
checked_ids = set(request.POST.getlist('checked_items'))
checklist_state = {}
for section in sections:
for item in section['items']:
checklist_state[item['id']] = item['id'] in checked_ids
action = (request.POST.get('session_action') or 'save').strip()
session.checklist_state = checklist_state
session.notes = (request.POST.get('notes') or '').strip()
if action == 'reset':
session.checklist_state = {}
session.notes = ''
session.status = 'draft'
session.completed_at = None
session.completed_by_name = ''
session.exported_pdf_path = ''
session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path'])
audit_fn(request, 'intro_session_reset', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name)
messages.success(request, _('Einweisung wurde zurückgesetzt.'))
return redirect('onboarding_intro_session_page', request_id=request_id)
if action == 'complete':
session.status = 'completed'
session.completed_at = timezone.now()
session.completed_by_name = display_user_name_fn(request.user)
audit_fn(
request,
'intro_session_completed',
target_type='onboarding',
target_id=onboarding.id,
target_label=onboarding.full_name,
details={'checked_count': len([value for value in checklist_state.values() if value])},
)
messages.success(request, _('Einweisung wurde als abgeschlossen gespeichert.'))
else:
session.status = 'draft'
session.completed_at = None
session.completed_by_name = ''
audit_fn(
request,
'intro_session_saved',
target_type='onboarding',
target_id=onboarding.id,
target_label=onboarding.full_name,
details={'checked_count': len([value for value in checklist_state.values() if value])},
)
messages.success(request, _('Einweisung wurde als Entwurf gespeichert.'))
session.save()
return redirect('onboarding_intro_session_page', request_id=request_id)
checked_map = session.checklist_state or {}
checked_count = 0
total_count = 0
for section in sections:
for item in section['items']:
item['checked'] = bool(checked_map.get(item['id']))
total_count += 1
if item['checked']:
checked_count += 1
salutation = (onboarding.get_gender_display() or '').strip()
display_name = f"{salutation} {onboarding.full_name}".strip() if salutation else onboarding.full_name
progress_percent = int((checked_count / total_count) * 100) if total_count else 0
return render(
request,
'workflows/onboarding_intro_session.html',
{
'onboarding': onboarding,
'session': session,
'display_name': display_name,
'sections': sections,
'checked_count': checked_count,
'total_count': total_count,
'progress_percent': progress_percent,
'session_pdf_url': f"/media/pdfs/{Path(session.exported_pdf_path).name}" if session.exported_pdf_path else None,
},
)
def offboarding_create_impl(request, *, build_offboarding_sections_fn, display_user_name_fn):
profile_id = request.GET.get('profile')
search_query = request.GET.get('q', '').strip()
selected_profile = None
if profile_id:
selected_profile = EmployeeProfile.objects.filter(id=profile_id).first()
search_results = []
if search_query:
search_results = list(
EmployeeProfile.objects.filter(full_name__icontains=search_query)[:10]
) + list(
EmployeeProfile.objects.filter(work_email__icontains=search_query)[:10]
)
# preserve order while removing duplicates
seen = set()
unique = []
for r in search_results:
if r.id not in seen:
unique.append(r)
seen.add(r.id)
search_results = unique[:10]
if request.method == 'POST':
form = OffboardingRequestForm(request.POST, prefill_profile=selected_profile)
if form.is_valid():
obj = form.save(commit=False)
if selected_profile:
obj.employee_profile = selected_profile
requester_email = (request.user.email or '').strip().lower()
company_suffix = f"@{get_company_email_domain()}"
if requester_email and requester_email.endswith(company_suffix):
obj.requested_by_email = requester_email
else:
obj.requested_by_email = settings.DEFAULT_FROM_EMAIL
obj.requested_by_name = display_user_name_fn(request.user)
obj.preferred_language = ((getattr(request, 'LANGUAGE_CODE', '') or get_language() or 'de').split('-')[0])
obj.save()
process_offboarding_request.delay(obj.id)
return redirect(f"/offboarding/new/?saved=1&id={obj.id}")
else:
form = OffboardingRequestForm(prefill_profile=selected_profile, initial={'search_query': search_query})
field_pages = getattr(form, '_field_page_keys', {})
section_configs = ensure_form_section_configs('offboarding')
visible_section_keys = {
key for key in OFFBOARDING_PAGE_ORDER
if key in LOCKED_SECTION_RULES.get('offboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible
}
offboarding_sections = build_offboarding_sections_fn(form, visible_section_keys=visible_section_keys)
return render(
request,
'workflows/offboarding_form.html',
{
'form': form,
'search_results': search_results,
'selected_profile': selected_profile,
'search_query': search_query,
'saved': request.GET.get('saved') == '1',
'saved_request_id': request.GET.get('id', ''),
'portal_email_domain': get_company_email_domain(),
'offboarding_sections': offboarding_sections,
},
)
def offboarding_success_impl(request, request_id: int):
obj = get_object_or_404(OffboardingRequest, id=request_id)
pdf_url = None
if obj.generated_pdf_path:
pdf_url = f"/media/pdfs/{Path(obj.generated_pdf_path).name}"
return render(request, 'workflows/offboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
def delete_request_from_dashboard_impl(request, kind: str, request_id: int, *, audit_fn, request_target_label_fn):
if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id)
elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id)
else:
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')
target_label = request_target_label_fn(obj, kind)
obj.delete()
audit_fn(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=target_label)
messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.')
return redirect('requests_dashboard')
def retry_request_from_dashboard_impl(request, kind: str, request_id: int, *, audit_fn, request_target_label_fn):
if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id)
obj.processing_status = 'submitted'
obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error'])
process_onboarding_request.delay(obj.id)
audit_fn(request, 'request_retried', target_type='onboarding', target_id=obj.id, target_label=request_target_label_fn(obj, 'onboarding'))
elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id)
obj.processing_status = 'submitted'
obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error'])
process_offboarding_request.delay(obj.id)
audit_fn(request, 'request_retried', target_type='offboarding', target_id=obj.id, target_label=request_target_label_fn(obj, 'offboarding'))
else:
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')
messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde erneut angestoßen.')
return redirect('requests_dashboard')

View File

@@ -147,6 +147,36 @@
margin-bottom: 0;
}
.section-stammdaten .field:not(.field-full):not(.inline-check),
.section-vertrag .field:not(.field-full):not(.inline-check) {
border: 1px solid #d7e0ea;
border-radius: 14px;
background: #ffffff;
padding: 12px;
min-height: 118px;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(17, 52, 95, 0.04);
}
.section-stammdaten .field:not(.field-full):not(.inline-check) input,
.section-stammdaten .field:not(.field-full):not(.inline-check) select,
.section-stammdaten .field:not(.field-full):not(.inline-check) textarea,
.section-vertrag .field:not(.field-full):not(.inline-check) input,
.section-vertrag .field:not(.field-full):not(.inline-check) select,
.section-vertrag .field:not(.field-full):not(.inline-check) textarea {
margin-top: auto;
}
.section-stammdaten .field.field-full:not(.inline-check):not(.checkbox-list):not(.empty-step),
.section-vertrag .field.field-full:not(.inline-check):not(.checkbox-list):not(.empty-step) {
border: 1px solid #d7e0ea;
border-radius: 14px;
background: #ffffff;
padding: 12px;
box-shadow: 0 4px 14px rgba(17, 52, 95, 0.04);
}
.section-itsetup .field-group {
border: 1px solid #d7e0ea;
border-radius: 14px;
@@ -282,11 +312,11 @@ textarea {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border: 1px solid #d4dbf7;
border-radius: 10px;
box-sizing: border-box;
font-size: 14px;
background: #fff;
background: #f8fbff;
color: var(--ink);
}

File diff suppressed because it is too large Load Diff