chore: initial snapshot of tubco people portal

This commit is contained in:
Md Bayazid Bostame
2026-03-19 10:22:20 +01:00
commit 9fe3c2ea82
81 changed files with 8698 additions and 0 deletions

34
.env.example Normal file
View File

@@ -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

69
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

16
.gitignore vendored Normal file
View File

@@ -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

40
README.md Normal file
View File

@@ -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`

19
backend/Dockerfile Normal file
View File

@@ -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

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

5
backend/config/asgi.py Normal file
View File

@@ -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()

8
backend/config/celery.py Normal file
View File

@@ -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()

141
backend/config/settings.py Normal file
View File

@@ -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'))

13
backend/config/urls.py Normal file
View File

@@ -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)

5
backend/config/wsgi.py Normal file
View File

@@ -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()

11
backend/entrypoint-web.sh Executable file
View File

@@ -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 -

9
backend/entrypoint-worker.sh Executable file
View File

@@ -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

13
backend/manage.py Normal file
View File

@@ -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()

11
backend/requirements.txt Normal file
View File

@@ -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

View File

167
backend/workflows/admin.py Normal file
View File

@@ -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'

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class WorkflowsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'workflows'

View File

@@ -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)

View File

@@ -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
}

396
backend/workflows/forms.py Normal file
View File

@@ -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)

View File

View File

@@ -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.'))

View File

@@ -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)),
],
),
]

View File

@@ -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)'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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')),
],
),
]

View File

@@ -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),
]

View File

@@ -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),
),
]

View File

@@ -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)'),
),
]

View File

@@ -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)'),
),
]

View File

@@ -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'],
},
),
]

View File

@@ -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),
]

View File

@@ -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',
},
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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),
]

View File

@@ -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),
),
]

View File

@@ -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),
]

View File

@@ -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)'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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)'),
),
]

View File

@@ -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)'),
),
]

View File

@@ -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'],
},
),
]

View File

@@ -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'],
},
),
]

View File

@@ -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),
),
]

View File

320
backend/workflows/models.py Normal file
View File

@@ -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}"

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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; } }

View File

@@ -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;
}
}

View File

@@ -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);
}
});
}
})();

790
backend/workflows/tasks.py Normal file
View File

@@ -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 = (
'<style>'
'@page { size: A4; margin: 58mm 16mm 16mm 16mm; }'
'body { margin: 0; }'
'</style>'
)
if '<head>' in html_content:
html_content = html_content.replace('<head>', f'<head>{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'])

View File

@@ -0,0 +1,39 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anmeldung</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
body { margin: 0; font-family: Arial, sans-serif; min-height: 100vh; display: grid; place-items: center; background: linear-gradient(160deg, #eef6ff, #fff3f3); }
.card { width: min(420px, calc(100% - 28px)); background: #fff; border: 1px solid #d9e3f0; border-radius: 14px; padding: 20px; box-shadow: 0 12px 30px rgba(28, 45, 79, 0.12); }
.logo { width: 190px; max-width: 100%; height: auto; display: block; margin-bottom: 12px; }
h1 { margin: 0 0 8px; font-size: 24px; }
p { margin: 0 0 14px; color: #607086; }
.field { margin-bottom: 12px; }
label { display: block; font-weight: 600; margin-bottom: 6px; }
input { width: 100%; padding: 10px; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; }
.btn { width: 100%; }
.errorlist { color: #b91c1c; margin: 6px 0; }
</style>
</head>
<body>
<div class="card">
<img class="logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<h1>Anmeldung</h1>
<p>Bitte melden Sie sich mit Ihrem Benutzerkonto an.</p>
<form method="post" action="/accounts/login/">
{% csrf_token %}
{% if form.errors %}
<div class="errorlist">Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.</div>
{% endif %}
<div class="field">{{ form.username.label_tag }}{{ form.username }}</div>
<div class="field">{{ form.password.label_tag }}{{ form.password }}</div>
<button class="btn btn-primary" type="submit">Anmelden</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,131 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Form Builder</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/form_builder.css' %}" />
</head>
<body>
<div class="shell">
<div class="topbar">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
<header class="header">
<h1>Form Builder</h1>
<p>Felder per Drag-and-Drop sortieren und pro Schritt gruppieren.</p>
</header>
{% if messages %}
{% for message in messages %}
<div class="flash {% if message.tags == 'error' %}error{% endif %}">{{ message }}</div>
{% endfor %}
{% endif %}
<div class="toolbar">
{% for key, label in form_types %}
<a
class="tab {% if form_type == key %}active{% endif %}"
href="/admin-tools/form-builder/?form_type={{ key }}"
>
{{ label }}
</a>
{% endfor %}
<button id="save-order" class="btn btn-primary" type="button">Reihenfolge speichern</button>
</div>
<div id="status-message" class="status" aria-live="polite"></div>
<div class="columns {% if form_type == 'offboarding' %}single{% endif %}" id="builder-columns" data-form-type="{{ form_type }}">
{% for column in columns %}
<section class="column" data-column-key="{{ column.key }}">
<h2>{{ column.title }}</h2>
<div class="dropzone" data-column-key="{{ column.key }}">
{% for item in column.items %}
<article class="field-card" draggable="true" data-field-name="{{ item.field_name }}">
<div class="field-main">
<div class="field-label">{{ item.label }}</div>
<div class="field-name">{{ item.field_name }}</div>
</div>
<div class="badges">
{% if item.locked %}<span class="badge locked">Fix</span>{% endif %}
{% if not item.is_visible %}<span class="badge hidden">Hidden</span>{% endif %}
{% if item.is_required %}<span class="badge required">Pflicht</span>{% endif %}
</div>
</article>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<section class="options-panel">
<div class="options-head">
<h2>Optionen verwalten</h2>
<form class="category-switch" method="get" action="/admin-tools/form-builder/">
<input type="hidden" name="form_type" value="{{ form_type }}" />
<label for="option_category">Kategorie</label>
<select id="option_category" name="option_category" onchange="this.form.submit()">
{% for value, label in option_categories %}
<option value="{{ value }}" {% if value == selected_option_category %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
</div>
<form class="add-option-form" method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<input type="hidden" name="builder_action" value="add_option" />
<input type="hidden" name="category" value="{{ selected_option_category }}" />
<input type="text" name="label" placeholder="Neuer Optionsname" required />
<input type="text" name="value" placeholder="Technischer Wert (optional)" />
<button class="btn btn-primary" type="submit">Option hinzufügen</button>
</form>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="option-table-wrap">
<table class="option-table">
<thead>
<tr>
<th>Sortierung</th>
<th>Label</th>
<th>Value</th>
<th>Aktiv</th>
<th>Löschen</th>
</tr>
</thead>
<tbody id="option-table-body">
{% for item in option_items %}
<tr class="option-row" draggable="true" data-option-row="1">
<td>
<input type="hidden" name="option_ids" value="{{ item.id }}" />
<span class="drag-handle" title="Ziehen zum Sortieren">⋮⋮</span>
</td>
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
<td><input type="text" name="value_{{ item.id }}" value="{{ item.value }}" /></td>
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
<td>
<button class="btn btn-secondary" type="submit" name="delete_option_id" value="{{ item.id }}" onclick="return confirm('Option wirklich löschen?');">Löschen</button>
</td>
</tr>
{% empty %}
<tr><td colspan="5">Keine Optionen in dieser Kategorie.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_options">Optionen speichern</button>
</div>
</form>
</section>
</div>
<script src="{% static 'workflows/js/form_builder.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,454 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Onboarding/Offboarding Portal</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
:root {
--brand-blue: #000078;
--brand-red: #8c1d1d;
--ink: #102039;
--muted: #5f6f85;
--line: #d8e1ee;
--panel: #ffffff;
--bg-soft: #eff4ff;
--ok-bg: #effaf2;
--ok-ink: #166534;
--warn-bg: #fff6ea;
--warn-ink: #8a4f00;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background:
radial-gradient(80% 120% at 85% 8%, rgba(0, 0, 120, 0.12), rgba(0, 0, 120, 0)),
radial-gradient(70% 90% at 8% 92%, rgba(140, 29, 29, 0.10), rgba(140, 29, 29, 0)),
linear-gradient(165deg, #eef3ff, #f7f9ff 48%, #f0f5ff);
min-height: 100vh;
padding: 24px;
}
.shell {
width: min(1220px, 100%);
margin: 0 auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: 0 20px 44px rgba(16, 32, 57, 0.13);
overflow: hidden;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
background: #fff;
}
.brand-logo {
width: 210px;
max-width: 100%;
height: auto;
display: block;
margin: 0;
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.hero {
padding: 22px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(135deg, rgba(0, 0, 120, 0.06), rgba(0, 0, 120, 0) 48%),
linear-gradient(180deg, #ffffff, #f8fbff);
}
.hero h1 {
margin: 0;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--brand-blue);
}
.hero p {
margin: 8px 0 0;
color: var(--muted);
max-width: 820px;
font-size: 15px;
}
.status-row {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.status-pill {
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
color: #30445f;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
}
.status-pill.ok {
background: var(--ok-bg);
color: var(--ok-ink);
border-color: #c7ecd2;
}
.status-pill.warn {
background: var(--warn-bg);
color: var(--warn-ink);
border-color: #f7dfbb;
}
.main {
padding: 20px 22px 24px;
}
.section-head {
margin: 0 0 12px;
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
.section-head h2 {
margin: 0;
font-size: 19px;
color: #172b4a;
}
.section-head p {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.app-card {
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
padding: 14px;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: inset 0 1px 0 #fff;
}
.app-card.primary {
background: linear-gradient(180deg, #ffffff, #f5f9ff);
}
.app-card.red {
background: linear-gradient(180deg, #ffffff, #fff6f6);
}
.accent {
width: 56px;
height: 4px;
border-radius: 999px;
background: var(--brand-blue);
margin-bottom: 10px;
}
.accent.red { background: var(--brand-red); }
.app-title {
margin: 0;
font-size: 22px;
line-height: 1.1;
}
.app-text {
margin: 8px 0 10px;
color: #5a6a81;
font-size: 14px;
line-height: 1.45;
}
.tag-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.tag {
border: 1px solid #d3deed;
border-radius: 999px;
padding: 3px 8px;
background: #f6f9ff;
color: #486183;
font-size: 11px;
font-weight: 700;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.admin-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 11px;
background: linear-gradient(180deg, #ffffff, #f7faff);
}
.admin-card h3 {
margin: 0 0 5px;
font-size: 14px;
color: #18345f;
}
.admin-card p {
margin: 0 0 9px;
font-size: 12px;
color: #607088;
min-height: 30px;
}
.msg {
border-radius: 10px;
padding: 10px 12px;
margin: 0 0 14px;
border: 1px solid #d6e1ef;
background: #f8fbff;
color: #1f3a5f;
font-size: 14px;
}
.msg.error {
border-color: #fecaca;
background: #fff1f2;
color: #991b1b;
}
.footer-note {
margin-top: 16px;
border-top: 1px solid var(--line);
padding-top: 12px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1080px) {
.apps-grid { grid-template-columns: 1fr 1fr; }
.admin-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 760px) {
body { padding: 12px; }
.topbar, .hero, .main { padding-left: 14px; padding-right: 14px; }
.hero h1 { font-size: 28px; }
.apps-grid, .admin-grid { grid-template-columns: 1fr; }
.quick-actions { justify-content: flex-start; }
}
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<div class="quick-actions">
<form method="post" action="/accounts/logout/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Abmelden</button>
</form>
</div>
</div>
<div class="hero">
<h1>Onboarding/Offboarding Portal</h1>
<p>Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud.</p>
<div class="status-row">
<span class="status-pill">Rolle: {% if request.user.is_staff %}Admin{% else %}Mitarbeiter{% endif %}</span>
<span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}">
Nextcloud: {% if nextcloud_enabled %}aktiv{% else %}inaktiv{% endif %}
</span>
<span class="status-pill {% if email_test_mode %}warn{% else %}ok{% endif %}">
E-Mail: {% if email_test_mode %}Testmodus{% else %}Produktion{% endif %}
</span>
<span class="status-pill">PDF + Email Workflow Ready</span>
</div>
</div>
<main class="main">
{% if messages %}
{% for message in messages %}
<div class="msg {% if message.tags == 'error' %}error{% endif %}">{{ message }}</div>
{% endfor %}
{% endif %}
<div class="section-head">
<h2>Apps</h2>
<p>Wählen Sie den gewünschten Prozess.</p>
</div>
<div class="apps-grid">
<section class="app-card primary">
<div>
<div class="accent"></div>
<h3 class="app-title">Onboarding</h3>
<p class="app-text">Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen.</p>
<div class="tag-row">
<span class="tag">Mehrschritt-Formular</span>
<span class="tag">PDF</span>
<span class="tag">E-Mail Routing</span>
</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/onboarding/new/">Onboarding starten</a>
</div>
</section>
<section class="app-card red">
<div>
<div class="accent red"></div>
<h3 class="app-title">Offboarding</h3>
<p class="app-text">Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten.</p>
<div class="tag-row">
<span class="tag">Profile-Suche</span>
<span class="tag">Hardware-Liste</span>
<span class="tag">IT-Rückgabe</span>
</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/offboarding/new/">Offboarding starten</a>
</div>
</section>
<section class="app-card">
<div>
<div class="accent"></div>
<h3 class="app-title">Anfragen Dashboard</h3>
<p class="app-text">Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen.</p>
<div class="tag-row">
<span class="tag">Suche</span>
<span class="tag">Status</span>
<span class="tag">PDF Zugriff</span>
</div>
</div>
<div class="card-actions">
<a class="btn btn-secondary" href="/requests/">Dashboard öffnen</a>
</div>
</section>
</div>
{% if request.user.is_staff %}
<div class="section-head">
<h2>Admin Apps</h2>
<p>Konfiguration, Tests und Steuerung.</p>
</div>
<div class="admin-grid">
<section class="admin-card">
<h3>Form Builder</h3>
<p>Felder, Schritte und Optionen verwalten.</p>
<a class="btn btn-secondary" href="/admin-tools/form-builder/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Projekt Wiki</h3>
<p>Dokumentation, Architektur und Runbook.</p>
<a class="btn btn-secondary" href="/admin-tools/wiki/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Integrationen</h3>
<p>Nextcloud- und E-Mail-Setup.</p>
<a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">Öffnen</a>
</section>
<section class="admin-card">
<h3>Welcome E-Mails</h3>
<p>Geplante Welcome Mails verwalten.</p>
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Django Admin</h3>
<p>Vollständige Datenverwaltung.</p>
<a class="btn btn-secondary" href="/admin/">Öffnen</a>
</section>
<section class="admin-card">
<h3>SMTP Einstellungen</h3>
<p>Server und Absender in der Backend-UI.</p>
<a class="btn btn-secondary" href="/admin/workflows/systememailconfig/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Nextcloud schalten</h3>
<p>Aktiv/Inaktiv direkt umschalten.</p>
<form method="post" action="/admin-tools/nextcloud/toggle/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">
{% if nextcloud_enabled %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
</section>
<section class="admin-card">
<h3>E-Mail Modus</h3>
<p>Zwischen Testmodus und Produktion wechseln.</p>
<form method="post" action="/admin-tools/email-mode/toggle/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">
Auf {% if email_test_mode %}Produktion{% else %}Testmodus{% endif %}
</button>
</form>
</section>
<section class="admin-card">
<h3>Verbindungstests</h3>
<p>Testupload und Testmail auslösen.</p>
<div class="card-actions">
<form method="post" action="/test/nextcloud/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Nextcloud-Test</button>
</form>
<form method="post" action="/test/email/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">SMTP-Test</button>
</form>
</div>
</section>
</div>
{% endif %}
<div class="footer-note">
Tipp: Die letzten Vorgänge sehen Sie jederzeit im Anfragen Dashboard.
</div>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,348 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Integrationen Setup</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; }
.shell { max-width: 980px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; }
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.brand-logo { width: 190px; max-width: 100%; height: auto; display: block; }
h1 { margin: 12px 0 6px; color: #000078; }
.sub { margin: 0 0 12px; color: #54657c; }
.switch { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.switch .tab {
border: 1px solid #c9d6e7; border-radius: 999px; padding: 8px 14px; text-decoration: none;
color: #1f2f49; font-weight: 700; background: #f6f9ff;
}
.switch .tab.active { background: #000078; color: #fff; border-color: #000078; }
.msg { border-radius: 10px; padding: 10px 12px; margin: 0 0 12px; border: 1px solid #d6e1ef; background: #f8fbff; color: #1f3a5f; }
.msg.error { border-color: #fecaca; background: #fff1f2; color: #991b1b; }
.card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 10px; }
label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; }
input { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; }
select { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; }
.check-row { margin-top: 8px; display: flex; gap: 12px; flex-wrap: wrap; }
.check-row label { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; }
.check-row input[type="checkbox"] { width: auto; }
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
.hint { margin-top: 6px; color: #64748b; font-size: 12px; }
.template-block {
border: 1px solid #d8e3f0;
border-radius: 10px;
background: #fff;
padding: 10px;
margin-top: 10px;
}
.template-title {
margin: 0 0 8px;
color: #24344e;
font-weight: 700;
font-size: 14px;
}
.rule-card {
margin-top: 12px;
border: 1px solid #d8e3f0;
border-radius: 12px;
padding: 10px;
background: #fff;
}
.rule-title {
margin: 0 0 8px;
color: #23344f;
font-weight: 700;
font-size: 14px;
}
textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 8px 9px;
min-height: 120px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
<h1>Integrationen Setup</h1>
<p class="sub">Verwalten Sie Nextcloud- und Mail-Konfiguration ohne Backend-Wechsel.</p>
<div class="switch">
<a class="tab {% if kind == 'nextcloud' %}active{% endif %}" href="/admin-tools/integrations/?kind=nextcloud">Setup Nextcloud</a>
<a class="tab {% if kind == 'mail' %}active{% endif %}" href="/admin-tools/integrations/?kind=mail">Setup Mail</a>
<a class="tab {% if kind == 'emails' %}active{% endif %}" href="/admin-tools/integrations/?kind=emails">E-Mail Routing & Vorlagen</a>
</div>
{% if messages %}
{% for message in messages %}
<div class="msg {% if message.tags == 'error' %}error{% endif %}">{{ message }}</div>
{% endfor %}
{% endif %}
{% if kind == 'nextcloud' %}
<form class="card" method="post" action="/admin-tools/integrations/save-nextcloud/">
{% csrf_token %}
<div class="grid">
<div>
<label for="nc_base">NEXTCLOUD_BASE_URL</label>
<input id="nc_base" name="nextcloud_base_url_override" value="{{ workflow_config.nextcloud_base_url_override }}" />
</div>
<div>
<label for="nc_dir">NEXTCLOUD_DIRECTORY</label>
<input id="nc_dir" name="nextcloud_directory_override" value="{{ workflow_config.nextcloud_directory_override }}" />
</div>
<div>
<label for="nc_user">NEXTCLOUD_USERNAME</label>
<input id="nc_user" name="nextcloud_username_override" value="{{ workflow_config.nextcloud_username_override }}" />
</div>
<div>
<label for="nc_pass">NEXTCLOUD_PASSWORD</label>
<input id="nc_pass" name="nextcloud_password_override" type="password" placeholder="Leer lassen = unverändert" />
</div>
<div>
<label for="sync_interval">SYNC_INTERVAL (Sekunden)</label>
<input id="sync_interval" name="sync_interval_seconds" type="number" min="10" step="1" value="{{ workflow_config.sync_interval_seconds }}" />
</div>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Nextcloud speichern</button>
</div>
<div class="hint">Leeres Passwortfeld lässt das bestehende Passwort unverändert.</div>
</form>
{% endif %}
{% if kind == 'mail' %}
<form class="card" method="post" action="/admin-tools/integrations/save-mail/">
{% csrf_token %}
<div class="grid">
<div>
<label for="imap_server">IMAP_SERVER</label>
<input id="imap_server" name="imap_server" value="{{ workflow_config.imap_server }}" />
</div>
<div>
<label for="mailbox">MAILBOX</label>
<input id="mailbox" name="mailbox" value="{{ workflow_config.mailbox }}" />
</div>
<div>
<label for="smtp_server">SMTP_SERVER</label>
<input id="smtp_server" name="smtp_server" value="{{ workflow_config.smtp_server }}" />
</div>
<div>
<label for="smtp_port">EMAIL_PORT</label>
<input id="smtp_port" name="smtp_port" type="number" min="1" step="1" value="{{ workflow_config.smtp_port }}" />
</div>
<div>
<label for="email_account">EMAIL_ACCOUNT</label>
<input id="email_account" name="email_account" value="{{ workflow_config.email_account }}" />
</div>
<div>
<label for="email_password">PASSWORD</label>
<input id="email_password" name="email_password" type="password" placeholder="Leer lassen = unverändert" />
</div>
</div>
<div class="check-row">
<label><input type="checkbox" name="smtp_use_ssl" {% if workflow_config.smtp_use_ssl %}checked{% endif %} /> SMTP SSL</label>
<label><input type="checkbox" name="smtp_use_tls" {% if workflow_config.smtp_use_tls %}checked{% endif %} /> SMTP TLS</label>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Mail speichern</button>
</div>
<div class="hint">Leeres Passwortfeld lässt das bestehende Passwort unverändert.</div>
</form>
{% endif %}
{% if kind == 'emails' %}
<form class="card" method="post" action="/admin-tools/integrations/save-emails/">
{% csrf_token %}
<div class="grid">
<div>
<label for="it_onboarding_email">It onboarding email</label>
<input id="it_onboarding_email" name="it_onboarding_email" value="{{ workflow_config.it_onboarding_email }}" />
</div>
<div>
<label for="general_info_email">General info email</label>
<input id="general_info_email" name="general_info_email" value="{{ workflow_config.general_info_email }}" />
</div>
<div>
<label for="business_card_email">Business card email</label>
<input id="business_card_email" name="business_card_email" value="{{ workflow_config.business_card_email }}" />
</div>
<div>
<label for="hr_works_email">Hr works email</label>
<input id="hr_works_email" name="hr_works_email" value="{{ workflow_config.hr_works_email }}" />
</div>
<div>
<label for="key_notification_email">Key notification email</label>
<input id="key_notification_email" name="key_notification_email" value="{{ workflow_config.key_notification_email }}" />
</div>
</div>
<div class="hint">Diese Empfänger werden für condition-based E-Mail Routing genutzt.</div>
{% for tpl in templates %}
<div class="template-block">
<p class="template-title">{{ tpl.get_key_display }} ({{ tpl.key }})</p>
<div class="grid">
<div>
<label for="subject_{{ tpl.key }}">Subject</label>
<input id="subject_{{ tpl.key }}" name="subject_{{ tpl.key }}" value="{{ tpl.subject_template }}" />
</div>
<div>
<label for="body_{{ tpl.key }}">Body</label>
<textarea id="body_{{ tpl.key }}" name="body_{{ tpl.key }}">{{ tpl.body_template }}</textarea>
</div>
</div>
</div>
{% endfor %}
<div class="actions">
<button class="btn btn-primary" type="submit">E-Mail Routing & Vorlagen speichern</button>
</div>
</form>
<form class="rule-card" method="post" action="/admin-tools/integrations/save-rules/">
{% csrf_token %}
<p class="rule-title">Bedingungsregeln für zusätzliche E-Mails</p>
<div class="hint">Zusätzliche Regeln laufen nach dem Standard-Routing.</div>
{% for rule in notification_rules %}
<div class="template-block">
<input type="hidden" name="rule_ids" value="{{ rule.id }}" />
<div class="grid">
<div>
<label for="name_{{ rule.id }}">Regelname</label>
<input id="name_{{ rule.id }}" name="name_{{ rule.id }}" value="{{ rule.name }}" />
</div>
<div>
<label for="event_type_{{ rule.id }}">Event</label>
<select id="event_type_{{ rule.id }}" name="event_type_{{ rule.id }}">
{% for key, label in rule_event_choices %}
<option value="{{ key }}" {% if key == rule.event_type %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="field_name_{{ rule.id }}">Feldname</label>
<input id="field_name_{{ rule.id }}" name="field_name_{{ rule.id }}" value="{{ rule.field_name }}" placeholder="z. B. needed_devices" />
</div>
<div>
<label for="operator_{{ rule.id }}">Operator</label>
<select id="operator_{{ rule.id }}" name="operator_{{ rule.id }}">
{% for key, label in rule_operator_choices %}
<option value="{{ key }}" {% if key == rule.operator %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="expected_value_{{ rule.id }}">Vergleichswert</label>
<input id="expected_value_{{ rule.id }}" name="expected_value_{{ rule.id }}" value="{{ rule.expected_value }}" placeholder="z. B. Schlüssel" />
</div>
<div>
<label for="recipients_{{ rule.id }}">Empfänger</label>
<input id="recipients_{{ rule.id }}" name="recipients_{{ rule.id }}" value="{{ rule.recipients }}" placeholder="a@b.de, c@d.de" />
</div>
<div>
<label for="template_key_{{ rule.id }}">Template Key (optional)</label>
<select id="template_key_{{ rule.id }}" name="template_key_{{ rule.id }}">
<option value="">-- Custom Betreff/Body verwenden --</option>
{% for key, label in template_choices %}
<option value="{{ key }}" {% if key == rule.template_key %}selected{% endif %}>{{ label }} ({{ key }})</option>
{% endfor %}
</select>
</div>
<div>
<label for="custom_subject_{{ rule.id }}">Custom Subject (optional)</label>
<input id="custom_subject_{{ rule.id }}" name="custom_subject_{{ rule.id }}" value="{{ rule.custom_subject }}" />
</div>
<div>
<label for="custom_body_{{ rule.id }}">Custom Body (optional)</label>
<textarea id="custom_body_{{ rule.id }}" name="custom_body_{{ rule.id }}">{{ rule.custom_body }}</textarea>
</div>
</div>
<div class="check-row">
<label><input type="checkbox" name="active_{{ rule.id }}" {% if rule.is_active %}checked{% endif %} /> Aktiv</label>
<label><input type="checkbox" name="include_pdf_{{ rule.id }}" {% if rule.include_pdf_attachment %}checked{% endif %} /> PDF anhängen</label>
<label><input type="checkbox" name="delete_{{ rule.id }}" /> Löschen</label>
</div>
</div>
{% empty %}
<div class="hint">Noch keine zusätzlichen Regeln vorhanden.</div>
{% endfor %}
<div class="template-block">
<p class="template-title">Neue Regel hinzufügen</p>
<div class="grid">
<div>
<label for="new_name">Regelname</label>
<input id="new_name" name="new_name" placeholder="z. B. Extra Schlüssel-Mail" />
</div>
<div>
<label for="new_event_type">Event</label>
<select id="new_event_type" name="new_event_type">
{% for key, label in rule_event_choices %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="new_field_name">Feldname</label>
<input id="new_field_name" name="new_field_name" placeholder="z. B. needed_devices" />
</div>
<div>
<label for="new_operator">Operator</label>
<select id="new_operator" name="new_operator">
{% for key, label in rule_operator_choices %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="new_expected_value">Vergleichswert</label>
<input id="new_expected_value" name="new_expected_value" placeholder="z. B. Schlüssel" />
</div>
<div>
<label for="new_recipients">Empfänger</label>
<input id="new_recipients" name="new_recipients" placeholder="a@b.de, c@d.de" />
</div>
<div>
<label for="new_template_key">Template Key (optional)</label>
<select id="new_template_key" name="new_template_key">
<option value="">-- Custom Betreff/Body verwenden --</option>
{% for key, label in template_choices %}
<option value="{{ key }}">{{ label }} ({{ key }})</option>
{% endfor %}
</select>
</div>
<div>
<label for="new_custom_subject">Custom Subject (optional)</label>
<input id="new_custom_subject" name="new_custom_subject" />
</div>
<div>
<label for="new_custom_body">Custom Body (optional)</label>
<textarea id="new_custom_body" name="new_custom_body"></textarea>
</div>
</div>
<div class="check-row">
<label><input type="checkbox" name="new_include_pdf" /> PDF anhängen</label>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Regeln speichern</button>
</div>
</form>
{% endif %}
</div>
</body>
</html>

View File

@@ -0,0 +1,66 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Offboarding-Anfrage</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/offboarding_form.css' %}" />
</head>
<body>
<div id="saved-popup" class="popup-backdrop {% if saved %}show{% endif %}">
<div class="popup">
<h3>Anfrage gespeichert</h3>
<p>Offboarding wurde erfolgreich gespeichert (ID: {{ saved_request_id }}). Das PDF wird im Hintergrund erzeugt.</p>
<button class="btn btn-secondary" type="button" onclick="document.getElementById('saved-popup').classList.remove('show')">Schließen</button>
</div>
</div>
<div class="wrap">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<div class="top-link"><a class="btn btn-secondary" href="/">Zur Startseite</a></div>
<div class="card">
<h1>Offboarding-Anfrage</h1>
<form method="get" action="/offboarding/new/">
<div class="field">
<label for="q">Mitarbeitende suchen (Name oder E-Mail)</label>
<input id="q" name="q" value="{{ search_query }}" placeholder="z. B. max.mustermann@tub.co" />
</div>
<button class="btn btn-primary" type="submit">Suchen</button>
</form>
{% if search_results %}
<div class="results" style="margin-top:10px;">
{% for p in search_results %}
<a href="/offboarding/new/?profile={{ p.id }}">{{ p.full_name }} ({{ p.work_email }})</a>
{% endfor %}
</div>
{% endif %}
{% if selected_profile %}
<p style="margin-top:10px; color:#2563eb;">Vorbefüllt aus: <strong>{{ selected_profile.full_name }}</strong> ({{ selected_profile.work_email }})</p>
{% endif %}
</div>
<div class="card">
<form method="post">
{% csrf_token %}
<div class="grid">
{% for field in form.visible_fields %}
{% if field.name != 'search_query' %}
<div class="field {% if field.name == 'notes' %}field-full{% endif %}">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% endif %}
{% endfor %}
</div>
<button class="btn btn-primary" type="submit">Offboarding-Anfrage speichern</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Offboarding gespeichert</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
code { background: #f4f4f4; padding: 2px 6px; }
</style>
</head>
<body>
<h1>Offboarding gespeichert</h1>
<p>Vorgangs-ID: <code>{{ obj.id }}</code></p>
<p>Name: <code>{{ obj.full_name }}</code></p>
<p>E-Mail: <code>{{ obj.work_email }}</code></p>
<p>Letzter Arbeitstag: <code>{{ obj.last_working_day }}</code></p>
{% if pdf_url %}
<p>PDF: <a href="{{ pdf_url }}" target="_blank" rel="noopener">PDF öffnen</a></p>
<p>Datei: <code>{{ obj.generated_pdf_path }}</code></p>
{% else %}
<p>PDF wird im Hintergrund erstellt.</p>
{% endif %}
<p><a href="/">Zur Startseite</a></p>
<p><a href="/offboarding/new/">Neue Offboarding-Anfrage erfassen</a></p>
</body>
</html>

View File

@@ -0,0 +1,341 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Onboarding-Anfrage</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/onboarding_form.css' %}" />
</head>
<body>
<div id="saved-popup" class="popup-backdrop {% if saved %}show{% endif %}">
<div class="popup">
<h3>Anfrage gespeichert</h3>
<p>Onboarding wurde erfolgreich gespeichert (ID: {{ saved_request_id }}). Das PDF wird im Hintergrund erzeugt.</p>
<button class="btn btn-secondary" type="button" onclick="document.getElementById('saved-popup').classList.remove('show')">Schließen</button>
</div>
</div>
<div class="top-wrap">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<div class="top-link"><a class="btn btn-secondary" href="/">Zur Startseite</a></div>
</div>
<div class="shell">
<aside class="panel">
<h1>Onboarding</h1>
<p class="sub">Mehrseitiges Formular mit konfigurierbaren Feldern aus dem Admin.</p>
<ol class="step-list">
{% for section in onboarding_sections %}
<li class="step-item {% if forloop.first %}active{% endif %}" data-nav-step="{{ forloop.counter }}" role="button" tabindex="0" aria-label="{{ section.title }}">
<span class="dot">{{ forloop.counter }}</span>
<div>
<div class="step-title">{{ section.title }}</div>
<div class="step-sub">{{ section.subtitle }}</div>
</div>
</li>
{% endfor %}
</ol>
</aside>
<main class="main">
{% if form.errors %}
<div class="error-banner">Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt.</div>
{% endif %}
<form method="post" id="onboarding-form" enctype="multipart/form-data">
{% csrf_token %}
{% for section in onboarding_sections %}
<section class="page {% if forloop.first %}active{% endif %}" data-step="{{ forloop.counter }}">
<div class="section-card section-{{ section.key }}">
<div class="section-head">
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
<div class="grid-2">
{% 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 %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% else %}
<div class="field {% if section.key == 'abschluss' %}finish-field{% endif %} {% if field.name in onboarding_checkbox_lists or field.name == 'gender' %}field-full{% endif %} {% if field.name in onboarding_checkbox_lists %}checkbox-list{% endif %}">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% endif %}
{% endwith %}
{% else %}
<div id="{{ block.id }}" class="field-group field-full {% if block.hidden_default %}hidden{% endif %}">
<div class="grid-2">
{% for field in block.fields %}
{% if field.is_hidden %}
{{ field }}
{% elif field.name in onboarding_inline_checks %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% else %}
<div class="field {% if section.key == 'abschluss' %}finish-field{% endif %} {% if field.name in onboarding_checkbox_lists or field.name == 'gender' %}field-full{% endif %} {% if field.name in onboarding_checkbox_lists %}checkbox-list{% endif %}">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% if not section.blocks %}
<div class="field field-full empty-step">Keine konfigurierten Felder in diesem Schritt.</div>
{% endif %}
{% if section.key == 'abschluss' %}
<div class="field-full finish-note">
Fast geschafft. Bitte Abschlussdaten prüfen und die Anfrage absenden.
</div>
<div class="field-full">
<div class="legal">{{ legal_text }}</div>
</div>
{% endif %}
</div>
</div>
</section>
{% endfor %}
<div class="actions">
<button class="btn btn-secondary" type="button" id="btn-prev">Zurück</button>
<button class="btn btn-primary" type="button" id="btn-next">Weiter</button>
<button type="submit" id="btn-submit" class="btn btn-primary hidden">Onboarding-Anfrage absenden</button>
</div>
</form>
</main>
</div>
<script>
(function () {
const pages = Array.from(document.querySelectorAll('.page'));
const navItems = Array.from(document.querySelectorAll('.step-item'));
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
const btnSubmit = document.getElementById('btn-submit');
const form = document.getElementById('onboarding-form');
let current = 0;
form.setAttribute('novalidate', 'novalidate');
function byName(name) { return document.querySelector('[name="' + name + '"]'); }
function toggle(id, state) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('hidden', !state);
}
function syncConditionals() {
const orderCards = byName('order_business_cards');
toggle('business-card-box', orderCards && orderCards.checked);
const employmentType = byName('employment_type');
toggle('employment-end-box', employmentType && employmentType.value === 'befristet');
const groupMailbox = byName('group_mailboxes_required_choice');
toggle('group-mailboxes-box', groupMailbox && groupMailbox.value === 'ja');
const extraHardware = byName('additional_hardware_needed_choice');
toggle('extra-hardware-box', extraHardware && extraHardware.value === 'ja');
const extraSoftware = byName('additional_software_needed_choice');
toggle('extra-software-box', extraSoftware && extraSoftware.value === 'ja');
const extraAccess = byName('additional_access_needed_choice');
toggle('extra-access-box', extraAccess && extraAccess.value === 'ja');
const successor = byName('successor_required_choice');
const showSuccessor = successor && successor.value === 'ja';
toggle('successor-box', showSuccessor);
const inheritPhone = byName('inherit_phone_number_choice');
const hidePhone = showSuccessor && inheritPhone && inheritPhone.value === 'ja';
toggle('phone-box', !hidePhone);
// Hidden conditional groups must not block submit with invisible required fields.
document.querySelectorAll('.field-group').forEach(function (group) {
const hidden = group.classList.contains('hidden');
group.querySelectorAll('input, select, textarea').forEach(function (el) {
if (el.type === 'hidden' || el.disabled) return;
if (hidden) {
if (el.required) {
el.dataset.requiredOriginal = '1';
el.required = false;
}
} else if (el.dataset.requiredOriginal === '1') {
el.required = true;
}
});
});
}
function slugifyForEmail(value) {
const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' };
const lower = (value || '').toLowerCase();
const mapped = lower.replace(/[äöüß]/g, function (m) { return map[m] || m; });
return mapped
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '.')
.replace(/^\.+|\.+$/g, '')
.replace(/\.{2,}/g, '.');
}
function setupWorkEmailAutofill() {
const lastName = byName('last_name');
const workEmail = byName('work_email');
if (!lastName || !workEmail) return;
let lastSuggested = '';
let userEditedEmail = !!(workEmail.value && workEmail.value.trim());
function suggestEmail() {
const slug = slugifyForEmail(lastName.value);
if (!slug) return;
const suggestion = slug + '@tub.co';
if (!userEditedEmail || workEmail.value === '' || workEmail.value === lastSuggested) {
workEmail.value = suggestion;
lastSuggested = suggestion;
}
}
workEmail.addEventListener('input', function () {
const current = (workEmail.value || '').trim();
userEditedEmail = current !== '' && current !== lastSuggested;
});
lastName.addEventListener('input', suggestEmail);
suggestEmail();
}
function setupBusinessCardAutofill() {
const checkbox = byName('order_business_cards');
const firstName = byName('first_name');
const lastName = byName('last_name');
const jobTitle = byName('job_title');
const workEmail = byName('work_email');
const cardName = byName('business_card_name');
const cardTitle = byName('business_card_title');
const cardEmail = byName('business_card_email');
if (!checkbox || !cardName || !cardTitle || !cardEmail) return;
const cardBox = document.getElementById('business-card-box');
function suggestionName() {
const first = (firstName && firstName.value || '').trim();
const last = (lastName && lastName.value || '').trim();
return [first, last].filter(Boolean).join(' ').trim();
}
function setField(field, value, force) {
if (!field || !value) return;
const current = (field.value || '').trim();
const autoMarked = field.dataset.autofilled === '1';
if (force || !current || autoMarked) {
field.value = value;
field.dataset.autofilled = '1';
}
}
function applyDefaults(force) {
if (!checkbox.checked) return;
const name = suggestionName();
const title = (jobTitle && jobTitle.value || '').trim();
const email = (workEmail && workEmail.value || '').trim();
setField(cardName, name, force);
setField(cardTitle, title, force);
setField(cardEmail, email, force);
}
[cardName, cardTitle, cardEmail].forEach(function (field) {
field.addEventListener('input', function () {
field.dataset.autofilled = '0';
});
});
if (cardBox) {
const grid = cardBox.querySelector('.grid-2');
if (grid && !document.getElementById('business-card-autofill-btn')) {
const actionWrap = document.createElement('div');
actionWrap.className = 'field field-full';
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'business-card-autofill-btn';
btn.className = 'btn btn-secondary';
btn.textContent = 'Visitenkarten-Felder automatisch ausfüllen';
btn.addEventListener('click', function () {
applyDefaults(true);
});
actionWrap.appendChild(btn);
grid.insertBefore(actionWrap, grid.firstChild);
}
}
// Manual-only behavior: fill only when button is clicked.
}
function updateStep() {
pages.forEach((p, i) => p.classList.toggle('active', i === current));
navItems.forEach((n, i) => n.classList.toggle('active', i === current));
btnPrev.disabled = current === 0;
const last = current === pages.length - 1;
btnNext.classList.toggle('hidden', last);
btnSubmit.classList.toggle('hidden', !last);
}
function jumpToFirstErrorPage() {
const firstError = document.querySelector('.errorlist');
if (!firstError) return;
const page = firstError.closest('.page');
if (!page) return;
const step = Number(page.getAttribute('data-step') || '1') - 1;
current = Math.max(0, step);
}
document.addEventListener('change', syncConditionals);
navItems.forEach((n, idx) => {
n.addEventListener('click', function () { current = idx; updateStep(); });
n.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
current = idx;
updateStep();
}
});
});
btnPrev.addEventListener('click', function () { if (current > 0) { current -= 1; updateStep(); } });
btnNext.addEventListener('click', function () { if (current < pages.length - 1) { current += 1; updateStep(); } });
btnSubmit.addEventListener('click', function () {
syncConditionals();
btnSubmit.disabled = true;
btnSubmit.textContent = 'Wird gesendet...';
form.submit();
});
syncConditionals();
setupWorkEmailAutofill();
setupBusinessCardAutofill();
jumpToFirstErrorPage();
updateStep();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Onboarding gespeichert</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; color: #222; }
code { background: #f4f4f4; padding: 2px 6px; }
</style>
</head>
<body>
<h1>Anfrage erfolgreich gespeichert</h1>
<p>Vorgangs-ID: <code>{{ obj.id }}</code></p>
<p>Name: <code>{{ obj.full_name }}</code></p>
<p>E-Mail: <code>{{ obj.work_email }}</code></p>
{% if pdf_url %}
<p>PDF: <a href="{{ pdf_url }}" target="_blank" rel="noopener">PDF öffnen</a></p>
<p>Datei: <code>{{ obj.generated_pdf_path }}</code></p>
{% else %}
<p>PDF wird im Hintergrund erstellt.</p>
{% endif %}
<p><a href="/">Zur Startseite</a></p>
<p><a href="/onboarding/new/">Neue Anfrage erfassen</a></p>
</body>
</html>

View File

@@ -0,0 +1,246 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Project Wiki</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #1b2b43; padding: 20px; }
.shell { max-width: 1120px; margin: 0 auto; background: #fff; border: 1px solid #d7e0ea; border-radius: 14px; padding: 18px; }
.brand-logo { width: 190px; max-width: 100%; height: auto; margin: 0 0 10px; display: block; }
.top { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
h1 { margin: 0; color: #000078; font-size: 30px; }
.sub { margin: 8px 0 16px; color: #5f6f85; }
.toc { border: 1px solid #d7e0ea; border-radius: 10px; padding: 10px; background: #f7fbff; margin-bottom: 16px; }
.toc a { color: #0b4da2; text-decoration: none; margin-right: 10px; white-space: nowrap; }
h2 { margin: 20px 0 8px; color: #113a74; border-bottom: 1px solid #e1e8f2; padding-bottom: 4px; }
h3 { margin: 14px 0 6px; color: #183f77; }
ul { margin: 8px 0 12px 20px; }
li { margin: 4px 0; }
code { background: #f1f5fb; border: 1px solid #dce6f3; border-radius: 6px; padding: 2px 6px; }
.box { border: 1px solid #d7e0ea; border-radius: 10px; padding: 10px; background: #fcfdff; margin: 8px 0 12px; }
.note { border-left: 4px solid #000078; padding: 8px 10px; background: #f4f8ff; margin: 10px 0; }
</style>
</head>
<body>
<div class="shell">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<div class="top">
<h1>Project Wiki</h1>
<a class="btn btn-secondary" href="/">Back to Home</a>
</div>
<p class="sub">Operational and technical documentation for the Onboarding/Offboarding platform.</p>
<div class="toc">
<a href="#overview">Overview</a>
<a href="#architecture">Architecture</a>
<a href="#model">Data Model</a>
<a href="#onboarding">Onboarding Flow</a>
<a href="#offboarding">Offboarding Flow</a>
<a href="#emails">Email Engine</a>
<a href="#pdfs">PDF Engine</a>
<a href="#integrations">Integrations</a>
<a href="#admin">Admin Apps</a>
<a href="#operations">Operations</a>
<a href="#hardening">Hardening</a>
<a href="#troubleshooting">Troubleshooting</a>
<a href="#security">Security</a>
</div>
<h2 id="overview">1) Overview</h2>
<div class="box">
<p>
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.
</p>
<ul>
<li>Primary users: regular staff (submit forms) and admin users (configure and operate).</li>
<li>Main entry points: Home, Onboarding form, Offboarding form, Requests Dashboard, Admin Apps.</li>
<li>Asynchronous processing: heavy tasks (PDF/email/upload) are executed by Celery worker jobs.</li>
</ul>
</div>
<h2 id="architecture">2) Architecture</h2>
<h3>Runtime Components</h3>
<ul>
<li><strong>Web:</strong> Django app served by Gunicorn</li>
<li><strong>Worker:</strong> Celery worker for async processing</li>
<li><strong>Broker:</strong> Redis (Celery queue)</li>
<li><strong>DB:</strong> PostgreSQL</li>
<li><strong>Email Sink (test):</strong> MailHog (when test mode is active)</li>
</ul>
<h3>Request Processing Pattern</h3>
<ul>
<li>User submits form in web UI.</li>
<li>Form data is validated and stored as request object.</li>
<li>Background task is queued with request ID.</li>
<li>Worker generates PDF, sends notifications, uploads to Nextcloud (if enabled), and updates record.</li>
</ul>
<h2 id="model">3) Data Model (Key Entities)</h2>
<ul>
<li><code>OnboardingRequest</code>: onboarding data, generated PDF path, requester info, signature file/url, conditional fields.</li>
<li><code>OffboardingRequest</code>: offboarding data, requester info, generated PDF path.</li>
<li><code>EmployeeProfile</code>: searchable profile for offboarding prefill.</li>
<li><code>WorkflowConfig</code>: routing emails, integration overrides, feature flags, welcome email delay.</li>
<li><code>NotificationTemplate</code>: subject/body templates with placeholders.</li>
<li><code>NotificationRule</code>: condition-based custom routing rules.</li>
<li><code>FormFieldConfig</code> + <code>FormOption</code>: Form Builder configuration and selectable options.</li>
<li><code>ScheduledWelcomeEmail</code>: delayed welcome email queue state.</li>
</ul>
<h2 id="onboarding">4) Onboarding Flow</h2>
<ol>
<li>User opens <code>/onboarding/new/</code> and completes multi-step form.</li>
<li>Form saves request; requester identity is taken from logged-in user.</li>
<li>Task <code>process_onboarding_request</code> runs in worker.</li>
<li>PDF is generated using HTML template + letterhead overlay.</li>
<li>Default notification emails + optional rule-based emails are sent.</li>
<li>Welcome email job is scheduled (configurable delay).</li>
<li>PDF is uploaded to Nextcloud if enabled.</li>
</ol>
<h2 id="offboarding">5) Offboarding Flow</h2>
<ol>
<li>User opens <code>/offboarding/new/</code> and can search existing profile first.</li>
<li>Form saves request with requester name/email from logged-in user.</li>
<li>Task <code>process_offboarding_request</code> runs in worker.</li>
<li>PDF is generated (hardware section can be derived from latest onboarding request).</li>
<li>Notification emails are sent (default + rules).</li>
<li>PDF upload to Nextcloud runs if enabled.</li>
</ol>
<h2 id="emails">6) Email Engine</h2>
<h3>Modes</h3>
<ul>
<li><strong>Production mode:</strong> real recipients are used.</li>
<li><strong>Test mode:</strong> recipients are redirected to test mailbox; original recipients are included in body.</li>
</ul>
<h3>Template Placeholders</h3>
<p>Examples: <code>{{ VORNAME }}</code>, <code>{{ NACHNAME }}</code>, <code>{{ FULL_NAME }}</code>, <code>{{ EMAIL }}</code>, <code>{{ DEPARTMENT }}</code>, <code>{{ CONTRACT_START }}</code>.</p>
<h3>Condition-based Rules</h3>
<ul>
<li>Configured in Admin Integrations page.</li>
<li>Rule supports operators such as <code>always</code>, <code>equals</code>, <code>contains</code>, <code>is_true</code>, <code>is_false</code>.</li>
<li>Can use template-based emails or custom subject/body.</li>
</ul>
<h2 id="pdfs">7) PDF Engine</h2>
<ul>
<li>Template source: <code>/backend/media/templates/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li>
<li>Letterhead: <code>/backend/media/templates/templates.pdf</code>.</li>
<li>Output folder: <code>/backend/media/pdfs/</code>.</li>
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li>
<li>Conditional sections are hidden if no data is provided.</li>
</ul>
<h2 id="integrations">8) Integrations</h2>
<h3>Nextcloud</h3>
<ul>
<li>Configured from Admin Integrations UI (base URL, user, password, target directory).</li>
<li>Can be globally enabled/disabled from Home Admin Apps.</li>
</ul>
<h3>Mail Server</h3>
<ul>
<li>SMTP host/port/account configured in Admin Integrations UI / backend config.</li>
<li>Use SMTP test action before switching to production mode.</li>
</ul>
<h2 id="admin">9) Admin Apps (Home)</h2>
<ul>
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
<li><strong>Project Wiki:</strong> this documentation page.</li>
</ul>
<h2 id="operations">10) Operations Runbook</h2>
<h3>Health Checks</h3>
<ul>
<li>App endpoint: <code>/healthz/</code></li>
<li>If <code>db=error</code>, verify DB container and connection settings.</li>
</ul>
<h3>Where to Find Generated PDFs</h3>
<ul>
<li>Container path: <code>/app/media/pdfs/</code></li>
<li>Host path: project <code>backend/media/pdfs/</code> via mounted volume.</li>
</ul>
<h3>Deployment Notes</h3>
<ul>
<li>Use Docker Compose for web + worker + db + redis services.</li>
<li>After template/form/task changes, restart web and worker containers.</li>
<li>Run <code>python manage.py check</code> before release.</li>
</ul>
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
<ul>
<li><strong>Cookie + header hardening:</strong> HTTPOnly cookies, SameSite cookies, <code>X-Content-Type-Options: nosniff</code>, stricter referrer policy, and frame protection.</li>
<li><strong>Optional secure-cookie mode:</strong> can be enabled via environment for HTTPS deployments.</li>
<li><strong>Upload guards:</strong> server-side upload size limits plus signature image magic-byte validation for PNG/JPEG.</li>
<li><strong>SMTP reliability:</strong> explicit SMTP timeout to avoid hanging worker/web on mail network issues.</li>
<li><strong>Nextcloud reliability:</strong> retry/backoff on upload errors, bounded timeouts, and graceful failure return instead of crashing flow.</li>
<li><strong>Filename safety:</strong> PDF filenames are sanitized to safe filesystem characters.</li>
<li><strong>Least privilege runtime:</strong> web and worker containers run as non-root <code>app</code> user.</li>
</ul>
<div class="note">
Recommended for production: set secure cookies, explicit allowed hosts, CSRF trusted origins, and a strong secret key via environment variables.
</div>
<h2 id="troubleshooting">12) Troubleshooting</h2>
<div class="box">
<h3>Browser timeout or page hangs</h3>
<ul>
<li>Try <code>http://127.0.0.1:8088/</code> instead of <code>localhost</code> if local DNS/proxy is unstable.</li>
<li>Check <code>/healthz/</code> and web logs.</li>
</ul>
<h3>Onboarding submit does nothing</h3>
<ul>
<li>Check required/hidden conditional fields and form errors.</li>
<li>Open browser dev tools for JS errors.</li>
</ul>
<h3>PDF not generated</h3>
<ul>
<li>Check Celery worker logs for PDF task errors.</li>
<li>Verify template files exist and media folder permissions are correct.</li>
</ul>
<h3>Email not received</h3>
<ul>
<li>Verify email mode (test vs production).</li>
<li>Run SMTP test from Admin Apps.</li>
<li>Check SMTP settings and worker logs.</li>
</ul>
<h3>Nextcloud upload missing</h3>
<ul>
<li>Verify Nextcloud is enabled.</li>
<li>Test upload from Admin Apps.</li>
<li>Check credentials and destination directory path.</li>
</ul>
</div>
<h2 id="security">13) Security and Access Notes</h2>
<ul>
<li>Do not expose secrets in UI screenshots or logs.</li>
<li>Only staff users should access Admin Apps and this wiki page.</li>
<li>Use environment variables and admin overrides carefully in production.</li>
<li>Prefer production SMTP only after successful test-mode verification.</li>
</ul>
<div class="note">
Last updated for current system behavior as of March 10, 2026.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,173 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anfragen Dashboard</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f7fafc; color: #1f2937; padding: 20px; }
.shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d7e0ea; border-radius: 12px; padding: 18px; }
.brand-logo { width: 190px; max-width: 100%; height: auto; margin-bottom: 10px; display: block; }
.top-actions { margin: 0 0 10px; }
h1 { margin: 0 0 6px; }
.sub { margin: 0 0 14px; color: #5b6b7f; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 14px; }
.stat { border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px; background: #f9fbff; }
.stat .n { font-size: 22px; font-weight: 700; color: #0f5fcf; }
.stat .l { color: #64748b; font-size: 12px; }
.chart-wrap { border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px; margin-bottom: 14px; background: #fcfdff; }
.chart-title { margin: 0 0 8px; font-size: 13px; color: #334155; }
.chart { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; min-height: 120px; }
.bar { background: linear-gradient(180deg, #0f5fcf, #0b7fb8); border-radius: 6px 6px 2px 2px; }
.bar-label { font-size: 10px; color: #64748b; margin-top: 4px; text-align: center; }
.bar-value { font-size: 10px; color: #334155; text-align: center; }
.search { display: flex; gap: 8px; margin: 0 0 14px; }
.search input { flex: 1; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border: 1px solid #e2e8f0; padding: 8px; text-align: left; }
th { background: #f6f8fb; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.ok { background: #e8f7ec; color: #1f7a3f; }
.pending { background: #fff6e5; color: #9a6400; }
.toolbar { margin-top: 14px; }
.actions-cell { white-space: nowrap; }
.inline-delete { display: inline; }
.flash { margin: 0 0 12px; padding: 10px; border-radius: 8px; border: 1px solid #dbe5f2; background: #f8fbff; }
.flash.success { border-color: #bfe6c9; background: #edf9f1; color: #116634; }
.flash.warning { border-color: #f5d8a8; background: #fff8ea; color: #8a5a00; }
.flash.error { border-color: #f4c7c7; background: #fff1f1; color: #8e1e1e; }
.bulk-toolbar { display: flex; align-items: center; gap: 10px; margin: 0 0 10px; }
.bulk-info { color: #5b6b7f; font-size: 13px; }
.select-col { width: 42px; text-align: center; }
</style>
</head>
<body>
<div class="shell">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<div class="top-actions">
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
<h1>Anfragen Dashboard</h1>
<p class="sub">Neueste Onboarding- und Offboarding-Vorgänge inklusive PDF-Status.</p>
{% if messages %}
{% for message in messages %}
<div class="flash {{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
<div class="stats">
<div class="stat"><div class="n">{{ onboarding_total }}</div><div class="l">Onboarding gesamt</div></div>
<div class="stat"><div class="n">{{ offboarding_total }}</div><div class="l">Offboarding gesamt</div></div>
<div class="stat"><div class="n">{{ combined_total }}</div><div class="l">Gesamtvorgänge</div></div>
</div>
<div class="chart-wrap">
<p class="chart-title">Aktivität der letzten 14 Tage (Onboarding + Offboarding)</p>
<div class="chart">
{% for p in chart_points %}
<div>
<div class="bar" style="height: {{ p.height }}px;" title="{{ p.label }} | On: {{ p.onboarding }} | Off: {{ p.offboarding }}"></div>
<div class="bar-value">{{ p.total }}</div>
<div class="bar-label">{{ p.label }}</div>
</div>
{% endfor %}
</div>
</div>
<form class="search" method="get" action="/requests/">
<input name="q" value="{{ search_query }}" placeholder="Suche nach Name oder E-Mail" />
<button class="btn btn-primary" type="submit">Suchen</button>
</form>
{% if request.user.is_staff %}
<form method="post" action="/requests/" id="bulk-delete-form" onsubmit="return confirm('Ausgewählte Einträge wirklich löschen?');">
{% csrf_token %}
<div class="bulk-toolbar">
<button class="btn btn-secondary" type="submit">Auswahl löschen</button>
<span class="bulk-info"><span id="selected-count">0</span> ausgewählt</span>
</div>
{% endif %}
<table>
<thead>
<tr>
{% if request.user.is_staff %}<th class="select-col"><input type="checkbox" id="select-all" aria-label="Alle auswählen" /></th>{% endif %}
<th>Typ</th>
<th>Name</th>
<th>E-Mail</th>
<th>Erstellt</th>
<th>Status</th>
<th>PDF</th>
{% if request.user.is_staff %}<th>Aktion</th>{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% if request.user.is_staff %}
<td class="select-col">
<input type="checkbox" class="row-select" name="selected_requests" value="{{ row.kind_slug }}:{{ row.id }}" aria-label="{{ row.kind }} {{ row.name }}" />
</td>
{% endif %}
<td>{{ row.kind }}</td>
<td>{{ row.name }}</td>
<td>{{ row.work_email }}</td>
<td>{{ row.created_at|date:"Y-m-d H:i" }}</td>
<td>
{% if row.pdf_url %}
<span class="badge ok">{{ row.status }}</span>
{% else %}
<span class="badge pending">{{ row.status }}</span>
{% endif %}
</td>
<td>
{% if row.pdf_url %}
<a href="{{ row.pdf_url }}" target="_blank" rel="noopener">PDF öffnen</a>
{% else %}
-
{% endif %}
</td>
{% if request.user.is_staff %}
<td class="actions-cell">
<button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}" onclick="return confirm('Eintrag wirklich löschen?');">Löschen</button>
</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="{% if request.user.is_staff %}8{% else %}6{% endif %}">Noch keine Vorgänge vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
{% if request.user.is_staff %}
</form>
{% endif %}
<div class="toolbar">
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
</div>
{% if request.user.is_staff %}
<script>
(function () {
const selectAll = document.getElementById('select-all');
const rowChecks = Array.from(document.querySelectorAll('.row-select'));
const selectedCount = document.getElementById('selected-count');
if (!selectAll || !selectedCount || !rowChecks.length) return;
function updateCount() {
const checked = rowChecks.filter((c) => c.checked).length;
selectedCount.textContent = String(checked);
selectAll.checked = checked > 0 && checked === rowChecks.length;
selectAll.indeterminate = checked > 0 && checked < rowChecks.length;
}
selectAll.addEventListener('change', function () {
rowChecks.forEach((c) => { c.checked = selectAll.checked; });
updateCount();
});
rowChecks.forEach((c) => c.addEventListener('change', updateCount));
updateCount();
})();
</script>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,233 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Welcome E-Mails</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; }
.shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; }
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.brand-logo { width: 190px; max-width: 100%; height: auto; display: block; }
h1 { margin: 12px 0 6px; color: #000078; }
.sub { margin: 0 0 12px; color: #54657c; }
.msg { border-radius: 10px; padding: 10px 12px; margin: 0 0 12px; border: 1px solid #d6e1ef; background: #f8fbff; color: #1f3a5f; }
.msg.error { border-color: #fecaca; background: #fff1f2; color: #991b1b; }
.card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; margin-bottom: 14px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap: 10px; }
label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; }
input, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; }
textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
.check-row { margin-top: 8px; display: flex; gap: 12px; flex-wrap: wrap; }
.check-row label { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; }
.check-row input[type="checkbox"] { width: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border: 1px solid #dce5f1; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f6f9ff; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.scheduled { background: #eff6ff; color: #1d4ed8; }
.paused { background: #fef9c3; color: #854d0e; }
.cancelled { background: #f1f5f9; color: #334155; }
.sent { background: #ecfdf3; color: #166534; }
.failed { background: #fff1f2; color: #991b1b; }
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
.table-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.hint { margin-top: 6px; color: #64748b; font-size: 12px; }
.bulk-bar { margin: 0 0 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.bulk-bar select { width: auto; min-width: 180px; }
.select-col { width: 42px; text-align: center; }
.bulk-note { color: #64748b; font-size: 12px; }
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
<h1>Geplante Welcome E-Mails</h1>
<p class="sub">Welcome-Mails konfigurieren und geplante Mails steuern (sofort senden, pausieren, fortsetzen, abbrechen).</p>
{% if messages %}
{% for message in messages %}
<div class="msg {% if message.tags == 'error' %}error{% endif %}">{{ message }}</div>
{% endfor %}
{% endif %}
<form class="card" method="post" action="/admin-tools/welcome-emails/settings/">
{% csrf_token %}
<div class="grid">
<div>
<label for="welcome_email_delay_days">Verzögerung in Tagen</label>
<input id="welcome_email_delay_days" name="welcome_email_delay_days" type="number" min="0" step="1" value="{{ workflow_config.welcome_email_delay_days }}" />
</div>
<div>
<label for="welcome_sender_email">Absenderadresse (optional)</label>
<input id="welcome_sender_email" name="welcome_sender_email" type="email" value="{{ workflow_config.welcome_sender_email }}" placeholder="Leer = System-Absender" />
</div>
<div>
<label for="welcome_subject">Welcome Subject</label>
<input id="welcome_subject" name="welcome_subject" value="{{ welcome_subject_value }}" />
</div>
<div>
<label for="welcome_body">Welcome Text</label>
<textarea id="welcome_body" name="welcome_body">{{ welcome_body_value }}</textarea>
</div>
</div>
<div class="check-row">
<label><input type="checkbox" name="welcome_include_pdf" {% if workflow_config.welcome_include_pdf %}checked{% endif %} /> Onboarding-PDF anhängen</label>
</div>
<div class="hint">
Verfügbare Keywords:
{% for key in welcome_keywords %}
<code>{{ key }}</code>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Welcome-Einstellungen speichern</button>
</div>
</form>
<form class="bulk-bar" id="welcome-bulk-form" method="post" action="/admin-tools/welcome-emails/bulk-action/" onsubmit="return confirmBulkAction();">
{% csrf_token %}
<label style="display:inline-flex; align-items:center; gap:6px; margin:0;">
<input type="checkbox" id="select-all-welcome" />
Alle auswählen
</label>
<select name="bulk_action" id="bulk_action">
<option value="pause">Pausieren</option>
<option value="send_now">Sofort senden</option>
<option value="delete">Löschen</option>
</select>
<input type="hidden" name="selected_ids" id="selected_ids" />
<button class="btn btn-secondary" type="submit">Bulk ausführen</button>
<span class="bulk-note"><span id="selected-count">0</span> ausgewählt</span>
</form>
<table>
<thead>
<tr>
<th class="select-col">Auswahl</th>
<th>ID</th>
<th>Mitarbeitende Person</th>
<th>Empfänger</th>
<th>Geplant für</th>
<th>Status</th>
<th>Gesendet am</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="select-col"><input type="checkbox" class="welcome-select" value="{{ row.id }}" /></td>
<td>{{ row.id }}</td>
<td>{{ row.onboarding_request.full_name }}</td>
<td>{{ row.recipient_email }}</td>
<td>{{ row.send_at|date:"Y-m-d H:i" }}</td>
<td>
{% if row.status == 'scheduled' %}
<span class="badge scheduled">Geplant</span>
{% elif row.status == 'paused' %}
<span class="badge paused">Pausiert</span>
{% elif row.status == 'cancelled' %}
<span class="badge cancelled">Abgebrochen</span>
{% elif row.status == 'sent' %}
<span class="badge sent">Gesendet</span>
{% else %}
<span class="badge failed">Fehlgeschlagen</span>
{% endif %}
</td>
<td>{% if row.sent_at %}{{ row.sent_at|date:"Y-m-d H:i" }}{% else %}-{% endif %}</td>
<td>
<div class="table-actions">
{% if row.status != 'sent' and row.status != 'cancelled' %}
<form method="post" action="/admin-tools/welcome-emails/{{ row.id }}/trigger-now/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Sofort senden</button>
</form>
{% endif %}
{% if row.status == 'scheduled' %}
<form method="post" action="/admin-tools/welcome-emails/{{ row.id }}/pause/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Pausieren</button>
</form>
{% elif row.status == 'paused' %}
<form method="post" action="/admin-tools/welcome-emails/{{ row.id }}/resume/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Fortsetzen</button>
</form>
{% endif %}
{% if row.status != 'sent' and row.status != 'cancelled' %}
<form method="post" action="/admin-tools/welcome-emails/{{ row.id }}/cancel/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Abbrechen</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="8">Keine geplanten Welcome E-Mails vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function () {
const selectAll = document.getElementById('select-all-welcome');
const rowChecks = Array.from(document.querySelectorAll('.welcome-select'));
const selectedCount = document.getElementById('selected-count');
const selectedIds = document.getElementById('selected_ids');
const bulkForm = document.getElementById('welcome-bulk-form');
const bulkAction = document.getElementById('bulk_action');
function currentSelected() {
return rowChecks.filter((c) => c.checked).map((c) => c.value);
}
function syncState() {
const ids = currentSelected();
selectedCount.textContent = String(ids.length);
selectedIds.value = ids.join(',');
if (!rowChecks.length) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
selectAll.checked = ids.length > 0 && ids.length === rowChecks.length;
selectAll.indeterminate = ids.length > 0 && ids.length < rowChecks.length;
}
selectAll.addEventListener('change', function () {
rowChecks.forEach((c) => { c.checked = selectAll.checked; });
syncState();
});
rowChecks.forEach((c) => c.addEventListener('change', syncState));
bulkForm.addEventListener('submit', syncState);
window.confirmBulkAction = function () {
syncState();
const count = currentSelected().length;
if (!count) {
alert('Bitte mindestens einen Welcome-Eintrag auswählen.');
return false;
}
const action = bulkAction.value;
if (action === 'delete') {
return confirm('Ausgewählte Welcome-Einträge wirklich löschen?');
}
if (action === 'pause') {
return confirm('Ausgewählte Welcome-Einträge pausieren?');
}
return confirm('Ausgewählte Welcome-Einträge sofort senden?');
};
syncState();
})();
</script>
</body>
</html>

View File

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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'))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = '<html><body><h1>PDF Smoke</h1><p>This is a smoke test.</p></body></html>'
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)

View File

@@ -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)

View File

@@ -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')

34
backend/workflows/urls.py Normal file
View File

@@ -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/<int:request_id>/', views.onboarding_success, name='onboarding_success'),
path('offboarding/new/', views.offboarding_create, name='offboarding_create'),
path('offboarding/success/<int:request_id>/', 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/<int:schedule_id>/trigger-now/', views.trigger_welcome_email_now, name='trigger_welcome_email_now'),
path('admin-tools/welcome-emails/<int:schedule_id>/pause/', views.pause_welcome_email, name='pause_welcome_email'),
path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'),
path('admin-tools/welcome-emails/<int:schedule_id>/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/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
]

1241
backend/workflows/views.py Normal file

File diff suppressed because it is too large Load Diff

59
docker-compose.yml Normal file
View File

@@ -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: