From 753e33f23539ef10f55fc0cdab28daa13fb87bbe Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Sat, 28 Mar 2026 12:35:13 +0100 Subject: [PATCH] snapshot: modularize integrations and builder order flows --- backend/workflows/form_builder_views.py | 77 +- backend/workflows/integration_admin_views.py | 459 ++++++++++ backend/workflows/integrations_views.py | 828 +------------------ backend/workflows/views.py | 53 +- backend/workflows/welcome_email_views.py | 279 +++++++ 5 files changed, 878 insertions(+), 818 deletions(-) create mode 100644 backend/workflows/integration_admin_views.py create mode 100644 backend/workflows/welcome_email_views.py diff --git a/backend/workflows/form_builder_views.py b/backend/workflows/form_builder_views.py index 68840bf..277c912 100644 --- a/backend/workflows/form_builder_views.py +++ b/backend/workflows/form_builder_views.py @@ -1,7 +1,12 @@ +import json +import re + from django.contrib import messages from django.db import IntegrityError +from django.http import JsonResponse 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, @@ -27,7 +32,6 @@ from .models import ( FormOption, ) from .roles import ROLE_PLATFORM_OWNER, get_user_role_key -import re def form_builder_page_impl( @@ -482,6 +486,7 @@ def form_builder_page_impl( if form_type == 'onboarding' else OffboardingRequestForm.base_fields.keys() ) + for name in existing_names: if name not in default_names: default_names.append(name) @@ -891,3 +896,73 @@ def form_builder_page_impl( 'can_override_locked_builder_rules': can_override_locked_builder_rules, }, ) + + +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}) diff --git a/backend/workflows/integration_admin_views.py b/backend/workflows/integration_admin_views.py new file mode 100644 index 0000000..b849de2 --- /dev/null +++ b/backend/workflows/integration_admin_views.py @@ -0,0 +1,459 @@ +import json +from pathlib import Path +from tempfile import NamedTemporaryFile + +from django.conf import settings +from django.contrib import messages +from django.shortcuts import redirect, render +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .models import NotificationRule, NotificationTemplate, 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 .branding import get_default_notification_templates +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 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') diff --git a/backend/workflows/integrations_views.py b/backend/workflows/integrations_views.py index 5ac1262..c1e04a7 100644 --- a/backend/workflows/integrations_views.py +++ b/backend/workflows/integrations_views.py @@ -1,783 +1,45 @@ -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') +from .integration_admin_views import ( + integrations_setup_page_impl, + nextcloud_test_upload_impl, + save_backup_settings_impl, + save_email_routing_settings_impl, + save_integrations_settings_impl, + save_mail_settings_impl, + save_nextcloud_settings_impl, + save_notification_rules_impl, + save_workflow_rules_impl, + send_test_email_impl, + toggle_email_mode_impl, + toggle_nextcloud_enabled_impl, +) +from .welcome_email_views import ( + bulk_welcome_email_action_impl, + cancel_welcome_email_impl, + pause_welcome_email_impl, + resume_welcome_email_impl, + save_welcome_email_settings_impl, + trigger_welcome_email_now_impl, + welcome_emails_page_impl, +) + +__all__ = [ + 'integrations_setup_page_impl', + 'welcome_emails_page_impl', + 'trigger_welcome_email_now_impl', + 'save_welcome_email_settings_impl', + 'bulk_welcome_email_action_impl', + 'pause_welcome_email_impl', + 'resume_welcome_email_impl', + 'cancel_welcome_email_impl', + 'send_test_email_impl', + 'nextcloud_test_upload_impl', + 'toggle_nextcloud_enabled_impl', + 'toggle_email_mode_impl', + 'save_integrations_settings_impl', + 'save_nextcloud_settings_impl', + 'save_workflow_rules_impl', + 'save_backup_settings_impl', + 'save_mail_settings_impl', + 'save_email_routing_settings_impl', + 'save_notification_rules_impl', +] diff --git a/backend/workflows/views.py b/backend/workflows/views.py index ce31b61..ef340b6 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -67,7 +67,7 @@ from .form_builder import ( get_section_order, apply_form_preset, ) -from .form_builder_views import form_builder_page_impl +from .form_builder_views import form_builder_page_impl, form_builder_save_order_impl from .intro_builder_views import intro_builder_page_impl from .observability_views import ( audit_log_page_impl, @@ -499,7 +499,12 @@ def welcome_emails_page(request): @_require_capability('manage_welcome_emails') @require_POST def trigger_welcome_email_now(request, schedule_id: int): - return integrations_views.trigger_welcome_email_now_impl(request, schedule_id, audit_fn=_audit) + return integrations_views.trigger_welcome_email_now_impl( + request, + schedule_id, + audit_fn=_audit, + send_task_fn=send_scheduled_welcome_email, + ) @_require_capability('manage_welcome_emails') @@ -508,39 +513,14 @@ def save_welcome_email_settings(request): return integrations_views.save_welcome_email_settings_impl(request, audit_fn=_audit) -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 - - @_require_capability('manage_welcome_emails') @require_POST def bulk_welcome_email_action(request): - return integrations_views.bulk_welcome_email_action_impl(request, audit_fn=_audit) + return integrations_views.bulk_welcome_email_action_impl( + request, + audit_fn=_audit, + send_task_fn=send_scheduled_welcome_email, + ) @_require_capability('manage_welcome_emails') @@ -552,7 +532,12 @@ def pause_welcome_email(request, schedule_id: int): @_require_capability('manage_welcome_emails') @require_POST def resume_welcome_email(request, schedule_id: int): - return integrations_views.resume_welcome_email_impl(request, schedule_id, audit_fn=_audit) + return integrations_views.resume_welcome_email_impl( + request, + schedule_id, + audit_fn=_audit, + send_task_fn=send_scheduled_welcome_email, + ) @_require_capability('manage_welcome_emails') @@ -564,7 +549,7 @@ def cancel_welcome_email(request, schedule_id: int): @_require_capability('manage_builders') @require_POST def form_builder_save_order(request): - return integrations_views.form_builder_save_order_impl(request, audit_fn=_audit) + return form_builder_save_order_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') diff --git a/backend/workflows/welcome_email_views.py b/backend/workflows/welcome_email_views.py new file mode 100644 index 0000000..bb76e7a --- /dev/null +++ b/backend/workflows/welcome_email_views.py @@ -0,0 +1,279 @@ +from celery import current_app +from django.contrib import messages +from django.shortcuts import redirect, render +from django.utils import timezone + +from .branding import get_default_notification_templates +from .models import NotificationTemplate, ScheduledWelcomeEmail, WorkflowConfig +from .tasks import send_scheduled_welcome_email + + +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, send_task_fn=send_scheduled_welcome_email): + 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_task_fn.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, send_task_fn=send_scheduled_welcome_email): + 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_task_fn.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, send_task_fn=send_scheduled_welcome_email): + 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_task_fn.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')