commit 9fe3c2ea82457b7ae5f087b5e0a4c16c861ae8d3 Author: Md Bayazid Bostame Date: Thu Mar 19 10:22:20 2026 +0100 chore: initial snapshot of tubco people portal diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b7df3c --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +DJANGO_SECRET_KEY=change-me +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 + +POSTGRES_DB=onoff +POSTGRES_USER=onoff +POSTGRES_PASSWORD=onoff +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_URL=redis://redis:6379/0 +CELERY_TASK_ALWAYS_EAGER=0 + +EMAIL_HOST=mailhog +EMAIL_PORT=1025 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=0 +EMAIL_USE_SSL=0 +DEFAULT_FROM_EMAIL=onboarding@example.local +TEST_NOTIFICATION_EMAIL=hr@example.local +IT_ONBOARDING_NOTIFICATION_EMAIL=it@tub.co +GENERAL_INFO_NOTIFICATION_EMAIL=ingo.einacker@tub.co +BUSINESS_CARD_NOTIFICATION_EMAIL=kommunikation@tub.co +HR_WORKS_NOTIFICATION_EMAIL=dittrich@tub.co +KEY_NOTIFICATION_EMAIL=minuth@tub.co + +NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding +NEXTCLOUD_USERNAME=onboarding@example.com +NEXTCLOUD_PASSWORD=change-me +NEXTCLOUD_DIRECTORY=Group-on-off-boarding +NEXTCLOUD_ENABLED=0 + +PDF_OUTPUT_DIR=/app/media/pdfs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8d6a92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + pull_request: + +jobs: + django-tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: onoff + POSTGRES_USER: onoff + POSTGRES_PASSWORD: onoff + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U onoff -d onoff" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + + env: + DJANGO_SECRET_KEY: ci-secret-key + DJANGO_DEBUG: "0" + DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1 + POSTGRES_DB: onoff + POSTGRES_USER: onoff + POSTGRES_PASSWORD: onoff + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: "5432" + REDIS_URL: redis://127.0.0.1:6379/0 + CELERY_TASK_ALWAYS_EAGER: "1" + NEXTCLOUD_ENABLED: "0" + + defaults: + run: + working-directory: backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Django system check + run: python manage.py check + + - name: Migration drift check + run: python manage.py makemigrations --check --dry-run + + - name: Run tests + run: python manage.py test workflows.tests -v 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..291344a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env +.env.* +!.env.example +.venv/ +venv/ +db.sqlite3 +media/ +staticfiles/ +.pytest_cache/ +.mypy_cache/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef2f484 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Onboarding/Offboarding v2 (Step 1 Scaffold) + +This is a new dockerized web app scaffold next to your current automation. + +## Services +- `web`: Django app (`http://localhost:8000`) +- `worker`: Celery async tasks +- `db`: PostgreSQL +- `redis`: task broker +- `mailhog`: local email inbox (`http://localhost:8025`) + +## Quick start +1. Copy env file: + - `cp .env.example .env` +2. Fill `.env` values (reuse your current credentials privately). +3. Start services: + - `docker compose up --build` +4. Open app: + - `http://localhost:8000/onboarding/new/` +5. Open test mailbox: + - `http://localhost:8025` + +## Current implemented scope +- Onboarding form with labels mapped from your CSV schema. +- Stores requests in PostgreSQL. +- Generates a personalized PDF (simple first version). +- Sends notification email via Celery. +- Optional Nextcloud upload hook (toggle with `NEXTCLOUD_ENABLED=1`). + +## Staging E2E verification +Run a real workflow verification (onboarding + offboarding), including PDF checks and optional email/Nextcloud evidence: + +- Default (auto MailHog detection, Nextcloud check enabled if configured): + - `docker compose exec -T web python manage.py run_staging_e2e_check` +- With cleanup (removes generated E2E DB rows/PDFs after run): + - `docker compose exec -T web python manage.py run_staging_e2e_check --cleanup` +- Force MailHog verification mode: + - `docker compose exec -T web python manage.py run_staging_e2e_check --email-check mailhog --mailhog-api-url http://mailhog:8025/api/v2/messages` +- Skip Nextcloud existence checks: + - `docker compose exec -T web python manage.py run_staging_e2e_check --skip-nextcloud` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..46136ee --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app + +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +COPY . /app +RUN chmod +x /app/entrypoint-web.sh /app/entrypoint-worker.sh +RUN chown -R app:app /app diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..dac9666 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,5 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +application = get_asgi_application() diff --git a/backend/config/celery.py b/backend/config/celery.py new file mode 100644 index 0000000..fb276c1 --- /dev/null +++ b/backend/config/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('config') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..cd7ddd9 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,141 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key') +DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1' +ALLOWED_HOSTS = [h.strip() for h in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if h.strip()] +CSRF_TRUSTED_ORIGINS = [o.strip() for o in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if o.strip()] + +# Security hardening +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = os.getenv('DJANGO_SESSION_COOKIE_SAMESITE', 'Lax') +CSRF_COOKIE_SAMESITE = os.getenv('DJANGO_CSRF_COOKIE_SAMESITE', 'Lax') +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = os.getenv('DJANGO_SECURE_REFERRER_POLICY', 'same-origin') +X_FRAME_OPTIONS = os.getenv('DJANGO_X_FRAME_OPTIONS', 'DENY') +SECURE_SSL_REDIRECT = os.getenv('DJANGO_SECURE_SSL_REDIRECT', '0') == '1' +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +_secure_cookies = os.getenv('DJANGO_SECURE_COOKIES', '0') == '1' +SESSION_COOKIE_SECURE = _secure_cookies +CSRF_COOKIE_SECURE = _secure_cookies + +# Upload guards +DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv('DJANGO_DATA_UPLOAD_MAX_MEMORY_SIZE', str(10 * 1024 * 1024))) +FILE_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv('DJANGO_FILE_UPLOAD_MAX_MEMORY_SIZE', str(5 * 1024 * 1024))) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'workflows', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = 'config.asgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('POSTGRES_DB', 'onoff'), + 'USER': os.getenv('POSTGRES_USER', 'onoff'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'onoff'), + 'HOST': os.getenv('POSTGRES_HOST', 'db'), + 'PORT': int(os.getenv('POSTGRES_PORT', '5432')), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'de-de' +TIME_ZONE = 'Europe/Berlin' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATIC_ROOT.mkdir(parents=True, exist_ok=True) +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +LOGIN_URL = '/accounts/login/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/accounts/login/' + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv('EMAIL_HOST', 'mailhog') +EMAIL_PORT = int(os.getenv('EMAIL_PORT', '1025')) +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', '0') == '1' +EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', '0') == '1' +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'onboarding@example.local') +TEST_NOTIFICATION_EMAIL = os.getenv('TEST_NOTIFICATION_EMAIL', 'hr@example.local') +IT_ONBOARDING_NOTIFICATION_EMAIL = os.getenv('IT_ONBOARDING_NOTIFICATION_EMAIL', 'it@tub.co') +GENERAL_INFO_NOTIFICATION_EMAIL = os.getenv('GENERAL_INFO_NOTIFICATION_EMAIL', 'ingo.einacker@tub.co') +BUSINESS_CARD_NOTIFICATION_EMAIL = os.getenv('BUSINESS_CARD_NOTIFICATION_EMAIL', 'kommunikation@tub.co') +HR_WORKS_NOTIFICATION_EMAIL = os.getenv('HR_WORKS_NOTIFICATION_EMAIL', 'dittrich@tub.co') +KEY_NOTIFICATION_EMAIL = os.getenv('KEY_NOTIFICATION_EMAIL', 'minuth@tub.co') +EMAIL_TEST_MODE = os.getenv('EMAIL_TEST_MODE', '0') == '1' +EMAIL_TEST_REDIRECT = os.getenv('EMAIL_TEST_REDIRECT', TEST_NOTIFICATION_EMAIL) + +REDIS_URL = os.getenv('REDIS_URL', 'redis://redis:6379/0') +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL +CELERY_TASK_ALWAYS_EAGER = os.getenv('CELERY_TASK_ALWAYS_EAGER', '0') == '1' +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = os.getenv('CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP', '1') == '1' + +NEXTCLOUD_BASE_URL = os.getenv('NEXTCLOUD_BASE_URL', '').rstrip('/') +NEXTCLOUD_USERNAME = os.getenv('NEXTCLOUD_USERNAME', '') +NEXTCLOUD_PASSWORD = os.getenv('NEXTCLOUD_PASSWORD', '') +NEXTCLOUD_DIRECTORY = os.getenv('NEXTCLOUD_DIRECTORY', '').strip('/') +NEXTCLOUD_ENABLED = os.getenv('NEXTCLOUD_ENABLED', '0') == '1' + +PDF_OUTPUT_DIR = Path(os.getenv('PDF_OUTPUT_DIR', str(MEDIA_ROOT / 'pdfs'))) +PDF_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +PDF_TEMPLATES_DIR = MEDIA_ROOT / 'templates' +PDF_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) +EMAIL_TEXT_DIR = MEDIA_ROOT / 'email_text' +EMAIL_TEXT_DIR.mkdir(parents=True, exist_ok=True) +ONBOARDING_SHARED_PDF_LINK = os.getenv('ONBOARDING_SHARED_PDF_LINK', '') +SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20')) + +NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS = int(os.getenv('NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', '30')) +NEXTCLOUD_UPLOAD_RETRIES = int(os.getenv('NEXTCLOUD_UPLOAD_RETRIES', '2')) diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..899547b --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,13 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('admin/', admin.site.urls), + path('accounts/', include('django.contrib.auth.urls')), + path('', include('workflows.urls')), +] + +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..885c6e5 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +application = get_wsgi_application() diff --git a/backend/entrypoint-web.sh b/backend/entrypoint-web.sh new file mode 100755 index 0000000..78ac22e --- /dev/null +++ b/backend/entrypoint-web.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +until nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do + echo "Waiting for postgres at $POSTGRES_HOST:$POSTGRES_PORT..." + sleep 1 +done + +python manage.py migrate --noinput +python manage.py collectstatic --noinput +exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 --access-logfile - --error-logfile - diff --git a/backend/entrypoint-worker.sh b/backend/entrypoint-worker.sh new file mode 100755 index 0000000..d91fbd9 --- /dev/null +++ b/backend/entrypoint-worker.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +until nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do + echo "Waiting for postgres at $POSTGRES_HOST:$POSTGRES_PORT..." + sleep 1 +done + +celery -A config worker -l info diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..58849d8 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1d56d66 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +Django==5.1.5 +celery==5.4.0 +redis==5.2.1 +psycopg2-binary==2.9.10 +python-dotenv==1.0.1 +reportlab==4.2.5 +requests==2.32.3 +pypdf==5.1.0 +jinja2==3.1.4 +xhtml2pdf==0.2.16 +gunicorn==23.0.0 diff --git a/backend/workflows/__init__.py b/backend/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py new file mode 100644 index 0000000..f39cf78 --- /dev/null +++ b/backend/workflows/admin.py @@ -0,0 +1,167 @@ +from django.contrib import admin +from django.conf import settings +from django import forms + +from .emailing import send_system_email +from .models import EmployeeProfile, FormFieldConfig, FormOption, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig + + +@admin.register(EmployeeProfile) +class EmployeeProfileAdmin(admin.ModelAdmin): + list_display = ('full_name', 'work_email', 'department', 'job_title', 'updated_at') + search_fields = ('full_name', 'work_email', 'department') + + +@admin.register(OnboardingRequest) +class OnboardingRequestAdmin(admin.ModelAdmin): + list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at') + search_fields = ('full_name', 'work_email', 'department') + list_filter = ('department', 'created_at', 'group_mailboxes_required', 'additional_software_needed') + + +@admin.register(OffboardingRequest) +class OffboardingRequestAdmin(admin.ModelAdmin): + list_display = ('id', 'full_name', 'work_email', 'department', 'last_working_day', 'generated_pdf_path', 'created_at') + search_fields = ('full_name', 'work_email', 'department') + list_filter = ('department', 'created_at') + + +@admin.register(FormOption) +class FormOptionAdmin(admin.ModelAdmin): + list_display = ('category', 'label', 'value', 'sort_order', 'is_active') + list_filter = ('category', 'is_active') + search_fields = ('label', 'value') + ordering = ('category', 'sort_order', 'label') + + +@admin.register(FormFieldConfig) +class FormFieldConfigAdmin(admin.ModelAdmin): + list_display = ('form_type', 'field_name', 'page_key', 'sort_order', 'is_visible', 'is_required') + list_filter = ('form_type', 'page_key', 'is_visible', 'is_required') + search_fields = ('field_name', 'label_override', 'help_text_override') + ordering = ('form_type', 'sort_order', 'field_name') + list_editable = ('page_key', 'sort_order', 'is_visible', 'is_required') + + +@admin.register(WorkflowConfig) +class WorkflowConfigAdmin(admin.ModelAdmin): + list_display = ( + 'name', + 'it_onboarding_email', + 'general_info_email', + 'business_card_email', + 'hr_works_email', + 'key_notification_email', + 'nextcloud_enabled_override', + 'email_test_mode_override', + ) + fieldsets = ( + ('Benachrichtigungen', { + 'fields': ( + 'name', + 'it_onboarding_email', + 'general_info_email', + 'business_card_email', + 'hr_works_email', + 'key_notification_email', + ) + }), + ('Modi', { + 'fields': ( + 'nextcloud_enabled_override', + 'email_test_mode_override', + 'sync_interval_seconds', + ) + }), + ('Nextcloud Konfiguration', { + 'fields': ( + 'nextcloud_base_url_override', + 'nextcloud_username_override', + 'nextcloud_password_override', + 'nextcloud_directory_override', + ) + }), + ('Mail Server Konfiguration', { + 'fields': ( + 'imap_server', + 'mailbox', + 'smtp_server', + 'smtp_port', + 'email_account', + 'email_password', + 'smtp_use_ssl', + 'smtp_use_tls', + ) + }), + ('Rechtlicher Text', {'fields': ('legal_text',)}), + ) + + def formfield_for_dbfield(self, db_field, request, **kwargs): + if db_field.name in {'nextcloud_password_override', 'email_password'}: + kwargs['widget'] = forms.PasswordInput(render_value=True) + return super().formfield_for_dbfield(db_field, request, **kwargs) + + +@admin.register(NotificationTemplate) +class NotificationTemplateAdmin(admin.ModelAdmin): + list_display = ('key', 'is_active', 'updated_at') + list_filter = ('is_active', 'key') + search_fields = ('key', 'subject_template', 'body_template') + + +@admin.register(NotificationRule) +class NotificationRuleAdmin(admin.ModelAdmin): + list_display = ('name', 'event_type', 'operator', 'field_name', 'is_active', 'sort_order') + list_filter = ('event_type', 'operator', 'is_active') + search_fields = ('name', 'field_name', 'expected_value', 'recipients', 'template_key') + ordering = ('event_type', 'sort_order', 'id') + + +@admin.register(ScheduledWelcomeEmail) +class ScheduledWelcomeEmailAdmin(admin.ModelAdmin): + list_display = ('id', 'onboarding_request', 'recipient_email', 'send_at', 'status', 'sent_at', 'updated_at') + list_filter = ('status', 'send_at', 'sent_at') + search_fields = ('recipient_email', 'onboarding_request__full_name', 'onboarding_request__work_email') + ordering = ('-send_at', '-id') + + +@admin.register(SystemEmailConfig) +class SystemEmailConfigAdmin(admin.ModelAdmin): + list_display = ('name', 'is_active', 'host', 'port', 'username', 'use_tls', 'use_ssl', 'from_email', 'updated_at') + list_filter = ('is_active', 'use_tls', 'use_ssl') + search_fields = ('name', 'host', 'username', 'from_email') + actions = ('send_smtp_test',) + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + if obj.is_active: + SystemEmailConfig.objects.exclude(id=obj.id).update(is_active=False) + + def send_smtp_test(self, request, queryset): + cfg = queryset.first() + if not cfg: + self.message_user(request, 'Bitte eine SMTP-Konfiguration auswählen.', level='warning') + return + + prev_active_ids = list(SystemEmailConfig.objects.filter(is_active=True).values_list('id', flat=True)) + try: + SystemEmailConfig.objects.filter(id=cfg.id).update(is_active=True) + SystemEmailConfig.objects.exclude(id=cfg.id).update(is_active=False) + send_system_email( + subject='SMTP Test aus Admin', + body=( + f'SMTP Test erfolgreich für Konfiguration: {cfg.name}\n' + f'Host: {cfg.host}:{cfg.port}\n' + f'User: {cfg.username or "-"}' + ), + to=[settings.TEST_NOTIFICATION_EMAIL], + ) + self.message_user(request, f'SMTP-Testmail gesendet über "{cfg.name}".') + except Exception as exc: + self.message_user(request, f'SMTP-Test fehlgeschlagen: {exc}', level='error') + finally: + if prev_active_ids: + SystemEmailConfig.objects.update(is_active=False) + SystemEmailConfig.objects.filter(id__in=prev_active_ids).update(is_active=True) + + send_smtp_test.short_description = 'SMTP-Testmail mit ausgewählter Konfiguration senden' diff --git a/backend/workflows/apps.py b/backend/workflows/apps.py new file mode 100644 index 0000000..756018c --- /dev/null +++ b/backend/workflows/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WorkflowsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'workflows' diff --git a/backend/workflows/emailing.py b/backend/workflows/emailing.py new file mode 100644 index 0000000..84620f9 --- /dev/null +++ b/backend/workflows/emailing.py @@ -0,0 +1,75 @@ +from django.conf import settings +from django.core.mail import EmailMessage, get_connection + +from .models import SystemEmailConfig, WorkflowConfig + + +def _active_smtp_config() -> SystemEmailConfig | None: + return SystemEmailConfig.objects.filter(is_active=True).order_by('-updated_at').first() + + +def _effective_smtp_settings() -> dict: + cfg = _active_smtp_config() + if not cfg or not cfg.host: + workflow_cfg = WorkflowConfig.objects.order_by('id').first() + if workflow_cfg and workflow_cfg.smtp_server: + return { + 'host': workflow_cfg.smtp_server, + 'port': workflow_cfg.smtp_port or settings.EMAIL_PORT, + 'username': workflow_cfg.email_account or settings.EMAIL_HOST_USER, + 'password': workflow_cfg.email_password or settings.EMAIL_HOST_PASSWORD, + 'use_tls': workflow_cfg.smtp_use_tls, + 'use_ssl': workflow_cfg.smtp_use_ssl, + 'from_email': workflow_cfg.email_account or settings.DEFAULT_FROM_EMAIL, + } + + return { + 'host': settings.EMAIL_HOST, + 'port': settings.EMAIL_PORT, + 'username': settings.EMAIL_HOST_USER, + 'password': settings.EMAIL_HOST_PASSWORD, + 'use_tls': settings.EMAIL_USE_TLS, + 'use_ssl': settings.EMAIL_USE_SSL, + 'from_email': settings.DEFAULT_FROM_EMAIL, + } + + return { + 'host': cfg.host, + 'port': cfg.port, + 'username': cfg.username, + 'password': cfg.password, + 'use_tls': cfg.use_tls, + 'use_ssl': cfg.use_ssl, + 'from_email': cfg.from_email or settings.DEFAULT_FROM_EMAIL, + } + + +def send_system_email( + subject: str, + body: str, + to: list[str], + attachments: list[str] | None = None, + from_email: str | None = None, +) -> None: + smtp = _effective_smtp_settings() + connection = get_connection( + backend='django.core.mail.backends.smtp.EmailBackend', + host=smtp['host'], + port=smtp['port'], + username=smtp['username'], + password=smtp['password'], + use_tls=smtp['use_tls'], + use_ssl=smtp['use_ssl'], + timeout=settings.SMTP_TIMEOUT_SECONDS, + ) + + msg = EmailMessage( + subject=subject, + body=body, + from_email=(from_email or smtp['from_email']), + to=to, + connection=connection, + ) + for path in attachments or []: + msg.attach_file(path) + msg.send(fail_silently=False) diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py new file mode 100644 index 0000000..ef0f965 --- /dev/null +++ b/backend/workflows/form_builder.py @@ -0,0 +1,188 @@ +from collections import OrderedDict + +from .models import FormFieldConfig + + +DEFAULT_FIELD_ORDER = { + 'onboarding': [ + 'first_name', + 'last_name', + 'full_name', + 'gender', + 'job_title', + 'department', + 'work_email', + 'order_business_cards', + 'business_card_name', + 'business_card_title', + 'business_card_email', + 'business_card_phone', + 'contract_start', + 'employment_type', + 'employment_end_date', + 'handover_date', + 'group_mailboxes_required_choice', + 'group_mailboxes', + 'needed_devices_multi', + 'additional_hardware_needed_choice', + 'additional_hardware_multi', + 'additional_hardware_other', + 'needed_software_multi', + 'additional_software_needed_choice', + 'additional_software_multi', + 'additional_software', + 'needed_accesses_multi', + 'additional_access_needed_choice', + 'additional_access_text', + 'needed_workspace_groups_multi', + 'needed_resources_multi', + 'successor_required_choice', + 'successor_name', + 'inherit_phone_number_choice', + 'phone_number_choice', + 'additional_notes', + 'signature_url', + 'signature_image', + 'onboarded_by_email', + 'agreement_confirm', + ], + 'offboarding': [ + 'full_name', + 'work_email', + 'department', + 'job_title', + 'last_working_day', + 'notes', + ], +} + +ONBOARDING_PAGE_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] +ONBOARDING_PAGE_LABELS = { + 'stammdaten': '1. Stammdaten', + 'vertrag': '2. Vertrag', + 'itsetup': '3. IT-Setup', + 'abschluss': '4. Abschluss', +} + +LOCKED_FIELD_RULES = { + 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, + 'offboarding': {'full_name', 'work_email', 'last_working_day'}, +} + +ONBOARDING_DEFAULT_PAGE = { + 'first_name': 'stammdaten', + 'last_name': 'stammdaten', + 'full_name': 'stammdaten', + 'gender': 'stammdaten', + 'job_title': 'stammdaten', + 'department': 'stammdaten', + 'work_email': 'stammdaten', + 'order_business_cards': 'stammdaten', + 'business_card_name': 'stammdaten', + 'business_card_title': 'stammdaten', + 'business_card_email': 'stammdaten', + 'business_card_phone': 'stammdaten', + 'contract_start': 'vertrag', + 'employment_type': 'vertrag', + 'employment_end_date': 'vertrag', + 'handover_date': 'vertrag', + 'group_mailboxes_required_choice': 'vertrag', + 'group_mailboxes': 'vertrag', + 'needed_devices_multi': 'itsetup', + 'additional_hardware_needed_choice': 'itsetup', + 'additional_hardware_multi': 'itsetup', + 'additional_hardware_other': 'itsetup', + 'needed_software_multi': 'itsetup', + 'additional_software_needed_choice': 'itsetup', + 'additional_software_multi': 'itsetup', + 'additional_software': 'itsetup', + 'needed_accesses_multi': 'itsetup', + 'additional_access_needed_choice': 'itsetup', + 'additional_access_text': 'itsetup', + 'needed_workspace_groups_multi': 'itsetup', + 'needed_resources_multi': 'itsetup', + 'successor_required_choice': 'itsetup', + 'successor_name': 'itsetup', + 'inherit_phone_number_choice': 'itsetup', + 'phone_number_choice': 'itsetup', + 'additional_notes': 'abschluss', + 'signature_url': 'abschluss', + 'signature_image': 'abschluss', + 'onboarded_by_email': 'abschluss', + 'agreement_confirm': 'abschluss', +} + + +def _default_sort(form_type: str, field_name: str) -> int: + ordered = DEFAULT_FIELD_ORDER.get(form_type, []) + if field_name in ordered: + return ordered.index(field_name) + return len(ordered) + 500 + + +def _ensure_configs(form_type: str, field_names: list[str]) -> dict[str, FormFieldConfig]: + existing = { + cfg.field_name: cfg + for cfg in FormFieldConfig.objects.filter(form_type=form_type, field_name__in=field_names) + } + missing_names = [name for name in field_names if name not in existing] + if missing_names: + FormFieldConfig.objects.bulk_create( + [ + FormFieldConfig( + form_type=form_type, + field_name=name, + sort_order=_default_sort(form_type, name), + page_key=ONBOARDING_DEFAULT_PAGE.get(name, '') if form_type == 'onboarding' else '', + ) + for name in missing_names + ], + ignore_conflicts=True, + ) + existing = { + cfg.field_name: cfg + for cfg in FormFieldConfig.objects.filter(form_type=form_type, field_name__in=field_names) + } + return existing + + +def ensure_form_field_configs(form_type: str, field_names: list[str]) -> dict[str, FormFieldConfig]: + return _ensure_configs(form_type, field_names) + + +def apply_form_field_config(form_type: str, form) -> None: + field_names = list(form.fields.keys()) + configs = _ensure_configs(form_type, field_names) + locked = LOCKED_FIELD_RULES.get(form_type, set()) + + for field_name, field in list(form.fields.items()): + cfg = configs.get(field_name) + if not cfg: + continue + + if cfg.label_override.strip(): + field.label = cfg.label_override.strip() + + if cfg.help_text_override.strip(): + field.help_text = cfg.help_text_override.strip() + + if field_name not in locked and cfg.is_required is not None: + field.required = cfg.is_required + + if field_name not in locked and not cfg.is_visible: + form.fields.pop(field_name, None) + + ordered_items = sorted( + form.fields.items(), + key=lambda item: ( + configs[item[0]].sort_order if item[0] in configs else _default_sort(form_type, item[0]), + item[0], + ), + ) + form.fields = OrderedDict(ordered_items) + if form_type == 'onboarding': + form._field_page_keys = { + name: (configs[name].page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss')) + for name in form.fields.keys() + if name in configs + } diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py new file mode 100644 index 0000000..535862c --- /dev/null +++ b/backend/workflows/forms.py @@ -0,0 +1,396 @@ +from django import forms +from pathlib import Path + +from .form_builder import apply_form_field_config +from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest + + +YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] +EMPLOYMENT_CHOICES = [('', 'Wählen Sie eine'), ('befristet', 'befristet'), ('unbefristet', 'unbefristet')] +GENDER_CHOICES = [('', 'Wählen Sie eine'), ('herr', 'Herr'), ('frau', 'Frau'), ('divers', 'Divers')] +DEPARTMENT_CHOICES = [ + ('Buchhaltung/Personalverwaltung', 'Buchhaltung/Personalverwaltung'), + ('IT-Service', 'IT-Service'), + ('Azubi', 'Azubi'), + ('Marketing/Kommunikation', 'Marketing/Kommunikation'), + ('Messe', 'Messe'), + ('Kongresse', 'Kongresse'), + ('TNB/Studiengänge', 'TNB/Studiengänge'), + ('TUB-Academy', 'TUB-Academy'), + ('Projekte TUB', 'Projekte TUB'), + ('TU Berlin Summer + Winter School', 'TU Berlin Summer + Winter School'), +] + +DEVICE_CHOICES = [ + ('Laptop', 'Laptop'), + ('Docking-Station', 'Docking-Station'), + ('Tastatur und Maus', 'Tastatur und Maus'), + ('Kopfhörer', 'Kopfhörer'), + ('Tragetasche', 'Tragetasche'), + ('Monitor', 'Monitor'), + ('Schlüssel', 'Schlüssel'), + ('Tischtelefon', 'Tischtelefon'), +] + +SOFTWARE_CHOICES = [ + ('eM Client', 'eM Client'), + ('KeepassXC', 'KeepassXC'), + ('Nextcloud', 'Nextcloud'), + ('7-Zip', '7-Zip'), + ('PDF Reader', 'PDF Reader'), + ('PDF-Editor (Flexi PDF)', 'PDF-Editor (Flexi PDF)'), + ('Firefox', 'Firefox'), + ('Chrome', 'Chrome'), + ('Backup Client', 'Backup Client'), + ('MS Office', 'MS Office'), + ('Zoom', 'Zoom'), + ('Cisco VPN Client', 'Cisco VPN Client'), +] + +ACCESS_CHOICES = [ + ('TU Konto', 'TU Konto'), + ('HR Works', 'HR Works'), + ('Datev', 'Datev'), + ('Odoo', 'Odoo'), +] + +WORKSPACE_GROUP_CHOICES = [ + ('Group-Academy', 'Group-Academy'), + ('Group-BuSu', 'Group-BuSu'), + ('Group-EIT-Urban-Mobility', 'Group-EIT-Urban-Mobility'), + ('Group-EL', 'Group-EL'), + ('Group-EM', 'Group-EM'), + ('Group-IT-Services', 'Group-IT-Services'), + ('Group-Kongresse-Events', 'Group-Kongresse-Events'), + ('Group-MaCo', 'Group-MaCo'), + ('Group-Messe', 'Group-Messe'), + ('Group-Messe-Kongresse', 'Group-Messe-Kongresse'), + ('Group-MSE', 'Group-MSE'), + ('Group-SuMo', 'Group-SuMo'), + ('Group-TNB', 'Group-TNB'), + ('Group-TUBS', 'Group-TUBS'), + ('Group-Wima', 'Group-Wima'), + ('Group-Leitungsrunde', 'Group-Leitungsrunde'), + ('Campus-Euref', 'Campus-Euref'), + ('Group-Lohnbuchhaltung', 'Group-Lohnbuchhaltung'), + ('Group-SU-WU', 'Group-SU-WU'), + ('NWM', 'NWM'), +] + +RESOURCE_CHOICES = [('Drucker HBS 5./6. OG', 'Drucker HBS 5./6. OG'), ('Drucker Euref', 'Drucker Euref')] +PHONE_CHOICES = [ + ('030 4472021 (0-9)', '030 4472021 (0-9)'), + ('030 4472022 (0-9)', '030 4472022 (0-9)'), + ('030 4472023 (0-9)', '030 4472023 (0-9)'), + ('030 4472024 (0-9)', '030 4472024 (0-9)'), + ('030 4472025 (0-9)', '030 4472025 (0-9)'), + ('030 4472026 (0-9)', '030 4472026 (0-9)'), + ('030 4472027 (0-9)', '030 4472027 (0-9)'), + ('030 4472028 (0-9)', '030 4472028 (0-9)'), +] + +HARDWARE_EXTRA_CHOICES = [('Smartphone', 'Smartphone'), ('Anderes', 'Anderes')] +SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)', 'Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'), ('Anderes', 'Anderes')] + + +class OnboardingRequestForm(forms.ModelForm): + first_name = forms.CharField(label='Vorname', required=False) + last_name = forms.CharField(label='Nachname', required=False) + full_name = forms.CharField(required=False, widget=forms.HiddenInput()) + gender = forms.ChoiceField(label='Anrede', choices=GENDER_CHOICES, required=True) + department = forms.ChoiceField(label='Abteilung', choices=DEPARTMENT_CHOICES, required=True) + work_email = forms.EmailField( + label='Gewünschte dienstliche E-Mail-Adresse', + help_text='Bitte nutzen Sie das Format name@tub.co.', + ) + contract_start = forms.DateField(label='Vertragsbeginn', widget=forms.DateInput(attrs={'type': 'date'})) + employment_type = forms.ChoiceField(label='Beschäftigungsverhältnis', choices=EMPLOYMENT_CHOICES, required=True) + employment_end_date = forms.DateField( + label='Enddatum (nur bei befristet)', + required=False, + widget=forms.DateInput(attrs={'type': 'date'}), + ) + handover_date = forms.DateField(label='Gewünschtes Übergabedatum der Geräte', required=False, widget=forms.DateInput(attrs={'type': 'date'})) + + group_mailboxes_required_choice = forms.ChoiceField(label='Gruppenpostfächer erforderlich?', choices=YES_NO_CHOICES, required=False) + additional_software_needed_choice = forms.ChoiceField(label='Wird zusätzliche Software benötigt?', choices=YES_NO_CHOICES, required=False) + additional_hardware_needed_choice = forms.ChoiceField(label='Darüber hinaus wird weitere Hardware benötigt?', choices=YES_NO_CHOICES, required=False) + additional_access_needed_choice = forms.ChoiceField(label='Darüber hinaus werden weitere Zugänge benötigt?', choices=YES_NO_CHOICES, required=False) + successor_required_choice = forms.ChoiceField(label='Neue Mitarbeitende ist Nachfolge von?', choices=YES_NO_CHOICES, required=False) + inherit_phone_number_choice = forms.ChoiceField(label='Telefonnummer von Vorgängerperson übernehmen?', choices=YES_NO_CHOICES, required=False) + + order_business_cards = forms.BooleanField(label='Visitenkarten bestellen?', required=False) + + needed_devices_multi = forms.MultipleChoiceField( + label='Benötigte Geräte und Gegenstände', + choices=DEVICE_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + needed_software_multi = forms.MultipleChoiceField( + label='Benötigte Software', + choices=SOFTWARE_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + needed_accesses_multi = forms.MultipleChoiceField( + label='Benötigte Zugänge', + choices=ACCESS_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + needed_workspace_groups_multi = forms.MultipleChoiceField( + label='Benötigte Gruppen im Workspace', + choices=WORKSPACE_GROUP_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + needed_resources_multi = forms.MultipleChoiceField( + label='Benötigte Ressourcen', + choices=RESOURCE_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + phone_number_choice = forms.CharField( + label='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)', + required=False, + widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}), + ) + additional_hardware_multi = forms.MultipleChoiceField( + label='Zusätzliche Hardware', + choices=HARDWARE_EXTRA_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + additional_software_multi = forms.MultipleChoiceField( + label='Zusätzlich gewünschte Software', + choices=SOFTWARE_EXTRA_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + ) + + agreement_confirm = forms.BooleanField( + label='Hiermit bestätige ich die Richtigkeit und Vollständigkeit meiner Angaben.', + required=True, + ) + + class Meta: + model = OnboardingRequest + fields = [ + 'full_name', + 'gender', + 'job_title', + 'department', + 'work_email', + 'contract_start', + 'employment_type', + 'employment_end_date', + 'handover_date', + 'order_business_cards', + 'business_card_name', + 'business_card_title', + 'business_card_email', + 'business_card_phone', + 'group_mailboxes', + 'additional_software', + 'additional_hardware_other', + 'additional_access_text', + 'needed_resources', + 'successor_name', + 'additional_notes', + 'signature_image', + ] + widgets = { + 'group_mailboxes': forms.Textarea(attrs={'rows': 2}), + 'additional_software': forms.Textarea(attrs={'rows': 2}), + 'additional_hardware_other': forms.Textarea(attrs={'rows': 2}), + 'additional_access_text': forms.Textarea(attrs={'rows': 2}), + 'successor_name': forms.TextInput(), + 'additional_notes': forms.Textarea(attrs={'rows': 4}), + } + + @staticmethod + def _choices_from_options(category: str, fallback: list[tuple[str, str]]) -> list[tuple[str, str]]: + options = FormOption.objects.filter(category=category, is_active=True).order_by('sort_order', 'label') + if not options.exists(): + return fallback + return [(o.value or o.label, o.label) for o in options] + + def __init__(self, *args, **kwargs): + self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower() + super().__init__(*args, **kwargs) + + self.fields['full_name'].label = 'Name' + full_name_initial = (self.initial.get('full_name') or '').strip() + if full_name_initial and not self.initial.get('first_name') and not self.initial.get('last_name'): + name_parts = full_name_initial.split() + if name_parts: + self.fields['first_name'].initial = name_parts[0] + self.fields['last_name'].initial = ' '.join(name_parts[1:]) if len(name_parts) > 1 else '' + self.fields['department'].choices = self._choices_from_options('department', DEPARTMENT_CHOICES) + self.fields['needed_devices_multi'].choices = self._choices_from_options('device', DEVICE_CHOICES) + self.fields['needed_software_multi'].choices = self._choices_from_options('software', SOFTWARE_CHOICES) + self.fields['needed_accesses_multi'].choices = self._choices_from_options('access', ACCESS_CHOICES) + self.fields['needed_workspace_groups_multi'].choices = self._choices_from_options('workspace_group', WORKSPACE_GROUP_CHOICES) + self.fields['needed_resources_multi'].choices = self._choices_from_options('resource', RESOURCE_CHOICES) + self.fields['signature_image'].required = False + apply_form_field_config('onboarding', self) + + def clean_work_email(self): + value = (self.cleaned_data.get('work_email') or '').strip().lower() + if not value: + return value + if not value.endswith('@tub.co'): + raise forms.ValidationError('Bitte verwenden Sie eine @tub.co E-Mail-Adresse.') + return value + + def clean_signature_image(self): + image = self.cleaned_data.get('signature_image') + if not image: + return image + max_size = 4 * 1024 * 1024 # 4 MB + if image.size > max_size: + raise forms.ValidationError('Die Signatur-Datei ist zu groß (max. 4 MB).') + content_type = (getattr(image, 'content_type', '') or '').lower().strip() + extension = Path(getattr(image, 'name', '')).suffix.lower() + allowed_content_types = { + 'image/png', + 'image/x-png', + 'image/jpeg', + 'image/jpg', + 'image/pjpeg', + } + allowed_extensions = {'.png', '.jpg', '.jpeg'} + if content_type and not content_type.startswith('image/'): + raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') + if content_type and content_type not in allowed_content_types and extension not in allowed_extensions: + raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') + if not content_type and extension not in allowed_extensions: + raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') + try: + header = image.read(16) + image.seek(0) + except Exception: + raise forms.ValidationError('Die Signatur-Datei konnte nicht gelesen werden.') + is_png = header.startswith(b'\x89PNG\r\n\x1a\n') + is_jpeg = header.startswith(b'\xff\xd8\xff') + if not (is_png or is_jpeg): + raise forms.ValidationError('Die Signatur-Datei ist kein gültiges PNG/JPG-Bild.') + return image + + def clean(self): + cleaned = super().clean() + has = self.fields.__contains__ + first_name = (cleaned.get('first_name') or '').strip() + last_name = (cleaned.get('last_name') or '').strip() + full_name = (cleaned.get('full_name') or '').strip() + + if first_name and last_name: + cleaned['full_name'] = f'{first_name} {last_name}'.strip() + elif full_name: + parts = full_name.split() + cleaned['first_name'] = parts[0] if parts else '' + cleaned['last_name'] = ' '.join(parts[1:]) if len(parts) > 1 else '' + else: + if not first_name: + self.add_error('first_name', 'Bitte Vornamen eingeben.') + if not last_name: + self.add_error('last_name', 'Bitte Nachnamen eingeben.') + + if has('employment_end_date') and cleaned.get('employment_type') == 'befristet' and not cleaned.get('employment_end_date'): + self.add_error('employment_end_date', 'Bei befristeter Beschäftigung ist ein Enddatum erforderlich.') + + if cleaned.get('order_business_cards'): + for f in ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone']: + if has(f) and not cleaned.get(f): + self.add_error(f, 'Dieses Feld ist für die Visitenkartenbestellung erforderlich.') + + if has('group_mailboxes') and cleaned.get('group_mailboxes_required_choice') == 'ja' and not cleaned.get('group_mailboxes'): + self.add_error('group_mailboxes', 'Bitte Gruppenpostfächer eintragen.') + + if has('additional_hardware_multi') and cleaned.get('additional_hardware_needed_choice') == 'ja' and not cleaned.get('additional_hardware_multi'): + self.add_error('additional_hardware_multi', 'Bitte mindestens eine Hardware-Option wählen.') + + if has('additional_software_multi') and cleaned.get('additional_software_needed_choice') == 'ja' and not cleaned.get('additional_software_multi'): + self.add_error('additional_software_multi', 'Bitte mindestens eine Software-Option wählen.') + + if has('additional_access_text') and cleaned.get('additional_access_needed_choice') == 'ja' and not cleaned.get('additional_access_text'): + self.add_error('additional_access_text', 'Bitte zusätzliche Zugänge eintragen.') + + if has('successor_name') and cleaned.get('successor_required_choice') == 'ja' and not cleaned.get('successor_name'): + self.add_error('successor_name', 'Bitte Name der Vorgängerperson eintragen.') + + return cleaned + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.group_mailboxes_required = self.cleaned_data.get('group_mailboxes_required_choice') == 'ja' + instance.additional_software_needed = self.cleaned_data.get('additional_software_needed_choice') == 'ja' + instance.additional_hardware_needed = self.cleaned_data.get('additional_hardware_needed_choice') == 'ja' + instance.additional_access_needed = self.cleaned_data.get('additional_access_needed_choice') == 'ja' + instance.successor_required = self.cleaned_data.get('successor_required_choice') == 'ja' + instance.inherit_phone_number = self.cleaned_data.get('inherit_phone_number_choice') == 'ja' + + instance.needed_devices = '\n'.join(self.cleaned_data.get('needed_devices_multi', [])) + instance.needed_software = '\n'.join(self.cleaned_data.get('needed_software_multi', [])) + instance.needed_accesses = '\n'.join(self.cleaned_data.get('needed_accesses_multi', [])) + instance.needed_workspace_groups = '\n'.join(self.cleaned_data.get('needed_workspace_groups_multi', [])) + instance.needed_resources = '\n'.join(self.cleaned_data.get('needed_resources_multi', [])) + instance.additional_hardware = '\n'.join(self.cleaned_data.get('additional_hardware_multi', [])) + + selected_extra_software = self.cleaned_data.get('additional_software_multi', []) + free_software = self.cleaned_data.get('additional_software', '').strip() + all_extra_software = list(selected_extra_software) + if free_software: + all_extra_software.append(free_software) + instance.additional_software = '\n'.join([s for s in all_extra_software if s]) + + if not instance.successor_required: + instance.successor_name = '' + instance.inherit_phone_number = False + + if instance.inherit_phone_number: + instance.phone_number = '' + else: + instance.phone_number = self.cleaned_data.get('phone_number_choice', '') + + instance.agreement = 'accepted' if self.cleaned_data.get('agreement_confirm') else '' + instance.onboarded_by_email = self.requester_email + + if commit: + instance.save() + return instance + + +class OffboardingRequestForm(forms.ModelForm): + search_query = forms.CharField( + label='Mitarbeitende suchen (Name oder E-Mail)', + required=False, + help_text='Optional: Suche zur automatischen Vorbefüllung aus bereits onboardeten Personen.', + ) + + class Meta: + model = OffboardingRequest + fields = [ + 'full_name', + 'work_email', + 'department', + 'job_title', + 'last_working_day', + 'notes', + ] + widgets = { + 'last_working_day': forms.DateInput(attrs={'type': 'date'}), + 'notes': forms.Textarea(attrs={'rows': 3}), + } + + def __init__(self, *args, **kwargs): + prefill_profile = kwargs.pop('prefill_profile', None) + super().__init__(*args, **kwargs) + if prefill_profile: + self.fields['full_name'].initial = prefill_profile.full_name + self.fields['work_email'].initial = prefill_profile.work_email + self.fields['department'].initial = prefill_profile.department + self.fields['job_title'].initial = prefill_profile.job_title + apply_form_field_config('offboarding', self) diff --git a/backend/workflows/management/__init__.py b/backend/workflows/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/workflows/management/commands/__init__.py b/backend/workflows/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/workflows/management/commands/run_staging_e2e_check.py b/backend/workflows/management/commands/run_staging_e2e_check.py new file mode 100644 index 0000000..cb415fd --- /dev/null +++ b/backend/workflows/management/commands/run_staging_e2e_check.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from pathlib import Path + +import requests +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from workflows.models import EmployeeProfile, OffboardingRequest, OnboardingRequest +from workflows.tasks import process_offboarding_request, process_onboarding_request + + +@dataclass +class CheckResult: + name: str + status: str + detail: str + + +def _mailhog_messages(api_url: str) -> list[dict]: + response = requests.get(api_url, timeout=15) + response.raise_for_status() + payload = response.json() + items = payload.get('items', []) + if isinstance(items, list): + return items + return [] + + +def _extract_subject(msg: dict) -> str: + headers = ((msg or {}).get('Content') or {}).get('Headers') or {} + subject = headers.get('Subject') or [''] + if isinstance(subject, list) and subject: + return str(subject[0]) + return str(subject) + + +def _nextcloud_file_exists(remote_filename: str) -> bool: + if not settings.NEXTCLOUD_BASE_URL or not settings.NEXTCLOUD_DIRECTORY: + return False + + remote_url = f"{settings.NEXTCLOUD_BASE_URL}/{settings.NEXTCLOUD_DIRECTORY}/{remote_filename}" + auth = (settings.NEXTCLOUD_USERNAME, settings.NEXTCLOUD_PASSWORD) + + try: + head = requests.head(remote_url, auth=auth, timeout=20, allow_redirects=True) + if head.status_code in (200, 204): + return True + if head.status_code not in (405, 501): + return False + except requests.RequestException: + return False + + try: + get_resp = requests.get( + remote_url, + auth=auth, + timeout=20, + headers={'Range': 'bytes=0-0'}, + stream=True, + allow_redirects=True, + ) + return get_resp.status_code in (200, 206) + except requests.RequestException: + return False + + +class Command(BaseCommand): + help = ( + 'Run a real end-to-end staging verification: onboarding + offboarding processing, ' + 'PDF generation, email evidence (optional MailHog), and Nextcloud verification.' + ) + + def add_arguments(self, parser): + parser.add_argument( + '--email-check', + choices=['auto', 'mailhog', 'none'], + default='auto', + help='Email verification mode. auto tries MailHog if reachable.', + ) + parser.add_argument( + '--mailhog-api-url', + default='http://mailhog:8025/api/v2/messages', + help='MailHog API URL used when email-check is auto/mailhog.', + ) + parser.add_argument( + '--skip-nextcloud', + action='store_true', + help='Skip Nextcloud existence checks.', + ) + parser.add_argument( + '--cleanup', + action='store_true', + help='Delete generated E2E DB rows and PDFs after checks.', + ) + + def handle(self, *args, **options): + run_id = timezone.now().strftime('%Y%m%d%H%M%S') + employee_name = f'E2E Check {run_id}' + work_email = f'e2e.{run_id}@tub.co' + requester_email = 'e2e.requester@tub.co' + + created_onboarding: OnboardingRequest | None = None + created_offboarding: OffboardingRequest | None = None + check_results: list[CheckResult] = [] + email_before: list[dict] = [] + email_after: list[dict] = [] + use_mailhog = False + + email_mode = options['email_check'] + mailhog_api_url = options['mailhog_api_url'] + + if email_mode in ('auto', 'mailhog'): + try: + email_before = _mailhog_messages(mailhog_api_url) + use_mailhog = True + check_results.append(CheckResult('mailhog_access', 'PASS', f'MailHog reachable: {mailhog_api_url}')) + except Exception as exc: + if email_mode == 'mailhog': + raise CommandError(f'MailHog is required but not reachable: {exc}') + check_results.append(CheckResult('mailhog_access', 'WARN', f'MailHog not reachable, skipping email evidence: {exc}')) + + try: + created_onboarding = OnboardingRequest.objects.create( + full_name=employee_name, + gender='herr', + job_title='QA Engineer', + department='IT-Service', + work_email=work_email, + contract_start=timezone.localdate() + timedelta(days=7), + employment_type='unbefristet', + order_business_cards=True, + business_card_name=employee_name, + business_card_title='QA Engineer', + business_card_email=work_email, + business_card_phone='030 44720210', + needed_devices='Laptop\nSchlüssel', + needed_accesses='HR Works', + needed_software='Nextcloud', + needed_resources='Drucker Euref', + group_mailboxes_required=False, + onboarded_by_email=requester_email, + agreement='accepted', + additional_notes=f'E2E run {run_id}', + ) + process_onboarding_request(created_onboarding.id) + created_onboarding.refresh_from_db() + + onboarding_pdf = Path(created_onboarding.generated_pdf_path) + if created_onboarding.generated_pdf_path and onboarding_pdf.exists() and onboarding_pdf.stat().st_size > 0: + check_results.append(CheckResult('onboarding_pdf', 'PASS', str(onboarding_pdf))) + else: + check_results.append(CheckResult('onboarding_pdf', 'FAIL', 'Onboarding PDF missing or empty')) + + profile_exists = EmployeeProfile.objects.filter(work_email=work_email).exists() + check_results.append( + CheckResult('employee_profile', 'PASS' if profile_exists else 'FAIL', f'Profile for {work_email}') + ) + + created_offboarding = OffboardingRequest.objects.create( + full_name=employee_name, + work_email=work_email, + department='IT-Service', + job_title='QA Engineer', + last_working_day=timezone.localdate() + timedelta(days=30), + notes=f'E2E run {run_id}', + requested_by_email=requester_email, + ) + process_offboarding_request(created_offboarding.id) + created_offboarding.refresh_from_db() + + offboarding_pdf = Path(created_offboarding.generated_pdf_path) + if created_offboarding.generated_pdf_path and offboarding_pdf.exists() and offboarding_pdf.stat().st_size > 0: + check_results.append(CheckResult('offboarding_pdf', 'PASS', str(offboarding_pdf))) + else: + check_results.append(CheckResult('offboarding_pdf', 'FAIL', 'Offboarding PDF missing or empty')) + + if not options['skip_nextcloud']: + if settings.NEXTCLOUD_ENABLED: + on_name = onboarding_pdf.name if created_onboarding.generated_pdf_path else '' + off_name = offboarding_pdf.name if created_offboarding.generated_pdf_path else '' + on_ok = bool(on_name) and _nextcloud_file_exists(on_name) + off_ok = bool(off_name) and _nextcloud_file_exists(off_name) + check_results.append( + CheckResult('nextcloud_onboarding_pdf', 'PASS' if on_ok else 'FAIL', on_name or 'no filename') + ) + check_results.append( + CheckResult('nextcloud_offboarding_pdf', 'PASS' if off_ok else 'FAIL', off_name or 'no filename') + ) + else: + check_results.append(CheckResult('nextcloud', 'WARN', 'NEXTCLOUD_ENABLED=0, skipped')) + + if use_mailhog: + email_after = _mailhog_messages(mailhog_api_url) + before_size = len(email_before) + new_msgs = email_after[before_size:] if len(email_after) >= before_size else email_after + matching = [m for m in new_msgs if run_id in _extract_subject(m)] + if len(matching) >= 8: + check_results.append( + CheckResult('email_evidence', 'PASS', f'Found {len(matching)} MailHog messages containing run id {run_id}') + ) + else: + check_results.append( + CheckResult('email_evidence', 'FAIL', f'Expected >=8 matching messages, found {len(matching)}') + ) + else: + check_results.append(CheckResult('email_evidence', 'WARN', 'MailHog disabled/unavailable, not verified')) + + finally: + if options['cleanup']: + for path in ( + Path(created_onboarding.generated_pdf_path) if created_onboarding and created_onboarding.generated_pdf_path else None, + Path(created_offboarding.generated_pdf_path) if created_offboarding and created_offboarding.generated_pdf_path else None, + ): + if path and path.exists(): + path.unlink(missing_ok=True) + + if created_offboarding: + created_offboarding.delete() + if created_onboarding: + created_onboarding.delete() + EmployeeProfile.objects.filter(work_email=work_email).delete() + + self.stdout.write('') + self.stdout.write(self.style.NOTICE(f'E2E staging check run id: {run_id}')) + for item in check_results: + if item.status == 'PASS': + style = self.style.SUCCESS + elif item.status == 'WARN': + style = self.style.WARNING + else: + style = self.style.ERROR + self.stdout.write(style(f'[{item.status}] {item.name}: {item.detail}')) + + failures = [r for r in check_results if r.status == 'FAIL'] + if failures: + raise CommandError(f'E2E staging check failed with {len(failures)} failing check(s).') + + self.stdout.write(self.style.SUCCESS('All mandatory staging checks passed.')) diff --git a/backend/workflows/migrations/0001_initial.py b/backend/workflows/migrations/0001_initial.py new file mode 100644 index 0000000..c009a25 --- /dev/null +++ b/backend/workflows/migrations/0001_initial.py @@ -0,0 +1,53 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='EmployeeProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=255)), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=155)), + ('department', models.CharField(blank=True, max_length=255)), + ('job_title', models.CharField(blank=True, max_length=255)), + ('work_email', models.EmailField(max_length=254, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='OnboardingRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=255, verbose_name='Vorname und Nachname')), + ('job_title', models.CharField(blank=True, max_length=255, verbose_name='Berufsbezeichnung')), + ('department', models.CharField(blank=True, max_length=255, verbose_name='Abteilung')), + ('work_email', models.EmailField(max_length=254, verbose_name='Gewünschte dienstliche E-Mail-Adresse')), + ('contract_start', models.DateField(verbose_name='Vertragsbeginn')), + ('handover_date', models.DateField(blank=True, null=True, verbose_name='Gewünschtes Übergabedatum der Geräte')), + ('group_mailboxes_required', models.BooleanField(default=False, verbose_name='Gruppenpostfächer erforderlich?')), + ('group_mailboxes', models.TextField(blank=True, verbose_name='Gruppenpostfächer')), + ('needed_devices', models.TextField(blank=True, verbose_name='Benötigte Geräte und Gegenstände')), + ('needed_software', models.TextField(blank=True, verbose_name='Benötigte Software')), + ('needed_accesses', models.TextField(blank=True, verbose_name='Benötigte Zugänge')), + ('needed_workspace_groups', models.TextField(blank=True, verbose_name='Benötigte Gruppen im Workspace')), + ('additional_software_needed', models.BooleanField(default=False, verbose_name='Wird zusätzliche Software benötigt?')), + ('additional_software', models.TextField(blank=True, verbose_name='Zusätzlich gewünschte Software (ohne Garantie)')), + ('needed_resources', models.TextField(blank=True, verbose_name='Benötigte Ressourcen')), + ('phone_number', models.CharField(blank=True, max_length=100, verbose_name='TUBS-Telefon-Direktwahl-Nr. 030 447202 (10-89)')), + ('additional_notes', models.TextField(blank=True, verbose_name='Raum für zusätzliche Anmerkungen und Wünsche')), + ('agreement', models.TextField(blank=True, verbose_name='Vereinbarung')), + ('signature_url', models.URLField(blank=True, verbose_name='Unterschrift')), + ('personalized_text', models.TextField(blank=True, help_text='Optionaler individueller Textblock im Onboarding PDF.', verbose_name='Personalisierter Text für PDF')), + ('generated_pdf_path', models.CharField(blank=True, max_length=500)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/backend/workflows/migrations/0002_form_updates.py b/backend/workflows/migrations/0002_form_updates.py new file mode 100644 index 0000000..8b40ce1 --- /dev/null +++ b/backend/workflows/migrations/0002_form_updates.py @@ -0,0 +1,91 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingrequest', + name='additional_access_needed', + field=models.BooleanField(default=False, verbose_name='Werden weitere Zugänge benötigt?'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='additional_access_text', + field=models.TextField(blank=True, verbose_name='Weitere Zugänge (Freitext)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='additional_hardware', + field=models.TextField(blank=True, verbose_name='Zusätzliche Hardware'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='additional_hardware_needed', + field=models.BooleanField(default=False, verbose_name='Wird zusätzliche Hardware benötigt?'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='additional_hardware_other', + field=models.TextField(blank=True, verbose_name='Weitere Hardware (Freitext)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='business_card_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='E-Mailadresse (Visitenkarte)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='business_card_name', + field=models.CharField(blank=True, max_length=255, verbose_name='Name (Visitenkarte)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='business_card_phone', + field=models.CharField(blank=True, max_length=100, verbose_name='Telefonnummer (Visitenkarte)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='business_card_title', + field=models.CharField(blank=True, max_length=255, verbose_name='Titel (Visitenkarte)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='employment_end_date', + field=models.DateField(blank=True, null=True, verbose_name='Enddatum (nur bei befristet)'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='employment_type', + field=models.CharField(blank=True, choices=[('befristet', 'befristet'), ('unbefristet', 'unbefristet')], max_length=20, verbose_name='Beschäftigungsverhältnis'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='inherit_phone_number', + field=models.BooleanField(default=False, verbose_name='Telefonnummer von Vorgängerperson übernehmen'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='order_business_cards', + field=models.BooleanField(default=False, verbose_name='Bestellung Visitenkarten'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='successor_name', + field=models.CharField(blank=True, max_length=255, verbose_name='Name der Vorgängerperson'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='successor_required', + field=models.BooleanField(default=False, verbose_name='Neue Mitarbeitende ist Nachfolge von?'), + ), + migrations.AlterField( + model_name='onboardingrequest', + name='phone_number', + field=models.CharField(blank=True, max_length=100, verbose_name='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)'), + ), + ] diff --git a/backend/workflows/migrations/0003_gender_onboarded_by.py b/backend/workflows/migrations/0003_gender_onboarded_by.py new file mode 100644 index 0000000..3863192 --- /dev/null +++ b/backend/workflows/migrations/0003_gender_onboarded_by.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0002_form_updates'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingrequest', + name='gender', + field=models.CharField(blank=True, choices=[('frau', 'Frau'), ('herr', 'Herr'), ('divers', 'Divers')], max_length=20, verbose_name='Geschlecht'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='onboarded_by_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='E-Mail der anfordernden Person'), + ), + ] diff --git a/backend/workflows/migrations/0004_backend_config_models.py b/backend/workflows/migrations/0004_backend_config_models.py new file mode 100644 index 0000000..4f213cc --- /dev/null +++ b/backend/workflows/migrations/0004_backend_config_models.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0003_gender_onboarded_by'), + ] + + operations = [ + migrations.CreateModel( + name='WorkflowConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default', max_length=120, unique=True)), + ('it_onboarding_email', models.EmailField(blank=True, max_length=254)), + ('general_info_email', models.EmailField(blank=True, max_length=254)), + ('business_card_email', models.EmailField(blank=True, max_length=254)), + ('hr_works_email', models.EmailField(blank=True, max_length=254)), + ('legal_text', models.TextField(blank=True, default='Eine Ausrüstungsvereinbarung erlaubt es einem Mitarbeitenden, die Ausrüstung des Unternehmens im Außendienst oder zu Hause zu nutzen und mitzunehmen.')), + ], + ), + migrations.CreateModel( + name='FormOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('department', 'Abteilung'), ('device', 'Geräte'), ('software', 'Software'), ('access', 'Zugänge'), ('workspace_group', 'Workspace-Gruppen'), ('resource', 'Ressourcen'), ('phone', 'Telefonnummern')], max_length=40)), + ('label', models.CharField(max_length=255)), + ('value', models.CharField(blank=True, max_length=255)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['category', 'sort_order', 'label'], + 'unique_together': {('category', 'label')}, + }, + ), + migrations.AlterField( + model_name='onboardingrequest', + name='gender', + field=models.CharField(blank=True, choices=[('herr', 'Herr'), ('frau', 'Frau'), ('divers', 'Divers')], max_length=20, verbose_name='Anrede'), + ), + ] diff --git a/backend/workflows/migrations/0005_seed_backend_config.py b/backend/workflows/migrations/0005_seed_backend_config.py new file mode 100644 index 0000000..53417ad --- /dev/null +++ b/backend/workflows/migrations/0005_seed_backend_config.py @@ -0,0 +1,68 @@ +from django.db import migrations + + +def seed_defaults(apps, schema_editor): + FormOption = apps.get_model('workflows', 'FormOption') + WorkflowConfig = apps.get_model('workflows', 'WorkflowConfig') + + option_map = { + 'department': [ + 'Buchhaltung/Personalverwaltung', 'IT-Service', 'Azubi', 'Marketing/Kommunikation', 'Messe', + 'Kongresse', 'TNB/Studiengänge', 'TUB-Academy', 'Projekte TUB', 'TU Berlin Summer + Winter School', + ], + 'device': [ + 'Laptop', 'Docking-Station', 'Tastatur und Maus', 'Kopfhörer', 'Tragetasche', 'Monitor', 'Schlüssel', 'Tischtelefon', + ], + 'software': [ + 'eM Client', 'KeepassXC', 'Nextcloud', '7-Zip', 'PDF Reader', 'PDF-Editor (Flexi PDF)', + 'Firefox', 'Chrome', 'Backup Client', 'MS Office', 'Zoom', 'Cisco VPN Client', + ], + 'access': ['TU Konto', 'HR Works', 'Datev', 'Odoo'], + 'workspace_group': [ + 'Group-Academy', 'Group-BuSu', 'Group-EIT-Urban-Mobility', 'Group-EL', 'Group-EM', 'Group-IT-Services', + 'Group-Kongresse-Events', 'Group-MaCo', 'Group-Messe', 'Group-Messe-Kongresse', 'Group-MSE', 'Group-SuMo', + 'Group-TNB', 'Group-TUBS', 'Group-Wima', 'Group-Leitungsrunde', 'Campus-Euref', 'Group-Lohnbuchhaltung', + 'Group-SU-WU', 'NWM', + ], + 'resource': ['Drucker HBS 5./6. OG', 'Drucker Euref'], + 'phone': [ + '030 4472021 (0-9)', '030 4472022 (0-9)', '030 4472023 (0-9)', '030 4472024 (0-9)', + '030 4472025 (0-9)', '030 4472026 (0-9)', '030 4472027 (0-9)', '030 4472028 (0-9)', + ], + } + + for category, labels in option_map.items(): + for idx, label in enumerate(labels, start=1): + FormOption.objects.get_or_create( + category=category, + label=label, + defaults={'value': label, 'sort_order': idx, 'is_active': True}, + ) + + WorkflowConfig.objects.get_or_create( + name='Default', + defaults={ + 'it_onboarding_email': 'it@tub.co', + 'general_info_email': 'ingo.einacker@tub.co', + 'business_card_email': 'kommunikation@tub.co', + 'hr_works_email': 'dittrich@tub.co', + }, + ) + + +def rollback_seed(apps, schema_editor): + FormOption = apps.get_model('workflows', 'FormOption') + WorkflowConfig = apps.get_model('workflows', 'WorkflowConfig') + FormOption.objects.all().delete() + WorkflowConfig.objects.filter(name='Default').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0004_backend_config_models'), + ] + + operations = [ + migrations.RunPython(seed_defaults, rollback_seed), + ] diff --git a/backend/workflows/migrations/0006_offboarding_and_key_email.py b/backend/workflows/migrations/0006_offboarding_and_key_email.py new file mode 100644 index 0000000..affc58a --- /dev/null +++ b/backend/workflows/migrations/0006_offboarding_and_key_email.py @@ -0,0 +1,33 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0005_seed_backend_config'), + ] + + operations = [ + migrations.AddField( + model_name='workflowconfig', + name='key_notification_email', + field=models.EmailField(blank=True, max_length=254), + ), + migrations.CreateModel( + name='OffboardingRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=255, verbose_name='Vorname und Nachname')), + ('work_email', models.EmailField(max_length=254, verbose_name='Dienstliche E-Mail-Adresse')), + ('department', models.CharField(blank=True, max_length=255, verbose_name='Abteilung')), + ('job_title', models.CharField(blank=True, max_length=255, verbose_name='Berufsbezeichnung')), + ('last_working_day', models.DateField(verbose_name='Letzter Arbeitstag')), + ('offboarding_reason', models.TextField(blank=True, verbose_name='Grund')), + ('notes', models.TextField(blank=True, verbose_name='Notizen')), + ('requested_by_email', models.EmailField(max_length=254, verbose_name='E-Mail der anfordernden Person')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('employee_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='workflows.employeeprofile')), + ], + ), + ] diff --git a/backend/workflows/migrations/0007_seed_key_email.py b/backend/workflows/migrations/0007_seed_key_email.py new file mode 100644 index 0000000..1cd4515 --- /dev/null +++ b/backend/workflows/migrations/0007_seed_key_email.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def seed_key_email(apps, schema_editor): + WorkflowConfig = apps.get_model('workflows', 'WorkflowConfig') + for cfg in WorkflowConfig.objects.all(): + if not cfg.key_notification_email: + cfg.key_notification_email = 'minuth@tub.co' + cfg.save(update_fields=['key_notification_email']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0006_offboarding_and_key_email'), + ] + + operations = [ + migrations.RunPython(seed_key_email, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0008_offboarding_pdf_path.py b/backend/workflows/migrations/0008_offboarding_pdf_path.py new file mode 100644 index 0000000..ba30a02 --- /dev/null +++ b/backend/workflows/migrations/0008_offboarding_pdf_path.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0007_seed_key_email'), + ] + + operations = [ + migrations.AddField( + model_name='offboardingrequest', + name='generated_pdf_path', + field=models.CharField(blank=True, max_length=500), + ), + ] diff --git a/backend/workflows/migrations/0009_offboardingrequest_signature.py b/backend/workflows/migrations/0009_offboardingrequest_signature.py new file mode 100644 index 0000000..83dc6a1 --- /dev/null +++ b/backend/workflows/migrations/0009_offboardingrequest_signature.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-09 12:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0008_offboarding_pdf_path'), + ] + + operations = [ + migrations.AddField( + model_name='offboardingrequest', + name='signature', + field=models.CharField(blank=True, max_length=255, verbose_name='Unterschrift (Name)'), + ), + ] diff --git a/backend/workflows/migrations/0010_onboardingrequest_signature_image.py b/backend/workflows/migrations/0010_onboardingrequest_signature_image.py new file mode 100644 index 0000000..012c248 --- /dev/null +++ b/backend/workflows/migrations/0010_onboardingrequest_signature_image.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-09 13:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0009_offboardingrequest_signature'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingrequest', + name='signature_image', + field=models.ImageField(blank=True, null=True, upload_to='signatures/', verbose_name='Unterschrift (Bilddatei)'), + ), + ] diff --git a/backend/workflows/migrations/0011_notificationtemplate.py b/backend/workflows/migrations/0011_notificationtemplate.py new file mode 100644 index 0000000..91c908c --- /dev/null +++ b/backend/workflows/migrations/0011_notificationtemplate.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.5 on 2026-03-09 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0010_onboardingrequest_signature_image'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(choices=[('onboarding_it', 'Onboarding: IT'), ('onboarding_general_info', 'Onboarding: Allgemeine Info'), ('onboarding_business_card', 'Onboarding: Visitenkarte'), ('onboarding_hr_works', 'Onboarding: HR Works'), ('onboarding_key', 'Onboarding: Schlüssel'), ('onboarding_reference', 'Onboarding: Referenz Anfordernde Person'), ('offboarding_it', 'Offboarding: IT'), ('offboarding_general_info', 'Offboarding: Allgemeine Info'), ('offboarding_hr_works_disable', 'Offboarding: HR Works Deaktivierung'), ('offboarding_reference', 'Offboarding: Referenz Anfordernde Person')], max_length=60, unique=True)), + ('subject_template', models.CharField(max_length=255)), + ('body_template', models.TextField()), + ('is_active', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['key'], + }, + ), + ] diff --git a/backend/workflows/migrations/0012_seed_notification_templates.py b/backend/workflows/migrations/0012_seed_notification_templates.py new file mode 100644 index 0000000..2224956 --- /dev/null +++ b/backend/workflows/migrations/0012_seed_notification_templates.py @@ -0,0 +1,72 @@ +from django.db import migrations + + +def seed_notification_templates(apps, schema_editor): + NotificationTemplate = apps.get_model('workflows', 'NotificationTemplate') + templates = { + 'onboarding_it': { + 'subject_template': '[Onboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Neue Onboarding-Anfrage für {{ FULL_NAME }}.\nAbteilung: {{ DEPARTMENT }}\nVertragsbeginn: {{ CONTRACT_START }}\nAngefordert von: {{ REQUESTED_BY }}\nBitte IT-Setup vorbereiten.', + }, + 'onboarding_general_info': { + 'subject_template': '[Info Onboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Hallo,\n\n{{ FULL_NAME }} wird onboarded.\nAbteilung: {{ DEPARTMENT }}\nVertragsbeginn: {{ CONTRACT_START }}\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + 'onboarding_business_card': { + 'subject_template': '[Visitenkarte] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Hallo,\n\nbitte Visitenkarten erstellen:\nName: {{ BUSINESS_CARD_NAME }}\nTitel: {{ BUSINESS_CARD_TITLE }}\nE-Mail: {{ BUSINESS_CARD_EMAIL }}\nTelefon: {{ BUSINESS_CARD_PHONE }}\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + 'onboarding_hr_works': { + 'subject_template': '[HR Works] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Hello Stefanie,\n\nEs ist wieder soweit. Zuwachs!\n\nKönntest du deshalb bitte ein HR Works Konto mit den folgenden Daten erstellen:\n\nName: {{ VORNAME }} {{ NACHNAME }}\nAbteilung: {{ DEPARTMENT }}\nVertragsbeginn: {{ CONTRACT_START }}\nE-Mail-Adresse: {{ EMAIL }}\n\n{% if PDF_LINK %}In 2 Minuten findest du alle Infos über den Mitarbeiter als PDF unter diesem Link: {{ PDF_LINK }}\n\n{% endif %}Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei der it@tub.co melden!\n\nVielen Dank und schöne Grüße,\nDie IT.', + }, + 'onboarding_key': { + 'subject_template': '[Schlüssel] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Hallo,\n\nbitte Schlüssel vorbereiten für:\nName: {{ FULL_NAME }}\nAbteilung: {{ DEPARTMENT }}\nVertragsbeginn: {{ CONTRACT_START }}\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + 'onboarding_reference': { + 'subject_template': '[Referenz Onboarding] {{ FULL_NAME }} | Ihre Anfrage', + 'body_template': 'Diese E-Mail dient als Referenz für Ihre Onboarding-Anfrage.\nName: {{ FULL_NAME }}\nAbteilung: {{ DEPARTMENT }}\nVertragsbeginn: {{ CONTRACT_START }}\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + 'offboarding_it': { + 'subject_template': '[Offboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Neue Offboarding-Anfrage für {{ FULL_NAME }}.\nAbteilung: {{ DEPARTMENT }}\nLetzter Arbeitstag: {{ LAST_WORKING_DAY }}\nAngefordert von: {{ REQUESTED_BY }}\nBitte IT-Offboarding durchführen.', + }, + 'offboarding_general_info': { + 'subject_template': '[Info Offboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Neue Offboarding-Anfrage für {{ FULL_NAME }}.\nAbteilung: {{ DEPARTMENT }}\nLetzter Arbeitstag: {{ LAST_WORKING_DAY }}\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + 'offboarding_hr_works_disable': { + 'subject_template': '[HR Works Deaktivierung] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body_template': 'Bitte HR Works Zugriff deaktivieren für {{ FULL_NAME }} ({{ EMAIL }}) zum {{ LAST_WORKING_DAY }}.\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + 'offboarding_reference': { + 'subject_template': '[Referenz Offboarding] {{ FULL_NAME }} | Ihre Anfrage', + 'body_template': 'Diese E-Mail dient als Referenz für Ihre Offboarding-Anfrage.\nName: {{ FULL_NAME }}\nAbteilung: {{ DEPARTMENT }}\nLetzter Arbeitstag: {{ LAST_WORKING_DAY }}\nAngefordert von: {{ REQUESTED_BY }}\n', + }, + } + + for key, payload in templates.items(): + NotificationTemplate.objects.get_or_create( + key=key, + defaults={ + 'subject_template': payload['subject_template'], + 'body_template': payload['body_template'], + 'is_active': True, + }, + ) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0011_notificationtemplate'), + ] + + operations = [ + migrations.RunPython(seed_notification_templates, noop_reverse), + ] diff --git a/backend/workflows/migrations/0013_systememailconfig.py b/backend/workflows/migrations/0013_systememailconfig.py new file mode 100644 index 0000000..682a74b --- /dev/null +++ b/backend/workflows/migrations/0013_systememailconfig.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-09 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0012_seed_notification_templates'), + ] + + operations = [ + migrations.CreateModel( + name='SystemEmailConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default SMTP', max_length=120, unique=True)), + ('is_active', models.BooleanField(default=False)), + ('host', models.CharField(blank=True, max_length=255)), + ('port', models.PositiveIntegerField(default=587)), + ('username', models.CharField(blank=True, max_length=255)), + ('password', models.CharField(blank=True, max_length=255)), + ('use_tls', models.BooleanField(default=True)), + ('use_ssl', models.BooleanField(default=False)), + ('from_email', models.EmailField(blank=True, max_length=254)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'System SMTP Konfiguration', + 'verbose_name_plural': 'System SMTP Konfigurationen', + }, + ), + ] diff --git a/backend/workflows/migrations/0014_formfieldconfig.py b/backend/workflows/migrations/0014_formfieldconfig.py new file mode 100644 index 0000000..5dca43d --- /dev/null +++ b/backend/workflows/migrations/0014_formfieldconfig.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.5 on 2026-03-09 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0013_systememailconfig'), + ] + + operations = [ + migrations.CreateModel( + name='FormFieldConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20)), + ('field_name', models.CharField(max_length=80)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_visible', models.BooleanField(default=True)), + ('is_required', models.BooleanField(blank=True, default=None, null=True)), + ('label_override', models.CharField(blank=True, max_length=255)), + ('help_text_override', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'Formularfeld-Konfiguration', + 'verbose_name_plural': 'Formularfeld-Konfigurationen', + 'ordering': ['form_type', 'sort_order', 'field_name'], + 'unique_together': {('form_type', 'field_name')}, + }, + ), + ] diff --git a/backend/workflows/migrations/0015_seed_formfieldconfig_defaults.py b/backend/workflows/migrations/0015_seed_formfieldconfig_defaults.py new file mode 100644 index 0000000..340ff56 --- /dev/null +++ b/backend/workflows/migrations/0015_seed_formfieldconfig_defaults.py @@ -0,0 +1,86 @@ +# Generated by Codex on 2026-03-09 + +from django.db import migrations + + +ONBOARDING_FIELDS = [ + 'full_name', + 'gender', + 'job_title', + 'department', + 'work_email', + 'order_business_cards', + 'business_card_name', + 'business_card_title', + 'business_card_email', + 'business_card_phone', + 'contract_start', + 'employment_type', + 'employment_end_date', + 'handover_date', + 'group_mailboxes_required_choice', + 'group_mailboxes', + 'needed_devices_multi', + 'additional_hardware_needed_choice', + 'additional_hardware_multi', + 'additional_hardware_other', + 'needed_software_multi', + 'additional_software_needed_choice', + 'additional_software_multi', + 'additional_software', + 'needed_accesses_multi', + 'additional_access_needed_choice', + 'additional_access_text', + 'needed_workspace_groups_multi', + 'needed_resources_multi', + 'successor_required_choice', + 'successor_name', + 'inherit_phone_number_choice', + 'phone_number_choice', + 'additional_notes', + 'signature_url', + 'signature_image', + 'onboarded_by_email', + 'agreement_confirm', +] + +OFFBOARDING_FIELDS = [ + 'full_name', + 'work_email', + 'department', + 'job_title', + 'last_working_day', + 'notes', + 'requested_by_email', +] + + +def seed_defaults(apps, schema_editor): + FormFieldConfig = apps.get_model('workflows', 'FormFieldConfig') + for idx, name in enumerate(ONBOARDING_FIELDS): + FormFieldConfig.objects.get_or_create( + form_type='onboarding', + field_name=name, + defaults={'sort_order': idx, 'is_visible': True}, + ) + for idx, name in enumerate(OFFBOARDING_FIELDS): + FormFieldConfig.objects.get_or_create( + form_type='offboarding', + field_name=name, + defaults={'sort_order': idx, 'is_visible': True}, + ) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('workflows', '0014_formfieldconfig'), + ] + + operations = [ + migrations.RunPython(seed_defaults, noop_reverse), + ] + diff --git a/backend/workflows/migrations/0016_formfieldconfig_page_key.py b/backend/workflows/migrations/0016_formfieldconfig_page_key.py new file mode 100644 index 0000000..35e4921 --- /dev/null +++ b/backend/workflows/migrations/0016_formfieldconfig_page_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-09 19:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0015_seed_formfieldconfig_defaults'), + ] + + operations = [ + migrations.AddField( + model_name='formfieldconfig', + name='page_key', + field=models.CharField(blank=True, choices=[('', 'Automatisch'), ('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss')], default='', max_length=20), + ), + ] diff --git a/backend/workflows/migrations/0017_seed_formfieldconfig_page_keys.py b/backend/workflows/migrations/0017_seed_formfieldconfig_page_keys.py new file mode 100644 index 0000000..d139553 --- /dev/null +++ b/backend/workflows/migrations/0017_seed_formfieldconfig_page_keys.py @@ -0,0 +1,69 @@ +# Generated by Codex on 2026-03-09 + +from django.db import migrations + + +ONBOARDING_DEFAULT_PAGE = { + 'full_name': 'stammdaten', + 'gender': 'stammdaten', + 'job_title': 'stammdaten', + 'department': 'stammdaten', + 'work_email': 'stammdaten', + 'order_business_cards': 'stammdaten', + 'business_card_name': 'stammdaten', + 'business_card_title': 'stammdaten', + 'business_card_email': 'stammdaten', + 'business_card_phone': 'stammdaten', + 'contract_start': 'vertrag', + 'employment_type': 'vertrag', + 'employment_end_date': 'vertrag', + 'handover_date': 'vertrag', + 'group_mailboxes_required_choice': 'vertrag', + 'group_mailboxes': 'vertrag', + 'needed_devices_multi': 'itsetup', + 'additional_hardware_needed_choice': 'itsetup', + 'additional_hardware_multi': 'itsetup', + 'additional_hardware_other': 'itsetup', + 'needed_software_multi': 'itsetup', + 'additional_software_needed_choice': 'itsetup', + 'additional_software_multi': 'itsetup', + 'additional_software': 'itsetup', + 'needed_accesses_multi': 'itsetup', + 'additional_access_needed_choice': 'itsetup', + 'additional_access_text': 'itsetup', + 'needed_workspace_groups_multi': 'itsetup', + 'needed_resources_multi': 'itsetup', + 'successor_required_choice': 'itsetup', + 'successor_name': 'itsetup', + 'inherit_phone_number_choice': 'itsetup', + 'phone_number_choice': 'itsetup', + 'additional_notes': 'abschluss', + 'signature_url': 'abschluss', + 'signature_image': 'abschluss', + 'onboarded_by_email': 'abschluss', + 'agreement_confirm': 'abschluss', +} + + +def seed_page_keys(apps, schema_editor): + FormFieldConfig = apps.get_model('workflows', 'FormFieldConfig') + for cfg in FormFieldConfig.objects.filter(form_type='onboarding'): + if cfg.page_key: + continue + cfg.page_key = ONBOARDING_DEFAULT_PAGE.get(cfg.field_name, 'abschluss') + cfg.save(update_fields=['page_key']) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('workflows', '0016_formfieldconfig_page_key'), + ] + + operations = [ + migrations.RunPython(seed_page_keys, noop_reverse), + ] + diff --git a/backend/workflows/migrations/0018_workflowconfig_nextcloud_enabled_override.py b/backend/workflows/migrations/0018_workflowconfig_nextcloud_enabled_override.py new file mode 100644 index 0000000..4767a5a --- /dev/null +++ b/backend/workflows/migrations/0018_workflowconfig_nextcloud_enabled_override.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-10 09:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0017_seed_formfieldconfig_page_keys'), + ] + + operations = [ + migrations.AddField( + model_name='workflowconfig', + name='nextcloud_enabled_override', + field=models.BooleanField(blank=True, default=None, help_text='Leer = ENV-Wert nutzen, Ja = erzwingen aktiv, Nein = erzwingen inaktiv', null=True, verbose_name='Nextcloud Upload aktiviert (Override)'), + ), + ] diff --git a/backend/workflows/migrations/0019_offboardingrequest_requested_by_name_and_more.py b/backend/workflows/migrations/0019_offboardingrequest_requested_by_name_and_more.py new file mode 100644 index 0000000..f6b5c34 --- /dev/null +++ b/backend/workflows/migrations/0019_offboardingrequest_requested_by_name_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.5 on 2026-03-10 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0018_workflowconfig_nextcloud_enabled_override'), + ] + + operations = [ + migrations.AddField( + model_name='offboardingrequest', + name='requested_by_name', + field=models.CharField(blank=True, max_length=255, verbose_name='Name der anfordernden Person'), + ), + migrations.AddField( + model_name='onboardingrequest', + name='onboarded_by_name', + field=models.CharField(blank=True, max_length=255, verbose_name='Name der anfordernden Person'), + ), + ] diff --git a/backend/workflows/migrations/0020_workflowconfig_email_test_mode_override.py b/backend/workflows/migrations/0020_workflowconfig_email_test_mode_override.py new file mode 100644 index 0000000..2d35fa7 --- /dev/null +++ b/backend/workflows/migrations/0020_workflowconfig_email_test_mode_override.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-10 12:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0019_offboardingrequest_requested_by_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='workflowconfig', + name='email_test_mode_override', + field=models.BooleanField(blank=True, default=None, help_text='Leer = ENV-Wert nutzen, Ja = Testmodus erzwingen, Nein = Produktionsmodus erzwingen', null=True, verbose_name='E-Mail Testmodus aktiv (Override)'), + ), + ] diff --git a/backend/workflows/migrations/0021_workflowconfig_email_account_and_more.py b/backend/workflows/migrations/0021_workflowconfig_email_account_and_more.py new file mode 100644 index 0000000..a756402 --- /dev/null +++ b/backend/workflows/migrations/0021_workflowconfig_email_account_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 5.1.5 on 2026-03-10 12:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0020_workflowconfig_email_test_mode_override'), + ] + + operations = [ + migrations.AddField( + model_name='workflowconfig', + name='email_account', + field=models.EmailField(blank=True, max_length=254, verbose_name='E-Mail Konto'), + ), + migrations.AddField( + model_name='workflowconfig', + name='email_password', + field=models.CharField(blank=True, max_length=255, verbose_name='E-Mail Passwort'), + ), + migrations.AddField( + model_name='workflowconfig', + name='imap_server', + field=models.CharField(blank=True, max_length=255, verbose_name='IMAP Server'), + ), + migrations.AddField( + model_name='workflowconfig', + name='mailbox', + field=models.CharField(blank=True, default='INBOX', max_length=120, verbose_name='Mailbox'), + ), + migrations.AddField( + model_name='workflowconfig', + name='nextcloud_base_url_override', + field=models.CharField(blank=True, max_length=500, verbose_name='Nextcloud Base URL (Override)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='nextcloud_directory_override', + field=models.CharField(blank=True, max_length=255, verbose_name='Nextcloud Verzeichnis (Override)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='nextcloud_password_override', + field=models.CharField(blank=True, max_length=255, verbose_name='Nextcloud Passwort (Override)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='nextcloud_username_override', + field=models.CharField(blank=True, max_length=255, verbose_name='Nextcloud Benutzername (Override)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='smtp_port', + field=models.PositiveIntegerField(default=465, verbose_name='SMTP Port'), + ), + migrations.AddField( + model_name='workflowconfig', + name='smtp_server', + field=models.CharField(blank=True, max_length=255, verbose_name='SMTP Server'), + ), + migrations.AddField( + model_name='workflowconfig', + name='smtp_use_ssl', + field=models.BooleanField(default=True, verbose_name='SMTP SSL nutzen'), + ), + migrations.AddField( + model_name='workflowconfig', + name='smtp_use_tls', + field=models.BooleanField(default=False, verbose_name='SMTP TLS nutzen'), + ), + migrations.AddField( + model_name='workflowconfig', + name='sync_interval_seconds', + field=models.PositiveIntegerField(default=60, verbose_name='Sync-Intervall (Sekunden)'), + ), + ] diff --git a/backend/workflows/migrations/0022_notificationrule.py b/backend/workflows/migrations/0022_notificationrule.py new file mode 100644 index 0000000..5bb9763 --- /dev/null +++ b/backend/workflows/migrations/0022_notificationrule.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2026-03-10 12:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0021_workflowconfig_email_account_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('is_active', models.BooleanField(default=True)), + ('event_type', models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20)), + ('field_name', models.CharField(blank=True, max_length=80)), + ('operator', models.CharField(choices=[('always', 'Immer'), ('contains', 'Enthält'), ('equals', 'Ist gleich'), ('is_true', 'Ist aktiv/Ja'), ('is_false', 'Ist inaktiv/Nein')], default='always', max_length=20)), + ('expected_value', models.CharField(blank=True, max_length=255)), + ('recipients', models.TextField(help_text='Mehrere E-Mail-Adressen mit Komma, Semikolon oder Zeilenumbruch trennen.')), + ('template_key', models.CharField(blank=True, max_length=60)), + ('custom_subject', models.CharField(blank=True, max_length=255)), + ('custom_body', models.TextField(blank=True)), + ('include_pdf_attachment', models.BooleanField(default=False)), + ('sort_order', models.PositiveIntegerField(default=0)), + ], + options={ + 'ordering': ['event_type', 'sort_order', 'id'], + }, + ), + ] diff --git a/backend/workflows/migrations/0023_alter_notificationtemplate_key_scheduledwelcomeemail.py b/backend/workflows/migrations/0023_alter_notificationtemplate_key_scheduledwelcomeemail.py new file mode 100644 index 0000000..a898ae5 --- /dev/null +++ b/backend/workflows/migrations/0023_alter_notificationtemplate_key_scheduledwelcomeemail.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2026-03-10 12:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0022_notificationrule'), + ] + + operations = [ + migrations.AlterField( + model_name='notificationtemplate', + name='key', + field=models.CharField(choices=[('onboarding_it', 'Onboarding: IT'), ('onboarding_general_info', 'Onboarding: Allgemeine Info'), ('onboarding_business_card', 'Onboarding: Visitenkarte'), ('onboarding_hr_works', 'Onboarding: HR Works'), ('onboarding_key', 'Onboarding: Schlüssel'), ('onboarding_reference', 'Onboarding: Referenz Anfordernde Person'), ('onboarding_welcome', 'Onboarding: Welcome E-Mail'), ('offboarding_it', 'Offboarding: IT'), ('offboarding_general_info', 'Offboarding: Allgemeine Info'), ('offboarding_hr_works_disable', 'Offboarding: HR Works Deaktivierung'), ('offboarding_reference', 'Offboarding: Referenz Anfordernde Person')], max_length=60, unique=True), + ), + migrations.CreateModel( + name='ScheduledWelcomeEmail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('recipient_email', models.EmailField(max_length=254)), + ('send_at', models.DateTimeField()), + ('status', models.CharField(choices=[('scheduled', 'Geplant'), ('sent', 'Gesendet'), ('failed', 'Fehlgeschlagen')], default='scheduled', max_length=20)), + ('celery_task_id', models.CharField(blank=True, max_length=100)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('last_error', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('onboarding_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='workflows.onboardingrequest')), + ], + options={ + 'ordering': ['-send_at', '-id'], + }, + ), + ] diff --git a/backend/workflows/migrations/0024_workflowconfig_welcome_email_delay_days_and_more.py b/backend/workflows/migrations/0024_workflowconfig_welcome_email_delay_days_and_more.py new file mode 100644 index 0000000..e83109b --- /dev/null +++ b/backend/workflows/migrations/0024_workflowconfig_welcome_email_delay_days_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-10 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0023_alter_notificationtemplate_key_scheduledwelcomeemail'), + ] + + operations = [ + migrations.AddField( + model_name='workflowconfig', + name='welcome_email_delay_days', + field=models.PositiveIntegerField(default=5, verbose_name='Welcome E-Mail Verzögerung (Tage)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='welcome_include_pdf', + field=models.BooleanField(default=True, verbose_name='Welcome E-Mail mit PDF-Anhang'), + ), + migrations.AddField( + model_name='workflowconfig', + name='welcome_sender_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Welcome E-Mail Absender'), + ), + migrations.AlterField( + model_name='scheduledwelcomeemail', + name='status', + field=models.CharField(choices=[('scheduled', 'Geplant'), ('paused', 'Pausiert'), ('cancelled', 'Abgebrochen'), ('sent', 'Gesendet'), ('failed', 'Fehlgeschlagen')], default='scheduled', max_length=20), + ), + ] diff --git a/backend/workflows/migrations/__init__.py b/backend/workflows/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/workflows/models.py b/backend/workflows/models.py new file mode 100644 index 0000000..fdfa341 --- /dev/null +++ b/backend/workflows/models.py @@ -0,0 +1,320 @@ +from django.db import models + + +class EmployeeProfile(models.Model): + full_name = models.CharField(max_length=255) + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=155) + department = models.CharField(max_length=255, blank=True) + job_title = models.CharField(max_length=255, blank=True) + work_email = models.EmailField(unique=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f"{self.full_name} <{self.work_email}>" + + +class OnboardingRequest(models.Model): + full_name = models.CharField(max_length=255, verbose_name='Vorname und Nachname') + gender = models.CharField( + max_length=20, + blank=True, + choices=[('herr', 'Herr'), ('frau', 'Frau'), ('divers', 'Divers')], + verbose_name='Anrede', + ) + job_title = models.CharField(max_length=255, blank=True, verbose_name='Berufsbezeichnung') + department = models.CharField(max_length=255, blank=True, verbose_name='Abteilung') + work_email = models.EmailField(verbose_name='Gewünschte dienstliche E-Mail-Adresse') + contract_start = models.DateField(verbose_name='Vertragsbeginn') + employment_type = models.CharField( + max_length=20, + blank=True, + choices=[('befristet', 'befristet'), ('unbefristet', 'unbefristet')], + verbose_name='Beschäftigungsverhältnis', + ) + employment_end_date = models.DateField(null=True, blank=True, verbose_name='Enddatum (nur bei befristet)') + handover_date = models.DateField(null=True, blank=True, verbose_name='Gewünschtes Übergabedatum der Geräte') + + order_business_cards = models.BooleanField(default=False, verbose_name='Bestellung Visitenkarten') + business_card_name = models.CharField(max_length=255, blank=True, verbose_name='Name (Visitenkarte)') + business_card_title = models.CharField(max_length=255, blank=True, verbose_name='Titel (Visitenkarte)') + business_card_email = models.EmailField(blank=True, verbose_name='E-Mailadresse (Visitenkarte)') + business_card_phone = models.CharField(max_length=100, blank=True, verbose_name='Telefonnummer (Visitenkarte)') + + group_mailboxes_required = models.BooleanField(default=False, verbose_name='Gruppenpostfächer erforderlich?') + group_mailboxes = models.TextField(blank=True, verbose_name='Gruppenpostfächer') + + needed_devices = models.TextField(blank=True, verbose_name='Benötigte Geräte und Gegenstände') + needed_software = models.TextField(blank=True, verbose_name='Benötigte Software') + needed_accesses = models.TextField(blank=True, verbose_name='Benötigte Zugänge') + needed_workspace_groups = models.TextField(blank=True, verbose_name='Benötigte Gruppen im Workspace') + + additional_software_needed = models.BooleanField(default=False, verbose_name='Wird zusätzliche Software benötigt?') + additional_software = models.TextField(blank=True, verbose_name='Zusätzlich gewünschte Software (ohne Garantie)') + additional_hardware_needed = models.BooleanField(default=False, verbose_name='Wird zusätzliche Hardware benötigt?') + additional_hardware = models.TextField(blank=True, verbose_name='Zusätzliche Hardware') + additional_hardware_other = models.TextField(blank=True, verbose_name='Weitere Hardware (Freitext)') + additional_access_needed = models.BooleanField(default=False, verbose_name='Werden weitere Zugänge benötigt?') + additional_access_text = models.TextField(blank=True, verbose_name='Weitere Zugänge (Freitext)') + needed_resources = models.TextField(blank=True, verbose_name='Benötigte Ressourcen') + phone_number = models.CharField(max_length=100, blank=True, verbose_name='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)') + successor_required = models.BooleanField(default=False, verbose_name='Neue Mitarbeitende ist Nachfolge von?') + successor_name = models.CharField(max_length=255, blank=True, verbose_name='Name der Vorgängerperson') + inherit_phone_number = models.BooleanField(default=False, verbose_name='Telefonnummer von Vorgängerperson übernehmen') + + additional_notes = models.TextField(blank=True, verbose_name='Raum für zusätzliche Anmerkungen und Wünsche') + onboarded_by_email = models.EmailField(blank=True, verbose_name='E-Mail der anfordernden Person') + onboarded_by_name = models.CharField(max_length=255, blank=True, verbose_name='Name der anfordernden Person') + agreement = models.TextField(blank=True, verbose_name='Vereinbarung') + signature_url = models.URLField(blank=True, verbose_name='Unterschrift') + signature_image = models.ImageField(upload_to='signatures/', blank=True, null=True, verbose_name='Unterschrift (Bilddatei)') + + personalized_text = models.TextField( + blank=True, + verbose_name='Personalisierter Text für PDF', + help_text='Optionaler individueller Textblock im Onboarding PDF.', + ) + + generated_pdf_path = models.CharField(max_length=500, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"Onboarding #{self.id} - {self.full_name}" + + +class FormOption(models.Model): + CATEGORY_CHOICES = [ + ('department', 'Abteilung'), + ('device', 'Geräte'), + ('software', 'Software'), + ('access', 'Zugänge'), + ('workspace_group', 'Workspace-Gruppen'), + ('resource', 'Ressourcen'), + ('phone', 'Telefonnummern'), + ] + + category = models.CharField(max_length=40, choices=CATEGORY_CHOICES) + label = models.CharField(max_length=255) + value = models.CharField(max_length=255, blank=True) + sort_order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['category', 'sort_order', 'label'] + unique_together = ('category', 'label') + + def __str__(self) -> str: + return f"{self.get_category_display()}: {self.label}" + + +class FormFieldConfig(models.Model): + PAGE_CHOICES = [ + ('', 'Automatisch'), + ('stammdaten', 'Stammdaten'), + ('vertrag', 'Vertrag'), + ('itsetup', 'IT-Setup'), + ('abschluss', 'Abschluss'), + ] + FORM_CHOICES = [ + ('onboarding', 'Onboarding'), + ('offboarding', 'Offboarding'), + ] + + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + field_name = models.CharField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) + is_visible = models.BooleanField(default=True) + is_required = models.BooleanField(null=True, blank=True, default=None) + page_key = models.CharField(max_length=20, blank=True, default='', choices=PAGE_CHOICES) + label_override = models.CharField(max_length=255, blank=True) + help_text_override = models.TextField(blank=True) + + class Meta: + ordering = ['form_type', 'sort_order', 'field_name'] + unique_together = ('form_type', 'field_name') + verbose_name = 'Formularfeld-Konfiguration' + verbose_name_plural = 'Formularfeld-Konfigurationen' + + def __str__(self) -> str: + return f'{self.get_form_type_display()}: {self.field_name}' + + +class NotificationTemplate(models.Model): + TEMPLATE_CHOICES = [ + ('onboarding_it', 'Onboarding: IT'), + ('onboarding_general_info', 'Onboarding: Allgemeine Info'), + ('onboarding_business_card', 'Onboarding: Visitenkarte'), + ('onboarding_hr_works', 'Onboarding: HR Works'), + ('onboarding_key', 'Onboarding: Schlüssel'), + ('onboarding_reference', 'Onboarding: Referenz Anfordernde Person'), + ('onboarding_welcome', 'Onboarding: Welcome E-Mail'), + ('offboarding_it', 'Offboarding: IT'), + ('offboarding_general_info', 'Offboarding: Allgemeine Info'), + ('offboarding_hr_works_disable', 'Offboarding: HR Works Deaktivierung'), + ('offboarding_reference', 'Offboarding: Referenz Anfordernde Person'), + ] + + key = models.CharField(max_length=60, choices=TEMPLATE_CHOICES, unique=True) + subject_template = models.CharField(max_length=255) + body_template = models.TextField() + is_active = models.BooleanField(default=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['key'] + + def __str__(self) -> str: + return self.get_key_display() + + +class NotificationRule(models.Model): + EVENT_CHOICES = [ + ('onboarding', 'Onboarding'), + ('offboarding', 'Offboarding'), + ] + OPERATOR_CHOICES = [ + ('always', 'Immer'), + ('contains', 'Enthält'), + ('equals', 'Ist gleich'), + ('is_true', 'Ist aktiv/Ja'), + ('is_false', 'Ist inaktiv/Nein'), + ] + + name = models.CharField(max_length=120) + is_active = models.BooleanField(default=True) + event_type = models.CharField(max_length=20, choices=EVENT_CHOICES) + field_name = models.CharField(max_length=80, blank=True) + operator = models.CharField(max_length=20, choices=OPERATOR_CHOICES, default='always') + expected_value = models.CharField(max_length=255, blank=True) + recipients = models.TextField( + help_text='Mehrere E-Mail-Adressen mit Komma, Semikolon oder Zeilenumbruch trennen.' + ) + template_key = models.CharField(max_length=60, blank=True) + custom_subject = models.CharField(max_length=255, blank=True) + custom_body = models.TextField(blank=True) + include_pdf_attachment = models.BooleanField(default=False) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['event_type', 'sort_order', 'id'] + + def __str__(self) -> str: + state = 'aktiv' if self.is_active else 'inaktiv' + return f'{self.get_event_type_display()} | {self.name} ({state})' + + +class ScheduledWelcomeEmail(models.Model): + STATUS_CHOICES = [ + ('scheduled', 'Geplant'), + ('paused', 'Pausiert'), + ('cancelled', 'Abgebrochen'), + ('sent', 'Gesendet'), + ('failed', 'Fehlgeschlagen'), + ] + + onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE) + recipient_email = models.EmailField() + send_at = models.DateTimeField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled') + celery_task_id = models.CharField(max_length=100, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + last_error = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-send_at', '-id'] + + def __str__(self) -> str: + return f'Welcome #{self.id} | {self.recipient_email} | {self.status}' + + +class WorkflowConfig(models.Model): + name = models.CharField(max_length=120, default='Default', unique=True) + it_onboarding_email = models.EmailField(blank=True) + general_info_email = models.EmailField(blank=True) + business_card_email = models.EmailField(blank=True) + hr_works_email = models.EmailField(blank=True) + key_notification_email = models.EmailField(blank=True) + nextcloud_enabled_override = models.BooleanField( + null=True, + blank=True, + default=None, + verbose_name='Nextcloud Upload aktiviert (Override)', + help_text='Leer = ENV-Wert nutzen, Ja = erzwingen aktiv, Nein = erzwingen inaktiv', + ) + email_test_mode_override = models.BooleanField( + null=True, + blank=True, + default=None, + verbose_name='E-Mail Testmodus aktiv (Override)', + help_text='Leer = ENV-Wert nutzen, Ja = Testmodus erzwingen, Nein = Produktionsmodus erzwingen', + ) + nextcloud_base_url_override = models.CharField(max_length=500, blank=True, verbose_name='Nextcloud Base URL (Override)') + nextcloud_username_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Benutzername (Override)') + nextcloud_password_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Passwort (Override)') + nextcloud_directory_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Verzeichnis (Override)') + sync_interval_seconds = models.PositiveIntegerField(default=60, verbose_name='Sync-Intervall (Sekunden)') + welcome_email_delay_days = models.PositiveIntegerField(default=5, verbose_name='Welcome E-Mail Verzögerung (Tage)') + welcome_sender_email = models.EmailField(blank=True, verbose_name='Welcome E-Mail Absender') + welcome_include_pdf = models.BooleanField(default=True, verbose_name='Welcome E-Mail mit PDF-Anhang') + + imap_server = models.CharField(max_length=255, blank=True, verbose_name='IMAP Server') + mailbox = models.CharField(max_length=120, blank=True, default='INBOX', verbose_name='Mailbox') + smtp_server = models.CharField(max_length=255, blank=True, verbose_name='SMTP Server') + smtp_port = models.PositiveIntegerField(default=465, verbose_name='SMTP Port') + email_account = models.EmailField(blank=True, verbose_name='E-Mail Konto') + email_password = models.CharField(max_length=255, blank=True, verbose_name='E-Mail Passwort') + smtp_use_ssl = models.BooleanField(default=True, verbose_name='SMTP SSL nutzen') + smtp_use_tls = models.BooleanField(default=False, verbose_name='SMTP TLS nutzen') + + legal_text = models.TextField( + blank=True, + default='Eine Ausrüstungsvereinbarung erlaubt es einem Mitarbeitenden, die Ausrüstung des Unternehmens im Außendienst oder zu Hause zu nutzen und mitzunehmen.', + ) + + def __str__(self) -> str: + return self.name + + +class SystemEmailConfig(models.Model): + name = models.CharField(max_length=120, default='Default SMTP', unique=True) + is_active = models.BooleanField(default=False) + host = models.CharField(max_length=255, blank=True) + port = models.PositiveIntegerField(default=587) + username = models.CharField(max_length=255, blank=True) + password = models.CharField(max_length=255, blank=True) + use_tls = models.BooleanField(default=True) + use_ssl = models.BooleanField(default=False) + from_email = models.EmailField(blank=True) + + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'System SMTP Konfiguration' + verbose_name_plural = 'System SMTP Konfigurationen' + + def __str__(self) -> str: + state = 'aktiv' if self.is_active else 'inaktiv' + return f'{self.name} ({state})' + + +class OffboardingRequest(models.Model): + employee_profile = models.ForeignKey(EmployeeProfile, null=True, blank=True, on_delete=models.SET_NULL) + full_name = models.CharField(max_length=255, verbose_name='Vorname und Nachname') + work_email = models.EmailField(verbose_name='Dienstliche E-Mail-Adresse') + department = models.CharField(max_length=255, blank=True, verbose_name='Abteilung') + job_title = models.CharField(max_length=255, blank=True, verbose_name='Berufsbezeichnung') + last_working_day = models.DateField(verbose_name='Letzter Arbeitstag') + offboarding_reason = models.TextField(blank=True, verbose_name='Grund') + notes = models.TextField(blank=True, verbose_name='Notizen') + signature = models.CharField(max_length=255, blank=True, verbose_name='Unterschrift (Name)') + requested_by_email = models.EmailField(verbose_name='E-Mail der anfordernden Person') + requested_by_name = models.CharField(max_length=255, blank=True, verbose_name='Name der anfordernden Person') + generated_pdf_path = models.CharField(max_length=500, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"Offboarding #{self.id} - {self.full_name}" diff --git a/backend/workflows/services.py b/backend/workflows/services.py new file mode 100644 index 0000000..ec0882b --- /dev/null +++ b/backend/workflows/services.py @@ -0,0 +1,104 @@ +from pathlib import Path +import logging +import time + +import requests +from django.conf import settings + +from .models import WorkflowConfig + +logger = logging.getLogger(__name__) + + +def is_nextcloud_enabled() -> bool: + config = WorkflowConfig.objects.order_by('id').first() + if config and config.nextcloud_enabled_override is not None: + return bool(config.nextcloud_enabled_override) + return bool(settings.NEXTCLOUD_ENABLED) + + +def is_email_test_mode() -> bool: + config = WorkflowConfig.objects.order_by('id').first() + if config and config.email_test_mode_override is not None: + return bool(config.email_test_mode_override) + return bool(settings.EMAIL_TEST_MODE) + + +def get_email_test_redirect() -> str: + return settings.EMAIL_TEST_REDIRECT + + +def get_nextcloud_settings() -> dict[str, str]: + config = WorkflowConfig.objects.order_by('id').first() + base_url = ( + config.nextcloud_base_url_override.strip() + if config and config.nextcloud_base_url_override.strip() + else settings.NEXTCLOUD_BASE_URL + ) + username = ( + config.nextcloud_username_override.strip() + if config and config.nextcloud_username_override.strip() + else settings.NEXTCLOUD_USERNAME + ) + password = ( + config.nextcloud_password_override + if config and config.nextcloud_password_override + else settings.NEXTCLOUD_PASSWORD + ) + directory = ( + config.nextcloud_directory_override.strip() + if config and config.nextcloud_directory_override.strip() + else settings.NEXTCLOUD_DIRECTORY + ) + return { + 'base_url': (base_url or '').rstrip('/'), + 'username': username or '', + 'password': password or '', + 'directory': (directory or '').strip('/'), + } + + +def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool: + if not is_nextcloud_enabled(): + return False + + nc = get_nextcloud_settings() + base_url = nc['base_url'] + directory = nc['directory'] + if not base_url or not directory: + return False + + safe_remote_name = Path(remote_filename).name + remote_url = f"{base_url}/{directory}/{safe_remote_name}" + retries = max(0, int(getattr(settings, 'NEXTCLOUD_UPLOAD_RETRIES', 2))) + timeout = max(5, int(getattr(settings, 'NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', 30))) + + for attempt in range(retries + 1): + try: + with local_file.open('rb') as handle: + response = requests.put( + remote_url, + data=handle, + auth=(nc['username'], nc['password']), + timeout=timeout, + ) + if response.status_code in (200, 201, 204): + return True + logger.warning( + 'Nextcloud upload failed with status %s (attempt %s/%s) for %s', + response.status_code, + attempt + 1, + retries + 1, + safe_remote_name, + ) + except requests.RequestException as exc: + logger.warning( + 'Nextcloud upload error (attempt %s/%s) for %s: %s', + attempt + 1, + retries + 1, + safe_remote_name, + exc, + ) + if attempt < retries: + time.sleep(0.6 * (attempt + 1)) + return False diff --git a/backend/workflows/static/workflows/css/buttons.css b/backend/workflows/static/workflows/css/buttons.css new file mode 100644 index 0000000..28ea76c --- /dev/null +++ b/backend/workflows/static/workflows/css/buttons.css @@ -0,0 +1,58 @@ +:root { + --btn-primary-bg: #000078; + --btn-primary-border: #000078; + --btn-primary-text: #ffffff; + --btn-secondary-bg: #ffffff; + --btn-secondary-border: #c7d3e0; + --btn-secondary-text: #000078; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid transparent; + text-decoration: none; + font-weight: 600; + font-size: 14px; + line-height: 1.2; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease; +} + +.btn:focus-visible { + outline: 3px solid rgba(0, 0, 120, 0.2); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn-primary { + background: var(--btn-primary-bg); + border-color: var(--btn-primary-border); + color: var(--btn-primary-text); +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(0.95); +} + +.btn-secondary { + background: var(--btn-secondary-bg); + border-color: var(--btn-secondary-border); + color: var(--btn-secondary-text); +} + +.btn-secondary:hover:not(:disabled) { + background: #f8fafc; +} diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css new file mode 100644 index 0000000..1e1a7c6 --- /dev/null +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -0,0 +1,324 @@ +body { + margin: 0; + font-family: Arial, sans-serif; + background: #f4f7fb; + color: #1f2937; +} + +.shell { + width: min(1280px, 94%); + margin: 20px auto 28px; + background: #ffffff; + border: 1px solid #d8e2f0; + border-radius: 14px; + padding: 18px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.brand-logo { + width: 190px; + max-width: 100%; + height: auto; + display: block; +} + +.header h1 { + margin: 0; + font-size: 28px; +} + +.header p { + margin: 6px 0 0; + color: #64748b; +} + +.toolbar { + margin-top: 14px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.tab { + border: 1px solid #cbd5e1; + border-radius: 999px; + padding: 8px 14px; + text-decoration: none; + color: #1f2937; + background: #f8fafc; + font-weight: 600; +} + +.tab.active { + background: #000078; + color: #ffffff; + border-color: #000078; +} + +.status { + min-height: 22px; + margin: 10px 0 8px; + color: #334155; + font-size: 14px; +} + +.flash { + margin-top: 10px; + border: 1px solid #bbf7d0; + background: #f0fdf4; + color: #166534; + border-radius: 10px; + padding: 8px 10px; + font-size: 14px; +} + +.flash.error { + border-color: #fecaca; + background: #fff1f2; + color: #991b1b; +} + +.status.error { + color: #991b1b; +} + +.status.success { + color: #166534; +} + +.columns { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 10px; +} + +.columns.single { + grid-template-columns: minmax(320px, 1fr); +} + +.column { + border: 1px solid #d4dce7; + border-radius: 12px; + background: #f9fbff; + display: flex; + flex-direction: column; + min-height: 460px; +} + +.column h2 { + margin: 0; + padding: 10px 12px; + border-bottom: 1px solid #dbe3ee; + font-size: 16px; + color: #0f172a; + background: #edf3fb; + border-radius: 12px 12px 0 0; +} + +.dropzone { + padding: 10px; + display: grid; + gap: 8px; + align-content: start; + min-height: 140px; + flex: 1; +} + +.dropzone.drag-over { + background: #ecf5ff; +} + +.field-card { + background: #ffffff; + border: 1px solid #d3dbe8; + border-radius: 10px; + padding: 9px 10px; + display: flex; + justify-content: space-between; + gap: 8px; + cursor: move; +} + +.field-card.dragging { + opacity: 0.5; +} + +.field-label { + font-weight: 700; + font-size: 14px; + color: #0f172a; +} + +.field-name { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.badges { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.badge { + font-size: 11px; + border-radius: 999px; + border: 1px solid #d1d5db; + padding: 2px 7px; + background: #f8fafc; + color: #334155; +} + +.badge.locked { + background: #e0e7ff; + border-color: #c7d2fe; + color: #3730a3; +} + +.badge.hidden { + background: #fff7ed; + border-color: #fed7aa; + color: #9a3412; +} + +.badge.required { + background: #dcfce7; + border-color: #bbf7d0; + color: #166534; +} + +.options-panel { + margin-top: 16px; + border: 1px solid #d4dce7; + border-radius: 12px; + background: #ffffff; + padding: 12px; +} + +.options-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.options-head h2 { + margin: 0; + font-size: 18px; +} + +.category-switch { + display: flex; + align-items: center; + gap: 8px; +} + +.category-switch select { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 6px 8px; +} + +.add-option-form { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; + gap: 8px; + margin-bottom: 10px; +} + +.add-option-form input { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 8px 9px; +} + +.option-table-wrap { + overflow-x: auto; +} + +.option-table { + width: 100%; + border-collapse: collapse; +} + +.option-table th, +.option-table td { + border: 1px solid #e2e8f0; + padding: 7px 8px; + text-align: left; +} + +.option-table th { + background: #f8fafc; +} + +.option-table input[type='text'] { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 7px; + padding: 6px 8px; + box-sizing: border-box; +} + +.option-row { + cursor: grab; +} + +.option-row.dragging { + opacity: 0.5; + background: #ecf5ff; +} + +.drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #f8fafc; + color: #475569; + font-size: 14px; + line-height: 1; + user-select: none; +} + +.options-actions { + margin-top: 10px; +} + +@media (max-width: 1120px) { + .columns { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + } +} + +@media (max-width: 760px) { + .columns { + grid-template-columns: 1fr; + } + + .add-option-form { + grid-template-columns: 1fr; + } + + .options-head { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/backend/workflows/static/workflows/css/offboarding_form.css b/backend/workflows/static/workflows/css/offboarding_form.css new file mode 100644 index 0000000..56fded5 --- /dev/null +++ b/backend/workflows/static/workflows/css/offboarding_form.css @@ -0,0 +1,29 @@ +body { + font-family: "IBM Plex Sans", "Trebuchet MS", "Segoe UI", sans-serif; + margin: 24px; + color: #1f2937; + background: + radial-gradient(900px 520px at 8% 0%, #dbe8ff, transparent), + radial-gradient(900px 520px at 92% 0%, #eef4ff, transparent), + #edf2fb; +} +.wrap { max-width: 920px; margin: 0 auto; } +.brand-logo { width: 180px; max-width: 100%; height: auto; margin: 0 0 10px; display: block; } +.top-link { margin-bottom: 10px; } +.card { background: linear-gradient(180deg, #ffffff, #fbfcff); border: 1px solid #d9dcf3; border-radius: 14px; padding: 18px; margin-bottom: 14px; box-shadow: 0 10px 24px rgba(0, 0, 120, 0.08); } +h1 { margin-top: 0; color: #000078; } +.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.field { margin-bottom: 12px; } +.field-full { grid-column: 1 / -1; } +label { display: block; font-weight: 600; margin-bottom: 6px; } +input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; box-sizing: border-box; border: 1px solid #d4dbf7; border-radius: 10px; background: #fff; } +textarea { min-height: 120px; resize: vertical; } +.hint { color: #64748b; font-size: 12px; margin-top: 4px; } +.results a { display: inline-block; margin: 4px 8px 4px 0; padding: 6px 8px; border: 1px solid #d4dbf7; border-radius: 6px; text-decoration: none; color: #000078; background: #f7f8ff; } +.errorlist { color: #b91c1c; margin: 4px 0; } +.popup-backdrop { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.38); display: none; align-items: center; justify-content: center; z-index: 1000; } +.popup-backdrop.show { display: flex; } +.popup { background: #fff; border: 1px solid #d8e0ec; border-radius: 12px; padding: 18px; width: min(460px, calc(100% - 28px)); box-shadow: 0 18px 40px rgba(2, 6, 23, 0.25); } +.popup h3 { margin: 0 0 8px; color: #000078; } +.popup p { margin: 0 0 14px; color: #475569; } +@media (max-width: 820px) { .grid { grid-template-columns: 1fr; } } diff --git a/backend/workflows/static/workflows/css/onboarding_form.css b/backend/workflows/static/workflows/css/onboarding_form.css new file mode 100644 index 0000000..b095902 --- /dev/null +++ b/backend/workflows/static/workflows/css/onboarding_form.css @@ -0,0 +1,442 @@ +:root { + --bg-a: #d3e3ff; + --bg-b: #eef4ff; + --ink: #182233; + --muted: #5e6f85; + --brand: #000078; + --brand-soft: #eef1ff; + --line: #d7dfeb; + --danger: #c53030; + --warn-bg: #fff7ed; + --warn-border: #fdba74; + --card: #ffffff; +} + +body { + margin: 0; + font-family: "IBM Plex Sans", "Trebuchet MS", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(980px 540px at 12% 0%, var(--bg-a), transparent), + radial-gradient(900px 520px at 88% 0%, var(--bg-b), transparent), + #edf2fb; + min-height: 100vh; + padding: 26px 14px; +} + +.shell { + max-width: 1120px; + margin: 0 auto; + display: grid; + grid-template-columns: 290px 1fr; + gap: 16px; +} + +.top-wrap { + max-width: 1120px; + margin: 0 auto 10px; +} + +.panel, +.main { + background: var(--card); + border: 1px solid var(--line); + border-radius: 16px; + box-shadow: 0 12px 28px rgba(30, 52, 87, 0.08); +} + +.panel { + padding: 18px; + height: fit-content; + position: sticky; + top: 20px; +} + +.main { + padding: 22px; + background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); +} + +h1 { + margin: 0 0 8px; + font-size: 28px; + letter-spacing: -0.02em; +} + +.sub { + margin: 0 0 16px; + color: var(--muted); + font-size: 14px; +} + +.brand-logo { + width: 180px; + max-width: 100%; + height: auto; + margin: 0 0 10px; + display: block; +} + +.top-link { + margin: 0 0 10px; +} + +.step-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.step-item { + display: flex; + gap: 10px; + align-items: flex-start; + border: 1px solid #d8e0f4; + border-radius: 12px; + padding: 10px; + background: linear-gradient(160deg, #f8faff, #fcfdff); + cursor: pointer; + transition: border-color 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease; +} + +.step-item.active { + border-color: #9db4ff; + background: linear-gradient(160deg, #eaf0ff, #f4f7ff); + box-shadow: 0 6px 16px rgba(0, 0, 120, 0.08); +} + +.step-item:hover { + border-color: #b2c3ff; +} + +.step-item:focus-visible { + outline: 3px solid rgba(0, 0, 120, 0.18); + outline-offset: 2px; +} + +.dot { + width: 24px; + height: 24px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--brand-soft); + border: 1px solid #c4cdf7; + color: var(--brand); + font-size: 12px; + font-weight: 700; +} + +.step-title { + font-weight: 700; + color: #1d2c68; + margin-bottom: 2px; +} + +.step-sub { + font-size: 12px; + color: var(--muted); +} + +.page { + display: none !important; +} + +.page.active { + display: block !important; + animation: fade 0.2s ease; +} + +@keyframes fade { + from { opacity: 0.5; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.section-card { + border: 1px solid #dbe3f0; + border-radius: 14px; + padding: 14px; + background: linear-gradient(160deg, #ffffff, #fbfcff); + box-shadow: 0 4px 10px rgba(15, 35, 74, 0.04); +} + +.section-stammdaten { + background: linear-gradient(160deg, #ffffff, #f8faff); +} + +.section-vertrag { + background: linear-gradient(160deg, #ffffff, #f7fbff); +} + +.section-itsetup { + background: linear-gradient(160deg, #ffffff, #f7faff); +} + +.section-abschluss { + border-color: #c7d4ff; + background: linear-gradient(160deg, #f8fbff, #edf3ff); +} + +.section-head { + margin-bottom: 12px; + border-bottom: 1px dashed #dde4f1; + padding-bottom: 8px; +} + +.section-head h2 { + margin: 0; + font-size: 20px; + color: #1d2c68; +} + +.section-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.field { + margin-bottom: 10px; +} + +.empty-step { + border: 1px dashed #d3dbec; + border-radius: 10px; + padding: 10px 12px; + color: #607086; + background: #fafcff; +} + +.field-full { + grid-column: 1 / -1; +} + +.field-group { + margin-bottom: 0; +} + +label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +.inline-check { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.inline-check label { + display: inline; + margin: 0; + font-weight: 600; +} + +input[type="text"], +input[type="email"], +input[type="url"], +input[type="date"], +input[type="file"], +select, +textarea { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + box-sizing: border-box; + font-size: 14px; + background: #fff; + color: var(--ink); +} + +textarea { + min-height: 120px; + resize: vertical; +} + +input[type="file"] { + background: #f8fbff; + border-style: dashed; +} + +input[type="file"]::file-selector-button { + border: 1px solid #c3d2ef; + border-radius: 8px; + padding: 6px 10px; + background: #fff; + color: #1d2c68; + margin-right: 10px; + cursor: pointer; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.12); +} + +.checkbox-list ul { + list-style: none; + margin: 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + columns: 2; + column-gap: 20px; + background: #fcfdff; +} + +.checkbox-list li { + margin-bottom: 6px; + break-inside: avoid; +} + +.hint { + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} + +.errorlist { + color: var(--danger); + margin: 4px 0; +} + +.error-banner { + background: var(--warn-bg); + border: 1px solid var(--warn-border); + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 14px; + color: #9a3412; + font-weight: 600; +} + +.legal { + background: #f3f7ff; + border: 1px solid #d4ddff; + border-left: 4px solid var(--brand); + border-radius: 10px; + padding: 12px; + color: #223b79; + margin-top: 6px; +} + +.finish-note { + border: 1px solid #c9d8ff; + border-radius: 10px; + padding: 10px 12px; + background: #ffffff; + color: #1f3a7b; + font-weight: 600; +} + +.section-abschluss .grid-2 { + align-items: stretch; +} + +.section-abschluss .finish-field:not(.field-full) { + border: 1px solid #d8e2ff; + border-radius: 12px; + background: #ffffff; + padding: 10px; + min-height: 170px; + display: flex; + flex-direction: column; +} + +.section-abschluss .finish-field:not(.field-full) input, +.section-abschluss .finish-field:not(.field-full) textarea, +.section-abschluss .finish-field:not(.field-full) select { + margin-top: auto; +} + +.section-abschluss .finish-field textarea { + min-height: 128px; +} + +.section-abschluss .finish-check { + border: 1px solid #d8e2ff; + border-radius: 12px; + background: #ffffff; + padding: 10px; +} + +.actions { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 14px; + padding: 12px 10px 2px; + border-top: 1px dashed var(--line); + background: #f9fbff; + border-radius: 12px; +} + +.hidden { + display: none; +} + +.popup-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.38); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.popup-backdrop.show { + display: flex; +} + +.popup { + background: #fff; + border: 1px solid var(--line); + border-radius: 12px; + padding: 18px; + width: min(480px, calc(100% - 30px)); + box-shadow: 0 18px 40px rgba(2, 6, 23, 0.25); +} + +.popup h3 { + margin: 0 0 8px; +} + +.popup p { + margin: 0 0 14px; + color: var(--muted); +} + +@media (max-width: 920px) { + .shell { + grid-template-columns: 1fr; + } + + .panel { + position: static; + } + + .grid-2 { + grid-template-columns: 1fr; + } + + .checkbox-list ul { + columns: 1; + } +} diff --git a/backend/workflows/static/workflows/js/form_builder.js b/backend/workflows/static/workflows/js/form_builder.js new file mode 100644 index 0000000..bb3b7c4 --- /dev/null +++ b/backend/workflows/static/workflows/js/form_builder.js @@ -0,0 +1,148 @@ +(function () { + const columnsRoot = document.getElementById('builder-columns'); + const saveBtn = document.getElementById('save-order'); + const statusEl = document.getElementById('status-message'); + if (!columnsRoot || !saveBtn || !statusEl) return; + + const formType = columnsRoot.dataset.formType; + let draggingCard = null; + + function setStatus(message, kind) { + statusEl.textContent = message || ''; + statusEl.classList.remove('error', 'success'); + if (kind) statusEl.classList.add(kind); + } + + function getCsrfToken() { + const cookie = document.cookie + .split(';') + .map((x) => x.trim()) + .find((x) => x.startsWith('csrftoken=')); + return cookie ? decodeURIComponent(cookie.split('=')[1]) : ''; + } + + function buildPayload() { + const payload = { form_type: formType, columns: {} }; + document.querySelectorAll('.dropzone').forEach((zone) => { + const key = zone.dataset.columnKey; + payload.columns[key] = Array.from(zone.querySelectorAll('.field-card')).map( + (card) => card.dataset.fieldName + ); + }); + return payload; + } + + function onDragStart(event) { + const card = event.currentTarget; + draggingCard = card; + card.classList.add('dragging'); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', card.dataset.fieldName); + } + + function onDragEnd(event) { + event.currentTarget.classList.remove('dragging'); + document.querySelectorAll('.dropzone').forEach((zone) => zone.classList.remove('drag-over')); + draggingCard = null; + } + + function getInsertBeforeNode(zone, mouseY) { + const cards = Array.from(zone.querySelectorAll('.field-card:not(.dragging)')); + return cards.find((card) => { + const box = card.getBoundingClientRect(); + return mouseY < box.top + box.height / 2; + }); + } + + function onDragOver(event) { + event.preventDefault(); + const zone = event.currentTarget; + zone.classList.add('drag-over'); + if (!draggingCard) return; + const beforeNode = getInsertBeforeNode(zone, event.clientY); + if (beforeNode) { + zone.insertBefore(draggingCard, beforeNode); + } else { + zone.appendChild(draggingCard); + } + } + + function onDragLeave(event) { + event.currentTarget.classList.remove('drag-over'); + } + + document.querySelectorAll('.field-card').forEach((card) => { + card.addEventListener('dragstart', onDragStart); + card.addEventListener('dragend', onDragEnd); + }); + + document.querySelectorAll('.dropzone').forEach((zone) => { + zone.addEventListener('dragover', onDragOver); + zone.addEventListener('dragleave', onDragLeave); + zone.addEventListener('drop', (event) => { + event.preventDefault(); + zone.classList.remove('drag-over'); + }); + }); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + setStatus('Speichere Reihenfolge ...'); + try { + const response = await fetch('/admin-tools/form-builder/save-order/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify(buildPayload()), + }); + const data = await response.json(); + if (!response.ok || !data.ok) { + throw new Error(data.error || 'Speichern fehlgeschlagen.'); + } + setStatus('Reihenfolge gespeichert.', 'success'); + } catch (error) { + setStatus(error.message || 'Speichern fehlgeschlagen.', 'error'); + } finally { + saveBtn.disabled = false; + } + }); + + const optionTableBody = document.getElementById('option-table-body'); + if (optionTableBody) { + let draggingRow = null; + + function getOptionInsertBeforeNode(mouseY) { + const rows = Array.from(optionTableBody.querySelectorAll('tr.option-row:not(.dragging)')); + return rows.find((row) => { + const box = row.getBoundingClientRect(); + return mouseY < box.top + box.height / 2; + }); + } + + optionTableBody.querySelectorAll('tr.option-row').forEach((row) => { + row.addEventListener('dragstart', (event) => { + draggingRow = row; + row.classList.add('dragging'); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.querySelector('input[name="option_ids"]')?.value || ''); + }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + draggingRow = null; + }); + }); + + optionTableBody.addEventListener('dragover', (event) => { + event.preventDefault(); + if (!draggingRow) return; + const beforeNode = getOptionInsertBeforeNode(event.clientY); + if (beforeNode) { + optionTableBody.insertBefore(draggingRow, beforeNode); + } else { + optionTableBody.appendChild(draggingRow); + } + }); + } +})(); diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py new file mode 100644 index 0000000..5c95ea2 --- /dev/null +++ b/backend/workflows/tasks.py @@ -0,0 +1,790 @@ +from pathlib import Path +from datetime import timedelta +import base64 +import mimetypes +import re + +from celery import shared_task +from django.contrib.auth import get_user_model +from django.conf import settings +from django.utils import timezone +from jinja2 import Template +from pypdf import PageObject, PdfReader, PdfWriter +from xhtml2pdf import pisa + +from .models import EmployeeProfile, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .emailing import send_system_email +from .services import upload_to_nextcloud +from .services import get_email_test_redirect, is_email_test_mode + + +DEFAULT_NOTIFICATION_TEMPLATES = { + 'onboarding_it': { + 'subject': '[Onboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Neue Onboarding-Anfrage für {{ FULL_NAME }}.\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Vertragsbeginn: {{ CONTRACT_START }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + 'Bitte IT-Setup vorbereiten.' + ), + }, + 'onboarding_general_info': { + 'subject': '[Info Onboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Hallo,\n\n' + '{{ FULL_NAME }} wird onboarded.\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Vertragsbeginn: {{ CONTRACT_START }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, + 'onboarding_business_card': { + 'subject': '[Visitenkarte] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Hallo,\n\n' + 'bitte Visitenkarten erstellen:\n' + 'Name: {{ BUSINESS_CARD_NAME }}\n' + 'Titel: {{ BUSINESS_CARD_TITLE }}\n' + 'E-Mail: {{ BUSINESS_CARD_EMAIL }}\n' + 'Telefon: {{ BUSINESS_CARD_PHONE }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, + 'onboarding_hr_works': { + 'subject': '[HR Works] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Hello Stefanie,\n\n' + 'Es ist wieder soweit. Zuwachs!\n\n' + 'Könntest du deshalb bitte ein HR Works Konto mit den folgenden Daten erstellen:\n\n' + 'Name: {{ VORNAME }} {{ NACHNAME }}\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Vertragsbeginn: {{ CONTRACT_START }}\n' + 'E-Mail-Adresse: {{ EMAIL }}\n\n' + '{% if PDF_LINK %}In 2 Minuten findest du alle Infos über den Mitarbeiter als PDF unter diesem Link: {{ PDF_LINK }}\n\n{% endif %}' + 'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei der it@tub.co melden!\n\n' + 'Vielen Dank und schöne Grüße,\n' + 'Die IT.' + ), + }, + 'onboarding_key': { + 'subject': '[Schlüssel] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Hallo,\n\n' + 'bitte Schlüssel vorbereiten für:\n' + 'Name: {{ FULL_NAME }}\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Vertragsbeginn: {{ CONTRACT_START }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, + 'onboarding_reference': { + 'subject': '[Referenz Onboarding] {{ FULL_NAME }} | Ihre Anfrage', + 'body': ( + 'Diese E-Mail dient als Referenz für Ihre Onboarding-Anfrage.\n' + 'Name: {{ FULL_NAME }}\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Vertragsbeginn: {{ CONTRACT_START }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, + 'onboarding_welcome': { + 'subject': 'Willkommen bei TUB/CO, {{ VORNAME }}', + 'body': ( + 'Hallo {{ FULL_NAME }},\n\n' + 'herzlich willkommen bei TUB/CO.\n' + 'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n' + 'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n' + 'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n' + 'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n' + 'Viele Grüße\n' + 'TUB/CO IT' + ), + }, + 'offboarding_it': { + 'subject': '[Offboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Neue Offboarding-Anfrage für {{ FULL_NAME }}.\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Letzter Arbeitstag: {{ LAST_WORKING_DAY }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + 'Bitte IT-Offboarding durchführen.' + ), + }, + 'offboarding_general_info': { + 'subject': '[Info Offboarding] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Neue Offboarding-Anfrage für {{ FULL_NAME }}.\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Letzter Arbeitstag: {{ LAST_WORKING_DAY }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, + 'offboarding_hr_works_disable': { + 'subject': '[HR Works Deaktivierung] {{ FULL_NAME }} | Anfrage von {{ REQUESTED_BY }}', + 'body': ( + 'Bitte HR Works Zugriff deaktivieren für {{ FULL_NAME }} ({{ EMAIL }}) zum {{ LAST_WORKING_DAY }}.\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, + 'offboarding_reference': { + 'subject': '[Referenz Offboarding] {{ FULL_NAME }} | Ihre Anfrage', + 'body': ( + 'Diese E-Mail dient als Referenz für Ihre Offboarding-Anfrage.\n' + 'Name: {{ FULL_NAME }}\n' + 'Abteilung: {{ DEPARTMENT }}\n' + 'Letzter Arbeitstag: {{ LAST_WORKING_DAY }}\n' + 'Angefordert von: {{ REQUESTED_BY }}\n' + ), + }, +} + +def _split_name(full_name: str) -> tuple[str, str]: + parts = full_name.split() + if not parts: + return '', '' + return parts[0], ' '.join(parts[1:]) + + +def _safe_filename_fragment(text: str, fallback: str = 'document') -> str: + value = re.sub(r'[^A-Za-z0-9._-]+', '_', (text or '').strip()).strip('._') + return value[:120] if value else fallback + + +def _resolve_user_display_name(email: str) -> str: + email = (email or '').strip().lower() + if not email: + return '' + user_model = get_user_model() + user = user_model.objects.filter(email__iexact=email).first() + if not user: + return '' + first_name = (getattr(user, 'first_name', '') or '').strip() + last_name = (getattr(user, 'last_name', '') or '').strip() + full_name = f'{first_name} {last_name}'.strip() + if full_name: + return full_name + return (getattr(user, 'username', '') or '').strip() + + +def _chunk_list(data_list: list[str], chunk_size: int = 3) -> list[list[str]]: + items = [i.strip() for i in data_list if i and i.strip()] + chunks = [] + for i in range(0, len(items), chunk_size): + chunks.append(items[i : i + chunk_size]) + return chunks + + +def _split_multiline(text: str) -> list[str]: + return [line.strip() for line in (text or '').split('\n') if line.strip()] + + +def _resolve_workflow_emails() -> tuple[str, str, str, str, str]: + config = WorkflowConfig.objects.order_by('id').first() + it_email = (config.it_onboarding_email if config and config.it_onboarding_email else settings.IT_ONBOARDING_NOTIFICATION_EMAIL) + general_info_email = (config.general_info_email if config and config.general_info_email else settings.GENERAL_INFO_NOTIFICATION_EMAIL) + business_card_email = (config.business_card_email if config and config.business_card_email else settings.BUSINESS_CARD_NOTIFICATION_EMAIL) + hr_works_email = (config.hr_works_email if config and config.hr_works_email else settings.HR_WORKS_NOTIFICATION_EMAIL) + key_email = (config.key_notification_email if config and config.key_notification_email else settings.KEY_NOTIFICATION_EMAIL) + return it_email, general_info_email, business_card_email, hr_works_email, key_email + + +def _send_workflow_email( + subject: str, + body: str, + to: list[str], + attachments: list[Path] | None = None, + from_email: str | None = None, +) -> None: + recipients = [r for r in to if r] + if not recipients: + return + + effective_to = recipients + effective_body = body + if is_email_test_mode(): + effective_to = [get_email_test_redirect()] + effective_body = ( + "[TEST MODE] Diese E-Mail wurde umgeleitet.\n" + f"Originale Empfänger: {', '.join(recipients)}\n\n{body}" + ) + + send_system_email( + subject=subject, + body=effective_body, + to=effective_to, + attachments=[str(a) for a in (attachments or [])], + from_email=from_email, + ) + + +def _render_notification_template(template_key: str, context: dict) -> tuple[str, str]: + db_template = NotificationTemplate.objects.filter(key=template_key, is_active=True).first() + if db_template: + subject_template = db_template.subject_template + body_template = db_template.body_template + else: + fallback = DEFAULT_NOTIFICATION_TEMPLATES[template_key] + subject_template = fallback['subject'] + body_template = fallback['body'] + + subject = Template(subject_template).render(context).strip() + body = Template(body_template).render(context).strip() + return subject, body + + +def _parse_recipients(raw: str) -> list[str]: + if not raw: + return [] + cleaned = raw.replace(';', ',').replace('\n', ',') + return [x.strip() for x in cleaned.split(',') if x.strip()] + + +def _as_bool(value) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + text = str(value).strip().lower() + return text in {'1', 'true', 'ja', 'yes', 'on', 'aktiv'} + + +def _rule_matches(rule: NotificationRule, request_obj) -> bool: + if rule.operator == 'always': + return True + + raw_value = getattr(request_obj, rule.field_name, '') + actual = '' if raw_value is None else str(raw_value) + expected = (rule.expected_value or '').strip() + + if rule.operator == 'contains': + return expected.lower() in actual.lower() + if rule.operator == 'equals': + return actual.strip().lower() == expected.lower() + if rule.operator == 'is_true': + return _as_bool(raw_value) + if rule.operator == 'is_false': + return not _as_bool(raw_value) + return False + + +def _apply_notification_rules( + event_type: str, + request_obj, + context: dict, + pdf_path: Path | None = None, +) -> None: + rules = NotificationRule.objects.filter(event_type=event_type, is_active=True).order_by('sort_order', 'id') + for rule in rules: + if not _rule_matches(rule, request_obj): + continue + + recipients = _parse_recipients(rule.recipients) + if not recipients: + continue + + attachments = [pdf_path] if (pdf_path and rule.include_pdf_attachment) else None + template_key = (rule.template_key or '').strip() + known_keys = {k for k, _ in NotificationTemplate.TEMPLATE_CHOICES} + + if template_key and template_key in known_keys: + _send_templated_email( + template_key=template_key, + context=context, + to=recipients, + attachments=attachments, + ) + continue + + subject = (rule.custom_subject or '').strip() + body = (rule.custom_body or '').strip() + if not subject and not body: + continue + + subject_rendered = Template(subject or f'[{event_type}] Regelmail').render(context).strip() + body_rendered = Template(body or '-').render(context).strip() + _send_workflow_email( + subject=subject_rendered, + body=body_rendered, + to=recipients, + attachments=attachments, + ) + + +def _schedule_welcome_email(request_obj: OnboardingRequest) -> None: + recipient = (request_obj.work_email or '').strip().lower() + if not recipient: + return + config = WorkflowConfig.objects.order_by('id').first() + delay_days = 5 + if config: + delay_days = max(0, int(config.welcome_email_delay_days or 5)) + send_at = timezone.now() + timedelta(days=delay_days) + scheduled, _ = ScheduledWelcomeEmail.objects.update_or_create( + onboarding_request=request_obj, + defaults={ + 'recipient_email': recipient, + 'send_at': send_at, + 'status': 'scheduled', + 'last_error': '', + 'sent_at': None, + }, + ) + try: + async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=send_at) + scheduled.celery_task_id = async_result.id or '' + scheduled.save(update_fields=['celery_task_id', 'updated_at']) + except Exception as exc: + scheduled.status = 'failed' + scheduled.last_error = f'Scheduling failed: {exc}' + scheduled.save(update_fields=['status', 'last_error', 'updated_at']) + + +def _send_templated_email( + template_key: str, + to: list[str], + context: dict, + attachments: list[Path] | None = None, + from_email: str | None = None, +) -> None: + subject, body = _render_notification_template(template_key, context) + _send_workflow_email(subject=subject, body=body, to=to, attachments=attachments, from_email=from_email) + + +def _render_html(template_path: Path, context: dict) -> str: + with template_path.open('r', encoding='utf-8') as handle: + template = Template(handle.read()) + return template.render(context) + + +def _generate_content_pdf(html_content: str, output_pdf: Path) -> None: + page_style = ( + '' + ) + if '' in html_content: + html_content = html_content.replace('', f'{page_style}', 1) + else: + html_content = page_style + html_content + + output_pdf.parent.mkdir(parents=True, exist_ok=True) + with output_pdf.open('wb') as fp: + result = pisa.CreatePDF( + src=html_content, + dest=fp, + encoding='utf-8', + ) + if result.err: + raise RuntimeError(f'Failed to render PDF content for {output_pdf.name}') + + +def _overlay_with_letterhead(content_pdf: Path, letterhead_pdf: Path, output_pdf: Path) -> None: + letterhead_reader = PdfReader(str(letterhead_pdf)) + content_reader = PdfReader(str(content_pdf)) + writer = PdfWriter() + + letterhead_page = letterhead_reader.pages[0] + for page in content_reader.pages: + merged = PageObject.create_blank_page( + width=letterhead_page.mediabox.width, + height=letterhead_page.mediabox.height, + ) + merged.merge_page(letterhead_page) + merged.merge_page(page) + writer.add_page(merged) + + with output_pdf.open('wb') as fp: + writer.write(fp) + + +def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path: + first_name, last_name = _split_name(request_obj.full_name) + safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_{request_obj.id}') + output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_letter_{safe_name}.pdf' + temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_{safe_name}.pdf' + + template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_template.html' + letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + + devices = _split_multiline(request_obj.needed_devices) + software = _split_multiline(request_obj.needed_software) + accesses = _split_multiline(request_obj.needed_accesses) + groups = _split_multiline(request_obj.needed_workspace_groups) + resources = _split_multiline(request_obj.needed_resources) + group_mailboxes_list = _split_multiline(request_obj.group_mailboxes or '') + additional_hardware_list = _split_multiline(request_obj.additional_hardware or '') + additional_software_list = _split_multiline(request_obj.additional_software or '') + additional_access_list = _split_multiline(request_obj.additional_access_text or '') + + signature_src = '' + signature_note = '-' + if getattr(request_obj, 'signature_image', None): + try: + signature_path = Path(request_obj.signature_image.path).resolve() + with signature_path.open('rb') as sig_fp: + encoded = base64.b64encode(sig_fp.read()).decode('ascii') + mime_type = mimetypes.guess_type(signature_path.name)[0] or 'image/png' + signature_src = f"data:{mime_type};base64,{encoded}" + signature_note = 'Digitale Signatur als Bilddatei hinterlegt.' + except Exception: + signature_src = '' + signature_note = request_obj.signature_url or '-' + elif request_obj.signature_url: + signature_note = request_obj.signature_url + + requester_email = request_obj.onboarded_by_email or '-' + requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-' + gender = (request_obj.get_gender_display() or '-').strip() or '-' + employment_type = (request_obj.employment_type or '-').strip() or '-' + employment_end = request_obj.employment_end_date or '-' + order_business_cards = bool(request_obj.order_business_cards) + group_mailboxes = (request_obj.group_mailboxes or '').strip() + additional_hardware_other = (request_obj.additional_hardware_other or '').strip() + additional_hardware = (request_obj.additional_hardware or '').strip() + additional_software = (request_obj.additional_software or '').strip() + additional_access_text = (request_obj.additional_access_text or '').strip() + successor_name = (request_obj.successor_name or '').strip() + additional_notes = (request_obj.additional_notes or '').strip() + phone_number = (request_obj.phone_number or '').strip() + + context = { + 'VORNAME': first_name, + 'NACHNAME': last_name, + 'ANREDE': gender, + 'BERUFSBEZEICHNUNG': request_obj.job_title or 'N/A', + 'ABTEILUNG': request_obj.department or 'N/A', + 'EMAIL': request_obj.work_email or 'N/A', + 'VERTRAGSBEGINN': request_obj.contract_start, + 'BESCHAEFTIGUNG': employment_type, + 'VERTRAGSENDE': employment_end, + 'UEBERGABEDATUM': request_obj.handover_date or '-', + 'ARBEITSGERAETE_TEXT': ' | '.join(devices) if devices else 'Keine Angabe', + 'WORKSPACE_GROUPS_TEXT': ' | '.join(groups) if groups else 'Keine Angabe', + 'SOFTWARE_TEXT': ' | '.join(software) if software else 'Keine Angabe', + 'ZUGAENGE_TEXT': ' | '.join(accesses) if accesses else 'Keine Angabe', + 'RESSOURCEN_TEXT': ' | '.join(resources) if resources else 'Keine Angabe', + 'VISITENKARTE_BESTELLT': order_business_cards, + 'HAS_VISITENKARTE_DATEN': order_business_cards and any( + [ + (request_obj.business_card_name or '').strip(), + (request_obj.business_card_title or '').strip(), + (request_obj.business_card_email or '').strip(), + (request_obj.business_card_phone or '').strip(), + ] + ), + 'VISITENKARTE_NAME': request_obj.business_card_name or '-', + 'VISITENKARTE_TITEL': request_obj.business_card_title or '-', + 'VISITENKARTE_EMAIL': request_obj.business_card_email or '-', + 'VISITENKARTE_TELEFON': request_obj.business_card_phone or '-', + 'GROUP_MAILBOXES': group_mailboxes or 'Keine Angabe', + 'ADDITIONAL_HARDWARE_OTHER': additional_hardware_other or 'Keine Angabe', + 'ADDITIONAL_HARDWARE': additional_hardware or 'Keine Angabe', + 'ADDITIONAL_SOFTWARE': additional_software or 'Keine Angabe', + 'ADDITIONAL_ACCESS_TEXT': additional_access_text or 'Keine Angabe', + 'SUCCESSOR_NAME': successor_name or 'Keine Angabe', + 'PHONE_NUMBER': phone_number or '-', + 'INHERIT_PHONE_NUMBER': 'Ja' if request_obj.inherit_phone_number else 'Nein', + 'ADDITIONAL_NOTES': additional_notes or 'Keine Angabe', + 'GROUP_MAILBOXES_REQUIRED': bool(request_obj.group_mailboxes_required), + 'ADDITIONAL_HARDWARE_NEEDED': bool(request_obj.additional_hardware_needed), + 'ADDITIONAL_SOFTWARE_NEEDED': bool(request_obj.additional_software_needed), + 'ADDITIONAL_ACCESS_NEEDED': bool(request_obj.additional_access_needed), + 'HAS_DEVICES': bool(devices), + 'HAS_GROUPS': bool(groups), + 'HAS_SOFTWARE': bool(software), + 'HAS_ACCESSES': bool(accesses), + 'HAS_RESOURCES': bool(resources), + 'HAS_GROUP_MAILBOXES': bool(group_mailboxes_list), + 'HAS_ADDITIONAL_HARDWARE': bool(additional_hardware_list), + 'HAS_ADDITIONAL_SOFTWARE': bool(additional_software_list), + 'HAS_ADDITIONAL_ACCESS': bool(additional_access_list), + 'HAS_ADDITIONAL_HARDWARE_OTHER': bool(additional_hardware_other), + 'HAS_SUCCESSOR_INFO': bool(successor_name) or bool(request_obj.inherit_phone_number) or bool(phone_number), + 'HAS_ADDITIONAL_NOTES': bool(additional_notes), + 'GROUP_MAILBOXES_LIST': _chunk_list(group_mailboxes_list), + 'ADDITIONAL_HARDWARE_LIST': _chunk_list(additional_hardware_list), + 'ADDITIONAL_SOFTWARE_LIST': _chunk_list(additional_software_list), + 'ADDITIONAL_ACCESS_LIST': _chunk_list(additional_access_list), + 'ZUGAENGE_LIST': _chunk_list(groups), + 'ARBEITSGERÄTE_LIST': _chunk_list(devices), + 'SOFTWARE_LIST': _chunk_list(software), + 'ACCOUNT_LIST': _chunk_list(accesses), + 'STANDARD_RESSOURCEN': _chunk_list(resources), + 'UNTERSCHRIFT': signature_src, + 'UNTERSCHRIFT_HINWEIS': signature_note, + 'REQUESTED_BY_NAME': requester_name, + 'REQUESTED_BY_EMAIL': requester_email, + } + + html = _render_html(template_path, context) + _generate_content_pdf(html, temp_pdf) + _overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf) + + if temp_pdf.exists(): + temp_pdf.unlink(missing_ok=True) + return output_pdf + + +def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path: + safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'offboarding_{request_obj.id}') + output_pdf = settings.PDF_OUTPUT_DIR / f'offboarding_letter_{safe_name}.pdf' + temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_offboarding_{safe_name}.pdf' + + template_path = settings.PDF_TEMPLATES_DIR / 'offboarding_template.html' + letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + latest_onboarding = ( + OnboardingRequest.objects.filter(work_email=request_obj.work_email) + .order_by('-created_at') + .first() + ) + onboarding_hardware = _split_multiline(latest_onboarding.needed_devices) if latest_onboarding else [] + selected_set = {item.lower() for item in onboarding_hardware} + hardware_catalog = [ + 'Laptop', + 'Docking-Station', + 'Tastatur und Maus', + 'Kopfhörer', + 'Tragetasche', + 'Monitor', + 'Schlüssel', + 'Tischtelefon', + ] + checklist = [{'label': item, 'selected': item.lower() in selected_set} for item in hardware_catalog] + extra_selected = [item for item in onboarding_hardware if item.lower() not in {x.lower() for x in hardware_catalog}] + for item in extra_selected: + checklist.append({'label': item, 'selected': True}) + + requester_email = request_obj.requested_by_email or '-' + requester_name = request_obj.requested_by_name or _resolve_user_display_name(request_obj.requested_by_email) or '-' + + context = { + 'FULL_NAME': request_obj.full_name, + 'EMAIL': request_obj.work_email, + 'DEPARTMENT': request_obj.department or '-', + 'JOB_TITLE': request_obj.job_title or '-', + 'LAST_WORKING_DAY': request_obj.last_working_day, + 'NOTES': request_obj.notes or '-', + 'REQUESTED_BY': requester_email, + 'REQUESTED_BY_NAME': requester_name, + 'ONBOARDING_HARDWARE': onboarding_hardware, + 'HARDWARE_CHECKLIST': checklist, + } + + html = _render_html(template_path, context) + _generate_content_pdf(html, temp_pdf) + _overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf) + + if temp_pdf.exists(): + temp_pdf.unlink(missing_ok=True) + return output_pdf + + +@shared_task +def process_onboarding_request(onboarding_request_id: int) -> None: + request_obj = OnboardingRequest.objects.get(id=onboarding_request_id) + it_email, general_info_email, business_card_email, hr_works_email, key_email = _resolve_workflow_emails() + salutation = (request_obj.get_gender_display() or '').strip() + display_name = f"{salutation} {request_obj.full_name}".strip() + + first_name, last_name = _split_name(request_obj.full_name) + EmployeeProfile.objects.update_or_create( + work_email=request_obj.work_email, + defaults={ + 'full_name': request_obj.full_name, + 'first_name': first_name, + 'last_name': last_name, + 'department': request_obj.department, + 'job_title': request_obj.job_title, + }, + ) + + pdf_path = _generate_onboarding_pdf(request_obj) + request_obj.generated_pdf_path = str(pdf_path) + request_obj.save(update_fields=['generated_pdf_path']) + + email_context = { + 'FULL_NAME': display_name, + 'VORNAME': first_name, + 'NACHNAME': last_name, + 'DEPARTMENT': request_obj.department or '-', + 'CONTRACT_START': request_obj.contract_start, + 'EMAIL': request_obj.work_email, + 'REQUESTED_BY': request_obj.onboarded_by_email or '-', + 'BUSINESS_CARD_NAME': request_obj.business_card_name or display_name, + 'BUSINESS_CARD_TITLE': request_obj.business_card_title or '-', + 'BUSINESS_CARD_EMAIL': request_obj.business_card_email or request_obj.work_email, + 'BUSINESS_CARD_PHONE': request_obj.business_card_phone or '-', + 'PDF_LINK': settings.ONBOARDING_SHARED_PDF_LINK, + } + + _send_templated_email( + template_key='onboarding_it', + context=email_context, + to=[it_email], + attachments=[pdf_path], + ) + _send_templated_email( + template_key='onboarding_general_info', + context=email_context, + to=[general_info_email], + ) + + if request_obj.order_business_cards: + _send_templated_email( + template_key='onboarding_business_card', + context=email_context, + to=[business_card_email], + ) + + if 'HR Works' in request_obj.needed_accesses: + _send_templated_email( + template_key='onboarding_hr_works', + context=email_context, + to=[hr_works_email], + ) + + if 'Schlüssel' in request_obj.needed_devices: + _send_templated_email( + template_key='onboarding_key', + context=email_context, + to=[key_email], + ) + + if request_obj.onboarded_by_email: + _send_templated_email( + template_key='onboarding_reference', + context=email_context, + to=[request_obj.onboarded_by_email], + attachments=[pdf_path], + ) + + _apply_notification_rules( + event_type='onboarding', + request_obj=request_obj, + context=email_context, + pdf_path=pdf_path, + ) + + _schedule_welcome_email(request_obj) + + upload_to_nextcloud(pdf_path, Path(pdf_path).name) + + +@shared_task +def process_offboarding_request(offboarding_request_id: int) -> None: + request_obj = OffboardingRequest.objects.get(id=offboarding_request_id) + it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails() + + pdf_path = _generate_offboarding_pdf(request_obj) + request_obj.generated_pdf_path = str(pdf_path) + request_obj.save(update_fields=['generated_pdf_path']) + + email_context = { + 'FULL_NAME': request_obj.full_name, + 'DEPARTMENT': request_obj.department or '-', + 'LAST_WORKING_DAY': request_obj.last_working_day, + 'REQUESTED_BY': request_obj.requested_by_email, + 'EMAIL': request_obj.work_email, + } + + _send_templated_email( + template_key='offboarding_it', + context=email_context, + to=[it_email], + attachments=[pdf_path], + ) + _send_templated_email( + template_key='offboarding_general_info', + context=email_context, + to=[general_info_email], + ) + + had_hr_works = OnboardingRequest.objects.filter( + work_email=request_obj.work_email, + needed_accesses__icontains='HR Works', + ).exists() + if had_hr_works: + _send_templated_email( + template_key='offboarding_hr_works_disable', + context=email_context, + to=[hr_works_email], + ) + + _send_templated_email( + template_key='offboarding_reference', + context=email_context, + to=[request_obj.requested_by_email], + attachments=[pdf_path], + ) + + _apply_notification_rules( + event_type='offboarding', + request_obj=request_obj, + context=email_context, + pdf_path=pdf_path, + ) + + upload_to_nextcloud(pdf_path, Path(pdf_path).name) + + +@shared_task +def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = False) -> None: + scheduled = ScheduledWelcomeEmail.objects.select_related('onboarding_request').filter(id=scheduled_email_id).first() + if not scheduled: + return + if scheduled.status in {'sent', 'cancelled'} and not force_now: + return + if scheduled.status == 'paused' and not force_now: + return + + if not force_now and timezone.now() < scheduled.send_at: + async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=scheduled.send_at) + scheduled.celery_task_id = async_result.id or scheduled.celery_task_id + scheduled.save(update_fields=['celery_task_id', 'updated_at']) + return + + request_obj = scheduled.onboarding_request + first_name, last_name = _split_name(request_obj.full_name) + salutation = (request_obj.get_gender_display() or '').strip() + display_name = f"{salutation} {request_obj.full_name}".strip() + email_context = { + 'FULL_NAME': display_name, + 'VORNAME': first_name, + 'NACHNAME': last_name, + 'DEPARTMENT': request_obj.department or '-', + 'CONTRACT_START': request_obj.contract_start, + 'EMAIL': request_obj.work_email, + 'REQUESTED_BY': request_obj.onboarded_by_email or '-', + } + + config = WorkflowConfig.objects.order_by('id').first() + include_pdf = True if not config else bool(config.welcome_include_pdf) + from_email = '' + if config: + from_email = (config.welcome_sender_email or config.email_account or '').strip() + + attachments = [] + if include_pdf and request_obj.generated_pdf_path: + pdf_path = Path(request_obj.generated_pdf_path) + if pdf_path.exists(): + attachments = [pdf_path] + + try: + _send_templated_email( + template_key='onboarding_welcome', + context=email_context, + to=[scheduled.recipient_email], + attachments=attachments, + from_email=from_email or None, + ) + scheduled.status = 'sent' + scheduled.sent_at = timezone.now() + scheduled.last_error = '' + except Exception as exc: + scheduled.status = 'failed' + scheduled.last_error = str(exc) + raise + finally: + scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at']) diff --git a/backend/workflows/templates/registration/login.html b/backend/workflows/templates/registration/login.html new file mode 100644 index 0000000..bd50514 --- /dev/null +++ b/backend/workflows/templates/registration/login.html @@ -0,0 +1,39 @@ +{% load static %} + + + + + + Anmeldung + + + + +
+ +

Anmeldung

+

Bitte melden Sie sich mit Ihrem Benutzerkonto an.

+ +
+ {% csrf_token %} + {% if form.errors %} +
Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.
+ {% endif %} +
{{ form.username.label_tag }}{{ form.username }}
+
{{ form.password.label_tag }}{{ form.password }}
+ +
+
+ + diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html new file mode 100644 index 0000000..65e48d0 --- /dev/null +++ b/backend/workflows/templates/workflows/form_builder.html @@ -0,0 +1,131 @@ +{% load static %} + + + + + + Form Builder + + + + +
+ + +
+

Form Builder

+

Felder per Drag-and-Drop sortieren und pro Schritt gruppieren.

+
+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + +
+ {% for key, label in form_types %} + + {{ label }} + + {% endfor %} + +
+ +
+ +
+ {% for column in columns %} +
+

{{ column.title }}

+
+ {% for item in column.items %} +
+
+
{{ item.label }}
+
{{ item.field_name }}
+
+
+ {% if item.locked %}Fix{% endif %} + {% if not item.is_visible %}{% endif %} + {% if item.is_required %}Pflicht{% endif %} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ +
+
+

Optionen verwalten

+
+ + + +
+
+ +
+ {% csrf_token %} + + + + + +
+ +
+ {% csrf_token %} +
+ + + + + + + + + + + + {% for item in option_items %} + + + + + + + + {% empty %} + + {% endfor %} + +
SortierungLabelValueAktivLöschen
+ + ⋮⋮ + + +
Keine Optionen in dieser Kategorie.
+
+
+ +
+
+
+
+ + + + diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html new file mode 100644 index 0000000..f4467ca --- /dev/null +++ b/backend/workflows/templates/workflows/home.html @@ -0,0 +1,454 @@ +{% load static %} + + + + + + Onboarding/Offboarding Portal + + + + +
+
+ +
+
+ {% csrf_token %} + +
+
+
+ +
+

Onboarding/Offboarding Portal

+

Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud.

+
+ Rolle: {% if request.user.is_staff %}Admin{% else %}Mitarbeiter{% endif %} + + Nextcloud: {% if nextcloud_enabled %}aktiv{% else %}inaktiv{% endif %} + + + PDF + Email Workflow Ready +
+
+ +
+ {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + +
+

Apps

+

Wählen Sie den gewünschten Prozess.

+
+
+
+
+
+

Onboarding

+

Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen.

+
+ Mehrschritt-Formular + PDF + E-Mail Routing +
+
+ +
+ +
+
+
+

Offboarding

+

Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten.

+
+ Profile-Suche + Hardware-Liste + IT-Rückgabe +
+
+ +
+ +
+
+
+

Anfragen Dashboard

+

Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen.

+
+ Suche + Status + PDF Zugriff +
+
+ +
+
+ + {% if request.user.is_staff %} +
+

Admin Apps

+

Konfiguration, Tests und Steuerung.

+
+
+
+

Form Builder

+

Felder, Schritte und Optionen verwalten.

+ Öffnen +
+
+

Projekt Wiki

+

Dokumentation, Architektur und Runbook.

+ Öffnen +
+
+

Integrationen

+

Nextcloud- und E-Mail-Setup.

+ Öffnen +
+
+

Welcome E-Mails

+

Geplante Welcome Mails verwalten.

+ Öffnen +
+
+

Django Admin

+

Vollständige Datenverwaltung.

+ Öffnen +
+
+

SMTP Einstellungen

+

Server und Absender in der Backend-UI.

+ Öffnen +
+
+

Nextcloud schalten

+

Aktiv/Inaktiv direkt umschalten.

+
+ {% csrf_token %} + +
+
+
+

E-Mail Modus

+

Zwischen Testmodus und Produktion wechseln.

+
+ {% csrf_token %} + +
+
+
+

Verbindungstests

+

Testupload und Testmail auslösen.

+
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+
+
+ {% endif %} + + +
+
+ + diff --git a/backend/workflows/templates/workflows/integrations_setup.html b/backend/workflows/templates/workflows/integrations_setup.html new file mode 100644 index 0000000..38aef19 --- /dev/null +++ b/backend/workflows/templates/workflows/integrations_setup.html @@ -0,0 +1,348 @@ +{% load static %} + + + + + + Integrationen Setup + + + + +
+ +

Integrationen Setup

+

Verwalten Sie Nextcloud- und Mail-Konfiguration ohne Backend-Wechsel.

+ + + + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if kind == 'nextcloud' %} +
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Leeres Passwortfeld lässt das bestehende Passwort unverändert.
+
+ {% endif %} + + {% if kind == 'mail' %} +
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
Leeres Passwortfeld lässt das bestehende Passwort unverändert.
+
+ {% endif %} + + {% if kind == 'emails' %} +
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Diese Empfänger werden für condition-based E-Mail Routing genutzt.
+ + {% for tpl in templates %} +
+

{{ tpl.get_key_display }} ({{ tpl.key }})

+
+
+ + +
+
+ + +
+
+
+ {% endfor %} + +
+ +
+
+ +
+ {% csrf_token %} +

Bedingungsregeln für zusätzliche E-Mails

+
Zusätzliche Regeln laufen nach dem Standard-Routing.
+ + {% for rule in notification_rules %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ {% empty %} +
Noch keine zusätzlichen Regeln vorhanden.
+ {% endfor %} + +
+

Neue Regel hinzufügen

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ +
+
+ {% endif %} +
+ + diff --git a/backend/workflows/templates/workflows/offboarding_form.html b/backend/workflows/templates/workflows/offboarding_form.html new file mode 100644 index 0000000..c06cdce --- /dev/null +++ b/backend/workflows/templates/workflows/offboarding_form.html @@ -0,0 +1,66 @@ +{% load static %} + + + + + + Offboarding-Anfrage + + + + + + +
+ + +
+

Offboarding-Anfrage

+
+
+ + +
+ +
+ + {% if search_results %} +
+ {% for p in search_results %} + {{ p.full_name }} ({{ p.work_email }}) + {% endfor %} +
+ {% endif %} + + {% if selected_profile %} +

Vorbefüllt aus: {{ selected_profile.full_name }} ({{ selected_profile.work_email }})

+ {% endif %} +
+ +
+
+ {% csrf_token %} +
+ {% for field in form.visible_fields %} + {% if field.name != 'search_query' %} +
+ {{ field.label_tag }} + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
+ {% endif %} + {% endfor %} +
+ +
+
+
+ + diff --git a/backend/workflows/templates/workflows/offboarding_success.html b/backend/workflows/templates/workflows/offboarding_success.html new file mode 100644 index 0000000..fbee718 --- /dev/null +++ b/backend/workflows/templates/workflows/offboarding_success.html @@ -0,0 +1,27 @@ + + + + + + Offboarding gespeichert + + + +

Offboarding gespeichert

+

Vorgangs-ID: {{ obj.id }}

+

Name: {{ obj.full_name }}

+

E-Mail: {{ obj.work_email }}

+

Letzter Arbeitstag: {{ obj.last_working_day }}

+ {% if pdf_url %} +

PDF: PDF öffnen

+

Datei: {{ obj.generated_pdf_path }}

+ {% else %} +

PDF wird im Hintergrund erstellt.

+ {% endif %} +

Zur Startseite

+

Neue Offboarding-Anfrage erfassen

+ + diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html new file mode 100644 index 0000000..0a045a9 --- /dev/null +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -0,0 +1,341 @@ +{% load static %} + + + + + + Onboarding-Anfrage + + + + + + +
+ + +
+ +
+ + +
+ {% if form.errors %} +
Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt.
+ {% endif %} + +
+ {% csrf_token %} + + {% for section in onboarding_sections %} +
+
+
+

{{ section.title }}

+

{{ section.subtitle }}

+
+
+ {% for block in section.blocks %} + {% if block.kind == 'field' %} + {% with field=block.field %} + {% if field.is_hidden %} + {{ field }} + {% elif field.name in onboarding_inline_checks %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
+ {% else %} +
+ {{ field.label_tag }} + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
+ {% endif %} + {% endwith %} + {% else %} +
+
+ {% for field in block.fields %} + {% if field.is_hidden %} + {{ field }} + {% elif field.name in onboarding_inline_checks %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
+ {% else %} +
+ {{ field.label_tag }} + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + {% endfor %} + {% if not section.blocks %} +
Keine konfigurierten Felder in diesem Schritt.
+ {% endif %} + + {% if section.key == 'abschluss' %} +
+ Fast geschafft. Bitte Abschlussdaten prüfen und die Anfrage absenden. +
+
+ +
+ {% endif %} +
+
+
+ {% endfor %} + +
+ + + +
+
+
+
+ + + + diff --git a/backend/workflows/templates/workflows/onboarding_success.html b/backend/workflows/templates/workflows/onboarding_success.html new file mode 100644 index 0000000..16f8af9 --- /dev/null +++ b/backend/workflows/templates/workflows/onboarding_success.html @@ -0,0 +1,26 @@ + + + + + + Onboarding gespeichert + + + +

Anfrage erfolgreich gespeichert

+

Vorgangs-ID: {{ obj.id }}

+

Name: {{ obj.full_name }}

+

E-Mail: {{ obj.work_email }}

+ {% if pdf_url %} +

PDF: PDF öffnen

+

Datei: {{ obj.generated_pdf_path }}

+ {% else %} +

PDF wird im Hintergrund erstellt.

+ {% endif %} +

Zur Startseite

+

Neue Anfrage erfassen

+ + diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html new file mode 100644 index 0000000..ec720ac --- /dev/null +++ b/backend/workflows/templates/workflows/project_wiki.html @@ -0,0 +1,246 @@ +{% load static %} + + + + + + Project Wiki + + + + +
+ +
+

Project Wiki

+ Back to Home +
+

Operational and technical documentation for the Onboarding/Offboarding platform.

+ + + +

1) Overview

+
+

+ This system handles employee onboarding and offboarding requests from web forms. It generates branded PDF documents, + sends condition-based notifications, stores records in the database, and optionally uploads PDFs to Nextcloud. +

+
    +
  • Primary users: regular staff (submit forms) and admin users (configure and operate).
  • +
  • Main entry points: Home, Onboarding form, Offboarding form, Requests Dashboard, Admin Apps.
  • +
  • Asynchronous processing: heavy tasks (PDF/email/upload) are executed by Celery worker jobs.
  • +
+
+ +

2) Architecture

+

Runtime Components

+ + +

Request Processing Pattern

+ + +

3) Data Model (Key Entities)

+ + +

4) Onboarding Flow

+
    +
  1. User opens /onboarding/new/ and completes multi-step form.
  2. +
  3. Form saves request; requester identity is taken from logged-in user.
  4. +
  5. Task process_onboarding_request runs in worker.
  6. +
  7. PDF is generated using HTML template + letterhead overlay.
  8. +
  9. Default notification emails + optional rule-based emails are sent.
  10. +
  11. Welcome email job is scheduled (configurable delay).
  12. +
  13. PDF is uploaded to Nextcloud if enabled.
  14. +
+ +

5) Offboarding Flow

+
    +
  1. User opens /offboarding/new/ and can search existing profile first.
  2. +
  3. Form saves request with requester name/email from logged-in user.
  4. +
  5. Task process_offboarding_request runs in worker.
  6. +
  7. PDF is generated (hardware section can be derived from latest onboarding request).
  8. +
  9. Notification emails are sent (default + rules).
  10. +
  11. PDF upload to Nextcloud runs if enabled.
  12. +
+ +

6) Email Engine

+

Modes

+ + +

Template Placeholders

+

Examples: {{ VORNAME }}, {{ NACHNAME }}, {{ FULL_NAME }}, {{ EMAIL }}, {{ DEPARTMENT }}, {{ CONTRACT_START }}.

+ +

Condition-based Rules

+ + +

7) PDF Engine

+ + +

8) Integrations

+

Nextcloud

+ + +

Mail Server

+ + +

9) Admin Apps (Home)

+ + +

10) Operations Runbook

+

Health Checks

+ + +

Where to Find Generated PDFs

+ + +

Deployment Notes

+ + +

11) Security & Reliability Hardening (Current)

+ +
+ Recommended for production: set secure cookies, explicit allowed hosts, CSRF trusted origins, and a strong secret key via environment variables. +
+ +

12) Troubleshooting

+
+

Browser timeout or page hangs

+
    +
  • Try http://127.0.0.1:8088/ instead of localhost if local DNS/proxy is unstable.
  • +
  • Check /healthz/ and web logs.
  • +
+ +

Onboarding submit does nothing

+
    +
  • Check required/hidden conditional fields and form errors.
  • +
  • Open browser dev tools for JS errors.
  • +
+ +

PDF not generated

+
    +
  • Check Celery worker logs for PDF task errors.
  • +
  • Verify template files exist and media folder permissions are correct.
  • +
+ +

Email not received

+
    +
  • Verify email mode (test vs production).
  • +
  • Run SMTP test from Admin Apps.
  • +
  • Check SMTP settings and worker logs.
  • +
+ +

Nextcloud upload missing

+
    +
  • Verify Nextcloud is enabled.
  • +
  • Test upload from Admin Apps.
  • +
  • Check credentials and destination directory path.
  • +
+
+ +

13) Security and Access Notes

+ + +
+ Last updated for current system behavior as of March 10, 2026. +
+
+ + diff --git a/backend/workflows/templates/workflows/requests_dashboard.html b/backend/workflows/templates/workflows/requests_dashboard.html new file mode 100644 index 0000000..abc429e --- /dev/null +++ b/backend/workflows/templates/workflows/requests_dashboard.html @@ -0,0 +1,173 @@ +{% load static %} + + + + + + Anfragen Dashboard + + + + +
+ + +

Anfragen Dashboard

+

Neueste Onboarding- und Offboarding-Vorgänge inklusive PDF-Status.

+ {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} +
+
{{ onboarding_total }}
Onboarding gesamt
+
{{ offboarding_total }}
Offboarding gesamt
+
{{ combined_total }}
Gesamtvorgänge
+
+
+

Aktivität der letzten 14 Tage (Onboarding + Offboarding)

+
+ {% for p in chart_points %} +
+
+
{{ p.total }}
+
{{ p.label }}
+
+ {% endfor %} +
+
+ + + {% if request.user.is_staff %} +
+ {% csrf_token %} +
+ + 0 ausgewählt +
+ {% endif %} + + + + + {% if request.user.is_staff %}{% endif %} + + + + + + + {% if request.user.is_staff %}{% endif %} + + + + {% for row in rows %} + + {% if request.user.is_staff %} + + {% endif %} + + + + + + + {% if request.user.is_staff %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
TypNameE-MailErstelltStatusPDFAktion
+ + {{ row.kind }}{{ row.name }}{{ row.work_email }}{{ row.created_at|date:"Y-m-d H:i" }} + {% if row.pdf_url %} + {{ row.status }} + {% else %} + {{ row.status }} + {% endif %} + + {% if row.pdf_url %} + PDF öffnen + {% else %} + - + {% endif %} + + +
Noch keine Vorgänge vorhanden.
+ {% if request.user.is_staff %} +
+ {% endif %} + + +
+ {% if request.user.is_staff %} + + {% endif %} + + diff --git a/backend/workflows/templates/workflows/welcome_emails.html b/backend/workflows/templates/workflows/welcome_emails.html new file mode 100644 index 0000000..dbdbadb --- /dev/null +++ b/backend/workflows/templates/workflows/welcome_emails.html @@ -0,0 +1,233 @@ +{% load static %} + + + + + + Welcome E-Mails + + + + +
+ +

Geplante Welcome E-Mails

+

Welcome-Mails konfigurieren und geplante Mails steuern (sofort senden, pausieren, fortsetzen, abbrechen).

+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ Verfügbare Keywords: + {% for key in welcome_keywords %} + {{ key }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+
+ +
+
+ +
+ {% csrf_token %} + + + + + 0 ausgewählt +
+ + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
AuswahlIDMitarbeitende PersonEmpfängerGeplant fürStatusGesendet amAktion
{{ row.id }}{{ row.onboarding_request.full_name }}{{ row.recipient_email }}{{ row.send_at|date:"Y-m-d H:i" }} + {% if row.status == 'scheduled' %} + Geplant + {% elif row.status == 'paused' %} + Pausiert + {% elif row.status == 'cancelled' %} + Abgebrochen + {% elif row.status == 'sent' %} + Gesendet + {% else %} + Fehlgeschlagen + {% endif %} + {% if row.sent_at %}{{ row.sent_at|date:"Y-m-d H:i" }}{% else %}-{% endif %} +
+ {% if row.status != 'sent' and row.status != 'cancelled' %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if row.status == 'scheduled' %} +
+ {% csrf_token %} + +
+ {% elif row.status == 'paused' %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if row.status != 'sent' and row.status != 'cancelled' %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
Keine geplanten Welcome E-Mails vorhanden.
+
+ + + diff --git a/backend/workflows/tests/__init__.py b/backend/workflows/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/workflows/tests/test_email_mode_override.py b/backend/workflows/tests/test_email_mode_override.py new file mode 100644 index 0000000..b4e4bb2 --- /dev/null +++ b/backend/workflows/tests/test_email_mode_override.py @@ -0,0 +1,27 @@ +from django.test import TestCase, override_settings + +from workflows.models import WorkflowConfig +from workflows.services import is_email_test_mode + + +class EmailModeOverrideTests(TestCase): + @override_settings(EMAIL_TEST_MODE=False) + def test_uses_env_when_no_override(self): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + config.email_test_mode_override = None + config.save(update_fields=['email_test_mode_override']) + self.assertEqual(is_email_test_mode(), False) + + @override_settings(EMAIL_TEST_MODE=False) + def test_true_override_enables_test_mode(self): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + config.email_test_mode_override = True + config.save(update_fields=['email_test_mode_override']) + self.assertEqual(is_email_test_mode(), True) + + @override_settings(EMAIL_TEST_MODE=True) + def test_false_override_disables_test_mode(self): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + config.email_test_mode_override = False + config.save(update_fields=['email_test_mode_override']) + self.assertEqual(is_email_test_mode(), False) diff --git a/backend/workflows/tests/test_emailing_fallback.py b/backend/workflows/tests/test_emailing_fallback.py new file mode 100644 index 0000000..1e3600f --- /dev/null +++ b/backend/workflows/tests/test_emailing_fallback.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from workflows.models import WorkflowConfig +from workflows.emailing import send_system_email + + +class EmailingFallbackTests(TestCase): + @patch('workflows.emailing.EmailMessage') + @patch('workflows.emailing.get_connection') + def test_uses_workflowconfig_smtp_when_no_active_system_config(self, mock_get_connection, mock_email_message): + WorkflowConfig.objects.update_or_create( + name='Default', + defaults={ + 'smtp_server': 'mx.tub.co', + 'smtp_port': 465, + 'email_account': 'onboarding@tub.co', + 'email_password': 'secret', + 'smtp_use_ssl': True, + 'smtp_use_tls': False, + }, + ) + + fake_connection = object() + mock_get_connection.return_value = fake_connection + msg_instance = MagicMock() + mock_email_message.return_value = msg_instance + + send_system_email( + subject='x', + body='y', + to=['target@example.com'], + ) + + self.assertEqual(mock_get_connection.call_count, 1) + kwargs = mock_get_connection.call_args.kwargs + self.assertEqual(kwargs['host'], 'mx.tub.co') + self.assertEqual(kwargs['port'], 465) + self.assertEqual(kwargs['username'], 'onboarding@tub.co') + self.assertEqual(kwargs['password'], 'secret') + self.assertEqual(kwargs['use_ssl'], True) + self.assertEqual(kwargs['use_tls'], False) diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py new file mode 100644 index 0000000..b37a472 --- /dev/null +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -0,0 +1,96 @@ +import json + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.models import FormFieldConfig, FormOption + + +class FormBuilderAdminTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.staff = user_model.objects.create_user( + username='builder_admin', + password='secret123', + email='builder_admin@tub.co', + is_staff=True, + ) + self.user = user_model.objects.create_user( + username='builder_user', + password='secret123', + email='builder_user@tub.co', + ) + + def test_staff_can_open_form_builder(self): + self.client.force_login(self.staff) + response = self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Form Builder') + self.assertContains(response, '1. Stammdaten') + + def test_non_staff_cannot_open_form_builder(self): + self.client.force_login(self.user) + response = self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 302) + + def test_save_order_updates_sort_order_and_step(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + payload = { + 'form_type': 'onboarding', + 'columns': { + 'stammdaten': ['department', 'full_name'], + 'vertrag': ['contract_start'], + 'itsetup': [], + 'abschluss': [], + }, + } + + response = self.client.post( + '/admin-tools/form-builder/save-order/', + data=json.dumps(payload), + content_type='application/json', + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['ok'], True) + + department = FormFieldConfig.objects.get(form_type='onboarding', field_name='department') + full_name = FormFieldConfig.objects.get(form_type='onboarding', field_name='full_name') + contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start') + + self.assertEqual(department.sort_order, 0) + self.assertEqual(department.page_key, 'stammdaten') + self.assertEqual(full_name.sort_order, 1) + self.assertEqual(full_name.page_key, 'stammdaten') + self.assertEqual(contract_start.sort_order, 2) + self.assertEqual(contract_start.page_key, 'vertrag') + + def test_save_order_requires_staff(self): + self.client.force_login(self.user) + response = self.client.post( + '/admin-tools/form-builder/save-order/', + data=json.dumps({'form_type': 'onboarding', 'columns': {}}), + content_type='application/json', + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 302) + + def test_staff_can_add_option_item_without_django_admin(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'add_option', + 'category': 'device', + 'label': 'Tablet', + 'value': 'Tablet', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue(FormOption.objects.filter(category='device', label='Tablet').exists()) diff --git a/backend/workflows/tests/test_nextcloud_service.py b/backend/workflows/tests/test_nextcloud_service.py new file mode 100644 index 0000000..d468893 --- /dev/null +++ b/backend/workflows/tests/test_nextcloud_service.py @@ -0,0 +1,73 @@ +from pathlib import Path +from unittest.mock import patch + +from django.test import TestCase, override_settings + +from workflows.models import WorkflowConfig +from workflows.services import upload_to_nextcloud + + +class NextcloudServiceTests(TestCase): + @override_settings(NEXTCLOUD_ENABLED=False) + @patch('workflows.services.requests.put') + def test_upload_returns_false_when_disabled(self, mock_put): + result = upload_to_nextcloud(Path('/tmp/nonexistent.txt'), 'x.txt') + self.assertFalse(result) + mock_put.assert_not_called() + + @override_settings( + NEXTCLOUD_ENABLED=True, + NEXTCLOUD_BASE_URL='https://cloud.example/remote.php/dav/files/test-user', + NEXTCLOUD_DIRECTORY='Onboarding', + NEXTCLOUD_USERNAME='u', + NEXTCLOUD_PASSWORD='p', + ) + @patch('workflows.services.requests.put') + def test_upload_calls_webdav_and_accepts_201(self, mock_put): + temp_file = Path('/tmp/nextcloud_mock_upload.txt') + temp_file.write_text('hello', encoding='utf-8') + mock_put.return_value.status_code = 201 + + try: + result = upload_to_nextcloud(temp_file, 'target.txt') + finally: + temp_file.unlink(missing_ok=True) + + self.assertTrue(result) + self.assertEqual(mock_put.call_count, 1) + called_url = mock_put.call_args.kwargs['url'] if 'url' in mock_put.call_args.kwargs else mock_put.call_args.args[0] + self.assertTrue(called_url.endswith('/Onboarding/target.txt')) + + @override_settings( + NEXTCLOUD_ENABLED=True, + NEXTCLOUD_BASE_URL='https://cloud.example/remote.php/dav/files/env-user', + NEXTCLOUD_DIRECTORY='EnvFolder', + NEXTCLOUD_USERNAME='env-user', + NEXTCLOUD_PASSWORD='env-pass', + ) + @patch('workflows.services.requests.put') + def test_upload_prefers_workflowconfig_overrides(self, mock_put): + WorkflowConfig.objects.update_or_create( + name='Default', + defaults={ + 'nextcloud_enabled_override': True, + 'nextcloud_base_url_override': 'https://cloud.example/remote.php/dav/files/admin-user', + 'nextcloud_directory_override': 'AdminFolder', + 'nextcloud_username_override': 'admin-user', + 'nextcloud_password_override': 'admin-pass', + }, + ) + temp_file = Path('/tmp/nextcloud_override_upload.txt') + temp_file.write_text('hello', encoding='utf-8') + mock_put.return_value.status_code = 201 + + try: + result = upload_to_nextcloud(temp_file, 'override.txt') + finally: + temp_file.unlink(missing_ok=True) + + self.assertTrue(result) + called_url = mock_put.call_args.kwargs['url'] if 'url' in mock_put.call_args.kwargs else mock_put.call_args.args[0] + self.assertTrue(called_url.endswith('/AdminFolder/override.txt')) + auth = mock_put.call_args.kwargs.get('auth') + self.assertEqual(auth, ('admin-user', 'admin-pass')) diff --git a/backend/workflows/tests/test_offboarding_flow.py b/backend/workflows/tests/test_offboarding_flow.py new file mode 100644 index 0000000..41244f6 --- /dev/null +++ b/backend/workflows/tests/test_offboarding_flow.py @@ -0,0 +1,59 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.models import EmployeeProfile, OffboardingRequest + + +class OffboardingFlowTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='offboard_user', + password='secret123', + email='operator@tub.co', + first_name='Nina', + last_name='Admin', + ) + self.client.force_login(self.user) + self.profile = EmployeeProfile.objects.create( + full_name='Lara Beispiel', + first_name='Lara', + last_name='Beispiel', + department='IT-Service', + job_title='Engineer', + work_email='lara.beispiel@tub.co', + ) + + def test_offboarding_prefill_from_profile(self): + response = self.client.get(f'/offboarding/new/?profile={self.profile.id}', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('value="Lara Beispiel"', html) + self.assertIn('value="lara.beispiel@tub.co"', html) + self.assertIn('value="Engineer"', html) + + @patch('workflows.views.process_offboarding_request.delay') + def test_offboarding_submit_uses_logged_in_user_email(self, mock_delay): + payload = { + 'full_name': self.profile.full_name, + 'work_email': self.profile.work_email, + 'department': self.profile.department, + 'job_title': self.profile.job_title, + 'last_working_day': '2026-12-31', + 'notes': 'Bitte Accounts sperren.', + } + + response = self.client.post( + f'/offboarding/new/?profile={self.profile.id}', + payload, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + obj = OffboardingRequest.objects.get(work_email=self.profile.work_email) + self.assertEqual(obj.requested_by_email, 'operator@tub.co') + self.assertEqual(obj.requested_by_name, 'Nina Admin') + mock_delay.assert_called_once_with(obj.id) diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py new file mode 100644 index 0000000..dd2a34d --- /dev/null +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -0,0 +1,50 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.models import OnboardingRequest + + +class OnboardingFlowTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='onboard_user', + password='secret123', + email='requester@tub.co', + first_name='Mia', + last_name='Beispiel', + ) + self.client.force_login(self.user) + + @patch('workflows.views.process_onboarding_request.delay') + def test_onboarding_submit_persists_and_enqueues_task(self, mock_delay): + payload = { + 'first_name': 'Max', + 'last_name': 'Mustermann', + 'gender': 'herr', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': 'max.mustermann@tub.co', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'additional_hardware_needed_choice': 'nein', + 'additional_software_needed_choice': 'nein', + 'additional_access_needed_choice': 'nein', + 'successor_required_choice': 'nein', + 'inherit_phone_number_choice': 'nein', + 'agreement_confirm': 'on', + } + + response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 302) + self.assertIn('/onboarding/new/?saved=1&id=', response['Location']) + + obj = OnboardingRequest.objects.get(work_email='max.mustermann@tub.co') + self.assertEqual(obj.full_name, 'Max Mustermann') + self.assertEqual(obj.onboarded_by_email, 'requester@tub.co') + self.assertEqual(obj.onboarded_by_name, 'Mia Beispiel') + mock_delay.assert_called_once_with(obj.id) diff --git a/backend/workflows/tests/test_pdf_smoke.py b/backend/workflows/tests/test_pdf_smoke.py new file mode 100644 index 0000000..dc20ed6 --- /dev/null +++ b/backend/workflows/tests/test_pdf_smoke.py @@ -0,0 +1,18 @@ +from pathlib import Path + +from django.test import SimpleTestCase + +from workflows.tasks import _generate_content_pdf + + +class PdfSmokeTests(SimpleTestCase): + def test_generate_content_pdf_creates_nonempty_file(self): + output_pdf = Path('/tmp/pdf_smoke_output.pdf') + html = '

PDF Smoke

This is a smoke test.

' + + try: + _generate_content_pdf(html, output_pdf) + self.assertTrue(output_pdf.exists()) + self.assertGreater(output_pdf.stat().st_size, 100) + finally: + output_pdf.unlink(missing_ok=True) diff --git a/backend/workflows/tests/test_tasks_email_routing.py b/backend/workflows/tests/test_tasks_email_routing.py new file mode 100644 index 0000000..5e7d718 --- /dev/null +++ b/backend/workflows/tests/test_tasks_email_routing.py @@ -0,0 +1,107 @@ +from pathlib import Path +from datetime import date +from unittest.mock import patch + +from django.test import TestCase, override_settings + +from workflows.models import NotificationRule, OnboardingRequest, WorkflowConfig +from workflows.tasks import process_onboarding_request + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class TaskEmailRoutingTests(TestCase): + def setUp(self): + WorkflowConfig.objects.update_or_create( + name='Default', + defaults={ + 'it_onboarding_email': 'it@tub.co', + 'general_info_email': 'ingo@tub.co', + 'business_card_email': 'kommunikation@tub.co', + 'hr_works_email': 'dittrich@tub.co', + 'key_notification_email': 'minuth@tub.co', + }, + ) + self.request_obj = OnboardingRequest.objects.create( + full_name='Nina Routing', + gender='frau', + job_title='Manager', + department='IT-Service', + work_email='nina.routing@tub.co', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + order_business_cards=True, + business_card_name='Nina Routing', + business_card_title='Manager', + business_card_email='nina.routing@tub.co', + business_card_phone='030 123456', + needed_devices='Laptop\nSchlüssel', + needed_accesses='HR Works', + onboarded_by_email='requester@tub.co', + agreement='accepted', + ) + + @patch('workflows.tasks.upload_to_nextcloud') + @patch('workflows.tasks._send_templated_email') + @patch('workflows.tasks._generate_onboarding_pdf') + def test_onboarding_email_routing_conditions( + self, + mock_generate_pdf, + mock_send_templated_email, + mock_upload, + ): + pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Nina_Routing.pdf') + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_path.write_bytes(b'%PDF-1.4\n%test\n') + mock_generate_pdf.return_value = pdf_path + + process_onboarding_request(self.request_obj.id) + + template_keys = [call.kwargs['template_key'] for call in mock_send_templated_email.call_args_list] + self.assertIn('onboarding_it', template_keys) + self.assertIn('onboarding_general_info', template_keys) + self.assertIn('onboarding_business_card', template_keys) + self.assertIn('onboarding_hr_works', template_keys) + self.assertIn('onboarding_key', template_keys) + self.assertIn('onboarding_reference', template_keys) + self.assertEqual(len(template_keys), 6) + + mock_upload.assert_called_once_with(pdf_path, pdf_path.name) + + @patch('workflows.tasks.upload_to_nextcloud') + @patch('workflows.tasks._send_templated_email') + @patch('workflows.tasks._generate_onboarding_pdf') + def test_onboarding_additional_notification_rule( + self, + mock_generate_pdf, + mock_send_templated_email, + mock_upload, + ): + NotificationRule.objects.create( + name='Extra Schluessel Regel', + event_type='onboarding', + field_name='needed_devices', + operator='contains', + expected_value='Schlüssel', + recipients='extra.recipient@tub.co', + template_key='onboarding_key', + include_pdf_attachment=True, + sort_order=1, + is_active=True, + ) + + pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Nina_Routing.pdf') + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_path.write_bytes(b'%PDF-1.4\n%test\n') + mock_generate_pdf.return_value = pdf_path + + process_onboarding_request(self.request_obj.id) + + matching_calls = [ + call for call in mock_send_templated_email.call_args_list + if call.kwargs.get('template_key') == 'onboarding_key' + and call.kwargs.get('to') == ['extra.recipient@tub.co'] + ] + self.assertEqual(len(matching_calls), 1) + self.assertEqual(matching_calls[0].kwargs.get('attachments'), [pdf_path]) + + mock_upload.assert_called_once_with(pdf_path, pdf_path.name) diff --git a/backend/workflows/tests/test_welcome_email_schedule.py b/backend/workflows/tests/test_welcome_email_schedule.py new file mode 100644 index 0000000..33f83e6 --- /dev/null +++ b/backend/workflows/tests/test_welcome_email_schedule.py @@ -0,0 +1,162 @@ +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.utils import timezone + +from workflows.models import NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class WelcomeEmailScheduleTests(TestCase): + def setUp(self): + WorkflowConfig.objects.update_or_create( + name='Default', + defaults={ + 'welcome_email_delay_days': 9, + 'welcome_sender_email': 'admin.sender@tub.co', + 'welcome_include_pdf': False, + }, + ) + self.onboarding = OnboardingRequest.objects.create( + full_name='Welcome Person', + gender='frau', + job_title='Manager', + department='IT-Service', + work_email='welcome.person@tub.co', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email='requester@tub.co', + agreement='accepted', + ) + + @patch('workflows.tasks.upload_to_nextcloud') + @patch('workflows.tasks.send_scheduled_welcome_email.apply_async') + @patch('workflows.tasks._send_templated_email') + @patch('workflows.tasks._generate_onboarding_pdf') + def test_onboarding_creates_scheduled_welcome_email( + self, + mock_generate_pdf, + mock_send_templated_email, + mock_welcome_apply_async, + mock_upload, + ): + pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Welcome_Person.pdf') + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_path.write_bytes(b'%PDF-1.4\n%test\n') + mock_generate_pdf.return_value = pdf_path + mock_welcome_apply_async.return_value.id = 'celery-welcome-1' + + process_onboarding_request(self.onboarding.id) + + scheduled = ScheduledWelcomeEmail.objects.get(onboarding_request_id=self.onboarding.id) + self.assertEqual(scheduled.recipient_email, 'welcome.person@tub.co') + self.assertEqual(scheduled.status, 'scheduled') + self.assertEqual(scheduled.celery_task_id, 'celery-welcome-1') + delay = scheduled.send_at - timezone.now() + self.assertGreaterEqual(delay.days, 8) + + @patch('workflows.views.send_scheduled_welcome_email.delay') + def test_admin_can_trigger_welcome_email_now(self, mock_delay): + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='welcome.person@tub.co', + send_at=timezone.now(), + status='scheduled', + ) + + user_model = get_user_model() + admin = user_model.objects.create_user( + username='welcome_admin', + password='secret123', + email='welcome_admin@tub.co', + is_staff=True, + is_superuser=True, + ) + self.client.force_login(admin) + mock_delay.return_value.id = 'forced-welcome-1' + + response = self.client.post( + f'/admin-tools/welcome-emails/{scheduled.id}/trigger-now/', + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + scheduled.refresh_from_db() + self.assertEqual(scheduled.celery_task_id, 'forced-welcome-1') + mock_delay.assert_called_once_with(scheduled.id, True) + + @patch('workflows.views.send_scheduled_welcome_email.apply_async') + def test_admin_can_pause_resume_and_cancel(self, mock_apply_async): + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='welcome.person@tub.co', + send_at=timezone.now(), + status='scheduled', + celery_task_id='task-abc', + ) + mock_apply_async.return_value.id = 'resumed-task-1' + + user_model = get_user_model() + admin = user_model.objects.create_user( + username='welcome_admin_2', + password='secret123', + email='welcome_admin_2@tub.co', + is_staff=True, + is_superuser=True, + ) + self.client.force_login(admin) + + pause_response = self.client.post( + f'/admin-tools/welcome-emails/{scheduled.id}/pause/', + HTTP_HOST='localhost', + ) + self.assertEqual(pause_response.status_code, 302) + scheduled.refresh_from_db() + self.assertEqual(scheduled.status, 'paused') + + resume_response = self.client.post( + f'/admin-tools/welcome-emails/{scheduled.id}/resume/', + HTTP_HOST='localhost', + ) + self.assertEqual(resume_response.status_code, 302) + scheduled.refresh_from_db() + self.assertEqual(scheduled.status, 'scheduled') + self.assertEqual(scheduled.celery_task_id, 'resumed-task-1') + + cancel_response = self.client.post( + f'/admin-tools/welcome-emails/{scheduled.id}/cancel/', + HTTP_HOST='localhost', + ) + self.assertEqual(cancel_response.status_code, 302) + scheduled.refresh_from_db() + self.assertEqual(scheduled.status, 'cancelled') + + @patch('workflows.tasks._send_templated_email') + def test_scheduled_welcome_uses_sender_override_without_pdf_if_disabled(self, mock_send_templated): + NotificationTemplate.objects.update_or_create( + key='onboarding_welcome', + defaults={ + 'subject_template': 'Welcome {{ FULL_NAME }}', + 'body_template': 'Body {{ EMAIL }}', + 'is_active': True, + }, + ) + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='welcome.person@tub.co', + send_at=timezone.now(), + status='scheduled', + ) + self.onboarding.generated_pdf_path = '/tmp/onoff_test_pdfs/should_not_attach.pdf' + self.onboarding.save(update_fields=['generated_pdf_path']) + + send_scheduled_welcome_email(scheduled.id, True) + + mock_send_templated.assert_called_once() + kwargs = mock_send_templated.call_args.kwargs + self.assertEqual(kwargs.get('attachments'), []) + self.assertEqual(kwargs.get('from_email'), 'admin.sender@tub.co') diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py new file mode 100644 index 0000000..c8225d5 --- /dev/null +++ b/backend/workflows/urls.py @@ -0,0 +1,34 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('healthz/', views.healthz, name='healthz'), + path('', views.home, name='home'), + path('requests/', views.requests_dashboard, name='requests_dashboard'), + path('onboarding/new/', views.onboarding_create, name='onboarding_create'), + path('onboarding/success//', views.onboarding_success, name='onboarding_success'), + path('offboarding/new/', views.offboarding_create, name='offboarding_create'), + path('offboarding/success//', views.offboarding_success, name='offboarding_success'), + path('test/email/', views.send_test_email, name='send_test_email'), + path('test/nextcloud/', views.nextcloud_test_upload, name='nextcloud_test_upload'), + path('admin-tools/nextcloud/toggle/', views.toggle_nextcloud_enabled, name='toggle_nextcloud_enabled'), + path('admin-tools/email-mode/toggle/', views.toggle_email_mode, name='toggle_email_mode'), + path('admin-tools/integrations/', views.integrations_setup_page, name='integrations_setup_page'), + path('admin-tools/integrations/save/', views.save_integrations_settings, name='save_integrations_settings'), + path('admin-tools/integrations/save-nextcloud/', views.save_nextcloud_settings, name='save_nextcloud_settings'), + path('admin-tools/integrations/save-mail/', views.save_mail_settings, name='save_mail_settings'), + path('admin-tools/integrations/save-emails/', views.save_email_routing_settings, name='save_email_routing_settings'), + path('admin-tools/integrations/save-rules/', views.save_notification_rules, name='save_notification_rules'), + path('admin-tools/welcome-emails/', views.welcome_emails_page, name='welcome_emails_page'), + path('admin-tools/welcome-emails/settings/', views.save_welcome_email_settings, name='save_welcome_email_settings'), + path('admin-tools/welcome-emails/bulk-action/', views.bulk_welcome_email_action, name='bulk_welcome_email_action'), + path('admin-tools/welcome-emails//trigger-now/', views.trigger_welcome_email_now, name='trigger_welcome_email_now'), + path('admin-tools/welcome-emails//pause/', views.pause_welcome_email, name='pause_welcome_email'), + path('admin-tools/welcome-emails//resume/', views.resume_welcome_email, name='resume_welcome_email'), + path('admin-tools/welcome-emails//cancel/', views.cancel_welcome_email, name='cancel_welcome_email'), + path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'), + path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'), + path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'), + path('requests/delete///', views.delete_request_from_dashboard, name='delete_request_from_dashboard'), +] diff --git a/backend/workflows/views.py b/backend/workflows/views.py new file mode 100644 index 0000000..466bd2f --- /dev/null +++ b/backend/workflows/views.py @@ -0,0 +1,1241 @@ +from pathlib import Path +from datetime import timedelta +from tempfile import NamedTemporaryFile +import json + +from celery import current_app +from django.conf import settings +from django.db import connection +from django.db import IntegrityError +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect, render +from django.contrib import messages +from django.contrib.auth.decorators import login_required, user_passes_test +from django.http import JsonResponse +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import ensure_csrf_cookie +from django.utils import timezone + +from .forms import OffboardingRequestForm, OnboardingRequestForm +from .form_builder import ( + DEFAULT_FIELD_ORDER, + LOCKED_FIELD_RULES, + ONBOARDING_DEFAULT_PAGE, + ONBOARDING_PAGE_LABELS, + ONBOARDING_PAGE_ORDER, + ensure_form_field_configs, +) +from .models import EmployeeProfile, FormFieldConfig, FormOption, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .emailing import send_system_email +from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud +from .tasks import ( + DEFAULT_NOTIFICATION_TEMPLATES, + process_offboarding_request, + process_onboarding_request, + send_scheduled_welcome_email, +) + +ONBOARDING_GROUPS = { + 'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'], + 'employment-end-box': ['employment_end_date'], + 'group-mailboxes-box': ['group_mailboxes'], + 'extra-hardware-box': ['additional_hardware_multi', 'additional_hardware_other'], + 'extra-software-box': ['additional_software_multi', 'additional_software'], + 'extra-access-box': ['additional_access_text'], + 'successor-box': ['successor_name', 'inherit_phone_number_choice'], + 'phone-box': ['phone_number_choice'], +} + +ONBOARDING_HIDDEN_BY_DEFAULT = { + 'business-card-box', + 'employment-end-box', + 'group-mailboxes-box', + 'extra-hardware-box', + 'extra-software-box', + 'extra-access-box', + 'successor-box', +} + +ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} +ONBOARDING_CHECKBOX_LISTS = { + 'needed_devices_multi', + 'additional_hardware_multi', + 'needed_software_multi', + 'additional_software_multi', + 'needed_accesses_multi', + 'needed_workspace_groups_multi', + 'needed_resources_multi', +} +ONBOARDING_SECTION_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] +ONBOARDING_SECTION_META = { + 'stammdaten': {'title': 'Stammdaten', 'subtitle': 'Person, Rolle, Abteilung'}, + 'vertrag': {'title': 'Vertrag', 'subtitle': 'Beschäftigung und Termine'}, + 'itsetup': {'title': 'IT-Setup', 'subtitle': 'Geräte, Software und Zugänge'}, + 'abschluss': {'title': 'Abschluss', 'subtitle': 'Notizen und Freigabe'}, +} + + +def healthz(request): + db_ok = True + try: + with connection.cursor() as cursor: + cursor.execute('SELECT 1') + cursor.fetchone() + except Exception: + db_ok = False + + status_code = 200 if db_ok else 503 + return JsonResponse( + { + 'status': 'ok' if db_ok else 'degraded', + 'service': 'onoff_v2', + 'db': 'ok' if db_ok else 'error', + 'time': timezone.now().isoformat(), + }, + status=status_code, + ) + + +def _is_staff(user) -> bool: + return user.is_authenticated and user.is_staff + + +def _display_user_name(user) -> str: + first_name = (getattr(user, 'first_name', '') or '').strip() + last_name = (getattr(user, 'last_name', '') or '').strip() + full_name = f'{first_name} {last_name}'.strip() + if full_name: + return full_name + username = (getattr(user, 'username', '') or '').strip() + if username: + return username + return (getattr(user, 'email', '') or '').strip() + + +def _form_field_labels(form_type: str) -> dict[str, str]: + if form_type == 'onboarding': + return {name: str(field.label or name) for name, field in OnboardingRequestForm.base_fields.items()} + if form_type == 'offboarding': + return {name: str(field.label or name) for name, field in OffboardingRequestForm.base_fields.items()} + return {} + + +def _build_onboarding_layout(form) -> list[dict]: + ordered_names = list(form.fields.keys()) + group_by_field = {} + for group_id, group_fields in ONBOARDING_GROUPS.items(): + for name in group_fields: + group_by_field[name] = group_id + + rendered_groups = set() + consumed = set() + blocks = [] + + for field_name in ordered_names: + if field_name in consumed: + continue + + group_id = group_by_field.get(field_name) + if group_id: + if group_id in rendered_groups: + continue + group_fields = [ + form[name] + for name in ONBOARDING_GROUPS[group_id] + if name in form.fields + ] + if not group_fields: + continue + blocks.append( + { + 'kind': 'group', + 'id': group_id, + 'hidden_default': group_id in ONBOARDING_HIDDEN_BY_DEFAULT, + 'fields': group_fields, + } + ) + rendered_groups.add(group_id) + consumed.update([f.name for f in group_fields]) + continue + + blocks.append({'kind': 'field', 'field': form[field_name]}) + consumed.add(field_name) + + return blocks + + +def _section_for_block(block: dict, field_pages: dict[str, str]) -> str: + if block['kind'] == 'field': + return field_pages.get(block['field'].name, 'abschluss') + fields = block.get('fields') or [] + if not fields: + return 'abschluss' + return field_pages.get(fields[0].name, 'abschluss') + + +def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str]) -> list[dict]: + grouped = {key: [] for key in ONBOARDING_SECTION_ORDER} + for block in blocks: + section_key = _section_for_block(block, field_pages) + if section_key not in grouped: + section_key = 'abschluss' + grouped[section_key].append(block) + return [ + { + 'key': key, + 'title': ONBOARDING_SECTION_META[key]['title'], + 'subtitle': ONBOARDING_SECTION_META[key]['subtitle'], + 'blocks': grouped[key], + } + for key in ONBOARDING_SECTION_ORDER + ] + + +@login_required +def home(request): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + return render( + request, + 'workflows/home.html', + { + 'nextcloud_enabled': is_nextcloud_enabled(), + 'email_test_mode': is_email_test_mode(), + 'workflow_config': config, + }, + ) + + +@login_required +@user_passes_test(_is_staff) +def project_wiki_page(request): + return render(request, 'workflows/project_wiki.html') + + +@login_required +def requests_dashboard(request): + if request.method == 'POST': + if not request.user.is_staff: + 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 + 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 + obj.delete() + deleted_count += 1 + + if deleted_count: + messages.success(request, f'{deleted_count} Eintrag/Einträge gelöscht.') + if invalid_count: + messages.warning(request, f'{invalid_count} Auswahl(en) konnten nicht verarbeitet werden.') + 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() + onboarding_qs = OnboardingRequest.objects.order_by('-created_at') + offboarding_qs = OffboardingRequest.objects.order_by('-created_at') + 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)) + + onboarding_items = onboarding_qs[:50] + offboarding_items = offboarding_qs[:50] + + rows = [] + for obj in onboarding_items: + 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, + 'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung', + } + ) + 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, + 'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung', + } + ) + + 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() + return render( + request, + 'workflows/requests_dashboard.html', + { + 'rows': rows[:60], + 'search_query': search_query, + 'onboarding_total': onboarding_total, + 'offboarding_total': offboarding_total, + 'combined_total': onboarding_total + offboarding_total, + 'chart_points': chart_points, + }, + ) + + +@login_required +@ensure_csrf_cookie +def onboarding_create(request): + 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(request.user) + obj.save(update_fields=['onboarded_by_name']) + 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(form) + field_pages = getattr(form, '_field_page_keys', {}) + onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages) + + 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, + 'legal_text': legal_text, + 'saved': request.GET.get('saved') == '1', + 'saved_request_id': request.GET.get('id', ''), + }, + ) + + +@login_required +def onboarding_success(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}) + + +@login_required +@ensure_csrf_cookie +def offboarding_create(request): + 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() + if requester_email and requester_email.endswith('@tub.co'): + obj.requested_by_email = requester_email + else: + obj.requested_by_email = settings.DEFAULT_FROM_EMAIL + obj.requested_by_name = _display_user_name(request.user) + 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}) + + 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', ''), + }, + ) + + +@login_required +def offboarding_success(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}) + + +@login_required +@user_passes_test(_is_staff) +def form_builder_page(request): + form_type = request.GET.get('form_type', 'onboarding') + 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] + + if request.method == 'POST': + delete_option_id = request.POST.get('delete_option_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 + option.delete() + messages.success(request, 'Option wurde gelöscht.') + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") + + action = request.POST.get('builder_action', '') + if action == 'add_option': + category = request.POST.get('category', '').strip() + label = request.POST.get('label', '').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( + category=category, + label=label, + value=value or label, + sort_order=(next_sort + 1) if next_sort is not None else 0, + is_active=True, + ) + 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.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', 'value', 'is_active', 'sort_order']) + except IntegrityError: + messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}') + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}") + option_category = option.category + messages.success(request, 'Optionen wurden gespeichert.') + + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") + + 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) + + 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()) + + if form_type == 'onboarding': + columns = [ + { + 'key': key, + 'title': ONBOARDING_PAGE_LABELS.get(key, key), + 'items': [], + } + for key in ONBOARDING_PAGE_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': labels.get(cfg.field_name, cfg.field_name), + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked, + } + ) + else: + columns = [ + { + 'key': 'all', + 'title': 'Offboarding Felder', + 'items': [ + { + 'field_name': cfg.field_name, + 'label': labels.get(cfg.field_name, cfg.field_name), + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked, + } + for cfg in configs + ], + } + ] + + return render( + request, + 'workflows/form_builder.html', + { + 'form_type': form_type, + 'columns': columns, + 'form_types': [('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], + 'option_categories': FormOption.CATEGORY_CHOICES, + 'selected_option_category': option_category, + 'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'), + }, + ) + + +@login_required +@user_passes_test(_is_staff) +def integrations_setup_page(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'}: + kind = 'nextcloud' + templates = list(NotificationTemplate.objects.all().order_by('key')) + return render( + request, + 'workflows/integrations_setup.html', + { + 'workflow_config': config, + '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, + }, + ) + + +@login_required +@user_passes_test(_is_staff) +def welcome_emails_page(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 = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {}) + default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip() + default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').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 + 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_keywords': ['{{ FULL_NAME }}', '{{ VORNAME }}', '{{ NACHNAME }}', '{{ DEPARTMENT }}', '{{ CONTRACT_START }}', '{{ EMAIL }}', '{{ REQUESTED_BY }}'], + }, + ) + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def trigger_welcome_email_now(request, schedule_id: int): + 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']) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde sofort angestoßen.') + return redirect('welcome_emails_page') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_welcome_email_settings(request): + 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') + if subject is not None or body is not None: + default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {}) + default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip() + default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip() + subject_clean = (subject or '').strip() or default_subject + body_clean = (body or '').strip() or default_body + template, _ = NotificationTemplate.objects.get_or_create( + key='onboarding_welcome', + defaults={ + 'subject_template': subject_clean, + 'body_template': body_clean, + '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 not template.is_active: + template.is_active = True + changes.append('is_active') + if changes: + template.save(update_fields=changes) + + 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 + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def bulk_welcome_email_action(request): + 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: + 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') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def pause_welcome_email(request, schedule_id: int): + 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']) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde pausiert.') + return redirect('welcome_emails_page') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def resume_welcome_email(request, schedule_id: int): + 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']) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde fortgesetzt.') + return redirect('welcome_emails_page') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def cancel_welcome_email(request, schedule_id: int): + 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']) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde abgebrochen.') + return redirect('welcome_emails_page') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def form_builder_save_order(request): + 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) + + 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')) + allowed_names = {cfg.field_name for cfg in configs} + seen = set() + ordered_names = [] + + if form_type == 'onboarding': + allowed_columns = ONBOARDING_PAGE_ORDER + else: + allowed_columns = ['all'] + + name_to_cfg = {cfg.field_name: cfg for cfg in 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': f'Ungültige Spalte: {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) + ordered_names.append(name) + cfg = name_to_cfg[name] + cfg.sort_order = sort_order + sort_order += 1 + if form_type == 'onboarding': + cfg.page_key = column_key + else: + cfg.page_key = '' + + 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 + if form_type == 'onboarding': + cfg.page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss') + else: + cfg.page_key = '' + + FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) + return JsonResponse({'ok': True, 'saved_count': len(configs)}) + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def send_test_email(request): + mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF' + redirect_email = get_email_test_redirect() + 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], + ) + messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).') + return redirect('home') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def nextcloud_test_upload(request): + 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: + messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}') + else: + messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.') + except Exception as exc: + messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}') + finally: + if temp_path and temp_path.exists(): + temp_path.unlink(missing_ok=True) + + return redirect('home') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def toggle_nextcloud_enabled(request): + 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']) + + state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert' + messages.success(request, f'Nextcloud Upload wurde {state}.') + return redirect('home') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def toggle_email_mode(request): + 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']) + + state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus' + messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.') + return redirect('home') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_integrations_settings(request): + 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() + messages.success(request, 'Integrations-Einstellungen wurden gespeichert.') + return redirect('home') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_nextcloud_settings(request): + 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() + messages.success(request, 'Nextcloud-Einstellungen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=nextcloud') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_mail_settings(request): + 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() + messages.success(request, 'Mail-Einstellungen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=mail') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_email_routing_settings(request): + 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}') + if subject is None and body is None: + continue + subject = (subject or '').strip() + body = (body or '').strip() + if not subject and not body: + continue + obj, _ = NotificationTemplate.objects.get_or_create( + key=key, + defaults={ + 'subject_template': subject or f'[{key}]', + 'body_template': body or '-', + '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 changed: + obj.save(update_fields=changed) + + messages.success(request, 'E-Mail Routing und Vorlagen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=emails') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_notification_rules(request): + 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.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(), + include_pdf_attachment=request.POST.get('new_include_pdf') == 'on', + is_active=True, + sort_order=NotificationRule.objects.filter(event_type=new_event).count() + 1, + ) + + messages.success(request, 'Benachrichtigungsregeln wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=emails') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def delete_request_from_dashboard(request, kind: str, request_id: int): + 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') + + obj.delete() + messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.') + return redirect('requests_dashboard') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dc336a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + web: + build: + context: ./backend + command: sh -c "./entrypoint-web.sh" + env_file: + - .env + volumes: + - ./backend:/app + - ./backend/media:/app/media + user: "app" + ports: + - "8000:8000" + - "8010:8000" + - "8088:8000" + depends_on: + - db + - redis + - mailhog + + worker: + build: + context: ./backend + command: sh -c "./entrypoint-worker.sh" + env_file: + - .env + volumes: + - ./backend:/app + - ./backend/media:/app/media + user: "app" + depends_on: + - db + - redis + - mailhog + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-onoff} + POSTGRES_USER: ${POSTGRES_USER:-onoff} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-onoff} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + mailhog: + image: mailhog/mailhog:v1.0.1 + ports: + - "8025:8025" + - "1025:1025" + +volumes: + postgres_data: