snapshot: modularize workflow views by domain
This commit is contained in:
211
backend/workflows/account_views.py
Normal file
211
backend/workflows/account_views.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
410
backend/workflows/admin_config_views.py
Normal file
410
backend/workflows/admin_config_views.py
Normal 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')
|
||||||
893
backend/workflows/form_builder_views.py
Normal file
893
backend/workflows/form_builder_views.py
Normal 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"§ion_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,
|
||||||
|
},
|
||||||
|
)
|
||||||
783
backend/workflows/integrations_views.py
Normal file
783
backend/workflows/integrations_views.py
Normal 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')
|
||||||
134
backend/workflows/intro_builder_views.py
Normal file
134
backend/workflows/intro_builder_views.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
160
backend/workflows/observability_views.py
Normal file
160
backend/workflows/observability_views.py
Normal 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')
|
||||||
650
backend/workflows/request_views.py
Normal file
650
backend/workflows/request_views.py
Normal 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')
|
||||||
@@ -147,6 +147,36 @@
|
|||||||
margin-bottom: 0;
|
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 {
|
.section-itsetup .field-group {
|
||||||
border: 1px solid #d7e0ea;
|
border: 1px solid #d7e0ea;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -282,11 +312,11 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid #d4dbf7;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background: #fff;
|
background: #f8fbff;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user