chore: initial snapshot of tubco people portal
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal 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
69
.github/workflows/ci.yml
vendored
Normal 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
16
.gitignore
vendored
Normal 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
40
README.md
Normal 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
19
backend/Dockerfile
Normal 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
|
||||
3
backend/config/__init__.py
Normal file
3
backend/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
5
backend/config/asgi.py
Normal file
5
backend/config/asgi.py
Normal 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
8
backend/config/celery.py
Normal 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
141
backend/config/settings.py
Normal 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
13
backend/config/urls.py
Normal 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
5
backend/config/wsgi.py
Normal 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
11
backend/entrypoint-web.sh
Executable 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
9
backend/entrypoint-worker.sh
Executable 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
13
backend/manage.py
Normal 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
11
backend/requirements.txt
Normal 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
|
||||
0
backend/workflows/__init__.py
Normal file
0
backend/workflows/__init__.py
Normal file
167
backend/workflows/admin.py
Normal file
167
backend/workflows/admin.py
Normal 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'
|
||||
6
backend/workflows/apps.py
Normal file
6
backend/workflows/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkflowsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'workflows'
|
||||
75
backend/workflows/emailing.py
Normal file
75
backend/workflows/emailing.py
Normal 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)
|
||||
188
backend/workflows/form_builder.py
Normal file
188
backend/workflows/form_builder.py
Normal 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
396
backend/workflows/forms.py
Normal 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)
|
||||
0
backend/workflows/management/__init__.py
Normal file
0
backend/workflows/management/__init__.py
Normal file
0
backend/workflows/management/commands/__init__.py
Normal file
0
backend/workflows/management/commands/__init__.py
Normal file
242
backend/workflows/management/commands/run_staging_e2e_check.py
Normal file
242
backend/workflows/management/commands/run_staging_e2e_check.py
Normal 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.'))
|
||||
53
backend/workflows/migrations/0001_initial.py
Normal file
53
backend/workflows/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
91
backend/workflows/migrations/0002_form_updates.py
Normal file
91
backend/workflows/migrations/0002_form_updates.py
Normal 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)'),
|
||||
),
|
||||
]
|
||||
21
backend/workflows/migrations/0003_gender_onboarded_by.py
Normal file
21
backend/workflows/migrations/0003_gender_onboarded_by.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
43
backend/workflows/migrations/0004_backend_config_models.py
Normal file
43
backend/workflows/migrations/0004_backend_config_models.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
68
backend/workflows/migrations/0005_seed_backend_config.py
Normal file
68
backend/workflows/migrations/0005_seed_backend_config.py
Normal 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),
|
||||
]
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
20
backend/workflows/migrations/0007_seed_key_email.py
Normal file
20
backend/workflows/migrations/0007_seed_key_email.py
Normal 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),
|
||||
]
|
||||
16
backend/workflows/migrations/0008_offboarding_pdf_path.py
Normal file
16
backend/workflows/migrations/0008_offboarding_pdf_path.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
27
backend/workflows/migrations/0011_notificationtemplate.py
Normal file
27
backend/workflows/migrations/0011_notificationtemplate.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
33
backend/workflows/migrations/0013_systememailconfig.py
Normal file
33
backend/workflows/migrations/0013_systememailconfig.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
32
backend/workflows/migrations/0014_formfieldconfig.py
Normal file
32
backend/workflows/migrations/0014_formfieldconfig.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
34
backend/workflows/migrations/0022_notificationrule.py
Normal file
34
backend/workflows/migrations/0022_notificationrule.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
0
backend/workflows/migrations/__init__.py
Normal file
0
backend/workflows/migrations/__init__.py
Normal file
320
backend/workflows/models.py
Normal file
320
backend/workflows/models.py
Normal 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}"
|
||||
104
backend/workflows/services.py
Normal file
104
backend/workflows/services.py
Normal 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
|
||||
58
backend/workflows/static/workflows/css/buttons.css
Normal file
58
backend/workflows/static/workflows/css/buttons.css
Normal 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;
|
||||
}
|
||||
324
backend/workflows/static/workflows/css/form_builder.css
Normal file
324
backend/workflows/static/workflows/css/form_builder.css
Normal 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;
|
||||
}
|
||||
}
|
||||
29
backend/workflows/static/workflows/css/offboarding_form.css
Normal file
29
backend/workflows/static/workflows/css/offboarding_form.css
Normal 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; } }
|
||||
442
backend/workflows/static/workflows/css/onboarding_form.css
Normal file
442
backend/workflows/static/workflows/css/onboarding_form.css
Normal 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;
|
||||
}
|
||||
}
|
||||
148
backend/workflows/static/workflows/js/form_builder.js
Normal file
148
backend/workflows/static/workflows/js/form_builder.js
Normal 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
790
backend/workflows/tasks.py
Normal 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'])
|
||||
39
backend/workflows/templates/registration/login.html
Normal file
39
backend/workflows/templates/registration/login.html
Normal 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>
|
||||
131
backend/workflows/templates/workflows/form_builder.html
Normal file
131
backend/workflows/templates/workflows/form_builder.html
Normal 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>
|
||||
454
backend/workflows/templates/workflows/home.html
Normal file
454
backend/workflows/templates/workflows/home.html
Normal 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>
|
||||
348
backend/workflows/templates/workflows/integrations_setup.html
Normal file
348
backend/workflows/templates/workflows/integrations_setup.html
Normal 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>
|
||||
66
backend/workflows/templates/workflows/offboarding_form.html
Normal file
66
backend/workflows/templates/workflows/offboarding_form.html
Normal 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>
|
||||
@@ -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>
|
||||
341
backend/workflows/templates/workflows/onboarding_form.html
Normal file
341
backend/workflows/templates/workflows/onboarding_form.html
Normal 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>
|
||||
@@ -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>
|
||||
246
backend/workflows/templates/workflows/project_wiki.html
Normal file
246
backend/workflows/templates/workflows/project_wiki.html
Normal 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>
|
||||
173
backend/workflows/templates/workflows/requests_dashboard.html
Normal file
173
backend/workflows/templates/workflows/requests_dashboard.html
Normal 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>
|
||||
233
backend/workflows/templates/workflows/welcome_emails.html
Normal file
233
backend/workflows/templates/workflows/welcome_emails.html
Normal 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>
|
||||
0
backend/workflows/tests/__init__.py
Normal file
0
backend/workflows/tests/__init__.py
Normal file
27
backend/workflows/tests/test_email_mode_override.py
Normal file
27
backend/workflows/tests/test_email_mode_override.py
Normal 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)
|
||||
43
backend/workflows/tests/test_emailing_fallback.py
Normal file
43
backend/workflows/tests/test_emailing_fallback.py
Normal 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)
|
||||
96
backend/workflows/tests/test_form_builder_admin.py
Normal file
96
backend/workflows/tests/test_form_builder_admin.py
Normal 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())
|
||||
73
backend/workflows/tests/test_nextcloud_service.py
Normal file
73
backend/workflows/tests/test_nextcloud_service.py
Normal 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'))
|
||||
59
backend/workflows/tests/test_offboarding_flow.py
Normal file
59
backend/workflows/tests/test_offboarding_flow.py
Normal 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)
|
||||
50
backend/workflows/tests/test_onboarding_flow.py
Normal file
50
backend/workflows/tests/test_onboarding_flow.py
Normal 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)
|
||||
18
backend/workflows/tests/test_pdf_smoke.py
Normal file
18
backend/workflows/tests/test_pdf_smoke.py
Normal 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)
|
||||
107
backend/workflows/tests/test_tasks_email_routing.py
Normal file
107
backend/workflows/tests/test_tasks_email_routing.py
Normal 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)
|
||||
162
backend/workflows/tests/test_welcome_email_schedule.py
Normal file
162
backend/workflows/tests/test_welcome_email_schedule.py
Normal 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
34
backend/workflows/urls.py
Normal 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
1241
backend/workflows/views.py
Normal file
File diff suppressed because it is too large
Load Diff
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal 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:
|
||||
Reference in New Issue
Block a user