diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 0000000..ab3ea14 --- /dev/null +++ b/.env.dev.example @@ -0,0 +1,46 @@ +DJANGO_SECRET_KEY=change-me +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS= +DJANGO_SECURE_COOKIES=0 +DJANGO_SECURE_SSL_REDIRECT=0 +SESSION_IDLE_TIMEOUT_SECONDS=1800 +SENSITIVE_ACTION_REAUTH_SECONDS=1200 + +POSTGRES_DB=workdock +POSTGRES_USER=workdock +POSTGRES_PASSWORD=workdock +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_URL=redis://redis:6379/0 +CELERY_TASK_ALWAYS_EAGER=0 +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_LOGIN_LIMIT=8 +RATE_LIMIT_LOGIN_WINDOW=300 +RATE_LIMIT_PASSWORD_RESET_LIMIT=5 +RATE_LIMIT_PASSWORD_RESET_WINDOW=600 +RATE_LIMIT_ADMIN_ACTION_LIMIT=20 +RATE_LIMIT_ADMIN_ACTION_WINDOW=300 + +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@workdock.de +GENERAL_INFO_NOTIFICATION_EMAIL=info@workdock.de +BUSINESS_CARD_NOTIFICATION_EMAIL=cards@workdock.de +HR_WORKS_NOTIFICATION_EMAIL=hr@workdock.de +KEY_NOTIFICATION_EMAIL=keys@workdock.de + +NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding +NEXTCLOUD_USERNAME=onboarding@example.com +NEXTCLOUD_PASSWORD=change-me +NEXTCLOUD_DIRECTORY=Group-on-off-boarding +NEXTCLOUD_ENABLED=0 + +PDF_OUTPUT_DIR=/app/media/pdfs diff --git a/.env.example b/.env.example index 2b7df3c..1e76286 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,26 @@ DJANGO_SECRET_KEY=change-me DJANGO_DEBUG=1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_SECURE_COOKIES=0 +DJANGO_SECURE_SSL_REDIRECT=0 +SESSION_IDLE_TIMEOUT_SECONDS=1800 +SENSITIVE_ACTION_REAUTH_SECONDS=1200 -POSTGRES_DB=onoff -POSTGRES_USER=onoff -POSTGRES_PASSWORD=onoff +POSTGRES_DB=workdock +POSTGRES_USER=workdock +POSTGRES_PASSWORD=workdock POSTGRES_HOST=db POSTGRES_PORT=5432 REDIS_URL=redis://redis:6379/0 CELERY_TASK_ALWAYS_EAGER=0 +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_LOGIN_LIMIT=8 +RATE_LIMIT_LOGIN_WINDOW=300 +RATE_LIMIT_PASSWORD_RESET_LIMIT=5 +RATE_LIMIT_PASSWORD_RESET_WINDOW=600 +RATE_LIMIT_ADMIN_ACTION_LIMIT=20 +RATE_LIMIT_ADMIN_ACTION_WINDOW=300 EMAIL_HOST=mailhog EMAIL_PORT=1025 @@ -19,11 +30,11 @@ 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 +IT_ONBOARDING_NOTIFICATION_EMAIL=it@workdock.de +GENERAL_INFO_NOTIFICATION_EMAIL=info@workdock.de +BUSINESS_CARD_NOTIFICATION_EMAIL=cards@workdock.de +HR_WORKS_NOTIFICATION_EMAIL=hr@workdock.de +KEY_NOTIFICATION_EMAIL=keys@workdock.de NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding NEXTCLOUD_USERNAME=onboarding@example.com diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..284485c --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,48 @@ +DJANGO_SECRET_KEY=change-me-long-random-value +DJANGO_DEBUG=0 +DJANGO_ALLOWED_HOSTS=workdock.example.com +DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com +DJANGO_SECURE_COOKIES=1 +DJANGO_SECURE_SSL_REDIRECT=1 +SESSION_IDLE_TIMEOUT_SECONDS=1800 +SENSITIVE_ACTION_REAUTH_SECONDS=1200 + +POSTGRES_DB=workdock +POSTGRES_USER=workdock +POSTGRES_PASSWORD=change-me-db-password +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_URL=redis://redis:6379/0 +CELERY_TASK_ALWAYS_EAGER=0 +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_LOGIN_LIMIT=8 +RATE_LIMIT_LOGIN_WINDOW=300 +RATE_LIMIT_PASSWORD_RESET_LIMIT=5 +RATE_LIMIT_PASSWORD_RESET_WINDOW=600 +RATE_LIMIT_ADMIN_ACTION_LIMIT=20 +RATE_LIMIT_ADMIN_ACTION_WINDOW=300 + +EMAIL_HOST=smtp.example.com +EMAIL_PORT=587 +EMAIL_HOST_USER=mailer@example.com +EMAIL_HOST_PASSWORD=change-me +EMAIL_USE_TLS=1 +EMAIL_USE_SSL=0 +DEFAULT_FROM_EMAIL=onboarding@example.com +TEST_NOTIFICATION_EMAIL=hr@example.com +IT_ONBOARDING_NOTIFICATION_EMAIL=it@example.com +GENERAL_INFO_NOTIFICATION_EMAIL=info@example.com +BUSINESS_CARD_NOTIFICATION_EMAIL=cards@example.com +HR_WORKS_NOTIFICATION_EMAIL=hr@example.com +KEY_NOTIFICATION_EMAIL=keys@example.com + +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=1 + +PDF_OUTPUT_DIR=/app/media/pdfs +APP_PORT=8088 +SITE_ADDRESS=workdock.example.com diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..e1c216c --- /dev/null +++ b/.env.test.example @@ -0,0 +1,48 @@ +DJANGO_SECRET_KEY=change-me-long-random-value +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088 +DJANGO_SECURE_COOKIES=0 +DJANGO_SECURE_SSL_REDIRECT=0 +SESSION_IDLE_TIMEOUT_SECONDS=1800 +SENSITIVE_ACTION_REAUTH_SECONDS=1200 + +POSTGRES_DB=workdock +POSTGRES_USER=workdock +POSTGRES_PASSWORD=change-me-db-password +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_URL=redis://redis:6379/0 +CELERY_TASK_ALWAYS_EAGER=0 +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_LOGIN_LIMIT=8 +RATE_LIMIT_LOGIN_WINDOW=300 +RATE_LIMIT_PASSWORD_RESET_LIMIT=5 +RATE_LIMIT_PASSWORD_RESET_WINDOW=600 +RATE_LIMIT_ADMIN_ACTION_LIMIT=20 +RATE_LIMIT_ADMIN_ACTION_WINDOW=300 + +EMAIL_HOST=mailhog +EMAIL_PORT=1025 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=0 +EMAIL_USE_SSL=0 +DEFAULT_FROM_EMAIL=onboarding@test.local +TEST_NOTIFICATION_EMAIL=hr@test.local +IT_ONBOARDING_NOTIFICATION_EMAIL=it@test.local +GENERAL_INFO_NOTIFICATION_EMAIL=info@test.local +BUSINESS_CARD_NOTIFICATION_EMAIL=cards@test.local +HR_WORKS_NOTIFICATION_EMAIL=hr@test.local +KEY_NOTIFICATION_EMAIL=keys@test.local + +NEXTCLOUD_BASE_URL= +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +NEXTCLOUD_DIRECTORY= +NEXTCLOUD_ENABLED=0 + +PDF_OUTPUT_DIR=/app/media/pdfs +APP_PORT=8088 +SITE_ADDRESS=:80 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8d6a92..cb08aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,12 @@ on: push: pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - django-tests: + python-validation: runs-on: ubuntu-latest services: @@ -59,11 +63,80 @@ jobs: - name: Install dependencies run: pip install -r requirements.txt + - name: Install gettext + run: | + sudo apt-get update + sudo apt-get install -y gettext + - name: Django system check run: python manage.py check - name: Migration drift check run: python manage.py makemigrations --check --dry-run + - name: Compile translations + run: django-admin compilemessages + + - name: Collect static assets + run: python manage.py collectstatic --noinput + - name: Run tests run: python manage.py test workflows.tests -v 2 + + docker-release-gate: + runs-on: ubuntu-latest + needs: python-validation + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare environment file + run: cp .env.example .env + + - name: Build and start stack + run: docker compose up -d --build db redis mailhog web worker + + - name: Wait for web health + run: | + for i in $(seq 1 30); do + if curl --fail --silent --show-error --max-time 5 http://127.0.0.1:8088/healthz/ >/dev/null; then + exit 0 + fi + sleep 2 + done + echo "web health check did not become ready in time" >&2 + exit 1 + + - name: Django system check in container + run: docker compose exec -T web python manage.py check + + - name: Backup verification gate + run: docker compose exec -T web python manage.py verify_latest_backup --create-if-missing + + - name: Staging smoke gate + run: docker compose exec -T web python manage.py run_staging_e2e_check --cleanup --email-check none --skip-nextcloud + + - name: Upload generated PDFs + if: always() + uses: actions/upload-artifact@v4 + with: + name: staging-pdfs + path: backend/media/pdfs/ + if-no-files-found: ignore + + - name: Upload docker logs on failure + if: failure() + run: docker compose logs --no-color web worker db redis mailhog > docker-compose-ci.log + + - name: Publish docker logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: docker-compose-ci-logs + path: docker-compose-ci.log + if-no-files-found: ignore + + - name: Stop stack + if: always() + run: docker compose down -v diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..a4ce23d --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,43 @@ +name: Deploy Production + +on: + workflow_dispatch: + +concurrency: + group: deploy-prod + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Upload release bundle + uses: appleboy/scp-action@v1.0.0 + with: + host: ${{ secrets.PROD_DEPLOY_HOST }} + username: ${{ secrets.PROD_DEPLOY_USER }} + key: ${{ secrets.PROD_DEPLOY_SSH_KEY }} + port: ${{ secrets.PROD_DEPLOY_PORT || 22 }} + source: "." + target: ${{ secrets.PROD_DEPLOY_PATH }} + rm: false + overwrite: true + strip_components: 0 + exclude: ".git,.github,.venv,__pycache__,node_modules,backend/media,backend/staticfiles" + + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.PROD_DEPLOY_HOST }} + username: ${{ secrets.PROD_DEPLOY_USER }} + key: ${{ secrets.PROD_DEPLOY_SSH_KEY }} + port: ${{ secrets.PROD_DEPLOY_PORT || 22 }} + script: | + set -e + DEPLOY_DIR="${{ secrets.PROD_DEPLOY_PATH }}" + cd "$DEPLOY_DIR" + RUN_DJANGO_CHECK=1 ./scripts/deploy_stack.sh .env.prod docker-compose.prod.yml diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 0000000..2435b2e --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -0,0 +1,46 @@ +name: Deploy Test + +on: + workflow_dispatch: + push: + branches: + - develop + +concurrency: + group: deploy-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: development + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Upload release bundle + uses: appleboy/scp-action@v1.0.0 + with: + host: ${{ secrets.TEST_DEPLOY_HOST }} + username: ${{ secrets.TEST_DEPLOY_USER }} + key: ${{ secrets.TEST_DEPLOY_SSH_KEY }} + port: ${{ secrets.TEST_DEPLOY_PORT || 22 }} + source: "." + target: ${{ secrets.TEST_DEPLOY_PATH }} + rm: false + overwrite: true + strip_components: 0 + exclude: ".git,.github,.venv,__pycache__,node_modules,backend/media,backend/staticfiles" + + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.TEST_DEPLOY_HOST }} + username: ${{ secrets.TEST_DEPLOY_USER }} + key: ${{ secrets.TEST_DEPLOY_SSH_KEY }} + port: ${{ secrets.TEST_DEPLOY_PORT || 22 }} + script: | + set -e + DEPLOY_DIR="${{ secrets.TEST_DEPLOY_PATH }}" + cd "$DEPLOY_DIR" + RUN_DJANGO_CHECK=0 DEPLOY_HEALTH_URL="http://127.0.0.1:8088/healthz/" ./scripts/deploy_stack.sh .env.test docker-compose.prod.yml diff --git a/.gitignore b/.gitignore index ce0ff02..be75676 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ .env .env.* !.env.example +!.env.dev.example +!.env.test.example +!.env.prod.example .venv/ venv/ db.sqlite3 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..55b88a4 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,372 @@ +# Deployment and CI/CD + +## Current deployment model +- one private GitHub repository +- `develop` deploys to the test server +- `main` is reserved for production deployment +- GitHub Actions uploads the repository contents to the server over SSH +- the server does not need GitHub access to deploy + +This is intentional. For a private repository, server-side `git clone` adds unnecessary credential management. + +## Branch strategy +- `develop`: test deployment branch +- `main`: production branch +- feature branches: normal product work + +## Environments +### Development / test +- target server: `192.168.2.55` +- deployment path: `/opt/workdock` +- stack file: `docker-compose.prod.yml` +- env file on server: `.env.test` +- current access URL: `http://192.168.2.55:8088` + +### Production +- same deployment mechanism +- different server +- env file on server: `.env.prod` +- should run behind real HTTPS +- should keep `DEBUG=0` + +## Important design choice +The current test server is a LAN-only HTTP deployment. + +Because the Django settings enforce secure-cookie checks when `DEBUG=0`, the test deployment uses: +- `DJANGO_DEBUG=1` +- `RUN_DJANGO_CHECK=0` + +That is acceptable for this internal test box only. + +Production must use: +- `DJANGO_DEBUG=0` +- `DJANGO_SECURE_COOKIES=1` +- HTTPS +- `RUN_DJANGO_CHECK=1` + +## Files used for deployment +- [docker-compose.prod.yml](/Users/bostame/Documents/workdock-platform/docker-compose.prod.yml) +- [scripts/deploy_stack.sh](/Users/bostame/Documents/workdock-platform/scripts/deploy_stack.sh) +- [backend/entrypoint-web-prod.sh](/Users/bostame/Documents/workdock-platform/backend/entrypoint-web-prod.sh) +- [backend/entrypoint-worker-prod.sh](/Users/bostame/Documents/workdock-platform/backend/entrypoint-worker-prod.sh) +- [deploy/Caddyfile](/Users/bostame/Documents/workdock-platform/deploy/Caddyfile) +- [.env.test.example](/Users/bostame/Documents/workdock-platform/.env.test.example) +- [.env.prod.example](/Users/bostame/Documents/workdock-platform/.env.prod.example) +- [.github/workflows/deploy-test.yml](/Users/bostame/Documents/workdock-platform/.github/workflows/deploy-test.yml) +- [.github/workflows/deploy-prod.yml](/Users/bostame/Documents/workdock-platform/.github/workflows/deploy-prod.yml) + +## What `deploy_stack.sh` does +The deployment script: +1. validates the env file exists +2. builds `web`, `worker`, and `caddy` +3. starts `db` and `redis` +4. initializes writable volume ownership for: + - `/app/media` + - `/app/staticfiles` + - `/app/backups` +5. runs: + - `migrate` + - `bootstrap_initial_users` + - `collectstatic` +6. optionally runs `manage.py check` +7. starts: + - `web` + - `worker` + - `caddy` +8. waits for `/healthz/` + +## Proxmox / LXC requirement +This project is running in an Ubuntu CT on Proxmox, with Docker inside the CT. + +For this to work, the CT needed Proxmox-side configuration in: +- `/etc/pve/lxc/.conf` + +Required settings: +```conf +features: nesting=1,keyctl=1 +lxc.apparmor.profile: unconfined +lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0 +``` + +Then restart the CT: +```bash +pct restart +``` + +Without this, Docker containers in the CT fail with: +```text +open sysctl net.ipv4.ip_unprivileged_port_start ... permission denied +``` + +This is a Proxmox/LXC nested-Docker issue, not an application bug. + +## Server bootstrap +Run on the server once: +```bash +apt-get update +apt-get install -y ca-certificates curl gnupg git +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +systemctl enable --now docker +``` + +## Server directory layout +Current test server path: +```bash +/opt/workdock +``` + +Important server-local files: +- `/opt/workdock/.env.test` +- later `/opt/workdock/.env.prod` + +These env files are intentionally not uploaded from GitHub Actions. + +## Test env file +Create on the server: +```bash +cp .env.test.example .env.test +``` + +Current important values for the LAN test box: +```env +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088 +DJANGO_SECURE_COOKIES=0 +DJANGO_SECURE_SSL_REDIRECT=0 +APP_PORT=8088 +SITE_ADDRESS=:80 +``` + +Generate strong values for: +- `DJANGO_SECRET_KEY` +- `POSTGRES_PASSWORD` + +## Production env file +Production should use: +```env +DJANGO_DEBUG=0 +DJANGO_SECURE_COOKIES=1 +DJANGO_SECURE_SSL_REDIRECT=1 +``` + +And a real HTTPS hostname in: +- `DJANGO_ALLOWED_HOSTS` +- `DJANGO_CSRF_TRUSTED_ORIGINS` +- `SITE_ADDRESS` + +## Manual test deployment +If you need to deploy manually on the test server: +```bash +cd /opt/workdock +RUN_DJANGO_CHECK=0 DEPLOY_HEALTH_URL="http://127.0.0.1:8088/healthz/" ./scripts/deploy_stack.sh .env.test docker-compose.prod.yml +``` + +Manual production deployment: +```bash +cd /opt/workdock +RUN_DJANGO_CHECK=1 ./scripts/deploy_stack.sh .env.prod docker-compose.prod.yml +``` + +## GitHub Actions workflows +### Test deployment workflow +File: +- [deploy-test.yml](/Users/bostame/Documents/workdock-platform/.github/workflows/deploy-test.yml) + +Behavior: +- triggers on push to `develop` +- can also be run manually with `workflow_dispatch` +- checks out the repo in GitHub Actions +- uploads the working tree to the server over SSH +- runs the server deployment script + +### Production deployment workflow +File: +- [deploy-prod.yml](/Users/bostame/Documents/workdock-platform/.github/workflows/deploy-prod.yml) + +Behavior: +- manual only +- uploads the working tree to the production server +- runs the production deployment script + +## GitHub environment setup +In GitHub: +1. open repository settings +2. open `Environments` +3. create: + - `development` + - `production` + +### Exact GitHub UI path +1. Open the private repository: + - `https://github.com/Bostame/workdock-platform` +2. Click: + - `Settings` +3. In the left sidebar, open: + - `Environments` +4. Click: + - `New environment` +5. Create: + - `development` +6. Repeat and create: + - `production` +7. Open the `development` environment +8. Under `Environment secrets`, click: + - `Add environment secret` +9. Add each required secret one by one +10. Repeat the same pattern later for `production` + +### Development environment secrets +Add: +- `TEST_DEPLOY_HOST` +- `TEST_DEPLOY_USER` +- `TEST_DEPLOY_PORT` +- `TEST_DEPLOY_PATH` +- `TEST_DEPLOY_SSH_KEY` + +Current test values: +- `TEST_DEPLOY_HOST=192.168.2.55` +- `TEST_DEPLOY_USER=root` +- `TEST_DEPLOY_PORT=22` +- `TEST_DEPLOY_PATH=/opt/workdock` +- `TEST_DEPLOY_SSH_KEY=` + +### Development secret entry example +Use these exact values in the `development` environment: + +`TEST_DEPLOY_HOST` +```text +192.168.2.55 +``` + +`TEST_DEPLOY_USER` +```text +root +``` + +`TEST_DEPLOY_PORT` +```text +22 +``` + +`TEST_DEPLOY_PATH` +```text +/opt/workdock +``` + +`TEST_DEPLOY_SSH_KEY` +```text + +``` + +The SSH key must include the full multi-line content, for example: +```text +-----BEGIN OPENSSH PRIVATE KEY----- +... +-----END OPENSSH PRIVATE KEY----- +``` + +### How to verify the SSH key before adding it +From your local machine: +```bash +ssh -4 root@192.168.2.55 +``` + +If that works without asking for a password, the matching private key is the correct one to store in `TEST_DEPLOY_SSH_KEY`. + +### Production environment secrets +Add: +- `PROD_DEPLOY_HOST` +- `PROD_DEPLOY_USER` +- `PROD_DEPLOY_PORT` +- `PROD_DEPLOY_PATH` +- `PROD_DEPLOY_SSH_KEY` + +## How the CI/CD test deploy works +### Normal flow +1. push code to `develop` +2. GitHub Actions runs `Deploy Test` +3. workflow uploads repository contents to `/opt/workdock` +4. server keeps its local `.env.test` +5. `deploy_stack.sh` rebuilds and restarts the stack +6. workflow succeeds only after `/healthz/` is healthy + +### Manual trigger +From GitHub Actions: +1. open `Deploy Test` +2. click `Run workflow` + +### First GitHub Actions validation +After you add the `development` environment secrets: +1. Open: + - `https://github.com/Bostame/workdock-platform/actions` +2. Open workflow: + - `Deploy Test` +3. Click: + - `Run workflow` +4. Select branch: + - `develop` +5. Run it +6. Wait until both steps complete: + - upload bundle + - deploy over SSH +7. Verify: + - `http://192.168.2.55:8088/healthz/` +8. Then open the app home page in the browser + +### What success looks like +- workflow status is green in GitHub Actions +- `Deploy Test` job finishes without SSH or health-check errors +- `/healthz/` returns `200 OK` +- the containers on the test server remain up + +### If the workflow fails +Check in this order: +1. wrong or incomplete `TEST_DEPLOY_SSH_KEY` +2. wrong `TEST_DEPLOY_USER` +3. wrong `TEST_DEPLOY_PATH` +4. changed server host key +5. server disk-space or Docker runtime issue + +## How to validate a deployment +### From your machine +```bash +curl -I http://192.168.2.55:8088/healthz/ +``` + +### On the server +```bash +cd /opt/workdock +docker compose --env-file .env.test -f docker-compose.prod.yml ps +docker compose --env-file .env.test -f docker-compose.prod.yml logs --tail=100 web +docker compose --env-file .env.test -f docker-compose.prod.yml logs --tail=100 worker +docker compose --env-file .env.test -f docker-compose.prod.yml logs --tail=100 caddy +``` + +## Rollback +This deployment path is source-upload based, not image-tag based. + +Rollback options: +1. revert the bad commit on `develop` and let GitHub Actions deploy again +2. manually re-upload a previous working checkout and rerun `deploy_stack.sh` + +For production, you may later want image-tag based rollback. That is not necessary yet for the test box. + +## Operational notes +- server-local env files must survive deployments +- do not store `.env.test` or `.env.prod` in Git +- test deployment is intentionally weaker than production on transport security +- production should not reuse the test env model + +## Current known-good state +Validated manually: +- repository pushed to private GitHub +- server bootstrap completed +- test stack deployed successfully +- health check reachable at: + - `http://192.168.2.55:8088/healthz/` diff --git a/Makefile b/Makefile index 878034a..8a88937 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ COMPOSE ?= docker compose -.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify +.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify release-validate i18n-extract-en: $(COMPOSE) exec -T web django-admin makemessages -l en @@ -21,3 +21,11 @@ backup-create: backup-verify: @if [ -z "$(BACKUP_DIR)" ]; then echo "Usage: make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS"; exit 1; fi ./scripts/backup_verify.sh "$(BACKUP_DIR)" + +release-validate: + $(COMPOSE) exec -T web python manage.py check + $(COMPOSE) exec -T web python manage.py test workflows.tests -v 2 + $(COMPOSE) exec -T web django-admin compilemessages + $(COMPOSE) exec -T web python manage.py collectstatic --noinput + $(COMPOSE) exec -T web python manage.py verify_latest_backup --create-if-missing + $(COMPOSE) exec -T web python manage.py run_staging_e2e_check --cleanup --email-check none --skip-nextcloud diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md new file mode 100644 index 0000000..9d46875 --- /dev/null +++ b/PRODUCTIZATION_ROADMAP.md @@ -0,0 +1,270 @@ +# Productization Roadmap + +## Goal + +Turn the current TUBCO-specific onboarding/offboarding portal into Workdock, a reusable company portal product, while preserving the current TUBCO deployment as a stable customer-specific baseline. + +Current branch roles: + +- `main`: current TUBCO customer baseline +- `product_company_portal_foundation`: generic product-development branch + +## Core Product Principles + +1. Do not build "TUBCO with exceptions". +2. Separate platform core from company configuration. +3. Start as single-tenant configurable, not full multi-tenant. +4. Make branding and document identity admin-managed, not code-managed. +5. Add new business apps only after the core platform layer is standardized. +6. Prefer inline editing for lightweight profile and configuration data, but keep explicit forms for sensitive or high-risk settings. + +## Product Layers + +### 1. Platform Core + +Reusable across all customer deployments: + +- authentication +- roles and permissions +- page shell and navigation +- audit log +- backup and recovery +- integrations framework +- translations +- document engine +- app registry + +### 2. Company Configuration + +Per deployment / per company: + +- portal title +- company display name +- logos +- favicon +- brand colors +- support/contact email +- legal text +- default language +- PDF letterhead +- email sender labels +- notification routing defaults + +### 3. Business Apps + +Examples: + +- onboarding / offboarding +- welcome emails +- asset return +- approvals +- leave / request flows +- checklists +- document workflows + +## Delivery Strategy + +### Phase 0. Freeze Current Customer Baseline + +Status: completed + +Purpose: + +- keep the current TUBCO state stable +- allow future customer-specific fixes without mixing them with productization work + +Snapshot commit: + +- `08971ab` `snapshot: preserve current tubco portal baseline` + +### Phase 1. Product Core Standardization + +Status: completed + +Purpose: + +- remove hardcoded company identity from the platform surface +- introduce config-driven branding and document identity + +Deliverables: + +1. `PortalBranding` or `TenantConfig` model + - portal title + - company name + - logo + - favicon + - primary / secondary colors + - support email + - default language + - PDF letterhead file + - legal / imprint / privacy text + +2. Branding management UI + - super-admin/admin controlled + - editable from frontend + +3. Shared context layer + - inject branding into templates + - replace hardcoded TUBCO title/logo references + +4. Tenant-aware document identity + - letterhead path from config + - company display name from config + - footer/legal text from config + +5. Documentation updates + - product setup flow + - branding flow + - PDF/letterhead override behavior + +Delivered: + +- generic branding model and management UI +- shared branding context across shell/auth/pages +- configurable favicon, logo, sender display, footer/legal text, and PDF letterhead +- company-domain-driven email defaults and validation +- platform vs company admin separation for product-level controls + +### Phase 2. App Registry and Navigation + +Status: completed + +Purpose: + +- stop hardcoding app cards and app visibility in the homepage template + +Deliverables: + +- app registry model or registry config +- title / subtitle / icon / route / required capability / enabled flag +- homepage and navigation driven by registry data +- ability to enable/disable apps per deployment +- role-based app visibility and section grouping +- drag-and-drop ordering with filter-safe behavior + +### Phase 3. Trial Mode Lifecycle + +Status: completed + +Purpose: + +- allow limited-time test environments for demos and sales + +Deliverables: + +- trial flag +- `trial_expires_at` +- trial banner +- safe default integrations behavior +- cleanup command / scheduled deletion +- DB/media cleanup policy + +Delivered: + +- platform-only trial management UI +- shared trial banner and expiry enforcement +- integration restriction during trial mode +- cleanup/verification management commands + +### Phase 4. New Business Apps + +Status: next + +Only start after phases 1-3 are stable and the workflow regression suite is green. + +Candidate apps: + +- asset management +- leave / absence requests +- approval workflows +- internal purchase requests +- visitor onboarding +- contractor onboarding +- policy acknowledgements + +## Recommended Deployment Model + +Initial product model: + +- single-tenant configurable deployment +- one company per deployment +- shared codebase +- company identity controlled through admin-managed config + +Do not start with: + +- true multi-tenant shared-data SaaS + +Reason: + +- tenant isolation affects auth, media, PDFs, routing, backups, audit, and cleanup +- it is much more complex than the current product stage requires + +## What Must Be Removed From Hardcoded Product Core + +Examples already identified: + +- former TUBCO-specific portal title kept only as historical baseline context +- logo asset references +- invitation email wording mentioning TUBCO +- historical product text references that still describe the original TUBCO baseline +- fixed letterhead file assumptions + +These should move into configuration progressively, not all at once in one risky rewrite. + +## Immediate Next Slice + +Implement next: + +1. restore and keep green the onboarding/offboarding regression suite +2. extend dynamic onboarding configuration: + - field visibility + - section visibility + - guarded required/optional controls +3. remove remaining hardcoded customer/product leakage from docs, fixtures, and fallback assets +4. continue security and observability hardening before the next business app + +This is the next productization slice because it gives: + +- reliable core workflow behavior +- safer deployment-neutral product defaults +- a configurable onboarding experience for future customers + +## Guardrails + +- keep current customer deployment values stable while generic product defaults move to Workdock +- keep migrations backward-compatible +- update both wiki and developer handbook for every architecture change +- snapshot at the end of each major phase + +## Shared UI Pattern: Inline Editing + +Use inline editing as a platform pattern where it improves speed without weakening clarity or safety. + +Good candidates: + +- user profile and contact data +- company config sections +- branding text and non-sensitive metadata +- low-risk app-registry metadata + +Do not use it by default for: + +- credentials and secrets +- integrations with side effects +- destructive actions +- multi-step workflow forms +- settings that need heavy validation or confirmation + +Preferred implementation style: + +- section-level inline editing +- explicit `Bearbeiten`, `Speichern`, `Abbrechen` +- no noisy per-field autosave +- clear view mode and edit mode separation + +Reason: + +- keeps Workdock faster and more product-grade +- avoids large admin-style forms for simple edits +- still preserves reliable validation and safer change boundaries diff --git a/README.md b/README.md index 8730ed8..c2377f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# TUBCO Onboarding & Offboarding Portal +# Workdock -This is the standalone dockerized web application for the TUBCO onboarding and offboarding workflow. +Workdock is the dockerized business operations platform that powers internal company apps such as onboarding, offboarding, requests, integrations, backups, and future modular workplace tools. ## Services - `web`: Django app (`http://localhost:8000`) @@ -99,3 +99,20 @@ Verification behavior: - restores the dump into a temporary verification database - extracts media into a temporary directory - checks that the restored DB and media structure are readable + +## Release validation +Use one local gate before shipping larger changes: + +- `make release-validate` + +What it runs: +- Django system checks +- full workflow test suite +- translation compile +- collectstatic +- latest-backup verification +- production-like onboarding/offboarding smoke check + +CI mirrors this split in two layers: +- fast Python validation +- Docker-based release gate with backup verification and smoke workflow checks diff --git a/backend/Dockerfile b/backend/Dockerfile index c9f8682..1325bd7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,5 +15,5 @@ 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 chmod +x /app/entrypoint-web.sh /app/entrypoint-worker.sh /app/entrypoint-web-prod.sh /app/entrypoint-worker-prod.sh RUN chown -R app:app /app diff --git a/backend/config/settings.py b/backend/config/settings.py index 5707e2c..045551c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent @@ -25,6 +26,28 @@ CSRF_COOKIE_SECURE = _secure_cookies 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))) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'workdock-default-cache', + } +} + +SESSION_COOKIE_AGE = int(os.getenv('DJANGO_SESSION_COOKIE_AGE', str(60 * 60 * 8))) +SESSION_SAVE_EVERY_REQUEST = os.getenv('DJANGO_SESSION_SAVE_EVERY_REQUEST', '1') == '1' +SESSION_EXPIRE_AT_BROWSER_CLOSE = os.getenv('DJANGO_SESSION_EXPIRE_AT_BROWSER_CLOSE', '1') == '1' +SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv('SESSION_IDLE_TIMEOUT_SECONDS', str(60 * 30))) +SENSITIVE_ACTION_REAUTH_SECONDS = int(os.getenv('SENSITIVE_ACTION_REAUTH_SECONDS', str(60 * 20))) + +RATE_LIMIT_LOGIN_LIMIT = int(os.getenv('RATE_LIMIT_LOGIN_LIMIT', '8')) +RATE_LIMIT_LOGIN_WINDOW = int(os.getenv('RATE_LIMIT_LOGIN_WINDOW', '300')) +RATE_LIMIT_PASSWORD_RESET_LIMIT = int(os.getenv('RATE_LIMIT_PASSWORD_RESET_LIMIT', '5')) +RATE_LIMIT_PASSWORD_RESET_WINDOW = int(os.getenv('RATE_LIMIT_PASSWORD_RESET_WINDOW', '600')) +RATE_LIMIT_ADMIN_ACTION_LIMIT = int(os.getenv('RATE_LIMIT_ADMIN_ACTION_LIMIT', '20')) +RATE_LIMIT_ADMIN_ACTION_WINDOW = int(os.getenv('RATE_LIMIT_ADMIN_ACTION_WINDOW', '300')) +RATE_LIMIT_ENABLED = os.getenv('RATE_LIMIT_ENABLED', '1') == '1' +RUN_SECURITY_CHECKS_DURING_TESTS = os.getenv('RUN_SECURITY_CHECKS_DURING_TESTS', '0') == '1' + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -40,10 +63,14 @@ MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', + 'workflows.middleware.RequestIDMiddleware', + 'workflows.middleware.RateLimitMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'workflows.middleware.AuthSessionHardeningMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'workflows.middleware.TrialModeMiddleware', ] ROOT_URLCONF = 'config.urls' @@ -70,9 +97,9 @@ 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'), + 'NAME': os.getenv('POSTGRES_DB', 'workdock'), + 'USER': os.getenv('POSTGRES_USER', 'workdock'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'workdock'), 'HOST': os.getenv('POSTGRES_HOST', 'db'), 'PORT': int(os.getenv('POSTGRES_PORT', '5432')), } @@ -115,11 +142,11 @@ 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') +IT_ONBOARDING_NOTIFICATION_EMAIL = os.getenv('IT_ONBOARDING_NOTIFICATION_EMAIL', 'it@workdock.de') +GENERAL_INFO_NOTIFICATION_EMAIL = os.getenv('GENERAL_INFO_NOTIFICATION_EMAIL', 'info@workdock.de') +BUSINESS_CARD_NOTIFICATION_EMAIL = os.getenv('BUSINESS_CARD_NOTIFICATION_EMAIL', 'cards@workdock.de') +HR_WORKS_NOTIFICATION_EMAIL = os.getenv('HR_WORKS_NOTIFICATION_EMAIL', 'hr@workdock.de') +KEY_NOTIFICATION_EMAIL = os.getenv('KEY_NOTIFICATION_EMAIL', 'keys@workdock.de') EMAIL_TEST_MODE = os.getenv('EMAIL_TEST_MODE', '0') == '1' EMAIL_TEST_REDIRECT = os.getenv('EMAIL_TEST_REDIRECT', TEST_NOTIFICATION_EMAIL) @@ -148,3 +175,52 @@ 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')) + +LOG_LEVEL = os.getenv('DJANGO_LOG_LEVEL', 'INFO') +LOG_JSON = os.getenv('DJANGO_LOG_JSON', '1') == '1' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'request_context': { + '()': 'workflows.logging_utils.RequestContextFilter', + }, + }, + 'formatters': { + 'structured': { + '()': 'workflows.logging_utils.JsonFormatter', + }, + 'verbose': { + 'format': '[%(asctime)s] %(levelname)s %(name)s request_id=%(request_id)s task_id=%(task_id)s %(message)s', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'filters': ['request_context'], + 'formatter': 'structured' if LOG_JSON else 'verbose', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'workflows': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'celery': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + }, +} diff --git a/backend/config/urls.py b/backend/config/urls.py index 12c21f2..b783ae7 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -4,16 +4,18 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path -from workflows.forms import AppAuthenticationForm, AppPasswordResetForm, AppSetPasswordForm +from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm +from workflows import views as workflow_views urlpatterns = [ path('admin/', admin.site.urls), path('i18n/', include('django.conf.urls.i18n')), path( 'accounts/login/', - auth_views.LoginView.as_view(template_name='workflows/auth/login.html', authentication_form=AppAuthenticationForm), + workflow_views.login_page, name='login', ), + path('accounts/login/totp/', workflow_views.login_totp_page, name='login_totp'), path( 'accounts/logout/', auth_views.LogoutView.as_view(), @@ -24,6 +26,19 @@ urlpatterns = [ auth_views.PasswordResetView.as_view(template_name='workflows/auth/password_reset_form.html', form_class=AppPasswordResetForm), name='password_reset', ), + path( + 'accounts/password_change/', + auth_views.PasswordChangeView.as_view( + template_name='workflows/auth/password_change_form.html', + form_class=AppPasswordChangeForm, + ), + name='password_change', + ), + path( + 'accounts/password_change/done/', + auth_views.PasswordChangeDoneView.as_view(template_name='workflows/auth/password_change_done.html'), + name='password_change_done', + ), path( 'accounts/password_reset/done/', auth_views.PasswordResetDoneView.as_view(template_name='workflows/auth/password_reset_done.html'), diff --git a/backend/entrypoint-web-prod.sh b/backend/entrypoint-web-prod.sh new file mode 100755 index 0000000..91c3e74 --- /dev/null +++ b/backend/entrypoint-web-prod.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +exec gunicorn config.wsgi:application \ + --bind 0.0.0.0:8000 \ + --workers 3 \ + --timeout 120 \ + --access-logfile - \ + --error-logfile - diff --git a/backend/entrypoint-worker-prod.sh b/backend/entrypoint-worker-prod.sh new file mode 100755 index 0000000..07bc3ad --- /dev/null +++ b/backend/entrypoint-worker-prod.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +exec celery -A config worker -l info diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index f69d1a6..d98df54 100644 Binary files a/backend/locale/en/LC_MESSAGES/django.mo and b/backend/locale/en/LC_MESSAGES/django.mo differ diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index af32b22..5aae7a2 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,496 +2,1390 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-26 09:25+0000\n" +"POT-Creation-Date: 2026-03-27 22:17+0000\n" "PO-Revision-Date: 2026-03-24 00:00+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: workflows/backup_ops.py:141 +#: workflows/app_registry.py:35 workflows/models.py:485 workflows/models.py:524 +#: workflows/models.py:544 workflows/models.py:564 workflows/models.py:602 +#: workflows/models.py:701 +#: workflows/templates/workflows/onboarding_form.html:25 +#: workflows/templates/workflows/requests_dashboard.html:68 +#: workflows/templates/workflows/requests_dashboard.html:131 +#: workflows/views.py:3008 workflows/views.py:3025 workflows/views.py:3037 +msgid "Onboarding" +msgstr "Onboarding" + +#: workflows/app_registry.py:36 +msgid "" +"Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen " +"senden und in Nextcloud ablegen." +msgstr "" +"Capture new employees, generate a PDF with letterhead, send notifications, " +"and store it in Nextcloud." + +#: workflows/app_registry.py:37 +msgid "Onboarding starten" +msgstr "Start onboarding" + +#: workflows/app_registry.py:39 +msgid "Mehrschritt-Formular" +msgstr "Multi-step form" + +#: workflows/app_registry.py:39 +msgid "E-Mail Routing" +msgstr "Email routing" + +#: workflows/app_registry.py:46 workflows/models.py:486 workflows/models.py:525 +#: workflows/models.py:603 workflows/models.py:702 +#: workflows/templates/workflows/requests_dashboard.html:78 +#: workflows/templates/workflows/requests_dashboard.html:132 +#: workflows/views.py:3009 workflows/views.py:3037 +msgid "Offboarding" +msgstr "Offboarding" + +#: workflows/app_registry.py:47 +msgid "" +"Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und " +"Rückgabe-Prozess starten." +msgstr "" +"Search employees, prefill data, generate offboarding documents, and start " +"the return process." + +#: workflows/app_registry.py:48 +msgid "Offboarding starten" +msgstr "Start offboarding" + +#: workflows/app_registry.py:50 +msgid "Profile-Suche" +msgstr "Profile search" + +#: workflows/app_registry.py:50 +msgid "Hardware-Liste" +msgstr "Hardware list" + +#: workflows/app_registry.py:50 +msgid "IT-Rückgabe" +msgstr "IT return" + +#: workflows/app_registry.py:57 +#: workflows/templates/workflows/requests_dashboard.html:4 +#: workflows/templates/workflows/requests_dashboard.html:33 +msgid "Anfragen Dashboard" +msgstr "Requests Dashboard" + +#: workflows/app_registry.py:58 +msgid "" +"Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-" +"Anfragen." +msgstr "" +"Status, search, PDF links, and history of all onboarding/offboarding " +"requests." + +#: workflows/app_registry.py:59 +msgid "Dashboard öffnen" +msgstr "Open dashboard" + +#: workflows/app_registry.py:62 +#: workflows/templates/workflows/app_registry.html:28 +msgid "Suche" +msgstr "Search" + +#: workflows/app_registry.py:62 +#: workflows/templates/workflows/app_registry.html:32 +#: workflows/templates/workflows/backup_recovery.html:72 +#: workflows/templates/workflows/form_builder.html:370 +#: workflows/templates/workflows/job_monitor.html:68 +#: workflows/templates/workflows/job_monitor.html:89 +#: workflows/templates/workflows/onboarding_intro_session.html:37 +#: workflows/templates/workflows/request_timeline.html:76 +#: workflows/templates/workflows/requests_dashboard.html:136 +#: workflows/templates/workflows/trial_expired.html:20 +#: workflows/templates/workflows/trial_management.html:25 +#: workflows/templates/workflows/welcome_emails.html:85 +msgid "Status" +msgstr "Status" + +#: workflows/app_registry.py:62 +msgid "PDF Zugriff" +msgstr "PDF access" + +#: workflows/app_registry.py:68 +#: workflows/templates/workflows/company_config.html:4 +#: workflows/templates/workflows/company_config.html:12 +msgid "Company Config" +msgstr "" + +#: workflows/app_registry.py:69 +msgid "" +"Rechtliche Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks " +"pflegen." +msgstr "" + +#: workflows/app_registry.py:70 workflows/app_registry.py:79 +#: workflows/app_registry.py:88 workflows/app_registry.py:97 +#: workflows/app_registry.py:106 workflows/app_registry.py:115 +#: workflows/app_registry.py:124 workflows/app_registry.py:133 +#: workflows/app_registry.py:142 workflows/app_registry.py:151 +#: workflows/app_registry.py:160 workflows/app_registry.py:169 +#: workflows/app_registry.py:178 workflows/app_registry.py:187 +#: workflows/templates/workflows/includes/app_header.html:57 +msgid "Öffnen" +msgstr "Open" + +#: workflows/app_registry.py:77 +#: workflows/templates/workflows/trial_management.html:4 +#: workflows/templates/workflows/trial_management.html:12 +msgid "Trial Management" +msgstr "" + +#: workflows/app_registry.py:78 +msgid "" +"Testlaufzeit, Banner und sichere Einschränkungen für Demo-Umgebungen steuern." +msgstr "" + +#: workflows/app_registry.py:86 +#: workflows/templates/workflows/branding_settings.html:4 +#: workflows/templates/workflows/branding_settings.html:12 +msgid "Branding" +msgstr "Branding" + +#: workflows/app_registry.py:87 +msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." +msgstr "Manage logo, portal name, colors, and PDF letterhead." + +#: workflows/app_registry.py:95 +#: workflows/templates/workflows/app_registry.html:6 +#: workflows/templates/workflows/app_registry.html:14 +msgid "App Registry" +msgstr "" + +#: workflows/app_registry.py:96 +msgid "Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten." +msgstr "" + +#: workflows/app_registry.py:104 +msgid "Integrationen" +msgstr "Integrations" + +#: workflows/app_registry.py:105 +msgid "Nextcloud- und E-Mail-Setup." +msgstr "Nextcloud and email setup." + +#: workflows/app_registry.py:113 +#: workflows/templates/workflows/job_monitor.html:4 +#: workflows/templates/workflows/job_monitor.html:12 +msgid "Job Monitor" +msgstr "" + +#: workflows/app_registry.py:114 +msgid "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe prüfen." +msgstr "" + +#: workflows/app_registry.py:122 +#: workflows/templates/workflows/user_management.html:4 +#: workflows/templates/workflows/user_management.html:14 +msgid "Benutzer & Rollen" +msgstr "Users & roles" + +#: workflows/app_registry.py:123 +msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." +msgstr "Create users, assign roles, and control access." + +#: workflows/app_registry.py:131 workflows/templates/workflows/audit_log.html:4 +#: workflows/templates/workflows/audit_log.html:15 +msgid "Audit Log" +msgstr "" + +#: workflows/app_registry.py:132 +msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." +msgstr "" + +#: workflows/app_registry.py:140 +#: workflows/templates/workflows/backup_recovery.html:4 +#: workflows/templates/workflows/backup_recovery.html:12 +msgid "Backup & Recovery" +msgstr "Backup & Recovery" + +#: workflows/app_registry.py:141 +msgid "Backups erstellen und sicher verifizieren." +msgstr "" + +#: workflows/app_registry.py:149 +#: workflows/templates/workflows/welcome_emails.html:4 +msgid "Welcome E-Mails" +msgstr "Welcome Emails" + +#: workflows/app_registry.py:150 +msgid "Geplante Welcome Mails verwalten." +msgstr "Manage scheduled welcome emails." + +#: workflows/app_registry.py:158 +#: workflows/templates/workflows/form_builder.html:5 +#: workflows/templates/workflows/form_builder.html:56 +msgid "Form Builder" +msgstr "Form Builder" + +#: workflows/app_registry.py:159 +msgid "Felder, Schritte und Optionen verwalten." +msgstr "Manage fields, steps, and options." + +#: workflows/app_registry.py:167 +#: workflows/templates/workflows/intro_builder.html:4 +#: workflows/templates/workflows/intro_builder.html:17 +msgid "Einweisungs-Builder" +msgstr "Introduction Builder" + +#: workflows/app_registry.py:168 +msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." +msgstr "Configure checklist items for the introduction protocol." + +#: workflows/app_registry.py:176 workflows/templates/workflows/handbook.html:4 +#: workflows/templates/workflows/handbook.html:15 +msgid "Handbook" +msgstr "Handbook" + +#: workflows/app_registry.py:177 +msgid "Project wiki and developer documentation in one place." +msgstr "Project wiki and developer documentation in one place." + +#: workflows/app_registry.py:185 +msgid "Django Admin" +msgstr "Django Admin" + +#: workflows/app_registry.py:186 +msgid "Vollständige Datenverwaltung." +msgstr "Full data management." + +#: workflows/app_registry.py:309 +msgid "Nur Platform" +msgstr "" + +#: workflows/app_registry.py:311 +#: workflows/templates/workflows/app_registry.html:107 +msgid "Alle Firmenrollen" +msgstr "" + +#: workflows/app_registry.py:317 workflows/models.py:259 +#: workflows/templates/workflows/app_registry.html:44 +#: workflows/templates/workflows/app_registry.html:100 +msgid "Apps" +msgstr "Apps" + +#: workflows/app_registry.py:318 +msgid "Wählen Sie den gewünschten Prozess." +msgstr "Choose the desired process." + +#: workflows/app_registry.py:323 workflows/models.py:260 +#: workflows/templates/workflows/app_registry.html:45 +#: workflows/templates/workflows/app_registry.html:96 +msgid "Platform Apps" +msgstr "" + +#: workflows/app_registry.py:324 +#, fuzzy +#| msgid "Konfiguration, Tests und Steuerung." +msgid "Produktweite Konfiguration und Produktsteuerung." +msgstr "Configuration, tests, and controls." + +#: workflows/app_registry.py:329 workflows/models.py:261 +#: workflows/templates/workflows/app_registry.html:46 +#: workflows/templates/workflows/app_registry.html:98 +msgid "Admin Apps" +msgstr "Admin Apps" + +#: workflows/app_registry.py:330 +msgid "Konfiguration, Tests und Steuerung." +msgstr "Configuration, tests, and controls." + +#: workflows/backup_ops.py:127 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Kein Backup vorhanden" +msgstr "No backup bundles available yet." + +#: workflows/backup_ops.py:128 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Es wurde noch kein Backup-Bundle erstellt." +msgstr "No backup bundles available yet." + +#: workflows/backup_ops.py:142 +#, fuzzy +#| msgid "Verifiziert" +msgid "Nicht verifiziert" +msgstr "Verified" + +#: workflows/backup_ops.py:143 +msgid "Das neueste Backup-Bundle wurde noch nicht erfolgreich verifiziert." +msgstr "" + +#: workflows/backup_ops.py:154 +#, fuzzy +#| msgid "Verifiziert" +msgid "Verifikation veraltet" +msgstr "Verified" + +#: workflows/backup_ops.py:155 +#, python-format +msgid "" +"Die letzte erfolgreiche Backup-Verifikation ist älter als %(hours)s Stunden." +msgstr "" + +#: workflows/backup_ops.py:163 +msgid "Verifikation aktuell" +msgstr "" + +#: workflows/backup_ops.py:164 +msgid "" +"Das neueste Backup-Bundle wurde erfolgreich und rechtzeitig verifiziert." +msgstr "" + +#: workflows/backup_ops.py:196 msgid "Remote Backup ist deaktiviert." msgstr "" -#: workflows/backup_ops.py:146 +#: workflows/backup_ops.py:201 #, python-format msgid "Zieltyp %(target)s ist vorbereitet, aber noch nicht implementiert." msgstr "" -#: workflows/backup_ops.py:152 +#: workflows/backup_ops.py:207 msgid "Nextcloud Backup-Verzeichnis fehlt." msgstr "" -#: workflows/backup_ops.py:170 +#: workflows/backup_ops.py:225 #, python-format msgid "Upload nach Nextcloud fehlgeschlagen bei %(file)s." msgstr "" -#: workflows/backup_ops.py:176 +#: workflows/backup_ops.py:231 #, python-format msgid "Nach Nextcloud hochgeladen: %(count)s Datei(en)." msgstr "" -#: workflows/backup_ops.py:239 workflows/backup_ops.py:318 +#: workflows/backup_ops.py:294 workflows/backup_ops.py:371 msgid "Backup-Dateien nicht gefunden." msgstr "" -#: workflows/backup_ops.py:289 +#: workflows/backup_ops.py:342 msgid "Media-Archiv enthält kein media/-Verzeichnis." msgstr "" -#: workflows/backup_ops.py:291 +#: workflows/backup_ops.py:344 #, python-format msgid "" "%(tables)s Tabellen, %(onboarding)s Onboarding, %(offboarding)s Offboarding, " "%(media)s Mediendateien geprüft." msgstr "" -#: workflows/backup_ops.py:316 +#: workflows/backup_ops.py:369 msgid "Ungültiger Backup-Pfad." msgstr "" -#: workflows/backup_ops.py:323 +#: workflows/backup_ops.py:376 msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:103 workflows/forms.py:128 +#: workflows/form_builder.py:65 +#, fuzzy +#| msgid "Stammdaten" +msgid "1. Stammdaten" +msgstr "Master data" + +#: workflows/form_builder.py:66 +#, fuzzy +#| msgid "Vertrag" +msgid "2. Vertrag" +msgstr "Contract" + +#: workflows/form_builder.py:67 +#, fuzzy +#| msgid "IT-Setup" +msgid "3. IT-Setup" +msgstr "IT setup" + +#: workflows/form_builder.py:68 +#, fuzzy +#| msgid "Abschluss" +msgid "4. Abschluss" +msgstr "Finish" + +#: workflows/form_builder.py:71 +#, fuzzy +#| msgid "Mitarbeiter" +msgid "1. Mitarbeitende" +msgstr "Staff" + +#: workflows/form_builder.py:72 +msgid "2. Austritt" +msgstr "" + +#: workflows/form_builder.py:73 +#, fuzzy +#| msgid "Abschluss" +msgid "3. Abschluss" +msgstr "Finish" + +#: workflows/form_builder.py:503 workflows/form_builder.py:556 +#: workflows/templates/workflows/form_builder.html:291 +#, fuzzy +#| msgid "Standardsprache" +msgid "Standard" +msgstr "Default language" + +#: workflows/form_builder.py:513 workflows/form_builder.py:565 +msgid "Lean" +msgstr "" + +#: workflows/form_builder.py:534 +msgid "IT-heavy" +msgstr "" + +#: workflows/form_builder.py:578 +msgid "HR-heavy" +msgstr "" + +#: workflows/forms.py:112 workflows/forms.py:476 #: workflows/templates/workflows/user_management.html:72 +#: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" -#: workflows/forms.py:104 +#: workflows/forms.py:114 msgid "Passwort" msgstr "Password" -#: workflows/forms.py:108 workflows/forms.py:129 +#: workflows/forms.py:120 +msgid "Benutzername oder Passwort sind nicht korrekt." +msgstr "" + +#: workflows/forms.py:121 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Dieses Konto ist deaktiviert." +msgstr "Disabled" + +#: workflows/forms.py:147 workflows/forms.py:307 workflows/forms.py:359 +msgid "TOTP-Code" +msgstr "" + +#: workflows/forms.py:153 workflows/forms.py:365 +msgid "Recovery-Code" +msgstr "" + +#: workflows/forms.py:160 workflows/forms.py:328 workflows/forms.py:385 +msgid "Der TOTP-Code ist ungültig." +msgstr "" + +#: workflows/forms.py:161 +msgid "Bitte geben Sie Ihren TOTP-Code ein." +msgstr "" + +#: workflows/forms.py:188 workflows/forms.py:249 workflows/forms.py:477 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:113 workflows/templates/workflows/user_management.html:77 +#: workflows/forms.py:193 workflows/forms.py:212 +#: workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:119 +#: workflows/forms.py:199 workflows/forms.py:218 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:126 +#: workflows/forms.py:207 workflows/forms.py:302 workflows/forms.py:334 +#, fuzzy +#| msgid "Neues Passwort" +msgid "Aktuelles Passwort" +msgstr "New password" + +#: workflows/forms.py:229 workflows/templates/workflows/account_profile.html:36 +#: workflows/templates/workflows/includes/app_header.html:79 +msgid "Profilbild" +msgstr "" + +#: workflows/forms.py:247 workflows/forms.py:474 +#: workflows/templates/workflows/account_profile.html:116 msgid "Vorname" msgstr "" -#: workflows/forms.py:127 +#: workflows/forms.py:248 workflows/forms.py:475 +#: workflows/templates/workflows/account_profile.html:120 msgid "Nachname" msgstr "" -#: workflows/forms.py:130 workflows/templates/workflows/user_management.html:74 +#: workflows/forms.py:250 +#: workflows/templates/workflows/account_profile.html:124 +msgid "Telefon" +msgstr "" + +#: workflows/forms.py:251 +#: workflows/templates/workflows/account_profile.html:128 +msgid "Mobil" +msgstr "" + +#: workflows/forms.py:252 +#: workflows/templates/workflows/account_profile.html:132 +#, fuzzy +#| msgid "Produktion" +msgid "Position" +msgstr "Production" + +#: workflows/forms.py:253 workflows/models.py:444 +#: workflows/templates/workflows/account_profile.html:136 +#: workflows/templates/workflows/onboarding_intro_session.html:28 +#: workflows/templates/workflows/requests_dashboard.html:145 +msgid "Abteilung" +msgstr "Department" + +#: workflows/forms.py:254 +#: workflows/templates/workflows/account_profile.html:140 +msgid "Standort" +msgstr "" + +#: workflows/forms.py:256 +#: workflows/templates/workflows/account_profile.html:144 +#, fuzzy +#| msgid "Einweisung" +msgid "Hinweise" +msgstr "Introduction" + +#: workflows/forms.py:320 workflows/forms.py:347 +msgid "Das aktuelle Passwort ist nicht korrekt." +msgstr "" + +#: workflows/forms.py:326 +msgid "Bitte geben Sie einen gültigen TOTP-Code ein." +msgstr "" + +#: workflows/forms.py:353 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP ist für dieses Konto nicht aktiv." +msgstr "Disabled" + +#: workflows/forms.py:381 +msgid "Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein." +msgstr "" + +#: workflows/forms.py:388 +msgid "Der Recovery-Code ist ungültig." +msgstr "" + +#: workflows/forms.py:393 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Onboarding erfolgreich" +msgstr "Save offboarding request" + +#: workflows/forms.py:394 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Onboarding fehlgeschlagen" +msgstr "Failed" + +#: workflows/forms.py:395 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding erfolgreich" +msgstr "Save offboarding request" + +#: workflows/forms.py:396 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding fehlgeschlagen" +msgstr "Save offboarding request" + +#: workflows/forms.py:397 +#, fuzzy +#| msgid "Eingereicht" +msgid "Backup erfolgreich" +msgstr "Submitted" + +#: workflows/forms.py:398 workflows/views.py:1553 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Backup fehlgeschlagen" +msgstr "Failed" + +#: workflows/forms.py:399 +#, fuzzy +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail erfolgreich" +msgstr "Welcome Emails" + +#: workflows/forms.py:400 +#, fuzzy +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail fehlgeschlagen" +msgstr "Welcome Emails" + +#: workflows/forms.py:401 +#, fuzzy +#| msgid "Einweisung" +msgid "Trial-Hinweise" +msgstr "Introduction" + +#: workflows/forms.py:402 +#, fuzzy +#| msgid "Einweisung" +msgid "System-Hinweise" +msgstr "Introduction" + +#: workflows/forms.py:418 +#, fuzzy +#| msgid "Workflow-Regeln" +msgid "Workflow" +msgstr "Workflow rules" + +#: workflows/forms.py:419 workflows/views.py:1712 +#, fuzzy +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail" +msgstr "Welcome Emails" + +#: workflows/forms.py:420 workflows/templates/workflows/handbook.html:21 +msgid "Operations" +msgstr "Operations" + +#: workflows/forms.py:421 +msgid "Platform" +msgstr "" + +#: workflows/forms.py:478 workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 +#: workflows/templates/workflows/user_management.html:171 #, fuzzy #| msgid "Rolle:" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:143 +#: workflows/forms.py:492 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:152 workflows/views.py:472 +#: workflows/forms.py:501 workflows/views.py:1362 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:408 +#: workflows/forms.py:503 workflows/views.py:1365 +msgid "Nur Platform Owner dürfen diese Rolle vergeben." +msgstr "" + +#: workflows/forms.py:542 +msgid "Portal-Titel" +msgstr "Portal title" + +#: workflows/forms.py:543 +msgid "Firmenname" +msgstr "Company name" + +#: workflows/forms.py:544 +#, fuzzy +#| msgid "Firmenname" +msgid "Firmen-Domain" +msgstr "Company name" + +#: workflows/forms.py:545 +msgid "Support-E-Mail" +msgstr "Support email" + +#: workflows/forms.py:546 +msgid "Absender-Anzeigename" +msgstr "" + +#: workflows/forms.py:547 +msgid "Login-Untertitel" +msgstr "" + +#: workflows/forms.py:548 +msgid "Footer-Text DE" +msgstr "" + +#: workflows/forms.py:549 +msgid "Footer-Text EN" +msgstr "" + +#: workflows/forms.py:550 +msgid "Rechtlicher Hinweis DE" +msgstr "" + +#: workflows/forms.py:551 +msgid "Rechtlicher Hinweis EN" +msgstr "" + +#: workflows/forms.py:552 +msgid "Standardsprache" +msgstr "Default language" + +#: workflows/forms.py:553 +msgid "Logo" +msgstr "Logo" + +#: workflows/forms.py:554 +msgid "PDF-Briefkopf" +msgstr "PDF letterhead" + +#: workflows/forms.py:555 +msgid "Favicon" +msgstr "" + +#: workflows/forms.py:556 +#: workflows/templates/workflows/branding_settings.html:89 +#: workflows/templates/workflows/branding_settings.html:162 +msgid "Primärfarbe" +msgstr "Primary color" + +#: workflows/forms.py:557 +#: workflows/templates/workflows/branding_settings.html:90 +#: workflows/templates/workflows/branding_settings.html:163 +msgid "Sekundärfarbe" +msgstr "Secondary color" + +#: workflows/forms.py:605 +#, fuzzy +#| msgid "Firmenname" +msgid "Rechtlicher Firmenname" +msgstr "Company name" + +#: workflows/forms.py:606 +msgid "Straße und Hausnummer" +msgstr "" + +#: workflows/forms.py:607 +msgid "Postleitzahl" +msgstr "" + +#: workflows/forms.py:608 +msgid "Stadt" +msgstr "" + +#: workflows/forms.py:609 +msgid "Land" +msgstr "" + +#: workflows/forms.py:610 workflows/templates/workflows/base_shell.html:64 +msgid "Website" +msgstr "" + +#: workflows/forms.py:611 +msgid "Impressum-URL" +msgstr "" + +#: workflows/forms.py:612 +msgid "Datenschutz-URL" +msgstr "" + +#: workflows/forms.py:613 +msgid "HR-Kontakt" +msgstr "" + +#: workflows/forms.py:614 +msgid "IT-Kontakt" +msgstr "" + +#: workflows/forms.py:615 +#, fuzzy +#| msgid "Operations" +msgid "Operations-Kontakt" +msgstr "Operations" + +#: workflows/forms.py:616 +msgid "Zentrale Telefonnummer" +msgstr "" + +#: workflows/forms.py:617 +msgid "USt-IdNr." +msgstr "" + +#: workflows/forms.py:618 +msgid "Register- oder Handelsnummer" +msgstr "" + +#: workflows/forms.py:635 +msgid "Trial-Modus aktiv" +msgstr "" + +#: workflows/forms.py:636 +msgid "Trial-Beginn" +msgstr "" + +#: workflows/forms.py:637 +msgid "Trial-Ende" +msgstr "" + +#: workflows/forms.py:638 +msgid "Produktive Integrationen begrenzen" +msgstr "" + +#: workflows/forms.py:639 +msgid "Cleanup nach Ablauf zulassen" +msgstr "" + +#: workflows/forms.py:640 +msgid "Banner-Text DE" +msgstr "" + +#: workflows/forms.py:641 +msgid "Banner-Text EN" +msgstr "" + +#: workflows/forms.py:661 +msgid "Bitte ein Trial-Ende festlegen." +msgstr "" + +#: workflows/forms.py:663 +msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." +msgstr "" + +#: workflows/forms.py:802 workflows/forms.py:969 +#, python-format +msgid "Bitte nutzen Sie das Format name@%(domain)s." +msgstr "" + +#: workflows/forms.py:825 workflows/forms.py:984 +#, python-format +msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." +msgstr "" + +#: workflows/forms.py:881 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " "(frühestens %(date)s)." msgstr "" -#: workflows/models.py:55 workflows/views.py:199 -msgid "Eingereicht" +#: workflows/management/commands/cleanup_expired_trial_workspace.py:16 +msgid "Bitte mit --yes-delete bestätigen." +msgstr "" + +#: workflows/management/commands/cleanup_expired_trial_workspace.py:18 +msgid "Kein abgelaufener Trial mit aktiviertem Cleanup gefunden." +msgstr "" + +#: workflows/management/commands/cleanup_expired_trial_workspace.py:21 +msgid "Trial-Workspace bereinigt." +msgstr "" + +#: workflows/management/commands/verify_latest_backup.py:21 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Kein Backup-Bundle vorhanden." +msgstr "No backup bundles available yet." + +#: workflows/management/commands/verify_latest_backup.py:24 +#, python-format +msgid "Kein Backup gefunden. Neues Bundle erstellt: %(name)s" +msgstr "" + +#: workflows/management/commands/verify_latest_backup.py:29 +#, fuzzy, python-format +#| msgid "Backup wird verifiziert" +msgid "Backup erfolgreich verifiziert: %(name)s" +msgstr "Backup is being verified" + +#: workflows/middleware.py:96 +msgid "Zu viele Anfragen. Bitte versuchen Sie es in wenigen Minuten erneut." +msgstr "" + +#: workflows/middleware.py:156 +msgid "" +"Ihre Sitzung ist wegen Inaktivität abgelaufen. Bitte melden Sie sich erneut " +"an." +msgstr "" + +#: workflows/middleware.py:171 +msgid "" +"Bitte bestätigen Sie Ihre Identität erneut, bevor Sie diese sensible Aktion " +"ausführen." +msgstr "" + +#: workflows/models.py:129 +msgid "Info" +msgstr "" + +#: workflows/models.py:130 +#, fuzzy +#| msgid "Eingereicht" +msgid "Erfolg" msgstr "Submitted" -#: workflows/models.py:56 workflows/views.py:200 -msgid "In Bearbeitung" -msgstr "Processing" +#: workflows/models.py:131 +msgid "Warnung" +msgstr "" -#: workflows/models.py:57 workflows/models.py:372 workflows/views.py:201 -msgid "Abgeschlossen" -msgstr "Completed" +#: workflows/models.py:132 workflows/templates/workflows/job_monitor.html:39 +#: workflows/templates/workflows/job_monitor.html:92 +msgid "Fehler" +msgstr "" -#: workflows/models.py:58 workflows/models.py:312 -#: workflows/templates/workflows/backup_recovery.html:70 +#: workflows/models.py:308 workflows/views.py:800 +#, fuzzy +#| msgid "Gesamtbestand" +msgid "Gestartet" +msgstr "Total records" + +#: workflows/models.py:309 workflows/views.py:800 +#, fuzzy +#| msgid "Eingereicht" +msgid "Erfolgreich" +msgstr "Submitted" + +#: workflows/models.py:310 workflows/models.py:363 workflows/models.py:755 +#: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:202 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:493 +#: workflows/views.py:800 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:65 +#: workflows/models.py:360 workflows/views.py:490 +msgid "Eingereicht" +msgstr "Submitted" + +#: workflows/models.py:361 workflows/views.py:491 +msgid "In Bearbeitung" +msgstr "Processing" + +#: workflows/models.py:362 workflows/models.py:815 workflows/views.py:492 +msgid "Abgeschlossen" +msgstr "Completed" + +#: workflows/models.py:370 msgid "Herr" msgstr "" -#: workflows/models.py:65 +#: workflows/models.py:370 msgid "Frau" msgstr "" -#: workflows/models.py:65 +#: workflows/models.py:370 msgid "Divers" msgstr "" -#: workflows/models.py:75 +#: workflows/models.py:380 msgid "befristet" msgstr "" -#: workflows/models.py:75 +#: workflows/models.py:380 msgid "unbefristet" msgstr "" -#: workflows/models.py:138 -#: workflows/templates/workflows/onboarding_intro_session.html:28 -#: workflows/templates/workflows/requests_dashboard.html:145 -msgid "Abteilung" -msgstr "Department" - -#: workflows/models.py:139 +#: workflows/models.py:445 msgid "Geräte" msgstr "" -#: workflows/models.py:140 +#: workflows/models.py:446 msgid "Software" msgstr "" -#: workflows/models.py:141 +#: workflows/models.py:447 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:142 +#: workflows/models.py:448 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:143 +#: workflows/models.py:449 msgid "Ressourcen" msgstr "" -#: workflows/models.py:144 +#: workflows/models.py:450 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:170 +#: workflows/models.py:476 msgid "Automatisch" msgstr "" -#: workflows/models.py:171 workflows/views.py:94 +#: workflows/models.py:477 workflows/views.py:123 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:172 workflows/views.py:95 +#: workflows/models.py:478 workflows/views.py:124 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:173 workflows/views.py:96 +#: workflows/models.py:479 workflows/views.py:125 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:174 workflows/views.py:97 +#: workflows/models.py:480 workflows/views.py:126 workflows/views.py:686 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:177 workflows/models.py:258 -#: workflows/templates/workflows/home.html:62 -#: workflows/templates/workflows/onboarding_form.html:25 -#: workflows/templates/workflows/requests_dashboard.html:68 -#: workflows/templates/workflows/requests_dashboard.html:131 -msgid "Onboarding" -msgstr "Onboarding" +#: workflows/models.py:481 workflows/views.py:684 +#, fuzzy +#| msgid "Mitarbeiter" +msgid "Mitarbeitende" +msgstr "Staff" -#: workflows/models.py:178 workflows/models.py:259 -#: workflows/templates/workflows/home.html:78 -#: workflows/templates/workflows/requests_dashboard.html:78 -#: workflows/templates/workflows/requests_dashboard.html:132 -msgid "Offboarding" -msgstr "Offboarding" +#: workflows/models.py:482 workflows/views.py:685 +msgid "Austritt" +msgstr "" -#: workflows/models.py:216 +#: workflows/models.py:596 +msgid "Text" +msgstr "" + +#: workflows/models.py:597 +msgid "Mehrzeilig" +msgstr "" + +#: workflows/models.py:598 workflows/templates/workflows/welcome_emails.html:80 +msgid "Auswahl" +msgstr "Select" + +#: workflows/models.py:599 +msgid "Checkbox" +msgstr "" + +#: workflows/models.py:659 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:217 +#: workflows/models.py:660 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:218 +#: workflows/models.py:661 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:219 +#: workflows/models.py:662 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:220 +#: workflows/models.py:663 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:221 +#: workflows/models.py:664 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:222 +#: workflows/models.py:665 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:223 +#: workflows/models.py:666 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:224 +#: workflows/models.py:667 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:225 +#: workflows/models.py:668 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:226 +#: workflows/models.py:669 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:262 +#: workflows/models.py:705 msgid "Immer" msgstr "" -#: workflows/models.py:263 workflows/models.py:341 +#: workflows/models.py:706 workflows/models.py:784 msgid "Enthält" msgstr "" -#: workflows/models.py:264 workflows/models.py:342 +#: workflows/models.py:707 workflows/models.py:785 msgid "Ist gleich" msgstr "" -#: workflows/models.py:265 +#: workflows/models.py:708 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:266 +#: workflows/models.py:709 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:308 +#: workflows/models.py:751 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:309 +#: workflows/models.py:752 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:310 +#: workflows/models.py:753 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:311 +#: workflows/models.py:754 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:334 workflows/tasks.py:575 +#: workflows/models.py:777 workflows/tasks.py:628 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:335 workflows/tasks.py:576 +#: workflows/models.py:778 workflows/tasks.py:629 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:336 workflows/tasks.py:577 +#: workflows/models.py:779 workflows/tasks.py:630 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:337 workflows/tasks.py:578 +#: workflows/models.py:780 workflows/tasks.py:631 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:340 +#: workflows/models.py:783 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:343 +#: workflows/models.py:786 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:344 +#: workflows/models.py:787 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:371 +#: workflows/models.py:814 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:391 +#: workflows/models.py:834 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:392 +#: workflows/models.py:835 msgid "S3" msgstr "" -#: workflows/models.py:393 +#: workflows/models.py:836 msgid "NFS" msgstr "" -#: workflows/roles.py:20 +#: workflows/roles.py:26 +msgid "Platform Owner" +msgstr "" + +#: workflows/roles.py:27 workflows/templates/workflows/app_registry.html:110 +#: workflows/templates/workflows/app_registry.html:136 msgid "Super Admin" msgstr "Super Admin" -#: workflows/roles.py:21 +#: workflows/roles.py:28 workflows/templates/workflows/app_registry.html:111 +#: workflows/templates/workflows/app_registry.html:140 msgid "Admin" msgstr "Admin" -#: workflows/roles.py:22 +#: workflows/roles.py:29 workflows/templates/workflows/app_registry.html:112 +#: workflows/templates/workflows/app_registry.html:144 msgid "IT Staff" msgstr "IT Staff" -#: workflows/roles.py:23 +#: workflows/roles.py:30 msgid "Mitarbeiter" msgstr "Staff" -#: workflows/tasks.py:591 +#: workflows/tasks.py:271 +#, fuzzy, python-format +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail gesendet: %(name)s" +msgstr "Welcome Emails" + +#: workflows/tasks.py:273 +#, fuzzy, python-format +#| msgid "Fehlgeschlagen" +msgid "Welcome E-Mail fehlgeschlagen: %(name)s" +msgstr "Failed" + +#: workflows/tasks.py:644 #, python-format msgid "%(item)s übergeben und Grundfunktionen erklärt" msgstr "%(item)s handed over and basic functions explained" -#: workflows/tasks.py:593 +#: workflows/tasks.py:646 #, python-format msgid "%(item)s gezeigt bzw. Nutzung erklärt" msgstr "%(item)s shown or usage explained" -#: workflows/tasks.py:595 +#: workflows/tasks.py:648 #, python-format msgid "Telefonnummer / Direktwahl erklärt: %(value)s" msgstr "Phone number / direct extension explained: %(value)s" -#: workflows/tasks.py:597 +#: workflows/tasks.py:650 msgid "Arbeitsplatz, Geräte und allgemeine Nutzung besprochen" msgstr "Workplace, devices, and general usage reviewed" -#: workflows/tasks.py:599 +#: workflows/tasks.py:652 #, python-format msgid "%(item)s Zugang erklärt" msgstr "%(item)s access explained" -#: workflows/tasks.py:600 +#: workflows/tasks.py:653 #, python-format msgid "%(item)s Gruppe / Berechtigung erläutert" msgstr "%(item)s group / permission explained" -#: workflows/tasks.py:602 +#: workflows/tasks.py:655 #, python-format msgid "Dienstliche E-Mail-Adresse erläutert: %(value)s" msgstr "Work email address explained: %(value)s" -#: workflows/tasks.py:604 +#: workflows/tasks.py:657 #, python-format msgid "Gruppenpostfach erklärt: %(item)s" msgstr "Group mailbox explained: %(item)s" -#: workflows/tasks.py:606 +#: workflows/tasks.py:659 msgid "Zugänge, Konten und Anmeldelogik besprochen" msgstr "Accesses, accounts, and login logic reviewed" -#: workflows/tasks.py:608 +#: workflows/tasks.py:661 #, python-format msgid "%(item)s Einführung durchgeführt" msgstr "%(item)s introduction completed" -#: workflows/tasks.py:609 +#: workflows/tasks.py:662 #, python-format msgid "%(item)s zusätzlich besprochen" msgstr "%(item)s discussed additionally" -#: workflows/tasks.py:611 +#: workflows/tasks.py:664 msgid "Benötigte Standardsoftware und tägliche Nutzung erklärt" msgstr "Required standard software and daily usage explained" -#: workflows/tasks.py:614 +#: workflows/tasks.py:667 msgid "Passwortregeln und sicherer Umgang besprochen" msgstr "Password rules and secure handling reviewed" -#: workflows/tasks.py:615 +#: workflows/tasks.py:668 msgid "Dateiablage, Nextcloud und Freigaben erklärt" msgstr "File storage, Nextcloud, and sharing explained" -#: workflows/tasks.py:616 +#: workflows/tasks.py:669 msgid "Kommunikationswege und Support-Prozess erklärt" msgstr "Communication channels and support process explained" -#: workflows/tasks.py:619 +#: workflows/tasks.py:672 #, python-format msgid "%(item)s als zusätzliche Ausstattung besprochen" msgstr "%(item)s discussed as additional equipment" -#: workflows/tasks.py:621 +#: workflows/tasks.py:674 #, python-format msgid "Zusätzlicher Zugang besprochen: %(item)s" msgstr "Additional access discussed: %(item)s" -#: workflows/tasks.py:623 +#: workflows/tasks.py:676 #, python-format msgid "Übergabe-/Nachfolgekontext besprochen: %(value)s" msgstr "Handover / successor context reviewed: %(value)s" +#: workflows/tasks.py:1367 +#, fuzzy, python-format +#| msgid "Einweisung wurde als abgeschlossen gespeichert." +msgid "Onboarding abgeschlossen: %(name)s" +msgstr "Introduction was saved as completed." + +#: workflows/tasks.py:1368 +msgid "Die Onboarding-Anfrage wurde erfolgreich verarbeitet." +msgstr "" + +#: workflows/tasks.py:1379 +#, fuzzy, python-format +#| msgid "Fehlgeschlagen" +msgid "Onboarding fehlgeschlagen: %(name)s" +msgstr "Failed" + +#: workflows/tasks.py:1468 +#, fuzzy, python-format +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding abgeschlossen: %(name)s" +msgstr "Save offboarding request" + +#: workflows/tasks.py:1469 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Die Offboarding-Anfrage wurde erfolgreich verarbeitet." +msgstr "Save offboarding request" + +#: workflows/tasks.py:1480 +#, fuzzy, python-format +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding fehlgeschlagen: %(name)s" +msgstr "Save offboarding request" + +#: workflows/tasks.py:1555 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Die geplante Welcome E-Mail wurde erfolgreich versendet." +msgstr "Save offboarding request" + #: workflows/templates/registration/login.html:4 #: workflows/templates/registration/login.html:19 #: workflows/templates/workflows/auth/login.html:4 -#: workflows/templates/workflows/auth/login.html:17 +#: workflows/templates/workflows/auth/login.html:27 msgid "Anmeldung" msgstr "Sign in" #: workflows/templates/registration/login.html:20 -#: workflows/templates/workflows/auth/login.html:18 msgid "Bitte melden Sie sich mit Ihrem Benutzerkonto an." msgstr "Please sign in with your user account." #: workflows/templates/registration/login.html:30 -#: workflows/templates/workflows/auth/login.html:28 +#: workflows/templates/workflows/auth/login.html:43 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Anmeldung fehlgeschlagen" msgstr "Failed" #: workflows/templates/registration/login.html:31 -#: workflows/templates/workflows/auth/login.html:29 +#: workflows/templates/workflows/auth/login.html:44 msgid "" "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." msgstr "" #: workflows/templates/registration/login.html:37 -#: workflows/templates/workflows/auth/login.html:35 +#: workflows/templates/workflows/auth/login.html:74 msgid "Anmelden" msgstr "Sign in" @@ -507,14 +1401,17 @@ msgstr "Password saved" msgid "" "Ihr Passwort wurde erfolgreich gesetzt. Sie können sich jetzt mit Ihrem " "Benutzerkonto anmelden." -msgstr "Your password has been set successfully. You can now sign in with your account." +msgstr "" +"Your password has been set successfully. You can now sign in with your " +"account." #: workflows/templates/registration/password_reset_complete.html:19 #: workflows/templates/registration/password_reset_confirm.html:41 #: workflows/templates/registration/password_reset_done.html:19 #: workflows/templates/workflows/auth/password_reset_complete.html:19 -#: workflows/templates/workflows/auth/password_reset_confirm.html:44 +#: workflows/templates/workflows/auth/password_reset_confirm.html:43 #: workflows/templates/workflows/auth/password_reset_done.html:19 +#: workflows/templates/workflows/trial_expired.html:39 msgid "Zur Anmeldung" msgstr "Back to sign in" @@ -541,17 +1438,18 @@ msgid "Bitte prüfen Sie die beiden Passwortfelder und versuchen Sie es erneut." msgstr "Please check both password fields and try again." #: workflows/templates/registration/password_reset_confirm.html:36 -#: workflows/templates/workflows/auth/password_reset_confirm.html:39 +#: workflows/templates/workflows/auth/password_change_form.html:33 +#: workflows/templates/workflows/auth/password_reset_confirm.html:38 msgid "Passwort speichern" msgstr "Save password" #: workflows/templates/registration/password_reset_confirm.html:39 -#: workflows/templates/workflows/auth/password_reset_confirm.html:42 +#: workflows/templates/workflows/auth/password_reset_confirm.html:41 msgid "Link ungültig" msgstr "Invalid link" #: workflows/templates/registration/password_reset_confirm.html:40 -#: workflows/templates/workflows/auth/password_reset_confirm.html:43 +#: workflows/templates/workflows/auth/password_reset_confirm.html:42 msgid "" "Dieser Link ist nicht mehr gültig. Bitte fordern Sie einen neuen Passwort-" "Link an." @@ -569,7 +1467,9 @@ msgstr "Email sent" msgid "" "Wenn ein passendes Konto existiert, wurde ein Passwort-Link an die " "hinterlegte E-Mail-Adresse verschickt." -msgstr "If a matching account exists, a password link has been sent to the stored email address." +msgstr "" +"If a matching account exists, a password link has been sent to the stored " +"email address." #: workflows/templates/registration/password_reset_form.html:4 #: workflows/templates/registration/password_reset_form.html:17 @@ -583,38 +1483,531 @@ msgstr "Reset password" msgid "" "Geben Sie Ihre E-Mail-Adresse ein. Wenn ein Konto vorhanden ist, erhalten " "Sie einen Passwort-Link." -msgstr "Enter your email address. If an account exists, you will receive a password link." +msgstr "" +"Enter your email address. If an account exists, you will receive a password " +"link." #: workflows/templates/registration/password_reset_form.html:24 #: workflows/templates/workflows/auth/password_reset_form.html:25 msgid "Link anfordern" msgstr "Request link" -#: workflows/templates/workflows/audit_log.html:4 -#: workflows/templates/workflows/audit_log.html:15 -#: workflows/templates/workflows/home.html:132 -msgid "Audit Log" +#: workflows/templates/workflows/account_profile.html:4 +#: workflows/templates/workflows/account_profile.html:20 +#: workflows/templates/workflows/includes/app_header.html:99 +msgid "Profil" +msgstr "Profile" + +#: workflows/templates/workflows/account_profile.html:19 +msgid "Konto" +msgstr "Account" + +#: workflows/templates/workflows/account_profile.html:21 +#, fuzzy +#| msgid "" +#| "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." +msgid "Ihre aktuelle Kontoübersicht und wichtige Sicherheitsaktionen." +msgstr "Your current Workdock account overview and important security actions." + +#: workflows/templates/workflows/account_profile.html:23 +#: workflows/templates/workflows/user_management.html:76 +msgid "Letzte Anmeldung" +msgstr "Last login" + +#: workflows/templates/workflows/account_profile.html:54 +msgid "Klicken Sie auf das Bild, um ein neues Profilbild auszuwählen." +msgstr "Click the image to choose a new profile picture." + +#: workflows/templates/workflows/account_profile.html:67 +#: workflows/templates/workflows/account_profile.html:92 +msgid "Kontodaten" +msgstr "Account details" + +#: workflows/templates/workflows/account_profile.html:75 +#: workflows/templates/workflows/account_profile.html:256 +msgid "Sicherheit & Aktionen" +msgstr "Security & actions" + +#: workflows/templates/workflows/account_profile.html:83 +#: workflows/templates/workflows/account_profile.html:178 +#: workflows/templates/workflows/includes/app_header.html:24 +#: workflows/templates/workflows/includes/app_header.html:37 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigungen" +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:93 +msgid "Die wichtigsten Stammdaten Ihres aktuellen Kontos." +msgstr "The most important master data of your current account." + +#: workflows/templates/workflows/account_profile.html:101 +#: workflows/templates/workflows/account_profile.html:187 +#: workflows/templates/workflows/branding_settings.html:32 +#: workflows/templates/workflows/company_config.html:25 +#, fuzzy +#| msgid "In Bearbeitung" +msgid "Bearbeiten" +msgstr "Processing" + +#: workflows/templates/workflows/account_profile.html:108 +#: workflows/templates/workflows/onboarding_intro_session.html:27 +#: workflows/templates/workflows/request_timeline.html:72 +#: workflows/templates/workflows/user_management.html:71 +msgid "Name" +msgstr "Name" + +#: workflows/templates/workflows/account_profile.html:112 +#: workflows/templates/workflows/request_timeline.html:80 +#: workflows/templates/workflows/requests_dashboard.html:190 +#: workflows/templates/workflows/user_management.html:73 +#: workflows/templates/workflows/user_management.html:172 +msgid "E-Mail" +msgstr "Email" + +#: workflows/templates/workflows/account_profile.html:169 +#: workflows/templates/workflows/account_profile.html:248 +#: workflows/templates/workflows/branding_settings.html:177 +#: workflows/templates/workflows/company_config.html:54 +#: workflows/templates/workflows/user_management.html:115 +msgid "Speichern" +msgstr "Save" + +#: workflows/templates/workflows/account_profile.html:170 +#: workflows/templates/workflows/account_profile.html:249 +#: workflows/templates/workflows/account_profile.html:332 +#: workflows/templates/workflows/account_profile.html:386 +#: workflows/templates/workflows/base_shell.html:79 +#: workflows/templates/workflows/branding_settings.html:178 +#: workflows/templates/workflows/company_config.html:55 +#: workflows/templates/workflows/welcome_emails.html:134 +msgid "Abbrechen" +msgstr "Cancel" + +#: workflows/templates/workflows/account_profile.html:179 +msgid "" +"Legen Sie fest, welche Workflow-Ereignisse im Header als Benachrichtigung " +"erscheinen sollen." msgstr "" +#: workflows/templates/workflows/account_profile.html:199 +#: workflows/templates/workflows/account_profile.html:262 +#: workflows/templates/workflows/app_registry.html:35 +#: workflows/templates/workflows/app_registry.html:84 +#: workflows/templates/workflows/form_builder.html:360 +#: workflows/templates/workflows/form_builder.html:371 +#: workflows/templates/workflows/form_builder.html:498 +#: workflows/templates/workflows/form_builder.html:652 +#: workflows/templates/workflows/form_builder.html:801 +#: workflows/templates/workflows/integrations_setup.html:263 +#: workflows/templates/workflows/intro_builder.html:65 +#: workflows/templates/workflows/trial_management.html:28 +#: workflows/templates/workflows/user_management.html:75 +msgid "Aktiv" +msgstr "Active" + +#: workflows/templates/workflows/account_profile.html:199 +#: workflows/templates/workflows/account_profile.html:262 +#, fuzzy +#| msgid "Auf" +msgid "Aus" +msgstr "To" + +#: workflows/templates/workflows/account_profile.html:223 +msgid "Benachrichtigung nach erfolgreich abgeschlossenem Onboarding." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:224 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn ein Onboarding fehlschlägt." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:225 +msgid "Benachrichtigung nach erfolgreich abgeschlossenem Offboarding." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:226 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn ein Offboarding fehlschlägt." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:227 +msgid "Benachrichtigung bei erfolgreicher Backup-Erstellung oder Verifikation." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:228 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn Backup-Aktionen fehlschlagen." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:229 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "" +"Benachrichtigung wenn eine geplante Welcome E-Mail erfolgreich gesendet " +"wurde." +msgstr "Password could not be saved" + +#: workflows/templates/workflows/account_profile.html:230 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn eine geplante Welcome E-Mail fehlschlägt." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:231 +msgid "Hinweise zu Trial-Ablauf, Ablaufdatum oder Deaktivierung." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:232 +msgid "Hinweise aus Systemtests wie SMTP oder Nextcloud." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:261 +msgid "TOTP" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:265 +msgid "Anmeldung wird zusätzlich mit einem zweiten Faktor geschützt." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:267 +msgid "Optional. Kann bei Bedarf direkt unten aktiviert werden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:272 +#: workflows/templates/workflows/account_profile.html:441 +msgid "Recovery-Codes" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:282 +msgid "Einmal-Codes für Notfälle oder verlorene Authenticator-Geräte." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:284 +msgid "Werden automatisch erzeugt, sobald TOTP aktiviert wird." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:293 +msgid "Zwei-Faktor-Authentifizierung" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:294 +msgid "" +"Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es " +"ausgeschaltet." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:301 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP ist aktiviert." +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:302 +msgid "Bestätigt am" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:310 +#, fuzzy +#| msgid "Aktivieren" +msgid "TOTP deaktivieren" +msgstr "Enable" + +#: workflows/templates/workflows/account_profile.html:331 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Deaktivierung bestätigen" +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:340 +#: workflows/templates/workflows/account_profile.html:349 +msgid "Recovery-Codes neu erzeugen" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:341 +msgid "" +"Neue Recovery-Codes sollten nur erzeugt werden, wenn die bisherigen Codes " +"nicht mehr sicher sind." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:368 +msgid "Stattdessen Recovery-Code verwenden" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:385 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Erzeugung bestätigen" +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:397 +#, fuzzy +#| msgid "Onboarding starten" +msgid "Manueller Schlüssel" +msgstr "Start onboarding" + +#: workflows/templates/workflows/account_profile.html:398 +msgid "Nur bei Bedarf anzeigen" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:406 +msgid "Manuellen Schlüssel anzeigen oder ausblenden" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:416 +msgid "" +"Scannen Sie den QR-Code mit Ihrer Authenticator-App. Den manuellen Schlüssel " +"können Sie bei Bedarf einblenden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:432 +#, fuzzy +#| msgid "Aktivieren" +msgid "TOTP aktivieren" +msgstr "Enable" + +#: workflows/templates/workflows/account_profile.html:442 +msgid "" +"Diese Codes werden nur jetzt im Klartext angezeigt. Jeden Code können Sie " +"genau einmal verwenden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:445 +msgid "Herunterladen" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:3 +#, fuzzy +#| msgid "Letzte Änderung" +msgid "Ungespeicherte Änderungen" +msgstr "Last updated" + +#: workflows/templates/workflows/app_registry.html:4 +#: workflows/templates/workflows/app_registry.html:103 +#: workflows/templates/workflows/form_builder.html:626 +#: workflows/templates/workflows/form_builder.html:667 +#: workflows/templates/workflows/form_builder.html:730 +#: workflows/templates/workflows/form_builder.html:791 +#: workflows/templates/workflows/form_builder.html:824 +#: workflows/templates/workflows/intro_builder.html:58 +msgid "Sortierung" +msgstr "Sort order" + +#: workflows/templates/workflows/app_registry.html:15 +msgid "" +"Apps zentral steuern, für Kunden vorbereiten und ohne Template-Eingriffe auf " +"der Landing Page ausspielen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:21 +msgid "" +"Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier " +"steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:22 +#, fuzzy +#| msgid "Produktion" +msgid "Produktkern" +msgstr "Production" + +#: workflows/templates/workflows/app_registry.html:29 +#, fuzzy +#| msgid "Nach Name oder E-Mail suchen" +msgid "Nach App-Name oder Key filtern" +msgstr "Search by name or email" + +#: workflows/templates/workflows/app_registry.html:34 +#: workflows/templates/workflows/app_registry.html:43 +#: workflows/templates/workflows/audit_log.html:25 +#: workflows/templates/workflows/job_monitor.html:61 +#: workflows/templates/workflows/job_monitor.html:70 +#: workflows/templates/workflows/requests_dashboard.html:130 +#: workflows/templates/workflows/requests_dashboard.html:138 +#: workflows/templates/workflows/requests_dashboard.html:147 +msgid "Alle" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:36 +#: workflows/templates/workflows/app_registry.html:86 +#: workflows/templates/workflows/backup_recovery.html:106 +#: workflows/templates/workflows/trial_management.html:30 +#: workflows/templates/workflows/trial_management.html:43 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Deaktiviert" +msgstr "Disabled" + +#: workflows/templates/workflows/app_registry.html:37 +#: workflows/templates/workflows/app_registry.html:105 +msgid "Platform only" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:41 +#: workflows/templates/workflows/app_registry.html:158 +#, fuzzy +#| msgid "Eingereicht" +msgid "Bereich" +msgstr "Submitted" + +#: workflows/templates/workflows/app_registry.html:50 +msgid "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:59 +msgid "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:61 +msgid "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:63 +msgid "" +"Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:79 +#, fuzzy +#| msgid "Ziehen zum Sortieren" +msgid "Ziehen zum Umordnen" +msgstr "Drag to reorder" + +#: workflows/templates/workflows/app_registry.html:91 +msgid "Empfohlener Standardzugriff:" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:113 +#: workflows/templates/workflows/app_registry.html:148 +#, fuzzy +#| msgid "IT Staff" +msgid "Staff" +msgstr "IT Staff" + +#: workflows/templates/workflows/app_registry.html:121 +#, fuzzy +#| msgid "Noch nicht verfügbar" +msgid "Verfügbarkeit" +msgstr "Not available yet" + +#: workflows/templates/workflows/app_registry.html:125 +#, fuzzy +#| msgid "Deaktivieren" +msgid "App aktiviert" +msgstr "Disabled" + +#: workflows/templates/workflows/app_registry.html:128 +msgid "" +"Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen " +"sie sehen dürften." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:132 +msgid "Sichtbarkeit nach Rolle" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:151 +msgid "" +"Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform " +"sichtbar." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:155 +#, fuzzy +#| msgid "Sortierung" +msgid "Platzierung" +msgstr "Sort order" + +#: workflows/templates/workflows/app_registry.html:166 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Reihenfolge" +msgstr "Save order" + +#: workflows/templates/workflows/app_registry.html:176 +msgid "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:182 +msgid "Bezeichnungen & Texte" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:185 +#: workflows/templates/workflows/branding_settings.html:40 +#: workflows/templates/workflows/branding_settings.html:111 +#: workflows/templates/workflows/trial_management.html:105 +msgid "Deutsch" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:187 +msgid "Titel" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:191 +msgid "Beschreibung" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:195 +#, fuzzy +#| msgid "Aktionen" +msgid "Aktionslabel" +msgstr "Actions" + +#: workflows/templates/workflows/app_registry.html:200 +#: workflows/templates/workflows/branding_settings.html:49 +#: workflows/templates/workflows/branding_settings.html:121 +#: workflows/templates/workflows/trial_management.html:112 +#, fuzzy +#| msgid "English label" +msgid "English" +msgstr "English label" + +#: workflows/templates/workflows/app_registry.html:202 +msgid "Title" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:206 +msgid "Description" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:210 +#, fuzzy +#| msgid "Aktionen" +msgid "Action label" +msgstr "Actions" + +#: workflows/templates/workflows/app_registry.html:226 +msgid "" +"Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter " +"Apps oder Admin Apps einordnen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:227 +msgid "Keine ungespeicherten Änderungen" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:229 +#, fuzzy +#| msgid "Regeln speichern" +msgid "App Registry speichern" +msgstr "Save rules" + #: workflows/templates/workflows/audit_log.html:16 msgid "Nachvollziehbarkeit aller wichtigen Admin-Aktionen im Portal." msgstr "" #: workflows/templates/workflows/audit_log.html:23 #: workflows/templates/workflows/audit_log.html:54 -#: workflows/templates/workflows/backup_recovery.html:43 +#: workflows/templates/workflows/backup_recovery.html:75 #: workflows/templates/workflows/requests_dashboard.html:193 +#: workflows/templates/workflows/user_management.html:156 #: workflows/templates/workflows/welcome_emails.html:87 msgid "Aktion" msgstr "Action" -#: workflows/templates/workflows/audit_log.html:25 -#: workflows/templates/workflows/requests_dashboard.html:130 -#: workflows/templates/workflows/requests_dashboard.html:138 -#: workflows/templates/workflows/requests_dashboard.html:147 -msgid "Alle" -msgstr "" - #: workflows/templates/workflows/audit_log.html:32 #: workflows/templates/workflows/audit_log.html:53 msgid "Nutzer" @@ -635,6 +2028,7 @@ msgid "Bis Datum" msgstr "" #: workflows/templates/workflows/audit_log.html:44 +#: workflows/templates/workflows/job_monitor.html:77 msgid "Filtern" msgstr "" @@ -644,21 +2038,27 @@ msgid "Zurücksetzen" msgstr "Reset" #: workflows/templates/workflows/audit_log.html:52 +#: workflows/templates/workflows/user_management.html:155 msgid "Zeit" msgstr "" #: workflows/templates/workflows/audit_log.html:55 -#: workflows/templates/workflows/request_timeline.html:62 +#: workflows/templates/workflows/form_builder.html:722 +#: workflows/templates/workflows/form_builder.html:816 +#: workflows/templates/workflows/request_timeline.html:68 #: workflows/templates/workflows/requests_dashboard.html:128 #: workflows/templates/workflows/requests_dashboard.html:188 msgid "Typ" msgstr "Type" #: workflows/templates/workflows/audit_log.html:56 +#: workflows/templates/workflows/job_monitor.html:38 +#: workflows/templates/workflows/job_monitor.html:90 msgid "Ziel" msgstr "" #: workflows/templates/workflows/audit_log.html:57 +#: workflows/templates/workflows/user_management.html:159 msgid "Details" msgstr "" @@ -686,11 +2086,71 @@ msgstr "" msgid "Noch keine Audit-Einträge vorhanden." msgstr "No requests available yet." -#: workflows/templates/workflows/backup_recovery.html:4 -#: workflows/templates/workflows/backup_recovery.html:12 -#: workflows/templates/workflows/home.html:139 -msgid "Backup & Recovery" -msgstr "Backup & Recovery" +#: workflows/templates/workflows/auth/login.html:18 +msgid "Zwei-Faktor-Prüfung" +msgstr "" + +#: workflows/templates/workflows/auth/login.html:19 +msgid "Geben Sie Ihren TOTP-Code ein, um die Anmeldung abzuschließen." +msgstr "" + +#: workflows/templates/workflows/auth/login.html:40 +#, fuzzy +#| msgid "Link ungültig" +msgid "Code ungültig" +msgstr "Invalid link" + +#: workflows/templates/workflows/auth/login.html:41 +msgid "" +"Der eingegebene TOTP- oder Recovery-Code ist nicht korrekt. Bitte versuchen " +"Sie es erneut." +msgstr "" + +#: workflows/templates/workflows/auth/login.html:60 +msgid "Recovery-Code verwenden" +msgstr "" + +#: workflows/templates/workflows/auth/login.html:66 +msgid "Nutzen Sie stattdessen einen einmaligen Recovery-Code." +msgstr "" + +#: workflows/templates/workflows/auth/login.html:69 +msgid "Code prüfen" +msgstr "" + +#: workflows/templates/workflows/auth/login.html:70 +#, fuzzy +#| msgid "Zur Anmeldung" +msgid "Zurück zur Anmeldung" +msgstr "Back to sign in" + +#: workflows/templates/workflows/auth/password_change_done.html:4 +#: workflows/templates/workflows/auth/password_change_done.html:17 +#: workflows/templates/workflows/user_management.html:174 +#, fuzzy +#| msgid "Passwort gespeichert" +msgid "Passwort geändert" +msgstr "Password saved" + +#: workflows/templates/workflows/auth/password_change_done.html:18 +msgid "Ihr Passwort wurde erfolgreich aktualisiert." +msgstr "" + +#: workflows/templates/workflows/auth/password_change_done.html:19 +msgid "Zum Profil" +msgstr "" + +#: workflows/templates/workflows/auth/password_change_form.html:4 +#: workflows/templates/workflows/auth/password_change_form.html:17 +#: workflows/templates/workflows/includes/app_header.html:100 +msgid "Passwort ändern" +msgstr "Change password" + +#: workflows/templates/workflows/auth/password_change_form.html:18 +#, fuzzy +#| msgid "Bitte vergeben Sie jetzt ein neues Passwort für Ihr Konto." +msgid "Vergeben Sie ein neues Passwort für Ihr Konto." +msgstr "Please set a new password for your account now." #: workflows/templates/workflows/backup_recovery.html:13 msgid "" @@ -699,11 +2159,24 @@ msgid "" msgstr "Create database and media backups and verify existing bundles safely." #: workflows/templates/workflows/backup_recovery.html:20 +#: workflows/templates/workflows/home.html:65 +#, fuzzy +#| msgid "Trial-Status" +msgid "Backup-Status" +msgstr "Trial status" + +#: workflows/templates/workflows/backup_recovery.html:37 +#, fuzzy +#| msgid "Backup jetzt verifizieren?" +msgid "Zuletzt verifiziert:" +msgstr "Verify backup now?" + +#: workflows/templates/workflows/backup_recovery.html:46 #: workflows/templates/workflows/user_management.html:78 msgid "Aktionen" msgstr "Actions" -#: workflows/templates/workflows/backup_recovery.html:21 +#: workflows/templates/workflows/backup_recovery.html:47 msgid "" "Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst " "CLI-only." @@ -711,280 +2184,842 @@ msgstr "" "Creation and verification run inside the app context. Restore intentionally " "remains CLI-only." -#: workflows/templates/workflows/backup_recovery.html:23 +#: workflows/templates/workflows/backup_recovery.html:49 msgid "Neues Backup jetzt erstellen?" msgstr "Create a new backup now?" -#: workflows/templates/workflows/backup_recovery.html:23 +#: workflows/templates/workflows/backup_recovery.html:49 msgid "Backup wird erstellt" msgstr "Backup is being created" -#: workflows/templates/workflows/backup_recovery.html:23 +#: workflows/templates/workflows/backup_recovery.html:49 msgid "Bitte warten. Datenbank- und Media-Bundle werden gerade vorbereitet." msgstr "Please wait. The database and media bundle are being prepared." -#: workflows/templates/workflows/backup_recovery.html:25 +#: workflows/templates/workflows/backup_recovery.html:51 msgid "Backup erstellen" msgstr "Create backup" -#: workflows/templates/workflows/backup_recovery.html:31 +#: workflows/templates/workflows/backup_recovery.html:57 +#, fuzzy +#| msgid "Aktion" +msgid "Automation" +msgstr "Action" + +#: workflows/templates/workflows/backup_recovery.html:58 +msgid "Für einen geplanten Verify-Run außerhalb der UI:" +msgstr "" + +#: workflows/templates/workflows/backup_recovery.html:63 msgid "Verfügbare Backup-Bundles" msgstr "Available backup bundles" -#: workflows/templates/workflows/backup_recovery.html:37 +#: workflows/templates/workflows/backup_recovery.html:69 msgid "Bundle" msgstr "Bundle" -#: workflows/templates/workflows/backup_recovery.html:38 +#: workflows/templates/workflows/backup_recovery.html:70 msgid "Erstellt" msgstr "Created" -#: workflows/templates/workflows/backup_recovery.html:39 -#: workflows/templates/workflows/backup_recovery.html:54 +#: workflows/templates/workflows/backup_recovery.html:71 +#: workflows/templates/workflows/backup_recovery.html:86 msgid "Verifiziert" msgstr "Verified" -#: workflows/templates/workflows/backup_recovery.html:40 -#: workflows/templates/workflows/home.html:99 -#: workflows/templates/workflows/onboarding_intro_session.html:37 -#: workflows/templates/workflows/request_timeline.html:70 -#: workflows/templates/workflows/requests_dashboard.html:136 -#: workflows/templates/workflows/welcome_emails.html:85 -msgid "Status" -msgstr "Status" - -#: workflows/templates/workflows/backup_recovery.html:41 +#: workflows/templates/workflows/backup_recovery.html:73 msgid "Inhalt" msgstr "Contents" -#: workflows/templates/workflows/backup_recovery.html:42 +#: workflows/templates/workflows/backup_recovery.html:74 msgid "Remote" msgstr "Remote" -#: workflows/templates/workflows/backup_recovery.html:56 +#: workflows/templates/workflows/backup_recovery.html:88 msgid "Nicht geprüft" msgstr "Not verified" -#: workflows/templates/workflows/backup_recovery.html:68 +#: workflows/templates/workflows/backup_recovery.html:100 msgid "Hochgeladen" msgstr "Uploaded" -#: workflows/templates/workflows/backup_recovery.html:72 +#: workflows/templates/workflows/backup_recovery.html:104 msgid "Vorbereitet" msgstr "Prepared" -#: workflows/templates/workflows/backup_recovery.html:74 -#, fuzzy -#| msgid "Deaktivieren" -msgid "Deaktiviert" -msgstr "Disabled" - -#: workflows/templates/workflows/backup_recovery.html:76 +#: workflows/templates/workflows/backup_recovery.html:108 msgid "Lokal" msgstr "Local" -#: workflows/templates/workflows/backup_recovery.html:79 +#: workflows/templates/workflows/backup_recovery.html:111 msgid "Lokal gespeichert" msgstr "Stored locally" -#: workflows/templates/workflows/backup_recovery.html:81 +#: workflows/templates/workflows/backup_recovery.html:113 msgid "Lokal nicht vorhanden" msgstr "Not stored locally" -#: workflows/templates/workflows/backup_recovery.html:95 +#: workflows/templates/workflows/backup_recovery.html:127 msgid "Backup jetzt verifizieren?" msgstr "Verify backup now?" -#: workflows/templates/workflows/backup_recovery.html:95 +#: workflows/templates/workflows/backup_recovery.html:127 msgid "Backup wird verifiziert" msgstr "Backup is being verified" -#: workflows/templates/workflows/backup_recovery.html:95 +#: workflows/templates/workflows/backup_recovery.html:127 msgid "Bitte warten. Bundle, Datenbank-Dump und Media-Archiv werden geprüft." msgstr "" "Please wait. The bundle, database dump, and media archive are being checked." -#: workflows/templates/workflows/backup_recovery.html:97 +#: workflows/templates/workflows/backup_recovery.html:129 msgid "Verifizieren" msgstr "Verify" -#: workflows/templates/workflows/backup_recovery.html:99 +#: workflows/templates/workflows/backup_recovery.html:131 msgid "Backup-Bundle wirklich löschen?" msgstr "Delete this backup bundle?" -#: workflows/templates/workflows/backup_recovery.html:101 -#: workflows/templates/workflows/form_builder.html:92 -#: workflows/templates/workflows/form_builder.html:107 +#: workflows/templates/workflows/backup_recovery.html:133 +#: workflows/templates/workflows/form_builder.html:500 +#: workflows/templates/workflows/form_builder.html:654 +#: workflows/templates/workflows/form_builder.html:804 #: workflows/templates/workflows/integrations_setup.html:265 #: workflows/templates/workflows/intro_builder.html:66 #: workflows/templates/workflows/intro_builder.html:102 -#: workflows/templates/workflows/requests_dashboard.html:286 +#: workflows/templates/workflows/requests_dashboard.html:288 #: workflows/templates/workflows/user_management.html:127 #: workflows/templates/workflows/welcome_emails.html:70 msgid "Löschen" msgstr "Delete" -#: workflows/templates/workflows/backup_recovery.html:111 +#: workflows/templates/workflows/backup_recovery.html:143 #, fuzzy #| msgid "Noch keine Vorgänge vorhanden." msgid "Noch keine Backup-Bundles vorhanden." msgstr "No backup bundles available yet." -#: workflows/templates/workflows/base_shell.html:24 -msgid "Bitte bestätigen" +#: workflows/templates/workflows/base_shell.html:21 +#: workflows/templates/workflows/trial_expired.html:4 +#: workflows/templates/workflows/trial_expired.html:15 +msgid "Trial abgelaufen" +msgstr "Trial expired" + +#: workflows/templates/workflows/base_shell.html:21 +msgid "Trial-Modus" +msgstr "Trial mode" + +#: workflows/templates/workflows/base_shell.html:26 +msgid "Zugriff für Testnutzer gesperrt" msgstr "" #: workflows/templates/workflows/base_shell.html:28 -#: workflows/templates/workflows/welcome_emails.html:134 -msgid "Abbrechen" -msgstr "Cancel" - -#: workflows/templates/workflows/base_shell.html:29 -msgid "Bestätigen" +msgid "Kontrollierte Testumgebung aktiv" msgstr "" #: workflows/templates/workflows/base_shell.html:36 +#, python-format +msgid "Diese Testumgebung ist seit %(expires)s abgelaufen." +msgstr "This trial environment has been expired since %(expires)s." + +#: workflows/templates/workflows/base_shell.html:38 +#, python-format +msgid "Diese Testumgebung ist bis %(expires)s aktiv." +msgstr "This trial environment is active until %(expires)s." + +#: workflows/templates/workflows/base_shell.html:41 +msgid "Diese Umgebung läuft im Trial-Modus." +msgstr "This environment is running in trial mode." + +#: workflows/templates/workflows/base_shell.html:47 +#: workflows/templates/workflows/trial_management.html:35 +msgid "Ende" +msgstr "End" + +#: workflows/templates/workflows/base_shell.html:65 +msgid "Impressum" +msgstr "" + +#: workflows/templates/workflows/base_shell.html:66 +msgid "Datenschutz" +msgstr "" + +#: workflows/templates/workflows/base_shell.html:75 +msgid "Bitte bestätigen" +msgstr "" + +#: workflows/templates/workflows/base_shell.html:80 +msgid "Bestätigen" +msgstr "" + +#: workflows/templates/workflows/base_shell.html:87 msgid "Bitte warten" msgstr "Please wait" -#: workflows/templates/workflows/base_shell.html:37 +#: workflows/templates/workflows/base_shell.html:88 msgid "Aktion läuft" msgstr "Action in progress" -#: workflows/templates/workflows/base_shell.html:38 +#: workflows/templates/workflows/base_shell.html:89 msgid "Die Aktion wird im aktuellen Tab ausgeführt." msgstr "The action is running in the current tab." -#: workflows/templates/workflows/form_builder.html:4 -#: workflows/templates/workflows/form_builder.html:14 -#: workflows/templates/workflows/home.html:153 -msgid "Form Builder" -msgstr "Form Builder" +#: workflows/templates/workflows/branding_settings.html:13 +msgid "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." +msgstr "" +"Manage portal name, company branding, logo, and PDF letterhead centrally." -#: workflows/templates/workflows/form_builder.html:15 -msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." -msgstr "Sort fields by drag and drop and group them by step." +#: workflows/templates/workflows/branding_settings.html:66 +#: workflows/templates/workflows/branding_settings.html:144 +msgid "öffnen" +msgstr "open" -#: workflows/templates/workflows/form_builder.html:29 +#: workflows/templates/workflows/branding_settings.html:140 +msgid "Aktuelles Logo:" +msgstr "Current logo:" + +#: workflows/templates/workflows/branding_settings.html:141 +#, fuzzy +#| msgid "Aktuelles Logo:" +msgid "Aktuelles Favicon:" +msgstr "Current logo:" + +#: workflows/templates/workflows/branding_settings.html:142 +msgid "Aktueller Briefkopf:" +msgstr "Current letterhead:" + +#: workflows/templates/workflows/branding_settings.html:185 +#, fuzzy +#| msgid "" +#| "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " +#| "hochgeladen werden." +msgid "" +"Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte " +"geändert oder Dateien hochgeladen werden." +msgstr "" +"TUBCO remains the default until values are changed or files are uploaded " +"here." + +#: workflows/templates/workflows/company_config.html:13 +msgid "" +"Strukturierte Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks " +"zentral pflegen." +msgstr "" + +#: workflows/templates/workflows/company_config.html:62 +msgid "" +"Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte " +"Firmendaten, nicht um visuelle Gestaltung." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:17 +msgid "Arbeitsbereich" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:18 +msgid "Formularsteuerung" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:21 +msgid "Builder Navigation" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:23 +#: workflows/templates/workflows/form_builder.html:85 workflows/views.py:2996 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Struktur & Reihenfolge" +msgstr "Save order" + +#: workflows/templates/workflows/form_builder.html:24 +#: workflows/templates/workflows/form_builder.html:92 +#: workflows/templates/workflows/form_builder.html:156 +#: workflows/templates/workflows/form_builder.html:173 workflows/views.py:2997 +#, fuzzy +#| msgid "Abschnitt" +msgid "Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:27 +#: workflows/templates/workflows/form_builder.html:146 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbarkeit & Regeln" +msgstr "Safety rules" + +#: workflows/templates/workflows/form_builder.html:28 +#: workflows/templates/workflows/form_builder.html:150 +#, fuzzy +#| msgid "Ausgeblendet" +msgid "ausgeblendet" +msgstr "Hidden" + +#: workflows/templates/workflows/form_builder.html:31 +#: workflows/templates/workflows/form_builder.html:433 +#, fuzzy +#| msgid "Optionen verwalten" +msgid "Optionen & Texte" +msgstr "Manage options" + +#: workflows/templates/workflows/form_builder.html:32 +#: workflows/templates/workflows/form_builder.html:47 +#: workflows/templates/workflows/form_builder.html:437 +#: workflows/templates/workflows/form_builder.html:692 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "eigene Felder" +msgstr "Save field text" + +#: workflows/templates/workflows/form_builder.html:39 +msgid "konfigurierbare Felder" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:43 +#: workflows/templates/workflows/form_builder.html:439 +#: workflows/templates/workflows/form_builder.html:604 +#, fuzzy +#| msgid "Abschnitt" +msgid "eigene Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:55 +msgid "Deployment Configuration" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:73 msgid "Reihenfolge speichern" msgstr "Save order" -#: workflows/templates/workflows/form_builder.html:46 +#: workflows/templates/workflows/form_builder.html:86 +msgid "" +"Ordnen Sie Abschnitte und Felder in der Reihenfolge, in der sie im Formular " +"erscheinen sollen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:98 +#: workflows/templates/workflows/form_builder.html:111 +#: workflows/templates/workflows/form_builder.html:197 +#: workflows/templates/workflows/form_builder.html:248 +#: workflows/templates/workflows/form_builder.html:259 +#: workflows/templates/workflows/form_builder.html:546 +#: workflows/templates/workflows/form_builder.html:767 +#: workflows/templates/workflows/form_builder.html:778 +#: workflows/templates/workflows/form_builder.html:882 +#, fuzzy, python-format +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "%(count)s Feld/Felder" +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:113 +#, fuzzy +#| msgid "öffnen" +msgid "Geöffnet" +msgstr "open" + +#: workflows/templates/workflows/form_builder.html:123 +msgid "Eigen" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:124 +#: workflows/templates/workflows/form_builder.html:213 +#: workflows/templates/workflows/form_builder.html:272 msgid "Fix" msgstr "Fixed" -#: workflows/templates/workflows/form_builder.html:47 +#: workflows/templates/workflows/form_builder.html:125 +#: workflows/templates/workflows/form_builder.html:215 +#: workflows/templates/workflows/form_builder.html:274 msgid "Ausgeblendet" msgstr "Hidden" -#: workflows/templates/workflows/form_builder.html:48 +#: workflows/templates/workflows/form_builder.html:126 +#: workflows/templates/workflows/form_builder.html:276 +#: workflows/templates/workflows/form_builder.html:289 +#: workflows/templates/workflows/form_builder.html:292 +#: workflows/templates/workflows/form_builder.html:797 msgid "Pflicht" msgstr "Required" -#: workflows/templates/workflows/form_builder.html:59 -msgid "Optionen verwalten" -msgstr "Manage options" +#: workflows/templates/workflows/form_builder.html:130 +#, fuzzy +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "Noch keine Felder in diesem Abschnitt." +msgstr "No configured fields in this step." -#: workflows/templates/workflows/form_builder.html:62 +#: workflows/templates/workflows/form_builder.html:147 +msgid "" +"Legen Sie fest, welche Teile sichtbar, erforderlich oder regelgesteuert sein " +"sollen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:151 +#, fuzzy +#| msgid "Abschnitt" +msgid "versteckte Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:155 +#, fuzzy +#| msgid "Regelname" +msgid "Regelmodule" +msgstr "Rule name" + +#: workflows/templates/workflows/form_builder.html:157 workflows/views.py:2998 +#, fuzzy +#| msgid "Feldtexte verwalten" +msgid "Feldregeln" +msgstr "Manage field text" + +#: workflows/templates/workflows/form_builder.html:159 +#: workflows/templates/workflows/form_builder.html:318 workflows/views.py:2999 +msgid "Bedingte Logik" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:169 +#, fuzzy +#| msgid "Abschnitt" +msgid "Abschnitte steuern" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:170 +msgid "Reihenfolge und Sichtbarkeit der Formularabschnitte." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:187 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Nach oben" +msgstr "Save order" + +#: workflows/templates/workflows/form_builder.html:190 +msgid "Nach unten" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:199 +#, fuzzy +#| msgid "Abschnitt" +msgid "Fixer Abschnitt" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:214 +#: workflows/templates/workflows/form_builder.html:285 +msgid "Sichtbar" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:222 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Abschnittsregeln speichern" +msgstr "Save rules" + +#: workflows/templates/workflows/form_builder.html:233 +#, fuzzy +#| msgid "Feldtexte verwalten" +msgid "Feldregeln verwalten" +msgstr "Manage field text" + +#: workflows/templates/workflows/form_builder.html:234 +msgid "Steuern Sie Sichtbarkeit und Pflichtstatus für einzelne Felder." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:237 +msgid "konfigurierbar" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:243 +#, fuzzy +#| msgid "Abschnitt" +msgid "Feldregel-Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:278 +msgid "Flexibel" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:293 +#: workflows/templates/workflows/user_management.html:109 +msgid "Optional" +msgstr "Optional" + +#: workflows/templates/workflows/form_builder.html:299 +#, fuzzy +#| msgid "Keine Feldkonfigurationen verfügbar." +msgid "Keine Feldregeln verfügbar." +msgstr "No field configurations available." + +#: workflows/templates/workflows/form_builder.html:307 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Feldregeln speichern" +msgstr "Save rules" + +#: workflows/templates/workflows/form_builder.html:319 +msgid "Lassen Sie Felder abhängig von anderen Antworten ein- oder ausblenden." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:322 +#, fuzzy +#| msgid "Regelname" +msgid "Regeln" +msgstr "Rule name" + +#: workflows/templates/workflows/form_builder.html:328 +#, fuzzy +#| msgid "Branding speichern" +msgid "Bedingte Regeln" +msgstr "Save branding" + +#: workflows/templates/workflows/form_builder.html:333 +#, fuzzy, python-format +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "%(count)s Ziel-Feld/Felder" +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:345 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbarkeit" +msgstr "Safety rules" + +#: workflows/templates/workflows/form_builder.html:349 +msgid "Steuert" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:354 +#, fuzzy +#| msgid "Keine geplanten Welcome E-Mails vorhanden." +msgid "Keine Ziel-Felder." +msgstr "No scheduled welcome emails available." + +#: workflows/templates/workflows/form_builder.html:366 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbar, wenn" +msgstr "Safety rules" + +#: workflows/templates/workflows/form_builder.html:371 +#, fuzzy +#| msgid "inaktiv" +msgid "Inaktiv" +msgstr "inactive" + +#: workflows/templates/workflows/form_builder.html:380 +msgid "Zeige dieses Element, wenn" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:382 +#: workflows/templates/workflows/form_builder.html:399 +msgid "Keine" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:392 +#: workflows/templates/workflows/form_builder.html:409 +#: workflows/templates/workflows/form_builder.html:513 +#: workflows/templates/workflows/intro_builder.html:64 +msgid "Wert" +msgstr "Value" + +#: workflows/templates/workflows/form_builder.html:395 +#, fuzzy +#| msgid "Zusätzlicher Zugang besprochen: %(item)s" +msgid "Zusätzliche Bedingung" +msgstr "Additional access discussed: %(item)s" + +#: workflows/templates/workflows/form_builder.html:397 +msgid "Und zusätzlich" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:419 +#, fuzzy +#| msgid "Branding speichern" +msgid "Bedingte Logik speichern" +msgstr "Save branding" + +#: workflows/templates/workflows/form_builder.html:434 +msgid "" +"Pflegen Sie Auswahlwerte, Feldtexte und benutzerdefinierte Erweiterungen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:444 +#, fuzzy +#| msgid "Inhalt" +msgid "Inhaltsmodule" +msgstr "Contents" + +#: workflows/templates/workflows/form_builder.html:445 workflows/views.py:3000 +#, fuzzy +#| msgid "Aktion" +msgid "Optionen" +msgstr "Action" + +#: workflows/templates/workflows/form_builder.html:446 workflows/views.py:3001 +#, fuzzy +#| msgid "Feldtexte verwalten" +msgid "Feldtexte" +msgstr "Manage field text" + +#: workflows/templates/workflows/form_builder.html:448 +#: workflows/templates/workflows/form_builder.html:600 workflows/views.py:3002 +#, fuzzy +#| msgid "Abschnitt" +msgid "Eigene Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:450 +#: workflows/templates/workflows/form_builder.html:688 workflows/views.py:3003 +msgid "Eigene Felder" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:451 workflows/views.py:3004 +msgid "Vorschau" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:462 msgid "Kategorie" msgstr "Category" -#: workflows/templates/workflows/form_builder.html:75 -#: workflows/templates/workflows/form_builder.html:88 -#: workflows/templates/workflows/form_builder.html:133 +#: workflows/templates/workflows/form_builder.html:475 +#: workflows/templates/workflows/form_builder.html:505 +#: workflows/templates/workflows/form_builder.html:565 +#: workflows/templates/workflows/form_builder.html:706 +#: workflows/templates/workflows/form_builder.html:828 msgid "Label (DE)" msgstr "Label (DE)" -#: workflows/templates/workflows/form_builder.html:76 +#: workflows/templates/workflows/form_builder.html:476 msgid "Label (EN, optional)" msgstr "Label (EN, optional)" -#: workflows/templates/workflows/form_builder.html:77 +#: workflows/templates/workflows/form_builder.html:477 msgid "Technischer Wert (optional)" msgstr "Technical value (optional)" -#: workflows/templates/workflows/form_builder.html:78 +#: workflows/templates/workflows/form_builder.html:478 msgid "Option hinzufügen" msgstr "Add option" -#: workflows/templates/workflows/form_builder.html:87 -#: workflows/templates/workflows/intro_builder.html:58 -msgid "Sortierung" -msgstr "Sort order" - -#: workflows/templates/workflows/form_builder.html:89 -#: workflows/templates/workflows/form_builder.html:134 -msgid "Label (EN)" -msgstr "Label (EN)" - -#: workflows/templates/workflows/form_builder.html:91 -#: workflows/templates/workflows/integrations_setup.html:263 -#: workflows/templates/workflows/intro_builder.html:65 -#: workflows/templates/workflows/user_management.html:75 -msgid "Aktiv" -msgstr "Active" - -#: workflows/templates/workflows/form_builder.html:100 +#: workflows/templates/workflows/form_builder.html:489 msgid "Ziehen zum Sortieren" msgstr "Drag to reorder" -#: workflows/templates/workflows/form_builder.html:107 +#: workflows/templates/workflows/form_builder.html:500 msgid "Option wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/form_builder.html:111 +#: workflows/templates/workflows/form_builder.html:509 +#: workflows/templates/workflows/form_builder.html:569 +#: workflows/templates/workflows/form_builder.html:710 +#: workflows/templates/workflows/form_builder.html:832 +msgid "Label (EN)" +msgstr "Label (EN)" + +#: workflows/templates/workflows/form_builder.html:519 msgid "Keine Optionen in dieser Kategorie." msgstr "No options in this category." -#: workflows/templates/workflows/form_builder.html:117 +#: workflows/templates/workflows/form_builder.html:523 msgid "Optionen speichern" msgstr "Save options" -#: workflows/templates/workflows/form_builder.html:124 +#: workflows/templates/workflows/form_builder.html:534 msgid "Feldtexte verwalten" msgstr "Manage field text" -#: workflows/templates/workflows/form_builder.html:132 -msgid "Feld" -msgstr "Field" +#: workflows/templates/workflows/form_builder.html:535 +msgid "Überschreiben Sie Labels und Hilfetexte pro Feld." +msgstr "" -#: workflows/templates/workflows/form_builder.html:135 -msgid "Hilfetext (DE)" -msgstr "Help text (DE)" +#: workflows/templates/workflows/form_builder.html:541 +#, fuzzy +#| msgid "Abschnitt" +msgid "Feldtext-Abschnitte" +msgstr "Section" -#: workflows/templates/workflows/form_builder.html:136 -msgid "Hilfetext (EN)" -msgstr "Help text (EN)" - -#: workflows/templates/workflows/form_builder.html:146 +#: workflows/templates/workflows/form_builder.html:566 msgid "Fallback: Standardlabel" msgstr "Fallback: default label" -#: workflows/templates/workflows/form_builder.html:147 +#: workflows/templates/workflows/form_builder.html:570 msgid "English label" msgstr "English label" -#: workflows/templates/workflows/form_builder.html:148 +#: workflows/templates/workflows/form_builder.html:573 +#: workflows/templates/workflows/form_builder.html:738 +#: workflows/templates/workflows/form_builder.html:836 +msgid "Hilfetext (DE)" +msgstr "Help text (DE)" + +#: workflows/templates/workflows/form_builder.html:574 msgid "Optionaler Hilfetext" msgstr "Optional help text" -#: workflows/templates/workflows/form_builder.html:149 +#: workflows/templates/workflows/form_builder.html:577 +#: workflows/templates/workflows/form_builder.html:742 +#: workflows/templates/workflows/form_builder.html:840 +msgid "Hilfetext (EN)" +msgstr "Help text (EN)" + +#: workflows/templates/workflows/form_builder.html:578 msgid "Optional English help text" msgstr "Optional English help text" -#: workflows/templates/workflows/form_builder.html:152 +#: workflows/templates/workflows/form_builder.html:583 msgid "Keine Feldkonfigurationen verfügbar." msgstr "No field configurations available." -#: workflows/templates/workflows/form_builder.html:158 +#: workflows/templates/workflows/form_builder.html:589 msgid "Feldtexte speichern" msgstr "Save field text" -#: workflows/templates/workflows/handbook.html:4 -#: workflows/templates/workflows/handbook.html:15 -#: workflows/templates/workflows/home.html:165 -msgid "Handbook" -msgstr "Handbook" +#: workflows/templates/workflows/form_builder.html:601 +msgid "Erweitern Sie den Workflow um eigene inhaltliche Blöcke." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:612 +#: workflows/templates/workflows/form_builder.html:631 +#, fuzzy +#| msgid "Punkt hinzufügen" +msgid "Abschnitt hinzufügen" +msgstr "Add item" + +#: workflows/templates/workflows/form_builder.html:613 +msgid "" +"Erstellen Sie zusätzliche Bereiche für deployment-spezifische Informationen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:618 +#: workflows/templates/workflows/form_builder.html:659 +#, fuzzy +#| msgid "Label (DE)" +msgid "Titel (DE)" +msgstr "Label (DE)" + +#: workflows/templates/workflows/form_builder.html:622 +#: workflows/templates/workflows/form_builder.html:663 +#, fuzzy +#| msgid "Label (EN)" +msgid "Titel (EN)" +msgstr "Label (EN)" + +#: workflows/templates/workflows/form_builder.html:646 +#, fuzzy +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "Feld/Felder" +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:654 +#, python-format +msgid "" +"Eigenen Abschnitt wirklich löschen? %(count)s zugehörige eigene Felder " +"werden ebenfalls entfernt." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:673 +#, fuzzy +#| msgid "Keine geplanten Welcome E-Mails vorhanden." +msgid "Keine eigenen Abschnitte vorhanden." +msgstr "No scheduled welcome emails available." + +#: workflows/templates/workflows/form_builder.html:677 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Abschnitte speichern" +msgstr "Save rules" + +#: workflows/templates/workflows/form_builder.html:689 +msgid "" +"Erstellen Sie zusätzliche Eingaben innerhalb bestehender oder eigener " +"Abschnitte." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:700 +#, fuzzy +#| msgid "Neue Regel hinzufügen" +msgid "Feld hinzufügen" +msgstr "Add new rule" + +#: workflows/templates/workflows/form_builder.html:701 +msgid "" +"Erstellen Sie zusätzliche Eingaben innerhalb eines bestehenden oder eigenen " +"Abschnitts." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:714 +#: workflows/templates/workflows/form_builder.html:808 +#: workflows/templates/workflows/intro_builder.html:29 +#: workflows/templates/workflows/intro_builder.html:59 +msgid "Abschnitt" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:735 +#, fuzzy +#| msgid "Pflicht" +msgid "Pflichtfeld" +msgstr "Required" + +#: workflows/templates/workflows/form_builder.html:746 +#: workflows/templates/workflows/form_builder.html:844 +#, fuzzy +#| msgid "Aktion" +msgid "Optionen (DE)" +msgstr "Action" + +#: workflows/templates/workflows/form_builder.html:747 +msgid "Eine Option pro Zeile, optional: wert|Label" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:750 +#: workflows/templates/workflows/form_builder.html:848 +#, fuzzy +#| msgid "Aktion" +msgid "Optionen (EN)" +msgstr "Action" + +#: workflows/templates/workflows/form_builder.html:751 +msgid "Eine Option pro Zeile, optional: value|Label" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:755 +#, fuzzy +#| msgid "Neue Regel hinzufügen" +msgid "Eigenes Feld hinzufügen" +msgstr "Add new rule" + +#: workflows/templates/workflows/form_builder.html:762 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "Eigene Feld-Abschnitte" +msgstr "Save field text" + +#: workflows/templates/workflows/form_builder.html:804 +#, fuzzy +#| msgid "Option wirklich löschen?" +msgid "Eigenes Feld wirklich löschen?" +msgstr "Delete this option?" + +#: workflows/templates/workflows/form_builder.html:854 +#, fuzzy +#| msgid "Keine geplanten Welcome E-Mails vorhanden." +msgid "Keine eigenen Felder vorhanden." +msgstr "No scheduled welcome emails available." + +#: workflows/templates/workflows/form_builder.html:862 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "Eigene Felder speichern" +msgstr "Save field text" + +#: workflows/templates/workflows/form_builder.html:873 workflows/views.py:3029 +msgid "Live-Vorschau" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:874 +msgid "So wirkt die aktuelle Struktur für das aktive Formular." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:888 +msgid "Keine sichtbaren Felder." +msgstr "" #: workflows/templates/workflows/handbook.html:17 msgid "" @@ -994,10 +3029,6 @@ msgstr "" "Single documentation entry point for both operational knowledge and long-" "term engineering knowledge." -#: workflows/templates/workflows/handbook.html:21 -msgid "Operations" -msgstr "Operations" - #: workflows/templates/workflows/handbook.html:22 msgid "Project Wiki" msgstr "Project Wiki" @@ -1103,22 +3134,12 @@ msgstr "" msgid "Open Release Checklist" msgstr "" -#: workflows/templates/workflows/home.html:4 -#: workflows/templates/workflows/home.html:35 -#: workflows/templates/workflows/requests_dashboard.html:303 -msgid "TUBCO Onboarding & Offboarding Portal" -msgstr "TUBCO Onboarding & Offboarding Portal" - -#: workflows/templates/workflows/home.html:26 -msgid "Abmelden" -msgstr "Log out" - -#: workflows/templates/workflows/home.html:34 +#: workflows/templates/workflows/home.html:18 #: workflows/templates/workflows/requests_dashboard.html:32 msgid "Operations Console" msgstr "Operations Console" -#: workflows/templates/workflows/home.html:36 +#: workflows/templates/workflows/home.html:20 msgid "" "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und " "Ablage in Nextcloud." @@ -1126,206 +3147,89 @@ msgstr "" "Central workspace for requests, PDF generation, email workflows, and storage " "in Nextcloud." -#: workflows/templates/workflows/home.html:38 +#: workflows/templates/workflows/home.html:23 msgid "Rolle:" msgstr "Role:" -#: workflows/templates/workflows/home.html:40 +#: workflows/templates/workflows/home.html:25 msgid "Nextcloud:" msgstr "Nextcloud:" -#: workflows/templates/workflows/home.html:40 +#: workflows/templates/workflows/home.html:25 #: workflows/templates/workflows/integrations_setup.html:60 #: workflows/templates/workflows/user_management.html:103 msgid "aktiv" msgstr "active" -#: workflows/templates/workflows/home.html:40 +#: workflows/templates/workflows/home.html:25 #: workflows/templates/workflows/integrations_setup.html:60 #: workflows/templates/workflows/user_management.html:103 msgid "inaktiv" msgstr "inactive" -#: workflows/templates/workflows/home.html:43 +#: workflows/templates/workflows/home.html:28 #: workflows/templates/workflows/offboarding_success.html:20 #: workflows/templates/workflows/onboarding_success.html:20 msgid "E-Mail:" msgstr "Email:" -#: workflows/templates/workflows/home.html:43 +#: workflows/templates/workflows/home.html:28 #: workflows/templates/workflows/integrations_setup.html:122 +#: workflows/templates/workflows/trial_management.html:49 msgid "Testmodus" msgstr "Test mode" -#: workflows/templates/workflows/home.html:43 +#: workflows/templates/workflows/home.html:28 #: workflows/templates/workflows/integrations_setup.html:122 msgid "Produktion" msgstr "Production" -#: workflows/templates/workflows/home.html:45 +#: workflows/templates/workflows/home.html:30 msgid "PDF + E-Mail Workflow bereit" msgstr "PDF + Email Workflow Ready" +#: workflows/templates/workflows/home.html:44 +#, fuzzy +#| msgid "Operations" +msgid "Operations Overview" +msgstr "Operations" + +#: workflows/templates/workflows/home.html:45 +msgid "Letzte Laufzeit- und Backup-Signale auf einen Blick." +msgstr "" + +#: workflows/templates/workflows/home.html:51 +#: workflows/templates/workflows/job_monitor.html:20 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Fehlgeschlagene Jobs (24h)" +msgstr "Failed" + #: workflows/templates/workflows/home.html:55 -msgid "Apps" -msgstr "Apps" +#: workflows/templates/workflows/job_monitor.html:24 +#, fuzzy +#| msgid "Eingereicht" +msgid "Erfolgreiche Jobs (24h)" +msgstr "Submitted" -#: workflows/templates/workflows/home.html:56 -msgid "Wählen Sie den gewünschten Prozess." -msgstr "Choose the desired process." - -#: workflows/templates/workflows/home.html:63 -msgid "" -"Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen " -"senden und in Nextcloud ablegen." +#: workflows/templates/workflows/home.html:59 +#: workflows/templates/workflows/job_monitor.html:28 +msgid "Offene Starts (24h)" msgstr "" -"Capture new employees, generate a PDF with letterhead, send notifications, " -"and store it in Nextcloud." -#: workflows/templates/workflows/home.html:65 -msgid "Mehrschritt-Formular" -msgstr "Multi-step form" +#: workflows/templates/workflows/home.html:74 +#, fuzzy +#| msgid "Letzte Anmeldung" +msgid "Letzte Fehler" +msgstr "Last login" -#: workflows/templates/workflows/home.html:67 -msgid "E-Mail Routing" -msgstr "Email routing" - -#: workflows/templates/workflows/home.html:71 -msgid "Onboarding starten" -msgstr "Start onboarding" - -#: workflows/templates/workflows/home.html:79 -msgid "" -"Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und " -"Rückgabe-Prozess starten." -msgstr "" -"Search employees, prefill data, generate offboarding documents, and start " -"the return process." - -#: workflows/templates/workflows/home.html:81 -msgid "Profile-Suche" -msgstr "Profile search" - -#: workflows/templates/workflows/home.html:82 -msgid "Hardware-Liste" -msgstr "Hardware list" - -#: workflows/templates/workflows/home.html:83 -msgid "IT-Rückgabe" -msgstr "IT return" - -#: workflows/templates/workflows/home.html:87 -msgid "Offboarding starten" -msgstr "Start offboarding" - -#: workflows/templates/workflows/home.html:95 -#: workflows/templates/workflows/requests_dashboard.html:4 -#: workflows/templates/workflows/requests_dashboard.html:33 -msgid "Anfragen Dashboard" -msgstr "Requests Dashboard" - -#: workflows/templates/workflows/home.html:96 -msgid "" -"Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-" -"Anfragen." -msgstr "" -"Status, search, PDF links, and history of all onboarding/offboarding " -"requests." - -#: workflows/templates/workflows/home.html:98 -msgid "Suche" -msgstr "Search" - -#: workflows/templates/workflows/home.html:100 -msgid "PDF Zugriff" -msgstr "PDF access" - -#: workflows/templates/workflows/home.html:104 -msgid "Dashboard öffnen" +#: workflows/templates/workflows/home.html:75 +#, fuzzy +#| msgid "Dashboard öffnen" +msgid "Job Monitor öffnen" msgstr "Open dashboard" -#: workflows/templates/workflows/home.html:112 -msgid "Admin Apps" -msgstr "Admin Apps" - -#: workflows/templates/workflows/home.html:113 -msgid "Konfiguration, Tests und Steuerung." -msgstr "Configuration, tests, and controls." - -#: workflows/templates/workflows/home.html:118 -msgid "Integrationen" -msgstr "Integrations" - -#: workflows/templates/workflows/home.html:119 -msgid "Nextcloud- und E-Mail-Setup." -msgstr "Nextcloud and email setup." - -#: workflows/templates/workflows/home.html:120 -#: workflows/templates/workflows/home.html:127 -#: workflows/templates/workflows/home.html:134 -#: workflows/templates/workflows/home.html:141 -#: workflows/templates/workflows/home.html:148 -#: workflows/templates/workflows/home.html:155 -#: workflows/templates/workflows/home.html:160 -#: workflows/templates/workflows/home.html:167 -#: workflows/templates/workflows/home.html:174 -msgid "Öffnen" -msgstr "Open" - -#: workflows/templates/workflows/home.html:125 -#: workflows/templates/workflows/user_management.html:4 -#: workflows/templates/workflows/user_management.html:14 -msgid "Benutzer & Rollen" -msgstr "Users & roles" - -#: workflows/templates/workflows/home.html:126 -msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." -msgstr "Create users, assign roles, and control access." - -#: workflows/templates/workflows/home.html:133 -msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." -msgstr "" - -#: workflows/templates/workflows/home.html:140 -msgid "Backups erstellen und sicher verifizieren." -msgstr "" - -#: workflows/templates/workflows/home.html:146 -#: workflows/templates/workflows/welcome_emails.html:4 -msgid "Welcome E-Mails" -msgstr "Welcome Emails" - -#: workflows/templates/workflows/home.html:147 -msgid "Geplante Welcome Mails verwalten." -msgstr "Manage scheduled welcome emails." - -#: workflows/templates/workflows/home.html:154 -msgid "Felder, Schritte und Optionen verwalten." -msgstr "Manage fields, steps, and options." - -#: workflows/templates/workflows/home.html:158 -#: workflows/templates/workflows/intro_builder.html:4 -#: workflows/templates/workflows/intro_builder.html:17 -msgid "Einweisungs-Builder" -msgstr "Introduction Builder" - -#: workflows/templates/workflows/home.html:159 -msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." -msgstr "Configure checklist items for the introduction protocol." - -#: workflows/templates/workflows/home.html:166 -msgid "Project wiki and developer documentation in one place." -msgstr "Project wiki and developer documentation in one place." - -#: workflows/templates/workflows/home.html:172 -msgid "Django Admin" -msgstr "Django Admin" - -#: workflows/templates/workflows/home.html:173 -msgid "Vollständige Datenverwaltung." -msgstr "Full data management." - -#: workflows/templates/workflows/home.html:181 +#: workflows/templates/workflows/home.html:131 msgid "Tipp: Die letzten Vorgänge sehen Sie jederzeit im Anfragen Dashboard." msgstr "Tip: You can always see the latest requests in the Requests Dashboard." @@ -1338,10 +3242,32 @@ msgstr "Back to home" #: workflows/templates/workflows/includes/app_header.html:17 #: workflows/templates/workflows/offboarding_success.html:30 #: workflows/templates/workflows/onboarding_success.html:29 -#: workflows/templates/workflows/request_timeline.html:55 +#: workflows/templates/workflows/request_timeline.html:61 msgid "Zum Dashboard" msgstr "Go to dashboard" +#: workflows/templates/workflows/includes/app_header.html:42 +#, fuzzy +#| msgid "Alle auswählen" +msgid "Alle als gelesen" +msgstr "Select all" + +#: workflows/templates/workflows/includes/app_header.html:63 +#, fuzzy +#| msgid "Gesendet" +msgid "Gelesen" +msgstr "Sent" + +#: workflows/templates/workflows/includes/app_header.html:71 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Keine Benachrichtigungen vorhanden." +msgstr "No backup bundles available yet." + +#: workflows/templates/workflows/includes/app_header.html:103 +msgid "Abmelden" +msgstr "Log out" + #: workflows/templates/workflows/integrations_setup.html:4 #: workflows/templates/workflows/integrations_setup.html:14 msgid "Integrationen Setup" @@ -1633,11 +3559,6 @@ msgstr "" msgid "Checklistenpunkte für das Einweisungs- und Übergabeprotokoll verwalten." msgstr "Manage checklist items for the introduction and handover protocol." -#: workflows/templates/workflows/intro_builder.html:29 -#: workflows/templates/workflows/intro_builder.html:59 -msgid "Abschnitt" -msgstr "Section" - #: workflows/templates/workflows/intro_builder.html:37 #: workflows/templates/workflows/intro_builder.html:60 msgid "Checklistenpunkt (DE)" @@ -1673,10 +3594,6 @@ msgstr "Checklist item (EN)" msgid "Feld-Bedingung" msgstr "Field condition" -#: workflows/templates/workflows/intro_builder.html:64 -msgid "Wert" -msgstr "Value" - #: workflows/templates/workflows/intro_builder.html:99 msgid "z. B. HR Works" msgstr "e.g. HR Works" @@ -1701,6 +3618,35 @@ msgstr "Order currently follows the table order when saving." msgid "Checkliste speichern" msgstr "Save checklist" +#: workflows/templates/workflows/job_monitor.html:13 +msgid "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe zentral prüfen." +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:37 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Zuletzt fehlgeschlagen" +msgstr "Failed" + +#: workflows/templates/workflows/job_monitor.html:59 +#: workflows/templates/workflows/job_monitor.html:88 +msgid "Task" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:87 +msgid "Start" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:91 +msgid "Task ID" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:106 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Noch keine Task-Läufe vorhanden." +msgstr "No backup bundles available yet." + #: workflows/templates/workflows/offboarding_form.html:4 #: workflows/templates/workflows/offboarding_form.html:27 msgid "Offboarding-Anfrage" @@ -1730,7 +3676,9 @@ msgid "Mitarbeitende suchen (Name oder E-Mail)" msgstr "Search employees (name or email)" #: workflows/templates/workflows/offboarding_form.html:31 -msgid "z. B. max.mustermann@tub.co" +#, fuzzy, python-format +#| msgid "z. B. max.mustermann@tub.co" +msgid "z. B. max.mustermann@%(domain)s" msgstr "e.g. john.doe@tub.co" #: workflows/templates/workflows/offboarding_form.html:33 @@ -1742,7 +3690,7 @@ msgstr "Search" msgid "Vorbefüllt aus:" msgstr "Prefilled from:" -#: workflows/templates/workflows/offboarding_form.html:64 +#: workflows/templates/workflows/offboarding_form.html:82 msgid "Offboarding-Anfrage speichern" msgstr "Save offboarding request" @@ -1776,7 +3724,7 @@ msgstr "" #: workflows/templates/workflows/offboarding_success.html:23 #: workflows/templates/workflows/onboarding_success.html:22 -#: workflows/templates/workflows/request_timeline.html:104 +#: workflows/templates/workflows/request_timeline.html:124 #: workflows/templates/workflows/requests_dashboard.html:217 msgid "PDF öffnen" msgstr "Open PDF" @@ -1819,42 +3767,45 @@ msgid "" "Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt." msgstr "Please check the highlighted fields. Invalid input was detected." -#: workflows/templates/workflows/onboarding_form.html:75 -#: workflows/templates/workflows/onboarding_form.html:77 -#: workflows/templates/workflows/onboarding_form.html:114 -#: workflows/templates/workflows/onboarding_form.html:116 +#: workflows/templates/workflows/onboarding_form.html:62 +#: workflows/templates/workflows/onboarding_form.html:64 +#: workflows/templates/workflows/onboarding_form.html:81 +#: workflows/templates/workflows/onboarding_form.html:83 +#: workflows/templates/workflows/onboarding_form.html:124 +#: workflows/templates/workflows/onboarding_form.html:126 #: workflows/templates/workflows/welcome_emails.html:65 msgid "Alle auswählen" msgstr "Select all" -#: workflows/templates/workflows/onboarding_form.html:76 -#: workflows/templates/workflows/onboarding_form.html:115 +#: workflows/templates/workflows/onboarding_form.html:63 +#: workflows/templates/workflows/onboarding_form.html:82 +#: workflows/templates/workflows/onboarding_form.html:125 #, fuzzy #| msgid "Auswahl löschen" msgid "Auswahl aufheben" msgstr "Delete selection" -#: workflows/templates/workflows/onboarding_form.html:138 +#: workflows/templates/workflows/onboarding_form.html:154 msgid "Keine konfigurierten Felder in diesem Schritt." msgstr "No configured fields in this step." -#: workflows/templates/workflows/onboarding_form.html:143 +#: workflows/templates/workflows/onboarding_form.html:159 msgid "Fast geschafft. Bitte Abschlussdaten prüfen und die Anfrage absenden." msgstr "Almost done. Please review the final details and submit the request." -#: workflows/templates/workflows/onboarding_form.html:155 +#: workflows/templates/workflows/onboarding_form.html:171 msgid "Zurück" msgstr "Back" -#: workflows/templates/workflows/onboarding_form.html:156 +#: workflows/templates/workflows/onboarding_form.html:172 msgid "Weiter" msgstr "Next" -#: workflows/templates/workflows/onboarding_form.html:157 +#: workflows/templates/workflows/onboarding_form.html:173 msgid "Wird gesendet..." msgstr "" -#: workflows/templates/workflows/onboarding_form.html:157 +#: workflows/templates/workflows/onboarding_form.html:173 msgid "Onboarding-Anfrage absenden" msgstr "Submit onboarding request" @@ -1875,12 +3826,6 @@ msgstr "" msgid "Mitarbeitende Person" msgstr "Employee" -#: workflows/templates/workflows/onboarding_intro_session.html:27 -#: workflows/templates/workflows/request_timeline.html:66 -#: workflows/templates/workflows/user_management.html:71 -msgid "Name" -msgstr "Name" - #: workflows/templates/workflows/onboarding_intro_session.html:29 msgid "Berufsbezeichnung" msgstr "Job title" @@ -1890,7 +3835,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:708 +#: workflows/views.py:1651 msgid "Vertragsbeginn" msgstr "Contract start" @@ -2040,103 +3985,114 @@ msgstr "" msgid "If dependencies changed, verify imports do not emit warnings." msgstr "" -#: workflows/templates/workflows/release_checklist.html:51 +#: workflows/templates/workflows/release_checklist.html:43 +msgid "" +"Verify the latest backup bundle before release if operational tooling, " +"storage, or restore behavior changed." +msgstr "" + +#: workflows/templates/workflows/release_checklist.html:44 +msgid "" +"Prefer the single local release gate command so local validation matches CI." +msgstr "" + +#: workflows/templates/workflows/release_checklist.html:57 msgid "3. Data and asset steps" msgstr "" -#: workflows/templates/workflows/release_checklist.html:53 +#: workflows/templates/workflows/release_checklist.html:59 msgid "Create and apply migrations if models changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:54 +#: workflows/templates/workflows/release_checklist.html:60 msgid "Run collectstatic if UI assets changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:55 +#: workflows/templates/workflows/release_checklist.html:61 msgid "Generate fresh PDFs if PDF templates or document logic changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:56 +#: workflows/templates/workflows/release_checklist.html:62 msgid "Confirm file outputs appear under backend/media/pdfs/." msgstr "" -#: workflows/templates/workflows/release_checklist.html:64 +#: workflows/templates/workflows/release_checklist.html:70 #, fuzzy #| msgid "Integrationen" msgid "4. Integration checks" msgstr "Integrations" -#: workflows/templates/workflows/release_checklist.html:66 +#: workflows/templates/workflows/release_checklist.html:72 msgid "Verify the health endpoint returns status ok." msgstr "" -#: workflows/templates/workflows/release_checklist.html:67 +#: workflows/templates/workflows/release_checklist.html:73 msgid "Verify MailHog in test mode or SMTP in production mode." msgstr "" -#: workflows/templates/workflows/release_checklist.html:68 +#: workflows/templates/workflows/release_checklist.html:74 msgid "Verify Nextcloud upload if synchronization behavior changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:69 +#: workflows/templates/workflows/release_checklist.html:75 msgid "" "Verify welcome-email scheduling or notification rules if email routing " "changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:76 +#: workflows/templates/workflows/release_checklist.html:82 msgid "5. Release evidence" msgstr "" -#: workflows/templates/workflows/release_checklist.html:78 +#: workflows/templates/workflows/release_checklist.html:84 msgid "Record which checks were run and their result." msgstr "" -#: workflows/templates/workflows/release_checklist.html:79 +#: workflows/templates/workflows/release_checklist.html:85 msgid "Take a snapshot commit before moving to the next change phase." msgstr "" -#: workflows/templates/workflows/release_checklist.html:80 +#: workflows/templates/workflows/release_checklist.html:86 msgid "" "If a release introduces new operations or engineering behavior, update both " "handbooks." msgstr "" -#: workflows/templates/workflows/release_checklist.html:81 +#: workflows/templates/workflows/release_checklist.html:87 msgid "" "Keep at least one successful onboarding and one offboarding smoke example " "during major workflow changes." msgstr "" -#: workflows/templates/workflows/release_checklist.html:86 +#: workflows/templates/workflows/release_checklist.html:92 msgid "6. Rollback basics" msgstr "" -#: workflows/templates/workflows/release_checklist.html:88 +#: workflows/templates/workflows/release_checklist.html:94 msgid "" "If rollout fails after code-only changes, redeploy the previous snapshot " "commit." msgstr "" -#: workflows/templates/workflows/release_checklist.html:89 +#: workflows/templates/workflows/release_checklist.html:95 msgid "" "If rollout includes schema changes, verify backward compatibility before " "rollback." msgstr "" -#: workflows/templates/workflows/release_checklist.html:90 +#: workflows/templates/workflows/release_checklist.html:96 msgid "" "If integrations fail, switch email mode/test settings conservatively before " "wider retry." msgstr "" -#: workflows/templates/workflows/release_checklist.html:91 +#: workflows/templates/workflows/release_checklist.html:97 msgid "" "Use logs from web and worker containers to isolate whether the issue is " "request, task, or integration related." msgstr "" -#: workflows/templates/workflows/release_checklist.html:98 +#: workflows/templates/workflows/release_checklist.html:104 msgid "" "Project rule: German remains the primary/fallback language. English is " "secondary. If a release adds new dynamic text, add the German source first " @@ -2144,21 +4100,21 @@ msgid "" msgstr "" #: workflows/templates/workflows/request_timeline.html:4 -#: workflows/templates/workflows/request_timeline.html:51 +#: workflows/templates/workflows/request_timeline.html:57 msgid "Request Timeline" msgstr "" -#: workflows/templates/workflows/request_timeline.html:74 -#: workflows/templates/workflows/requests_dashboard.html:190 -#: workflows/templates/workflows/user_management.html:73 -msgid "E-Mail" -msgstr "Email" - -#: workflows/templates/workflows/request_timeline.html:78 +#: workflows/templates/workflows/request_timeline.html:84 msgid "Hardware-Übergabetermin" msgstr "Hardware handover date" -#: workflows/templates/workflows/request_timeline.html:130 +#: workflows/templates/workflows/request_timeline.html:91 +#, fuzzy +#| msgid "Benutzer erstellen" +msgid "Benutzerdefinierte Felder" +msgstr "Create user" + +#: workflows/templates/workflows/request_timeline.html:150 #, fuzzy #| msgid "Noch keine Vorgänge vorhanden." msgid "Noch keine Timeline-Einträge vorhanden." @@ -2307,30 +4263,200 @@ msgstr "Generate PDF" msgid "Nicht relevant" msgstr "Not relevant" -#: workflows/templates/workflows/requests_dashboard.html:276 +#: workflows/templates/workflows/requests_dashboard.html:277 msgid "Timeline" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:278 +#: workflows/templates/workflows/requests_dashboard.html:280 msgid "Eintrag erneut verarbeiten?" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:280 +#: workflows/templates/workflows/requests_dashboard.html:282 msgid "Erneut versuchen" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:284 +#: workflows/templates/workflows/requests_dashboard.html:286 #, fuzzy #| msgid "Option wirklich löschen?" msgid "Eintrag wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/requests_dashboard.html:294 +#: workflows/templates/workflows/requests_dashboard.html:296 msgid "Noch keine Vorgänge vorhanden." msgstr "No requests available yet." +#: workflows/templates/workflows/trial_expired.html:14 +msgid "Trial expired" +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:16 +msgid "" +"Diese Testumgebung ist nicht mehr aktiv. Bitte wenden Sie sich für eine " +"Verlängerung oder ein Produktiv-Setup an den Plattformbetreiber." +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:21 +msgid "Zugriff gesperrt" +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:22 +msgid "" +"Nicht-Platform-Nutzer können diese Umgebung nach Ablauf nicht mehr verwenden." +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:25 +msgid "Nächster Schritt" +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:26 +msgid "Verlängern oder Produktiv-Setup" +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:27 +msgid "" +"Ein Platform Owner kann den Trial verlängern oder das Setup in einen " +"regulären Betrieb überführen." +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:31 +#, fuzzy +#| msgid "Abgelaufen" +msgid "Ablaufzeit" +msgstr "Expired" + +#: workflows/templates/workflows/trial_expired.html:33 +msgid "Das ist der im System hinterlegte Endzeitpunkt der Testumgebung." +msgstr "" + +#: workflows/templates/workflows/trial_expired.html:42 +#, python-format +msgid "Kontakt: %(email)s" +msgstr "Contact: %(email)s" + +#: workflows/templates/workflows/trial_management.html:13 +msgid "" +"Testlaufzeit, Banner und sichere Einschränkungen für Demo- und " +"Pilotumgebungen steuern." +msgstr "" +"Control trial runtime, banner messaging, and safe restrictions for demo and " +"pilot environments." + +#: workflows/templates/workflows/trial_management.html:20 +msgid "Übersicht" +msgstr "Overview" + +#: workflows/templates/workflows/trial_management.html:21 +msgid "Aktueller Trial-Status und die daraus resultierende Systemwirkung." +msgstr "Current trial status and the resulting system effect." + +#: workflows/templates/workflows/trial_management.html:28 +msgid "Abgelaufen" +msgstr "Expired" + +#: workflows/templates/workflows/trial_management.html:37 +msgid "Nicht gesetzt" +msgstr "Not set" + +#: workflows/templates/workflows/trial_management.html:41 +msgid "Nextcloud effektiv" +msgstr "Nextcloud effective" + +#: workflows/templates/workflows/trial_management.html:43 +#: workflows/templates/workflows/trial_management.html:49 +msgid "Unverändert" +msgstr "Unchanged" + +#: workflows/templates/workflows/trial_management.html:47 +msgid "E-Mail effektiv" +msgstr "Email effective" + +#: workflows/templates/workflows/trial_management.html:54 +msgid "" +"Zum Deaktivieren des Trial-Modus entfernen Sie den Haken bei „Trial-Modus " +"aktiv“ und speichern Sie die Seite." +msgstr "" +"To disable trial mode, remove the checkmark from ‘Trial mode enabled’ and " +"save the page." + +#: workflows/templates/workflows/trial_management.html:63 +msgid "Trial-Status" +msgstr "Trial status" + +#: workflows/templates/workflows/trial_management.html:64 +msgid "Aktivieren Sie den Trial-Modus und definieren Sie die gültige Laufzeit." +msgstr "Enable trial mode and define the valid runtime." + +#: workflows/templates/workflows/trial_management.html:70 +msgid "Diese Deployment-Umgebung als Trial führen" +msgstr "Run this deployment as a trial environment" + +#: workflows/templates/workflows/trial_management.html:72 +msgid "" +"Sobald dieser Schalter deaktiviert ist, verschwindet das Trial-Banner und " +"die normalen Integrationsregeln greifen wieder." +msgstr "" +"As soon as this switch is disabled, the trial banner disappears and the " +"normal integration rules apply again." + +#: workflows/templates/workflows/trial_management.html:81 +msgid "Der konfigurierte Trial ist derzeit abgelaufen." +msgstr "The configured trial is currently expired." + +#: workflows/templates/workflows/trial_management.html:88 +msgid "Sicherheitsregeln" +msgstr "Safety rules" + +#: workflows/templates/workflows/trial_management.html:89 +msgid "Testumgebungen sollen keine produktiven Integrationen verwenden." +msgstr "Trial environments should not use production integrations." + +#: workflows/templates/workflows/trial_management.html:92 +msgid "Nextcloud produktiv deaktivieren und E-Mail-Testmodus erzwingen" +msgstr "Disable production Nextcloud and force email test mode" + +#: workflows/templates/workflows/trial_management.html:93 +msgid "Cleanup nach Ablauf vorbereiten" +msgstr "Allow cleanup after expiry" + +#: workflows/templates/workflows/trial_management.html:95 +msgid "" +"Wenn diese Regel aktiv ist, bleiben produktive Integrationen technisch " +"gesperrt, auch wenn lokale Overrides anders gesetzt sind." +msgstr "" +"When this rule is active, production integrations remain technically blocked " +"even if local overrides are set differently." + +#: workflows/templates/workflows/trial_management.html:100 +msgid "Banner" +msgstr "Banner" + +#: workflows/templates/workflows/trial_management.html:101 +msgid "" +"Optionaler Hinweistext für die Shell. Ohne Text wird ein Standardhinweis mit " +"Enddatum verwendet." +msgstr "" +"Optional notice text for the shell. Without custom text, a default notice " +"with the expiry date is used." + +#: workflows/templates/workflows/trial_management.html:122 +msgid "" +"Die eigentliche Datenbereinigung läuft bewusst nicht über die Web-UI. Nutzen " +"Sie dafür den Cleanup-Command im Betrieb." +msgstr "" +"Actual data cleanup is intentionally not done through the web UI. Use the " +"cleanup command during operations instead." + +#: workflows/templates/workflows/trial_management.html:123 +msgid "Trial-Konfiguration speichern" +msgstr "Save trial configuration" + #: workflows/templates/workflows/user_management.html:15 -msgid "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." +#, fuzzy +#| msgid "" +#| "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." +msgid "" +"Platform Owner und Super Admins verwalten Benutzerkonten, Rollen und den " +"aktiven Zugriff." msgstr "Super admins manage user accounts, roles, and active access." #: workflows/templates/workflows/user_management.html:22 @@ -2357,22 +4483,10 @@ msgstr "User overview" msgid "Rollen ändern, Zugriffe sperren oder ein neues Passwort setzen." msgstr "Change roles, block access, or set a new password." -#: workflows/templates/workflows/user_management.html:76 -msgid "Letzte Anmeldung" -msgstr "Last login" - #: workflows/templates/workflows/user_management.html:87 msgid "Sie selbst" msgstr "You" -#: workflows/templates/workflows/user_management.html:109 -msgid "Optional" -msgstr "Optional" - -#: workflows/templates/workflows/user_management.html:115 -msgid "Speichern" -msgstr "Save" - #: workflows/templates/workflows/user_management.html:119 msgid "Reset-Link senden" msgstr "" @@ -2394,13 +4508,53 @@ msgid "Es sind noch keine Benutzer vorhanden." msgstr "No users exist yet." #: workflows/templates/workflows/user_management.html:140 +#, fuzzy +#| msgid "" +#| "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst " +#| "deaktivieren oder auf eine niedrigere Rolle setzen." msgid "" -"Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst " -"deaktivieren oder auf eine niedrigere Rolle setzen." +"Hinweis: Der letzte aktive Platform Owner oder Super Admin kann sich hier " +"nicht selbst entfernen oder auf eine niedrigere Rolle setzen." msgstr "" "Note: The currently signed-in super admin cannot deactivate themselves or " "assign a lower role here." +#: workflows/templates/workflows/user_management.html:146 +#, fuzzy +#| msgid "Benutzer anlegen" +msgid "Letzte Benutzeraktionen" +msgstr "Create user" + +#: workflows/templates/workflows/user_management.html:147 +msgid "Die letzten Änderungen an Benutzerkonten und Rollen." +msgstr "" + +#: workflows/templates/workflows/user_management.html:149 +msgid "Zum Audit Log" +msgstr "" + +#: workflows/templates/workflows/user_management.html:157 +#, fuzzy +#| msgid "Vorgänge" +msgid "Betroffen" +msgstr "Requests" + +#: workflows/templates/workflows/user_management.html:158 +msgid "Durch" +msgstr "" + +#: workflows/templates/workflows/user_management.html:173 +#, fuzzy +#| msgid "E-Mail versendet" +msgid "Einladung versendet" +msgstr "Email sent" + +#: workflows/templates/workflows/user_management.html:180 +#, fuzzy +#| msgid "Es sind noch keine Benutzer vorhanden." +msgid "Noch keine Benutzeraktionen vorhanden." +msgstr "No users exist yet." + #: workflows/templates/workflows/welcome_emails.html:14 msgid "Geplante Welcome E-Mails" msgstr "Scheduled welcome emails" @@ -2467,10 +4621,6 @@ msgstr "Send now" msgid "Bulk ausführen" msgstr "Run bulk action" -#: workflows/templates/workflows/welcome_emails.html:80 -msgid "Auswahl" -msgstr "Select" - #: workflows/templates/workflows/welcome_emails.html:84 msgid "Geplant für" msgstr "Scheduled for" @@ -2487,250 +4637,492 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:94 +#: workflows/upload_validation.py:75 +msgid "Bitte ein PNG-, JPG-, WEBP- oder SVG-Bild hochladen." +msgstr "" + +#: workflows/upload_validation.py:76 +msgid "Das Profilbild darf maximal 5 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:77 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Bilddatei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:95 +msgid "Bitte ein SVG-, PNG-, JPG- oder WEBP-Bild hochladen." +msgstr "" + +#: workflows/upload_validation.py:96 +msgid "Das Logo darf maximal 5 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:97 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Logo-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:114 +msgid "Bitte eine ICO-, PNG-, SVG- oder WEBP-Datei hochladen." +msgstr "" + +#: workflows/upload_validation.py:115 +msgid "Das Favicon darf maximal 2 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:116 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Favicon-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:126 +msgid "Bitte eine gültige PDF-Datei hochladen." +msgstr "" + +#: workflows/upload_validation.py:127 +msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:128 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die PDF-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:144 +msgid "Bitte eine PNG- oder JPG-Datei hochladen." +msgstr "" + +#: workflows/upload_validation.py:145 +msgid "Die Signatur-Datei ist zu groß (max. 4 MB)." +msgstr "" + +#: workflows/upload_validation.py:146 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Signatur-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/views.py:123 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:95 +#: workflows/views.py:124 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:96 +#: workflows/views.py:125 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:97 +#: workflows/views.py:126 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:128 workflows/views.py:794 workflows/views.py:799 +#: workflows/views.py:130 +#, fuzzy +#| msgid "Deaktivieren" +msgid "ist aktiviert" +msgstr "Disabled" + +#: workflows/views.py:131 +msgid "ist gleich" +msgstr "" + +#: workflows/views.py:132 +msgid "ist nicht gleich" +msgstr "" + +#: workflows/views.py:138 +msgid "Fixes Kernfeld, immer sichtbar." +msgstr "" + +#: workflows/views.py:140 +msgid "Ausgeblendet, erscheint nicht im Formular." +msgstr "" + +#: workflows/views.py:142 +msgid "Sichtbar und als Pflichtfeld markiert." +msgstr "" + +#: workflows/views.py:144 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbar und optional." +msgstr "Safety rules" + +#: workflows/views.py:145 +msgid "Sichtbar mit Standardverhalten." +msgstr "" + +#: workflows/views.py:156 +#, fuzzy, python-format +#| msgid "Deaktivieren" +msgid "%(field)s ist aktiviert" +msgstr "Disabled" + +#: workflows/views.py:159 +#, python-format +msgid "%(field)s ist gleich %(value)s" +msgstr "" + +#: workflows/views.py:160 +#, python-format +msgid "%(field)s ist gleich" +msgstr "" + +#: workflows/views.py:163 +#, python-format +msgid "%(field)s ist nicht gleich %(value)s" +msgstr "" + +#: workflows/views.py:164 +#, python-format +msgid "%(field)s ist nicht gleich" +msgstr "" + +#: workflows/views.py:165 +#, python-format +msgid "%(field)s erfüllt die Bedingung" +msgstr "" + +#: workflows/views.py:171 +msgid "Immer sichtbar." +msgstr "" + +#: workflows/views.py:173 +#, fuzzy, python-format +#| msgid "Sicherheitsregeln" +msgid "Sichtbar, wenn %(conditions)s." +msgstr "Safety rules" + +#: workflows/views.py:321 +#, fuzzy +#| msgid "Lokal gespeichert" +msgid "Profilbild gespeichert." +msgstr "Stored locally" + +#: workflows/views.py:323 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Profilbild konnte nicht gespeichert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:329 +#, fuzzy +#| msgid "Lokal gespeichert" +msgid "Profildaten gespeichert." +msgstr "Stored locally" + +#: workflows/views.py:331 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Profildaten konnten nicht gespeichert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:337 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigungseinstellungen gespeichert." +msgstr "Save offboarding request" + +#: workflows/views.py:339 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Benachrichtigungseinstellungen konnten nicht gespeichert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:348 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP wurde aktiviert." +msgstr "Disabled" + +#: workflows/views.py:350 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "TOTP konnte nicht aktiviert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:357 +msgid "TOTP wurde deaktiviert." +msgstr "" + +#: workflows/views.py:359 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "TOTP konnte nicht deaktiviert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:368 +msgid "Recovery-Codes wurden neu erzeugt." +msgstr "" + +#: workflows/views.py:370 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Recovery-Codes konnten nicht neu erzeugt werden." +msgstr "Password could not be saved" + +#: workflows/views.py:419 workflows/views.py:1738 workflows/views.py:1743 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:209 +#: workflows/views.py:530 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:210 +#: workflows/views.py:531 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:211 +#: workflows/views.py:532 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:212 +#: workflows/views.py:533 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:213 +#: workflows/views.py:534 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:214 +#: workflows/views.py:535 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:215 +#: workflows/views.py:536 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:216 +#: workflows/views.py:537 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:217 +#: workflows/views.py:538 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:218 +#: workflows/views.py:539 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:219 +#: workflows/views.py:540 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:220 +#: workflows/views.py:541 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:221 +#: workflows/views.py:542 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:222 +#: workflows/views.py:543 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:223 +#: workflows/views.py:544 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:224 +#: workflows/views.py:545 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:225 +#: workflows/views.py:546 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:226 +#: workflows/views.py:547 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:227 +#: workflows/views.py:548 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:228 +#: workflows/views.py:549 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:229 +#: workflows/views.py:550 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:230 +#: workflows/views.py:551 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:231 +#: workflows/views.py:552 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:232 +#: workflows/views.py:553 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:233 +#: workflows/views.py:554 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:234 +#: workflows/views.py:555 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:235 +#: workflows/views.py:556 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:236 +#: workflows/views.py:557 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:237 +#: workflows/views.py:558 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:238 +#: workflows/views.py:559 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:239 +#: workflows/views.py:560 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:240 +#: workflows/views.py:561 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:241 +#: workflows/views.py:562 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:242 +#: workflows/views.py:563 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:243 +#: workflows/views.py:564 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:244 +#: workflows/views.py:565 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:245 +#: workflows/views.py:566 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:246 +#: workflows/views.py:567 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:400 +#: workflows/views.py:568 +#, fuzzy +#| msgid "Anfrage gespeichert" +msgid "App-Registry gespeichert" +msgstr "Request saved" + +#: workflows/views.py:684 +#, fuzzy +#| msgid "Person, Rolle, Abteilung" +msgid "Person, Rolle und Bereich" +msgstr "Person, role, department" + +#: workflows/views.py:685 +msgid "Letzter Arbeitstag" +msgstr "" + +#: workflows/views.py:686 +#, fuzzy +#| msgid "Einweisung wurde als abgeschlossen gespeichert." +msgid "Hinweise und Abschlussnotizen" +msgstr "Introduction was saved as completed." + +#: workflows/views.py:849 +#, fuzzy +#| msgid "Anfrage gespeichert" +msgid "App-Registry gespeichert." +msgstr "Request saved" + +#: workflows/views.py:948 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:408 +#: workflows/views.py:957 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:410 +#: workflows/views.py:959 #, python-format msgid "" "Hallo %(name)s,\n" "\n" -"für Sie wurde ein Benutzerkonto im TUBCO Onboarding- und Offboarding-Portal " -"angelegt.\n" +"für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n" "Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n" "%(url)s\n" "\n" @@ -2738,12 +5130,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:420 +#: workflows/views.py:970 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:422 +#: workflows/views.py:972 #, python-format msgid "" "Hallo %(name)s,\n" @@ -2756,24 +5148,217 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:445 +#: workflows/views.py:1023 +#, fuzzy +#| msgid "" +#| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." +msgid "" +"Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." +msgstr "User could not be created. Please check the input." + +#: workflows/views.py:1051 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Portal-Branding wurde gespeichert." +msgstr "Save offboarding request" + +#: workflows/views.py:1068 +msgid "Identität" +msgstr "" + +#: workflows/views.py:1069 +msgid "Titel, Firmenname und zentrale Spracheinstellungen." +msgstr "" + +#: workflows/views.py:1073 +msgid "" +"Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " +"B. workdock.de." +msgstr "" + +#: workflows/views.py:1078 +msgid "Farben & Erscheinungsbild" +msgstr "" + +#: workflows/views.py:1079 +msgid "Zentrale visuelle Markenwerte und Browser-Icon." +msgstr "" + +#: workflows/views.py:1083 +msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." +msgstr "" + +#: workflows/views.py:1084 +msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." +msgstr "" + +#: workflows/views.py:1089 +#, fuzzy +#| msgid "Produktion" +msgid "Kommunikation" +msgstr "Production" + +#: workflows/views.py:1090 +msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." +msgstr "" + +#: workflows/views.py:1094 +msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." +msgstr "" + +#: workflows/views.py:1095 +msgid "Erlaubtes Format: PDF. Maximal 10 MB." +msgstr "" + +#: workflows/views.py:1100 +msgid "Footer & Rechtliches" +msgstr "" + +#: workflows/views.py:1101 +msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." +msgstr "" + +#: workflows/views.py:1155 +#, fuzzy +#| msgid "" +#| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." +msgid "" +"Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die " +"Eingaben." +msgstr "User could not be created. Please check the input." + +#: workflows/views.py:1184 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Firmenkonfiguration wurde gespeichert." +msgstr "Save offboarding request" + +#: workflows/views.py:1201 +#, fuzzy +#| msgid "Firmenname" +msgid "Firmenprofil" +msgstr "Company name" + +#: workflows/views.py:1202 +msgid "Rechtlicher Name und zentrale Stammdaten der Firma." +msgstr "" + +#: workflows/views.py:1207 +msgid "Adresse & Register" +msgstr "" + +#: workflows/views.py:1208 +msgid "Anschrift sowie optionale Register- und Steuerangaben." +msgstr "" + +#: workflows/views.py:1213 +msgid "Kontaktpunkte" +msgstr "" + +#: workflows/views.py:1214 +msgid "Zentrale Ansprechpartner für HR, IT und Operations." +msgstr "" + +#: workflows/views.py:1219 +msgid "Recht & Öffentlichkeit" +msgstr "" + +#: workflows/views.py:1220 +msgid "Öffentliche Links für Website, Impressum und Datenschutz." +msgstr "" + +#: workflows/views.py:1222 +msgid "" +"Diese Links können später im Portal-Footer oder in öffentlichen Seiten " +"verwendet werden." +msgstr "" + +#: workflows/views.py:1262 +#, fuzzy +#| msgid "" +#| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." +msgid "" +"Trial-Konfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die " +"Eingaben." +msgstr "Trial configuration could not be saved. Please check the input." + +#: workflows/views.py:1294 +#, fuzzy +#| msgid "Trial abgelaufen" +msgid "Trial ist abgelaufen" +msgstr "Trial expired" + +#: workflows/views.py:1295 +msgid "" +"Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt " +"blockiert." +msgstr "" + +#: workflows/views.py:1303 +msgid "Trial läuft bald ab" +msgstr "" + +#: workflows/views.py:1304 +#, python-format +msgid "Der Trial endet am %(date)s." +msgstr "" + +#: workflows/views.py:1312 +#, fuzzy +#| msgid "Trial-Modus" +msgid "Trial-Modus deaktiviert" +msgstr "Trial mode" + +#: workflows/views.py:1313 +#, fuzzy +#| msgid "Nextcloud schalten" +msgid "Der Trial-Modus wurde ausgeschaltet." +msgstr "Toggle Nextcloud" + +#: workflows/views.py:1318 +msgid "Trial-Konfiguration wurde gespeichert." +msgstr "Trial configuration was saved." + +#: workflows/views.py:1335 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:458 +#: workflows/views.py:1348 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:476 +#: workflows/views.py:1370 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "" +"Der aktuell angemeldete Platform Owner kann sich hier nicht selbst sperren " +"oder herabstufen." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:1373 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:479 +#: workflows/views.py:1376 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "" +"Der letzte aktive Platform Owner kann nicht deaktiviert oder herabgestuft " +"werden." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:1379 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2784,18 +5369,28 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:496 +#: workflows/views.py:1396 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:518 +#: workflows/views.py:1418 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:529 +#: workflows/views.py:1430 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "" +"Der aktuell angemeldete Platform Owner kann sich hier nicht selbst löschen." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:1433 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2805,7 +5400,16 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:532 +#: workflows/views.py:1436 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:1439 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2814,130 +5418,533 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:545 +#: workflows/views.py:1452 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:632 +#: workflows/views.py:1543 +#, fuzzy, python-format +#| msgid "Anfrage gespeichert" +msgid "Backup erstellt: %(name)s" +msgstr "Request saved" + +#: workflows/views.py:1544 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Das Backup-Bundle wurde erfolgreich erstellt." +msgstr "Save offboarding request" + +#: workflows/views.py:1549 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:634 +#: workflows/views.py:1559 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:650 +#: workflows/views.py:1577 +#, fuzzy, python-format +#| msgid "Backup wird verifiziert" +msgid "Backup verifiziert: %(name)s" +msgstr "Backup is being verified" + +#: workflows/views.py:1578 +#, fuzzy +#| msgid "Backup wird verifiziert" +msgid "Das Backup wurde erfolgreich verifiziert." +msgstr "Backup is being verified" + +#: workflows/views.py:1583 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:652 +#: workflows/views.py:1587 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Backup-Verifikation fehlgeschlagen" +msgstr "Failed" + +#: workflows/views.py:1593 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:668 +#: workflows/views.py:1609 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:670 +#: workflows/views.py:1611 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:696 +#: workflows/views.py:1638 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:698 +#: workflows/views.py:1640 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:710 +#: workflows/views.py:1653 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:720 +#: workflows/views.py:1663 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:722 +#: workflows/views.py:1665 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:731 +#: workflows/views.py:1674 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:757 +#: workflows/views.py:1700 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:769 -#, fuzzy -#| msgid "Welcome E-Mails" -msgid "Welcome E-Mail" -msgstr "Welcome Emails" - -#: workflows/views.py:808 +#: workflows/views.py:1752 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:851 +#: workflows/views.py:1795 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:853 +#: workflows/views.py:1797 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:855 +#: workflows/views.py:1799 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1082 +#: workflows/views.py:2038 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1099 +#: workflows/views.py:2055 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1128 +#: workflows/views.py:2084 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1142 +#: workflows/views.py:2098 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1155 +#: workflows/views.py:2111 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." +#: workflows/views.py:2282 +#, fuzzy +#| msgid "Optionen speichern" +msgid "Option nicht gefunden." +msgstr "Save options" + +#: workflows/views.py:2289 +#, fuzzy +#| msgid "Option wirklich löschen?" +msgid "Option wurde gelöscht." +msgstr "Delete this option?" + +#: workflows/views.py:2294 +msgid "Benutzerdefiniertes Feld nicht gefunden." +msgstr "" + +#: workflows/views.py:2300 +#, fuzzy +#| msgid "Benutzerübersicht" +msgid "Benutzerdefiniertes Feld wurde gelöscht." +msgstr "User overview" + +#: workflows/views.py:2305 +msgid "Benutzerdefinierter Abschnitt nicht gefunden." +msgstr "" + +#: workflows/views.py:2328 +msgid "Benutzerdefinierter Abschnitt wurde gelöscht." +msgstr "" + +#: workflows/views.py:2338 +#, fuzzy +#| msgid "Ungültige Rolle." +msgid "Ungültige Kategorie." +msgstr "Invalid role." + +#: workflows/views.py:2340 +msgid "Bitte einen Namen für die Option angeben." +msgstr "" + +#: workflows/views.py:2361 +#, fuzzy +#| msgid "Option hinzufügen" +msgid "Option wurde hinzugefügt." +msgstr "Add option" + +#: workflows/views.py:2379 +#, python-format +msgid "Doppelte Bezeichnung in Kategorie: %(label)s" +msgstr "" + +#: workflows/views.py:2383 +#, fuzzy +#| msgid "Optionen speichern" +msgid "Optionen wurden gespeichert." +msgstr "Save options" + +#: workflows/views.py:2397 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "Feldtexte wurden gespeichert." +msgstr "Save field text" + +#: workflows/views.py:2404 +msgid "Bitte einen Titel für den benutzerdefinierten Abschnitt angeben." +msgstr "" + +#: workflows/views.py:2425 +msgid "Benutzerdefinierter Abschnitt wurde hinzugefügt." +msgstr "" + +#: workflows/views.py:2445 +msgid "Benutzerdefinierte Abschnitte wurden gespeichert." +msgstr "" + +#: workflows/views.py:2460 +msgid "Bitte eine Bezeichnung für das benutzerdefinierte Feld angeben." +msgstr "" + +#: workflows/views.py:2462 +msgid "Ungültiger Abschnitt für das benutzerdefinierte Feld." +msgstr "" + +#: workflows/views.py:2464 +#, fuzzy +#| msgid "Ungültige Rolle." +msgid "Ungültiger Feldtyp." +msgstr "Invalid role." + +#: workflows/views.py:2466 +msgid "Auswahlfelder benötigen mindestens eine Option." +msgstr "" + +#: workflows/views.py:2494 +msgid "Benutzerdefiniertes Feld wurde hinzugefügt." +msgstr "" + +#: workflows/views.py:2525 +#, python-format +msgid "Auswahlfeld \"%(label)s\" benötigt mindestens eine Option." +msgstr "" + +#: workflows/views.py:2530 +#, fuzzy +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "Benutzerdefinierte Felder wurden gespeichert." +msgstr "No configured fields in this step." + +#: workflows/views.py:2550 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Feldregeln wurden gespeichert." +msgstr "Save rules" + +#: workflows/views.py:2589 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Abschnittsregeln wurden gespeichert." +msgstr "Save rules" + +#: workflows/views.py:2610 +#, fuzzy +#| msgid "Branding speichern" +msgid "Bedingte Logik wurde gespeichert." +msgstr "Save branding" + +#: workflows/views.py:2617 +msgid "Preset wurde angewendet." +msgstr "" + +#: workflows/views.py:2619 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Preset konnte nicht angewendet werden." +msgstr "Password could not be saved" + +#: workflows/views.py:2904 +msgid "Visitenkarten-Details" +msgstr "" + +#: workflows/views.py:2905 +#, fuzzy +#| msgid "Vertragsbeginn" +msgid "Vertragsende" +msgstr "Contract start" + +#: workflows/views.py:2906 +#, fuzzy +#| msgid "Gruppenpostfach erklärt: %(item)s" +msgid "Gruppenpostfächer" +msgstr "Group mailbox explained: %(item)s" + +#: workflows/views.py:2907 +msgid "Zusätzliche Hardware" +msgstr "" + +#: workflows/views.py:2908 +msgid "Zusätzliche Software" +msgstr "" + +#: workflows/views.py:2909 +#, fuzzy +#| msgid "Zusätzlicher Zugang besprochen: %(item)s" +msgid "Zusätzliche Zugänge" +msgstr "Additional access discussed: %(item)s" + +#: workflows/views.py:2910 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Nachfolge" +msgstr "Save order" + +#: workflows/views.py:2913 +msgid "Steuert die Detailfelder für Visitenkarten." +msgstr "" + +#: workflows/views.py:2914 +msgid "Steuert das Enddatum bei befristeter Beschäftigung." +msgstr "" + +#: workflows/views.py:2915 +msgid "Steuert das Freitextfeld für Gruppenpostfächer." +msgstr "" + +#: workflows/views.py:2916 +msgid "Steuert zusätzliche Hardware-Felder." +msgstr "" + +#: workflows/views.py:2917 +msgid "Steuert zusätzliche Software-Felder." +msgstr "" + +#: workflows/views.py:2918 +msgid "Steuert zusätzliche Zugangsangaben." +msgstr "" + +#: workflows/views.py:2919 +msgid "Steuert Nachfolge- und Übernahmefelder." +msgstr "" + +#: workflows/views.py:2929 +msgid "Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes." +msgstr "" + +#: workflows/views.py:3015 +#, fuzzy +#| msgid "Abschnitt" +msgid "Alle Abschnitte" +msgstr "Section" + +#: workflows/views.py:3501 +#, fuzzy +#| msgid "Ungültige Rolle." +msgid "Ungültige JSON-Daten." +msgstr "Invalid role." + +#: workflows/views.py:3505 +#, fuzzy +#| msgid "Ungültige Rolle." +msgid "Ungültiger Formulartyp." +msgstr "Invalid role." + +#: workflows/views.py:3510 +msgid "Spalten-Daten fehlen." +msgstr "" + +#: workflows/views.py:3527 +#, fuzzy, python-format +#| msgid "Ungültige Rolle." +msgid "Ungültige Spalte: %(column)s" +msgstr "Invalid role." + +#: workflows/views.py:3585 +#, fuzzy +#| msgid "SMTP-Test starten" +msgid "SMTP-Test erfolgreich" +msgstr "Run SMTP test" + +#: workflows/views.py:3586 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Die SMTP-Testmail wurde erfolgreich gesendet." +msgstr "Save offboarding request" + +#: workflows/views.py:3595 +#, fuzzy +#| msgid "SMTP-Test" +msgid "SMTP-Test fehlgeschlagen" +msgstr "SMTP test" + +#: workflows/views.py:3601 +#, fuzzy, python-format +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "SMTP-Testmail konnte nicht gesendet werden: %(error)s" +msgstr "Password could not be saved" + +#: workflows/views.py:3626 +#, fuzzy +#| msgid "Nextcloud-Test starten" +msgid "Nextcloud-Test erfolgreich" +msgstr "Run Nextcloud test" + +#: workflows/views.py:3627 +msgid "Der Testupload nach Nextcloud war erfolgreich." +msgstr "" + +#: workflows/views.py:3637 workflows/views.py:3647 +#, fuzzy +#| msgid "Nextcloud-Test starten" +msgid "Nextcloud-Test fehlgeschlagen" +msgstr "Run Nextcloud test" + +#: workflows/views.py:3638 +msgid "Der Testupload nach Nextcloud ist fehlgeschlagen." +msgstr "" + +#, fuzzy +#~| msgid "Abschnitt" +#~ msgid "Aktive Ansicht" +#~ msgstr "Section" + +#, fuzzy +#~| msgid "Testmodus" +#~ msgid "Arbeitskontext" +#~ msgstr "Test mode" + +#, fuzzy +#~| msgid "Testmodus" +#~ msgid "Arbeitsmodus" +#~ msgstr "Test mode" + +#, fuzzy +#~| msgid "Letzte Anmeldung" +#~ msgid "Direkte Anordnung" +#~ msgstr "Last login" + +#, fuzzy +#~| msgid "Abschnitt" +#~ msgid "aktive Abschnitte" +#~ msgstr "Section" + +#, fuzzy +#~| msgid "Ausgeblendet" +#~ msgid "ausgeblendete Felder" +#~ msgstr "Hidden" + +#, fuzzy, python-format +#~| msgid "Keine konfigurierten Felder in diesem Schritt." +#~ msgid "%(count)s Feld/Felder in diesem Abschnitt." +#~ msgstr "No configured fields in this step." + +#, fuzzy +#~| msgid "Feld-Bedingung" +#~ msgid "Keine Bedingung" +#~ msgstr "Field condition" + +#, fuzzy +#~| msgid "Bundle" +#~ msgid "und" +#~ msgstr "Bundle" + +#, fuzzy +#~| msgid "Eingereicht" +#~ msgid "Bereiche" +#~ msgstr "Submitted" + +#, fuzzy +#~| msgid "Ausgeblendet" +#~ msgid "Aktuell ausgeblendet" +#~ msgstr "Hidden" + +#~ msgid "Feld" +#~ msgstr "Field" + +#~ msgid "Optionen verwalten" +#~ msgstr "Manage options" + +#, fuzzy +#~| msgid "Hilfetext (DE)" +#~ msgid "Hilfetext (DE, optional)" +#~ msgstr "Help text (DE)" + +#, fuzzy +#~| msgid "Hilfetext (EN)" +#~ msgid "Hilfetext (EN, optional)" +#~ msgstr "Help text (EN)" + +#, fuzzy +#~| msgid "Onboarding starten" +#~ msgid "Schlüssel" +#~ msgstr "Start onboarding" + +#~ msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." +#~ msgstr "Sort fields by drag and drop and group them by step." + +#~ msgid "Direkte Aktionen für Ihr Workdock-Konto." +#~ msgstr "Direct actions for your Workdock account." + +#~ msgid "Aktualisieren Sie Ihr Passwort direkt im Konto." +#~ msgstr "Update your password directly in your account." + +#~ msgid "Sitzung" +#~ msgstr "Session" + +#, fuzzy +#~| msgid "Setup Mail" +#~ msgid "Setup-Link" +#~ msgstr "Setup Mail" + +#, fuzzy +#~| msgid "Optionen speichern" +#~ msgid "Firmenkonfiguration speichern" +#~ msgstr "Save options" + +#, fuzzy +#~| msgid "Produktion" +#~ msgid "Product Owner" +#~ msgstr "Production" + +#~ msgid "TUBCO Onboarding & Offboarding Portal" +#~ msgstr "TUBCO Onboarding & Offboarding Portal" + #~ msgid "Die Passwörter stimmen nicht überein." #~ msgstr "The passwords do not match." -#~ msgid "Benutzer erstellen" -#~ msgstr "Create user" - #~ msgid "Backup läuft" #~ msgstr "Backup in progress" @@ -2947,23 +5954,12 @@ msgstr "Introduction was saved as draft." #~ msgid "Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen." #~ msgstr "Login failed. Please check your credentials." -#, fuzzy -#~| msgid "Nextcloud speichern" -#~ msgid "Nextcloud deaktivieren" -#~ msgstr "Save Nextcloud" - #~ msgid "Aktiv/Inaktiv direkt umschalten." #~ msgstr "Switch active/inactive directly." -#~ msgid "Aktivieren" -#~ msgstr "Enable" - #~ msgid "Zwischen Testmodus und Produktion wechseln." #~ msgstr "Switch between test mode and production." -#~ msgid "Auf" -#~ msgstr "To" - #~ msgid "SMTP Einstellungen" #~ msgstr "SMTP Settings" diff --git a/backend/media/templates/offboarding_template.html b/backend/media/templates/offboarding_template.html index af585a6..39a1d1e 100644 --- a/backend/media/templates/offboarding_template.html +++ b/backend/media/templates/offboarding_template.html @@ -1,5 +1,5 @@ - + @@ -29,12 +29,6 @@ letter-spacing: 0.2px; } - .sub { - margin: 2px 0 0 0; - color: #6b7280; - font-size: 9.5px; - } - .section { margin-top: 9px; font-size: 11px; @@ -54,6 +48,8 @@ border: 1px solid #f0e1e1; padding: 4px 6px; vertical-align: top; + overflow-wrap: anywhere; + word-break: break-word; } th { @@ -141,16 +137,6 @@ font-size: 9.4px; } - .manual-title { - margin: 9px 0 5px; - font-size: 10px; - font-weight: bold; - color: #111827; - } - - .manual-grid td { - width: 50%; - } @@ -158,25 +144,26 @@

{{ T.offboarding_title }}

-
{{ T.employee_info }}
- - - - - - - - - - - - - - - - - -
{{ T.name }}{{ FULL_NAME }}{{ T.email }}{{ EMAIL }}
{{ T.department }}{{ DEPARTMENT }}{{ T.job_title }}{{ JOB_TITLE }}
{{ T.last_working_day }}{{ LAST_WORKING_DAY }}
+ {% for section in PDF_SECTIONS %} + {% if section.has_content %} +
{{ section.title }}
+ + {% if section.scalar_rows %} + + {% for row in section.scalar_rows %} + + + {{ row[0].display_value }} + {% if row[1] %} + + + {% endif %} + + {% endfor %} +
{{ row[0].label }}{{ row[1].label }}{{ row[1].display_value }}
+ {% endif %} + {% endif %} + {% endfor %}
{{ T.offboarding_requester }}
@@ -294,14 +281,6 @@
{{ T.return_complete }}  {{ T.yes }}      {{ T.no }}
-
{{ T.notes }}
-
- - - - -
{{ T.notes }}{{ NOTES }}
-

{{ T.offboarding_note }}

diff --git a/backend/media/templates/onboarding_intro_template.html b/backend/media/templates/onboarding_intro_template.html index 8be677a..5e4639a 100644 --- a/backend/media/templates/onboarding_intro_template.html +++ b/backend/media/templates/onboarding_intro_template.html @@ -134,6 +134,7 @@ vertical-align: bottom; margin: 0 6px; } + diff --git a/backend/media/templates/onboarding_template.html b/backend/media/templates/onboarding_template.html index 273619e..36abe06 100644 --- a/backend/media/templates/onboarding_template.html +++ b/backend/media/templates/onboarding_template.html @@ -29,12 +29,6 @@ letter-spacing: 0.2px; } - .sub { - margin: 2px 0 0 0; - color: #475569; - font-size: 9.5px; - } - .section { margin-top: 9px; font-size: 11px; @@ -101,11 +95,6 @@ word-break: break-word; } - .empty { - color: #94a3b8; - font-style: italic; - } - .signature { width: 150px; height: 70px; @@ -132,7 +121,9 @@

{{ T.onboarding_title }}

-
{{ T.onboarding_staff_data }}
+
{% if PDF_LANG == 'en' %}Master data{% else %}Stammdaten{% endif %}
+
+
{{ T.onboarding_staff_data }}
@@ -161,8 +152,10 @@
{{ T.name }}{{ UEBERGABEDATUM }}
+
-
{{ T.equipment_access }}
+ {% if HAS_DEVICES or HAS_GROUPS or HAS_SOFTWARE or HAS_ACCESSES or HAS_RESOURCES or HAS_GROUP_MAILBOXES or HAS_ADDITIONAL_HARDWARE or HAS_ADDITIONAL_SOFTWARE or HAS_ADDITIONAL_ACCESS %} +
{% if PDF_LANG == 'en' %}IT setup{% else %}IT-Setup{% endif %}
{% if HAS_DEVICES %}
@@ -307,6 +300,7 @@
{% endif %} + {% endif %} {% if (VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN) or HAS_ADDITIONAL_HARDWARE_OTHER or HAS_SUCCESSOR_INFO or HAS_ADDITIONAL_NOTES %}
{{ T.additional_details }}
diff --git a/backend/requirements.txt b/backend/requirements.txt index 918b900..172d22d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ pypdf==5.1.0 jinja2==3.1.4 xhtml2pdf==0.2.16 gunicorn==23.0.0 +qrcode==8.2 diff --git a/backend/workflows/account_views.py b/backend/workflows/account_views.py new file mode 100644 index 0000000..6b173a8 --- /dev/null +++ b/backend/workflows/account_views.py @@ -0,0 +1,211 @@ +from io import BytesIO + +from django.contrib import messages +from django.contrib.auth import get_user_model, login as auth_login +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .branding import get_branding_email_copy +from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm +from .models import UserProfile +from .roles import get_user_role_label +from .totp import build_otpauth_uri, generate_recovery_codes, generate_totp_secret + + +def login_page_impl(request): + if getattr(request.user, 'is_authenticated', False): + return redirect('home') + + next_target = (request.POST.get('next') or request.GET.get('next') or '').strip() + form = AppLoginForm(request=request, data=request.POST or None) + if request.method == 'POST' and form.is_valid(): + user = form.get_user() + profile, _ = UserProfile.objects.get_or_create(user=user) + safe_next = next_target if next_target.startswith('/') else '' + if profile.totp_enabled: + request.session['login_pending_user_id'] = user.pk + request.session['login_pending_backend'] = getattr(user, 'backend', '') + request.session['login_pending_next'] = safe_next + return redirect('login_totp') + + auth_login(request, user, backend=getattr(user, 'backend', None)) + now_ts = int(timezone.now().timestamp()) + request.session['auth_fresh_ts'] = now_ts + request.session['last_activity_ts'] = now_ts + return redirect(safe_next or reverse('home')) + + request.session.pop('login_pending_user_id', None) + request.session.pop('login_pending_backend', None) + request.session.pop('login_pending_next', None) + return render( + request, + 'workflows/auth/login.html', + { + 'form': form, + 'next': next_target, + 'login_step': 'password', + }, + ) + +def login_totp_page_impl(request): + if getattr(request.user, 'is_authenticated', False): + return redirect('home') + + pending_user_id = request.session.get('login_pending_user_id') + backend_path = (request.session.get('login_pending_backend') or '').strip() + next_target = (request.session.get('login_pending_next') or '').strip() + if not pending_user_id: + return redirect('login') + + user = get_object_or_404(get_user_model(), pk=pending_user_id) + profile, _ = UserProfile.objects.get_or_create(user=user) + if not profile.totp_enabled: + request.session.pop('login_pending_user_id', None) + request.session.pop('login_pending_backend', None) + request.session.pop('login_pending_next', None) + return redirect('login') + + show_recovery = request.method == 'POST' and bool((request.POST.get('recovery_code') or '').strip()) + form = AppTOTPChallengeForm(data=request.POST or None, profile=profile) + if request.method == 'POST' and form.is_valid(): + auth_login(request, user, backend=backend_path or 'django.contrib.auth.backends.ModelBackend') + request.session.pop('login_pending_user_id', None) + request.session.pop('login_pending_backend', None) + request.session.pop('login_pending_next', None) + now_ts = int(timezone.now().timestamp()) + request.session['auth_fresh_ts'] = now_ts + request.session['last_activity_ts'] = now_ts + return redirect(next_target if next_target.startswith('/') else reverse('home')) + + return render( + request, + 'workflows/auth/login.html', + { + 'form': form, + 'next': next_target, + 'login_step': 'totp', + 'login_totp_user': user, + 'show_recovery_code': show_recovery, + }, + ) + +def account_profile_page_impl(request): + session_secret_key = 'account_totp_pending_secret' + session_codes_key = 'account_totp_recovery_codes' + profile, created = UserProfile.objects.get_or_create(user=request.user) + recovery_codes = request.session.pop(session_codes_key, []) + pending_totp_secret = request.session.get(session_secret_key) or '' + if profile.totp_enabled: + pending_totp_secret = '' + request.session.pop(session_secret_key, None) + elif not pending_totp_secret: + pending_totp_secret = generate_totp_secret() + request.session[session_secret_key] = pending_totp_secret + + avatar_form = AccountAvatarForm(instance=profile) + details_form = AccountDetailsForm(user=request.user, profile=profile) + notification_preferences_form = AccountNotificationPreferencesForm(profile=profile, user=request.user) + totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret) + totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile) + totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(user=request.user, profile=profile) + account_edit_open = False + notifications_edit_open = False + totp_edit_open = False + if request.method == 'POST': + form_kind = (request.POST.get('account_form') or '').strip() + if form_kind == 'avatar': + avatar_form = AccountAvatarForm(request.POST, request.FILES, instance=profile) + if avatar_form.is_valid(): + avatar_form.save() + messages.success(request, _('Profilbild gespeichert.')) + return redirect('account_profile_page') + messages.error(request, _('Profilbild konnte nicht gespeichert werden.')) + elif form_kind == 'details': + account_edit_open = True + details_form = AccountDetailsForm(request.POST, user=request.user, profile=profile) + if details_form.is_valid(): + details_form.save() + messages.success(request, _('Profildaten gespeichert.')) + return redirect('account_profile_page') + messages.error(request, _('Profildaten konnten nicht gespeichert werden.')) + elif form_kind == 'notification_preferences': + notifications_edit_open = True + notification_preferences_form = AccountNotificationPreferencesForm(request.POST, profile=profile, user=request.user) + if notification_preferences_form.is_valid(): + notification_preferences_form.save() + messages.success(request, _('Benachrichtigungseinstellungen gespeichert.')) + return redirect('account_profile_page') + messages.error(request, _('Benachrichtigungseinstellungen konnten nicht gespeichert werden.')) + elif form_kind == 'totp_enable': + totp_edit_open = True + totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret) + if totp_enable_form.is_valid(): + recovery_codes = generate_recovery_codes() + profile.enable_totp(pending_totp_secret, recovery_codes) + request.session[session_codes_key] = recovery_codes + request.session.pop(session_secret_key, None) + messages.success(request, _('TOTP wurde aktiviert.')) + return redirect('account_profile_page') + messages.error(request, _('TOTP konnte nicht aktiviert werden.')) + elif form_kind == 'totp_disable': + totp_edit_open = True + totp_disable_form = AccountTOTPDisableForm(request.POST, user=request.user, profile=profile) + if totp_disable_form.is_valid(): + profile.disable_totp() + request.session.pop(session_secret_key, None) + messages.success(request, _('TOTP wurde deaktiviert.')) + return redirect('account_profile_page') + messages.error(request, _('TOTP konnte nicht deaktiviert werden.')) + elif form_kind == 'totp_regenerate_codes': + totp_edit_open = True + totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(request.POST, user=request.user, profile=profile) + if totp_regenerate_form.is_valid(): + recovery_codes = generate_recovery_codes() + profile.set_recovery_codes(recovery_codes) + profile.save(update_fields=['totp_recovery_codes', 'updated_at']) + request.session[session_codes_key] = recovery_codes + messages.success(request, _('Recovery-Codes wurden neu erzeugt.')) + return redirect('account_profile_page') + messages.error(request, _('Recovery-Codes konnten nicht neu erzeugt werden.')) + + branding_context = get_branding_email_copy() + totp_account_name = (request.user.email or request.user.username or '').strip() + totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip() + totp_otpauth_uri = '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer) + totp_qr_svg = '' + if totp_otpauth_uri: + try: + import qrcode + import qrcode.image.svg + + qr_image = qrcode.make(totp_otpauth_uri, image_factory=qrcode.image.svg.SvgPathImage) + stream = BytesIO() + qr_image.save(stream) + totp_qr_svg = stream.getvalue().decode('utf-8') + except Exception: + totp_qr_svg = '' + return render( + request, + 'workflows/account_profile.html', + { + 'account_user': request.user, + 'account_user_profile': profile, + 'avatar_form': avatar_form, + 'details_form': details_form, + 'notification_preferences_form': notification_preferences_form, + 'notification_preference_groups': notification_preferences_form.grouped_fields(), + 'totp_enable_form': totp_enable_form, + 'totp_disable_form': totp_disable_form, + 'totp_regenerate_form': totp_regenerate_form, + 'account_edit_open': account_edit_open, + 'notifications_edit_open': notifications_edit_open, + 'totp_edit_open': totp_edit_open, + 'role_label': get_user_role_label(request.user), + 'totp_pending_secret': pending_totp_secret, + 'totp_otpauth_uri': totp_otpauth_uri, + 'totp_qr_svg': totp_qr_svg, + 'totp_recovery_codes': recovery_codes, + }, + ) diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index 5895ee7..3f683b5 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -3,7 +3,7 @@ from django.conf import settings from django import forms from .emailing import send_system_email -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig @admin.register(EmployeeProfile) @@ -20,6 +20,48 @@ class AdminAuditLogAdmin(admin.ModelAdmin): ordering = ('-created_at', '-id') +@admin.register(AsyncTaskLog) +class AsyncTaskLogAdmin(admin.ModelAdmin): + list_display = ('started_at', 'task_name', 'status', 'target_type', 'target_id', 'target_label', 'task_id') + list_filter = ('status', 'task_name', 'target_type', 'started_at') + search_fields = ('task_name', 'task_id', 'target_label', 'error_message') + ordering = ('-started_at', '-id') + + +@admin.register(PortalBranding) +class PortalBrandingAdmin(admin.ModelAdmin): + list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at') + + +@admin.register(PortalCompanyConfig) +class PortalCompanyConfigAdmin(admin.ModelAdmin): + list_display = ('name', 'legal_company_name', 'website_url', 'hr_contact_email', 'it_contact_email', 'updated_at') + + +@admin.register(PortalTrialConfig) +class PortalTrialConfigAdmin(admin.ModelAdmin): + list_display = ('name', 'is_trial_mode', 'trial_expires_at', 'restrict_production_integrations', 'auto_cleanup_enabled', 'updated_at') + + +@admin.register(PortalAppConfig) +class PortalAppConfigAdmin(admin.ModelAdmin): + list_display = ( + 'key', + 'section', + 'sort_order', + 'is_enabled', + 'visible_to_super_admin', + 'visible_to_admin', + 'visible_to_it_staff', + 'visible_to_staff', + 'updated_at', + ) + list_filter = ('section', 'is_enabled', 'visible_to_super_admin', 'visible_to_admin', 'visible_to_it_staff', 'visible_to_staff') + search_fields = ('key', 'title_override', 'title_override_en') + ordering = ('section', 'sort_order', 'key') + list_editable = ('section', 'sort_order', 'is_enabled', 'visible_to_super_admin', 'visible_to_admin', 'visible_to_it_staff', 'visible_to_staff') + + @admin.register(OnboardingRequest) class OnboardingRequestAdmin(admin.ModelAdmin): list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at') diff --git a/backend/workflows/admin_config_views.py b/backend/workflows/admin_config_views.py new file mode 100644 index 0000000..91b4ef6 --- /dev/null +++ b/backend/workflows/admin_config_views.py @@ -0,0 +1,410 @@ +from datetime import timedelta + +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .app_registry import get_portal_app_registry_rows, normalize_portal_app_sort_orders +from .branding import get_portal_trial_config, is_trial_expired +from .forms import PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm +from .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile +from .notifications import notify_user +from .roles import ROLE_GROUP_NAMES, ROLE_PLATFORM_OWNER, get_user_role_key + + +def portal_app_registry_page_impl(request, *, translate_choice_list): + return render( + request, + 'workflows/app_registry.html', + { + 'rows': get_portal_app_registry_rows(), + 'section_choices': translate_choice_list(PortalAppConfig.SECTION_CHOICES), + }, + ) + +def save_portal_app_registry_impl(request, *, audit_fn): + rows = get_portal_app_registry_rows() + updated_configs = [] + for row in rows: + config = row['config'] + key = config.key + config.section = (request.POST.get(f'section__{key}') or config.section).strip() + if config.section not in dict(PortalAppConfig.SECTION_CHOICES): + config.section = row['default_section'] + config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on' + config.visible_to_super_admin = request.POST.get(f'visible_to_super_admin__{key}') == 'on' + config.visible_to_admin = request.POST.get(f'visible_to_admin__{key}') == 'on' + config.visible_to_it_staff = request.POST.get(f'visible_to_it_staff__{key}') == 'on' + config.visible_to_staff = request.POST.get(f'visible_to_staff__{key}') == 'on' + try: + config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order']) + except ValueError: + config.sort_order = row['default_sort_order'] + config.title_override = (request.POST.get(f'title_override__{key}') or '').strip() + config.title_override_en = (request.POST.get(f'title_override_en__{key}') or '').strip() + config.description_override = (request.POST.get(f'description_override__{key}') or '').strip() + config.description_override_en = (request.POST.get(f'description_override_en__{key}') or '').strip() + config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip() + config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip() + config.save() + updated_configs.append(config) + + normalize_portal_app_sort_orders() + + audit_fn( + request, + 'portal_app_registry_saved', + target_type='portal_app_registry', + target_label='Portal App Registry', + details={'updated_apps': len(rows)}, + ) + messages.success(request, _('App-Registry gespeichert.')) + return redirect('portal_app_registry_page') + +def user_management_page_impl(request, *, render_user_management_fn): + return render_user_management_fn(request) + +def portal_branding_page_impl(request, *, build_branding_sections_fn): + branding, created = PortalBranding.objects.get_or_create(name='Default') + form = PortalBrandingForm(instance=branding) + return render( + request, + 'workflows/branding_settings.html', + { + 'form': form, + 'branding': branding, + 'branding_sections': build_branding_sections_fn(form, branding), + 'editing_branding_section': '', + }, + ) + +def save_portal_branding_impl(request, *, audit_fn, build_branding_sections_fn): + branding, created = PortalBranding.objects.get_or_create(name='Default') + section_key = (request.POST.get('section_key') or '').strip() + data = request.POST.copy() + for field_name in PortalBrandingForm.Meta.fields: + if field_name not in data: + field = PortalBranding._meta.get_field(field_name) + if getattr(field, 'many_to_many', False): + continue + if getattr(field, 'null', False) and getattr(branding, field_name, None) is None: + data[field_name] = '' + else: + data[field_name] = getattr(branding, field_name, '') or '' + form = PortalBrandingForm(data, request.FILES, instance=branding) + if not form.is_valid(): + messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) + return render( + request, + 'workflows/branding_settings.html', + { + 'form': form, + 'branding': branding, + 'branding_sections': build_branding_sections_fn(form, branding), + 'editing_branding_section': section_key, + }, + status=400, + ) + + branding = form.save() + audit_fn( + request, + 'portal_branding_saved', + target_type='portal_branding', + target_id=branding.id, + target_label=branding.portal_title, + details={ + 'company_name': branding.company_name, + 'support_email': branding.support_email, + 'default_language': branding.default_language, + 'has_custom_logo': bool(branding.logo_image), + 'has_custom_letterhead': bool(branding.pdf_letterhead), + }, + ) + messages.success(request, _('Portal-Branding wurde gespeichert.')) + return render( + request, + 'workflows/branding_settings.html', + { + 'form': PortalBrandingForm(instance=branding), + 'branding': branding, + 'branding_sections': build_branding_sections_fn(PortalBrandingForm(instance=branding), branding), + 'editing_branding_section': '', + }, + ) + +def portal_company_config_page_impl(request, *, build_company_config_sections_fn): + company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') + form = PortalCompanyConfigForm(instance=company_config) + return render( + request, + 'workflows/company_config.html', + { + 'form': form, + 'company_config': company_config, + 'company_config_sections': build_company_config_sections_fn(form, company_config), + 'editing_company_section': '', + }, + ) + +def save_portal_company_config_impl(request, *, audit_fn, build_company_config_sections_fn): + company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') + section_key = (request.POST.get('section_key') or '').strip() + data = request.POST.copy() + for field_name in PortalCompanyConfigForm.Meta.fields: + if field_name not in data: + data[field_name] = getattr(company_config, field_name, '') or '' + form = PortalCompanyConfigForm(data, instance=company_config) + if not form.is_valid(): + messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) + return render( + request, + 'workflows/company_config.html', + { + 'form': form, + 'company_config': company_config, + 'company_config_sections': build_company_config_sections_fn(form, company_config), + 'editing_company_section': section_key, + }, + status=400, + ) + + company_config = form.save() + audit_fn( + request, + 'portal_company_config_saved', + target_type='portal_company_config', + target_id=company_config.id, + target_label=company_config.legal_company_name or 'Default', + details={ + 'website_url': company_config.website_url, + 'imprint_url': company_config.imprint_url, + 'privacy_url': company_config.privacy_url, + 'hr_contact_email': company_config.hr_contact_email, + 'it_contact_email': company_config.it_contact_email, + 'operations_contact_email': company_config.operations_contact_email, + }, + ) + messages.success(request, _('Firmenkonfiguration wurde gespeichert.')) + return render( + request, + 'workflows/company_config.html', + { + 'form': PortalCompanyConfigForm(instance=company_config), + 'company_config': company_config, + 'company_config_sections': build_company_config_sections_fn(PortalCompanyConfigForm(instance=company_config), company_config), + 'editing_company_section': '', + }, + ) + +def portal_trial_config_page_impl(request): + trial_config = get_portal_trial_config() + form = PortalTrialConfigForm(instance=trial_config) + return render( + request, + 'workflows/trial_management.html', + { + 'form': form, + 'trial_config': trial_config, + 'trial_is_expired': is_trial_expired(), + }, + ) + +def save_portal_trial_config_impl(request, *, audit_fn): + trial_config = get_portal_trial_config() + form = PortalTrialConfigForm(request.POST, instance=trial_config) + if not form.is_valid(): + messages.error(request, _('Trial-Konfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) + return render( + request, + 'workflows/trial_management.html', + { + 'form': form, + 'trial_config': trial_config, + 'trial_is_expired': is_trial_expired(), + }, + status=400, + ) + + trial_config = form.save() + audit_fn( + request, + 'portal_trial_config_saved', + target_type='portal_trial_config', + target_id=trial_config.id, + target_label='Default', + details={ + 'is_trial_mode': trial_config.is_trial_mode, + 'trial_started_at': trial_config.trial_started_at.isoformat() if trial_config.trial_started_at else '', + 'trial_expires_at': trial_config.trial_expires_at.isoformat() if trial_config.trial_expires_at else '', + 'restrict_production_integrations': trial_config.restrict_production_integrations, + 'auto_cleanup_enabled': trial_config.auto_cleanup_enabled, + }, + ) + if trial_config.is_trial_mode and trial_config.trial_expires_at: + remaining = trial_config.trial_expires_at - timezone.now() + if remaining.total_seconds() <= 0: + notify_user( + user=request.user, + title=_('Trial ist abgelaufen'), + body=_('Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt blockiert.'), + level=UserNotification.LEVEL_WARNING, + link_url='/admin-tools/trial/', + event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS, + ) + elif remaining <= timedelta(days=7): + notify_user( + user=request.user, + title=_('Trial läuft bald ab'), + body=_('Der Trial endet am %(date)s.') % {'date': timezone.localtime(trial_config.trial_expires_at).strftime('%d.%m.%Y %H:%M')}, + level=UserNotification.LEVEL_WARNING, + link_url='/admin-tools/trial/', + event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS, + ) + elif not trial_config.is_trial_mode: + notify_user( + user=request.user, + title=_('Trial-Modus deaktiviert'), + body=_('Der Trial-Modus wurde ausgeschaltet.'), + level=UserNotification.LEVEL_INFO, + link_url='/admin-tools/trial/', + event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS, + ) + messages.success(request, _('Trial-Konfiguration wurde gespeichert.')) + return render( + request, + 'workflows/trial_management.html', + { + 'form': PortalTrialConfigForm(instance=trial_config), + 'trial_config': trial_config, + 'trial_is_expired': is_trial_expired(), + }, + ) + +def create_user_from_admin_impl(request, *, render_user_management_fn, send_user_access_email_fn, audit_fn, display_user_name_fn): + form = UserManagementCreateForm(request.POST, include_product_owner=(get_user_role_key(request.user) == ROLE_PLATFORM_OWNER)) + if not form.is_valid(): + messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.')) + return render_user_management_fn(request, create_form=form, status_code=400) + + user = form.save() + send_user_access_email_fn(request, user, invitation=True) + audit_fn( + request, + 'user_created', + target_type='user', + target_id=user.id, + target_label=display_user_name_fn(user), + details={'username': user.username, 'role': get_user_role_key(user), 'invitation_sent': True}, + ) + messages.success(request, _('Benutzer wurde erstellt und eingeladen: %(username)s') % {'username': user.username}) + return redirect('user_management_page') + +def update_user_from_admin_impl(request, user_id: int, *, would_remove_last_platform_owner_fn, would_remove_last_super_admin_fn, audit_fn, display_user_name_fn): + user_model = get_user_model() + target_user = get_object_or_404(user_model, id=user_id) + role_key = (request.POST.get('role_key') or '').strip() + is_active = request.POST.get('is_active') == 'on' + new_password = (request.POST.get('new_password') or '').strip() + + if role_key not in ROLE_GROUP_NAMES: + messages.error(request, _('Ungültige Rolle.')) + return redirect('user_management_page') + if role_key == ROLE_PLATFORM_OWNER and get_user_role_key(request.user) != ROLE_PLATFORM_OWNER: + messages.error(request, _('Nur Platform Owner dürfen diese Rolle vergeben.')) + return redirect('user_management_page') + + current_role = get_user_role_key(request.user) + if target_user == request.user and current_role == ROLE_PLATFORM_OWNER and (role_key != ROLE_PLATFORM_OWNER or not is_active): + messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst sperren oder herabstufen.')) + return redirect('user_management_page') + if target_user == request.user and current_role == ROLE_SUPER_ADMIN and (role_key != ROLE_SUPER_ADMIN or not is_active): + messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.')) + return redirect('user_management_page') + if would_remove_last_platform_owner_fn(target_user, new_role_key=role_key, new_is_active=is_active): + messages.error(request, _('Der letzte aktive Platform Owner kann nicht deaktiviert oder herabgestuft werden.')) + return redirect('user_management_page') + if would_remove_last_super_admin_fn(target_user, new_role_key=role_key, new_is_active=is_active): + messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.')) + return redirect('user_management_page') + + assign_user_role(target_user, role_key) + target_user.is_active = is_active + if new_password: + target_user.set_password(new_password) + target_user.save() + + audit_fn( + request, + 'user_updated', + target_type='user', + target_id=target_user.id, + target_label=display_user_name_fn(target_user), + details={'username': target_user.username, 'role': role_key, 'is_active': is_active, 'password_changed': bool(new_password)}, + ) + messages.success(request, _('Benutzer wurde aktualisiert: %(username)s') % {'username': target_user.username}) + return redirect('user_management_page') + +def send_password_reset_from_admin_impl(request, user_id: int, *, send_user_access_email_fn, audit_fn, display_user_name_fn): + user_model = get_user_model() + target_user = get_object_or_404(user_model, id=user_id) + try: + send_user_access_email_fn(request, target_user, invitation=False) + except ValueError as exc: + messages.error(request, str(exc)) + return redirect('user_management_page') + audit_fn( + request, + 'user_password_reset_sent', + target_type='user', + target_id=target_user.id, + target_label=display_user_name_fn(target_user), + details={'username': target_user.username, 'email': target_user.email}, + ) + messages.success(request, _('Passwort-Reset-Link wurde versendet: %(username)s') % {'username': target_user.username}) + return redirect('user_management_page') + +def delete_user_from_admin_impl(request, user_id: int, *, would_remove_last_platform_owner_fn, would_remove_last_super_admin_fn, audit_fn, display_user_name_fn): + user_model = get_user_model() + target_user = get_object_or_404(user_model, id=user_id) + + current_role = get_user_role_key(request.user) + if target_user == request.user and current_role == ROLE_PLATFORM_OWNER: + messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst löschen.')) + return redirect('user_management_page') + if target_user == request.user: + messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.')) + return redirect('user_management_page') + if would_remove_last_platform_owner_fn(target_user, deleting=True): + messages.error(request, _('Der letzte aktive Platform Owner kann nicht gelöscht werden.')) + return redirect('user_management_page') + if would_remove_last_super_admin_fn(target_user, deleting=True): + messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.')) + return redirect('user_management_page') + + target_label = display_user_name_fn(target_user) + username = target_user.username + target_user.delete() + audit_fn( + request, + 'user_deleted', + target_type='user', + target_label=target_label, + details={'username': username}, + ) + messages.success(request, _('Benutzer wurde gelöscht: %(username)s') % {'username': username}) + return redirect('user_management_page') + +def handbook_page_impl(request): + return render(request, 'workflows/handbook.html') + +def project_wiki_page_impl(request): + return render(request, 'workflows/project_wiki.html') + +def developer_handbook_page_impl(request): + return render(request, 'workflows/developer_handbook.html') + +def release_checklist_page_impl(request): + return render(request, 'workflows/release_checklist.html') diff --git a/backend/workflows/admin_section_builders.py b/backend/workflows/admin_section_builders.py new file mode 100644 index 0000000..882f127 --- /dev/null +++ b/backend/workflows/admin_section_builders.py @@ -0,0 +1,109 @@ +from django.utils.translation import gettext as _ + + +def build_branding_sections(form, branding): + sections = [ + { + 'key': 'identity', + 'title': _('Identität'), + 'subtitle': _('Titel, Firmenname und zentrale Spracheinstellungen.'), + 'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'], + 'field_full': {'login_subtitle'}, + 'hint_map': { + 'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. workdock.de.'), + }, + }, + { + 'key': 'appearance', + 'title': _('Farben & Erscheinungsbild'), + 'subtitle': _('Zentrale visuelle Markenwerte und Browser-Icon.'), + 'fields': ['primary_color', 'secondary_color', 'logo_image', 'favicon_image'], + 'field_full': set(), + 'hint_map': { + 'logo_image': _('Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB.'), + 'favicon_image': _('Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB.'), + }, + }, + { + 'key': 'communication', + 'title': _('Kommunikation'), + 'subtitle': _('Absender, Support und PDF-Branding für ausgehende Kommunikation.'), + 'fields': ['support_email', 'sender_display_name', 'pdf_letterhead'], + 'field_full': {'pdf_letterhead'}, + 'hint_map': { + 'sender_display_name': _('Wird für ausgehende System-E-Mails als Anzeigename verwendet.'), + 'pdf_letterhead': _('Erlaubtes Format: PDF. Maximal 10 MB.'), + }, + }, + { + 'key': 'legal', + 'title': _('Footer & Rechtliches'), + 'subtitle': _('Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell.'), + 'fields': ['footer_text', 'legal_notice', 'footer_text_en', 'legal_notice_en'], + 'field_full': {'legal_notice', 'legal_notice_en'}, + 'hint_map': {}, + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + value = getattr(branding, field_name, '') or '' + is_file = bool(getattr(field.field.widget, 'input_type', '') == 'file') + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': value, + 'is_file': is_file, + 'is_full': field_name in section.get('field_full', set()), + 'hint': section.get('hint_map', {}).get(field_name, ''), + } + ) + section['rows'] = rows + return sections + + +def build_company_config_sections(form, company_config): + sections = [ + { + 'key': 'profile', + 'title': _('Firmenprofil'), + 'subtitle': _('Rechtlicher Name und zentrale Stammdaten der Firma.'), + 'fields': ['legal_company_name', 'phone_number', 'website_url', 'country'], + }, + { + 'key': 'address', + 'title': _('Adresse & Register'), + 'subtitle': _('Anschrift sowie optionale Register- und Steuerangaben.'), + 'fields': ['street_address', 'postal_code', 'city', 'registration_number', 'vat_id'], + }, + { + 'key': 'contacts', + 'title': _('Kontaktpunkte'), + 'subtitle': _('Zentrale Ansprechpartner für HR, IT und Operations.'), + 'fields': ['hr_contact_email', 'it_contact_email', 'operations_contact_email'], + }, + { + 'key': 'public', + 'title': _('Recht & Öffentlichkeit'), + 'subtitle': _('Öffentliche Links für Website, Impressum und Datenschutz.'), + 'fields': ['imprint_url', 'privacy_url'], + 'hint': _('Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden.'), + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': getattr(company_config, field_name, '') or '', + } + ) + section['rows'] = rows + return sections diff --git a/backend/workflows/admin_user_helpers.py b/backend/workflows/admin_user_helpers.py new file mode 100644 index 0000000..ab270a1 --- /dev/null +++ b/backend/workflows/admin_user_helpers.py @@ -0,0 +1,145 @@ +from django.contrib.auth import get_user_model +from django.shortcuts import render +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from django.utils.translation import gettext as _ +from django.urls import reverse +from django.contrib.auth.tokens import default_token_generator + +from .branding import get_branding_email_copy +from .forms import UserManagementCreateForm +from .models import AdminAuditLog +from .emailing import send_system_email +from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, get_user_role_key + + +def user_management_rows(*, display_user_name_fn): + user_model = get_user_model() + role_order = { + ROLE_PLATFORM_OWNER: 0, + ROLE_SUPER_ADMIN: 0, + 'admin': 1, + 'it_staff': 2, + 'staff': 3, + } + rows = [] + for user in user_model.objects.all().order_by('-is_active', 'username'): + role_key = get_user_role_key(user) + rows.append( + { + 'user': user, + 'role_key': role_key, + 'role_label': str(ROLE_LABELS[role_key]), + 'role_sort': role_order.get(role_key, 99), + 'display_name': display_user_name_fn(user), + } + ) + rows.sort(key=lambda item: (not item['user'].is_active, item['role_sort'], item['user'].username.lower())) + return rows + + +def render_user_management(request, *, create_form=None, status_code: int = 200, audit_action_label_fn, display_user_name_fn): + recent_user_events = list( + AdminAuditLog.objects.select_related('actor') + .filter(action__in=['user_created', 'user_updated', 'user_password_reset_sent', 'user_deleted']) + .order_by('-created_at', '-id')[:12] + ) + for row in recent_user_events: + row.action_label = audit_action_label_fn(row.action) + role_key = (row.details or {}).get('role') + row.role_label = str(ROLE_LABELS[role_key]) if role_key in ROLE_LABELS else role_key + include_product_owner = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER + return render( + request, + 'workflows/user_management.html', + { + 'create_form': create_form or UserManagementCreateForm(include_product_owner=include_product_owner), + 'rows': user_management_rows(display_user_name_fn=display_user_name_fn), + 'role_choices': [ + (key, str(ROLE_LABELS[key])) + for key in ROLE_GROUP_NAMES + if include_product_owner or key != ROLE_PLATFORM_OWNER + ], + 'include_product_owner': include_product_owner, + 'recent_user_events': recent_user_events, + }, + status=status_code, + ) + + +def platform_owner_user_count() -> int: + user_model = get_user_model() + return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_PLATFORM_OWNER and user.is_active) + + +def super_admin_user_count() -> int: + user_model = get_user_model() + return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active) + + +def would_remove_last_super_admin(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: + if get_user_role_key(user) != ROLE_SUPER_ADMIN or not user.is_active: + return False + if super_admin_user_count() > 1: + return False + if deleting: + return True + if new_role_key is not None and new_role_key != ROLE_SUPER_ADMIN: + return True + if new_is_active is not None and not new_is_active: + return True + return False + + +def would_remove_last_platform_owner(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: + if get_user_role_key(user) != ROLE_PLATFORM_OWNER or not user.is_active: + return False + if platform_owner_user_count() > 1: + return False + if deleting: + return True + if new_role_key is not None and new_role_key != ROLE_PLATFORM_OWNER: + return True + if new_is_active is not None and not new_is_active: + return True + return False + + +def send_user_access_email(request, target_user, *, invitation: bool, display_user_name_fn) -> None: + email = (target_user.email or '').strip() + if not email: + raise ValueError(_('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.')) + + uid = urlsafe_base64_encode(force_bytes(target_user.pk)) + token = default_token_generator.make_token(target_user) + reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) + reset_url = request.build_absolute_uri(reset_path) + branding_copy = get_branding_email_copy() + + if invitation: + subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username} + body = _( + 'Hallo %(name)s,\n\n' + 'für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n' + 'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n' + '%(url)s\n\n' + 'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.' + ) % { + 'name': display_user_name_fn(target_user), + 'portal_title': branding_copy['portal_title'], + 'url': reset_url, + } + else: + subject = _('Passwort zurücksetzen für %(username)s') % {'username': target_user.username} + body = _( + 'Hallo %(name)s,\n\n' + 'für Ihr Konto wurde ein Link zum Zurücksetzen des Passworts erstellt.\n' + 'Bitte öffnen Sie den folgenden Link:\n' + '%(url)s\n\n' + 'Wenn Sie diese Anfrage nicht erwartet haben, können Sie diese E-Mail ignorieren.' + ) % { + 'name': display_user_name_fn(target_user), + 'url': reset_url, + } + + send_system_email(subject=subject, body=body, to=[email]) diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py new file mode 100644 index 0000000..3e9db5b --- /dev/null +++ b/backend/workflows/app_registry.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from .models import PortalAppConfig +from .roles import ROLE_ADMIN, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, get_user_role_key, user_has_capability + +# The registry controls discoverability and packaging posture for apps. +# Actual authorization still comes from role capabilities in roles.py. + + +@dataclass(frozen=True) +class AppDefinition: + key: str + section: str + route_name: str + title: object + description: object + action_label: object + capability: str | None = None + accent: str = '' + accent_label: str = 'APP' + style_variant: str = '' + tags: tuple[object, ...] = () + + +APP_DEFINITIONS: tuple[AppDefinition, ...] = ( + AppDefinition( + key='onboarding', + section=PortalAppConfig.SECTION_APP, + route_name='onboarding_create', + title=_('Onboarding'), + description=_('Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen.'), + action_label=_('Onboarding starten'), + accent='ON', + tags=(_('Mehrschritt-Formular'), 'PDF', _('E-Mail Routing')), + style_variant='primary', + ), + AppDefinition( + key='offboarding', + section=PortalAppConfig.SECTION_APP, + route_name='offboarding_create', + title=_('Offboarding'), + description=_('Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten.'), + action_label=_('Offboarding starten'), + accent='OFF', + tags=(_('Profile-Suche'), _('Hardware-Liste'), _('IT-Rückgabe')), + style_variant='red', + ), + AppDefinition( + key='requests_dashboard', + section=PortalAppConfig.SECTION_APP, + route_name='requests_dashboard', + title=_('Anfragen Dashboard'), + description=_('Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen.'), + action_label=_('Dashboard öffnen'), + capability='access_requests_dashboard', + accent='APP', + tags=(_('Suche'), _('Status'), _('PDF Zugriff')), + ), + AppDefinition( + key='company_config', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_company_config_page', + title=_('Company Config'), + description=_('Rechtliche Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks pflegen.'), + action_label=_('Öffnen'), + capability='manage_company_config', + ), + AppDefinition( + key='trial_management', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_trial_config_page', + title=_('Trial Management'), + description=_('Testlaufzeit, Banner und sichere Einschränkungen für Demo-Umgebungen steuern.'), + action_label=_('Öffnen'), + capability='manage_trial_lifecycle', + ), + AppDefinition( + key='branding', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_branding_page', + title=_('Branding'), + description=_('Logo, Portalname, Farben und PDF-Briefkopf verwalten.'), + action_label=_('Öffnen'), + capability='manage_product_branding', + ), + AppDefinition( + key='app_registry', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_app_registry_page', + title=_('App Registry'), + description=_('Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten.'), + action_label=_('Öffnen'), + capability='manage_app_registry', + ), + AppDefinition( + key='integrations', + section=PortalAppConfig.SECTION_ADMIN, + route_name='integrations_setup_page', + title=_('Integrationen'), + description=_('Nextcloud- und E-Mail-Setup.'), + action_label=_('Öffnen'), + capability='manage_integrations', + ), + AppDefinition( + key='job_monitor', + section=PortalAppConfig.SECTION_ADMIN, + route_name='job_monitor_page', + title=_('Job Monitor'), + description=_('Asynchrone Aufgaben, Fehler und letzte Worker-Läufe prüfen.'), + action_label=_('Öffnen'), + capability='view_job_monitor', + ), + AppDefinition( + key='users', + section=PortalAppConfig.SECTION_ADMIN, + route_name='user_management_page', + title=_('Benutzer & Rollen'), + description=_('Benutzer anlegen, Rollen zuweisen und Zugriffe steuern.'), + action_label=_('Öffnen'), + capability='manage_users', + ), + AppDefinition( + key='audit_log', + section=PortalAppConfig.SECTION_ADMIN, + route_name='audit_log_page', + title=_('Audit Log'), + description=_('Wichtige Admin-Aktionen nachvollziehen und prüfen.'), + action_label=_('Öffnen'), + capability='view_audit_log', + ), + AppDefinition( + key='backups', + section=PortalAppConfig.SECTION_ADMIN, + route_name='backup_recovery_page', + title=_('Backup & Recovery'), + description=_('Backups erstellen und sicher verifizieren.'), + action_label=_('Öffnen'), + capability='manage_backups', + ), + AppDefinition( + key='welcome_emails', + section=PortalAppConfig.SECTION_ADMIN, + route_name='welcome_emails_page', + title=_('Welcome E-Mails'), + description=_('Geplante Welcome Mails verwalten.'), + action_label=_('Öffnen'), + capability='manage_welcome_emails', + ), + AppDefinition( + key='form_builder', + section=PortalAppConfig.SECTION_ADMIN, + route_name='form_builder_page', + title=_('Form Builder'), + description=_('Felder, Schritte und Optionen verwalten.'), + action_label=_('Öffnen'), + capability='manage_builders', + ), + AppDefinition( + key='intro_builder', + section=PortalAppConfig.SECTION_ADMIN, + route_name='intro_builder_page', + title=_('Einweisungs-Builder'), + description=_('Checklistenpunkte für das Einweisungsprotokoll konfigurieren.'), + action_label=_('Öffnen'), + capability='manage_builders', + ), + AppDefinition( + key='handbook', + section=PortalAppConfig.SECTION_ADMIN, + route_name='handbook_page', + title=_('Handbook'), + description=_('Project wiki and developer documentation in one place.'), + action_label=_('Öffnen'), + capability='view_docs', + ), + AppDefinition( + key='django_admin', + section=PortalAppConfig.SECTION_ADMIN, + route_name='admin:index', + title=_('Django Admin'), + description=_('Vollständige Datenverwaltung.'), + action_label=_('Öffnen'), + capability='access_django_admin_link', + ), +) + + +DEFAULT_ROLE_VISIBILITY = { + # These defaults are product recommendations for fresh deployments. + # Saved PortalAppConfig rows can override them per customer installation. + 'onboarding': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: True, + ROLE_STAFF: True, + }, + 'offboarding': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: True, + ROLE_STAFF: True, + }, + 'requests_dashboard': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: True, + ROLE_STAFF: False, + }, + 'branding': { + ROLE_SUPER_ADMIN: False, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'company_config': { + ROLE_SUPER_ADMIN: False, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'trial_management': { + ROLE_SUPER_ADMIN: False, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'app_registry': { + ROLE_SUPER_ADMIN: False, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'integrations': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'job_monitor': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'users': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'audit_log': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'backups': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'welcome_emails': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'form_builder': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'intro_builder': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'handbook': { + ROLE_SUPER_ADMIN: True, + ROLE_ADMIN: True, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, + 'django_admin': { + ROLE_SUPER_ADMIN: False, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, +} + + +def _default_visibility_summary(definition_key: str) -> str: + visibility = DEFAULT_ROLE_VISIBILITY.get(definition_key, {}) + enabled_roles = [ + role + for role in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF) + if visibility.get(role) + ] + if not enabled_roles: + return str(_('Nur Platform')) + if enabled_roles == [ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF]: + return str(_('Alle Firmenrollen')) + return ' + '.join(str(ROLE_LABELS[role]) for role in enabled_roles if role in ROLE_LABELS) + + +SECTION_META = { + PortalAppConfig.SECTION_APP: { + 'title': _('Apps'), + 'subtitle': _('Wählen Sie den gewünschten Prozess.'), + 'css_class': 'section-head-primary', + 'grid_class': 'apps-grid', + }, + PortalAppConfig.SECTION_PLATFORM: { + 'title': _('Platform Apps'), + 'subtitle': _('Produktweite Konfiguration und Produktsteuerung.'), + 'css_class': 'section-head-platform', + 'grid_class': 'admin-grid', + }, + PortalAppConfig.SECTION_ADMIN: { + 'title': _('Admin Apps'), + 'subtitle': _('Konfiguration, Tests und Steuerung.'), + 'css_class': 'section-head-admin', + 'grid_class': 'admin-grid', + }, +} + + +def ensure_portal_app_configs() -> None: + for index, definition in enumerate(APP_DEFINITIONS): + visibility = DEFAULT_ROLE_VISIBILITY.get(definition.key, {}) + PortalAppConfig.objects.get_or_create( + key=definition.key, + defaults={ + 'section': definition.section, + 'sort_order': index, + 'is_enabled': True, + 'visible_to_super_admin': visibility.get(ROLE_SUPER_ADMIN, False), + 'visible_to_admin': visibility.get(ROLE_ADMIN, False), + 'visible_to_it_staff': visibility.get(ROLE_IT_STAFF, False), + 'visible_to_staff': visibility.get(ROLE_STAFF, False), + }, + ) + normalize_portal_app_sort_orders() + + +def normalize_portal_app_sort_orders() -> None: + for section_key, _label in PortalAppConfig.SECTION_CHOICES: + configs = list(PortalAppConfig.objects.filter(section=section_key).order_by('sort_order', 'key')) + for position, config in enumerate(configs): + if config.sort_order != position: + config.sort_order = position + config.save(update_fields=['sort_order']) + + +def get_portal_app_registry_rows() -> list[dict[str, object]]: + ensure_portal_app_configs() + config_map = {config.key: config for config in PortalAppConfig.objects.all()} + rows: list[dict[str, object]] = [] + for index, definition in enumerate(APP_DEFINITIONS): + config = config_map[definition.key] + rows.append( + { + 'definition': definition, + 'config': config, + 'default_section': definition.section, + 'default_sort_order': index, + 'default_visibility_summary': _default_visibility_summary(definition.key), + } + ) + return rows + + +def build_portal_app_sections(user) -> list[dict[str, object]]: + ensure_portal_app_configs() + config_map = {config.key: config for config in PortalAppConfig.objects.all()} + grouped: dict[str, list[dict[str, object]]] = {key: [] for key in SECTION_META} + role_key = get_user_role_key(user) + + for definition in APP_DEFINITIONS: + config = config_map.get(definition.key) + if not config or not config.is_enabled: + continue + if definition.capability and not user_has_capability(user, definition.capability): + continue + if role_key != ROLE_PLATFORM_OWNER: + if role_key == ROLE_SUPER_ADMIN and not config.visible_to_super_admin: + continue + if role_key == ROLE_ADMIN and not config.visible_to_admin: + continue + if role_key == ROLE_IT_STAFF and not config.visible_to_it_staff: + continue + if role_key == ROLE_STAFF and not config.visible_to_staff: + continue + grouped[config.section].append( + { + 'key': definition.key, + 'href': reverse(definition.route_name), + 'title': config.translated_title_override() or str(definition.title), + 'description': config.translated_description_override() or str(definition.description), + 'action_label': config.translated_action_label_override() or str(definition.action_label), + 'accent': definition.accent, + 'accent_label': definition.accent_label, + 'style_variant': definition.style_variant, + 'tags': [str(tag) for tag in definition.tags], + 'sort_order': config.sort_order, + } + ) + + sections: list[dict[str, object]] = [] + for section_key, meta in SECTION_META.items(): + apps = sorted(grouped.get(section_key, []), key=lambda item: (item['sort_order'], item['title'])) + if not apps: + continue + sections.append( + { + 'key': section_key, + 'title': str(meta['title']), + 'subtitle': str(meta['subtitle']), + 'css_class': meta['css_class'], + 'grid_class': meta['grid_class'], + 'apps': apps, + } + ) + return sections diff --git a/backend/workflows/apps.py b/backend/workflows/apps.py index bf4f6e7..1706b63 100644 --- a/backend/workflows/apps.py +++ b/backend/workflows/apps.py @@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig): def ready(self): from . import signals # noqa: F401 + from . import checks # noqa: F401 diff --git a/backend/workflows/backup_ops.py b/backend/workflows/backup_ops.py index 8b6ec7c..d6516cf 100644 --- a/backend/workflows/backup_ops.py +++ b/backend/workflows/backup_ops.py @@ -12,11 +12,15 @@ from pathlib import Path from django.conf import settings from django.utils import timezone +from django.utils.dateparse import parse_datetime from django.utils.translation import gettext as _ from .models import WorkflowConfig from .services import delete_from_nextcloud, upload_to_nextcloud +# Backup bundles are local-first. Remote copy is a secondary delivery path and +# must never replace the ability to verify/restore the local bundle directly. + def _backup_root() -> Path: root = Path(settings.BACKUP_OUTPUT_DIR) @@ -113,6 +117,57 @@ def list_backup_bundles() -> list[dict]: return rows +def latest_backup_health_snapshot(stale_after_hours: int = 48) -> dict: + # A single snapshot keeps the UI, scheduled command, and future monitoring + # on the same health contract. + rows = list_backup_bundles() + if not rows: + return { + 'status': 'missing', + 'label': str(_('Kein Backup vorhanden')), + 'summary': str(_('Es wurde noch kein Backup-Bundle erstellt.')), + 'bundle_name': '', + 'is_stale': True, + } + + latest = rows[0] + verified_at_raw = latest.get('verified_at') or '' + verified_at = parse_datetime(verified_at_raw) if verified_at_raw else None + if verified_at and timezone.is_naive(verified_at): + verified_at = timezone.make_aware(verified_at, timezone.get_current_timezone()) + + if latest.get('verify_status') != 'verified' or not verified_at: + return { + 'status': 'unverified', + 'label': str(_('Nicht verifiziert')), + 'summary': str(_('Das neueste Backup-Bundle wurde noch nicht erfolgreich verifiziert.')), + 'bundle_name': latest['name'], + 'verified_at': verified_at_raw, + 'is_stale': True, + } + + age = timezone.now() - verified_at + is_stale = age.total_seconds() > stale_after_hours * 3600 + if is_stale: + return { + 'status': 'stale', + 'label': str(_('Verifikation veraltet')), + 'summary': _('Die letzte erfolgreiche Backup-Verifikation ist älter als %(hours)s Stunden.') % {'hours': stale_after_hours}, + 'bundle_name': latest['name'], + 'verified_at': verified_at_raw, + 'is_stale': True, + } + + return { + 'status': 'healthy', + 'label': str(_('Verifikation aktuell')), + 'summary': str(_('Das neueste Backup-Bundle wurde erfolgreich und rechtzeitig verifiziert.')), + 'bundle_name': latest['name'], + 'verified_at': verified_at_raw, + 'is_stale': False, + } + + def _remote_backup_config() -> dict: config = WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first() if not config: @@ -264,8 +319,6 @@ def verify_backup_bundle(backup_name: str) -> dict: output=restore.stdout, stderr=restore.stderr, ) - with connection.cursor() as cursor: - pass table_count = subprocess.check_output( ['psql', *args, '-d', verify_db, '-t', '-A', '-c', "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';"], env=env, @@ -281,7 +334,7 @@ def verify_backup_bundle(backup_name: str) -> dict: env=env, text=True, ).strip() - with tempfile.TemporaryDirectory(prefix='tubco_backup_verify_media_') as tmpdir: + with tempfile.TemporaryDirectory(prefix='workdock_backup_verify_media_') as tmpdir: with tarfile.open(media_archive_path, 'r:gz') as archive: archive.extractall(tmpdir, filter='data') media_dir = Path(tmpdir) / 'media' diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py new file mode 100644 index 0000000..1679a0a --- /dev/null +++ b/backend/workflows/branding.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from pathlib import Path +from email.utils import formataddr + +from django.conf import settings +from django.templatetags.static import static +from django.utils import timezone +from django.utils.translation import get_language + +from .models import PortalBranding, PortalCompanyConfig, PortalTrialConfig + +# Branding is the product/deployment boundary. +# Workdock is the generic default, while stored DB values preserve the current +# customer deployment identity such as TUBCO. + + +def get_portal_branding() -> PortalBranding: + branding, _ = PortalBranding.objects.get_or_create( + name='Default', + defaults={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Bitte melden Sie sich mit Ihrem Benutzerkonto an.', + 'footer_text': 'Workdock', + 'footer_text_en': 'Workdock', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + ) + return branding + + +def get_portal_company_config() -> PortalCompanyConfig: + company_config, _ = PortalCompanyConfig.objects.get_or_create( + name='Default', + defaults={ + 'legal_company_name': 'Workdock', + 'country': 'Deutschland', + 'website_url': '', + 'imprint_url': '', + 'privacy_url': '', + 'hr_contact_email': '', + 'it_contact_email': '', + 'operations_contact_email': '', + 'phone_number': '', + 'vat_id': '', + 'registration_number': '', + }, + ) + return company_config + + +def get_portal_trial_config() -> PortalTrialConfig: + trial_config, _ = PortalTrialConfig.objects.get_or_create( + name='Default', + defaults={ + 'is_trial_mode': False, + 'restrict_production_integrations': True, + 'auto_cleanup_enabled': True, + 'trial_banner_text': '', + 'trial_banner_text_en': '', + }, + ) + return trial_config + + +def is_trial_mode_enabled() -> bool: + return bool(get_portal_trial_config().is_trial_mode) + + +def is_trial_expired() -> bool: + trial_config = get_portal_trial_config() + if not trial_config.is_trial_mode or not trial_config.trial_expires_at: + return False + return timezone.now() >= trial_config.trial_expires_at + + +def should_restrict_trial_integrations() -> bool: + trial_config = get_portal_trial_config() + return bool(trial_config.is_trial_mode and trial_config.restrict_production_integrations) + + +def get_trial_context() -> dict[str, object]: + trial_config = get_portal_trial_config() + lang = (get_language() or 'de').split('-')[0] + banner_text = ((trial_config.trial_banner_text_en or '').strip() if lang == 'en' else '') or (trial_config.trial_banner_text or '').strip() + expired = is_trial_expired() + days_remaining = None + if trial_config.is_trial_mode and trial_config.trial_expires_at: + delta = timezone.localtime(trial_config.trial_expires_at) - timezone.localtime(timezone.now()) + days_remaining = max(0, delta.days + (1 if delta.seconds > 0 else 0)) + return { + 'portal_trial_config': trial_config, + 'portal_trial_enabled': bool(trial_config.is_trial_mode), + 'portal_trial_expired': expired, + 'portal_trial_started_at': trial_config.trial_started_at, + 'portal_trial_expires_at': trial_config.trial_expires_at, + 'portal_trial_days_remaining': days_remaining, + 'portal_trial_banner_text': banner_text, + 'portal_trial_restrict_integrations': bool(trial_config.is_trial_mode and trial_config.restrict_production_integrations), + 'portal_trial_cleanup_enabled': bool(trial_config.auto_cleanup_enabled), + } + + +def get_company_email_domain() -> str: + branding = get_portal_branding() + domain = (branding.company_domain or '').strip().lower().lstrip('@') + return domain or 'workdock.de' + + +def get_portal_logo_url() -> str: + branding = get_portal_branding() + if branding.logo_image: + try: + return branding.logo_image.url + except ValueError: + pass + # The fallback asset file is still the historical TUBCO wordmark. A later + # asset refresh can replace the file without changing the branding contract. + return static('workflows/img/tubco-logo.svg') + + +def get_portal_favicon_url() -> str: + branding = get_portal_branding() + if branding.favicon_image: + try: + return branding.favicon_image.url + except ValueError: + pass + # Same fallback rule as the logo: keep runtime stable now, replace asset later. + return static('workflows/img/tubco-logo.svg') + + +def get_portal_letterhead_path() -> Path: + branding = get_portal_branding() + if branding.pdf_letterhead: + try: + candidate = Path(branding.pdf_letterhead.path) + if candidate.exists(): + return candidate + except (ValueError, NotImplementedError): + pass + return settings.PDF_TEMPLATES_DIR / 'templates.pdf' + + +def get_branding_context() -> dict[str, object]: + branding = get_portal_branding() + company_config = get_portal_company_config() + lang = (get_language() or branding.default_language or 'de').split('-')[0] + footer_text = (branding.footer_text_en or '').strip() if lang == 'en' else '' + legal_notice = (branding.legal_notice_en or '').strip() if lang == 'en' else '' + if not footer_text: + footer_text = (branding.footer_text or branding.portal_title).strip() + if not legal_notice: + legal_notice = (branding.legal_notice or '').strip() + return { + 'portal_branding': branding, + 'portal_title': branding.portal_title, + 'portal_company_name': branding.company_name, + 'portal_email_domain': get_company_email_domain(), + 'portal_support_email': branding.support_email, + 'portal_sender_display_name': branding.sender_display_name or branding.company_name, + 'portal_login_subtitle': branding.login_subtitle, + 'portal_footer_text': footer_text, + 'portal_legal_notice': legal_notice, + 'portal_default_language': branding.default_language, + 'portal_primary_color': branding.primary_color, + 'portal_secondary_color': branding.secondary_color, + 'portal_logo_url': get_portal_logo_url(), + 'portal_favicon_url': get_portal_favicon_url(), + 'portal_has_custom_logo': bool(branding.logo_image), + 'portal_has_custom_letterhead': bool(branding.pdf_letterhead), + 'portal_has_custom_favicon': bool(branding.favicon_image), + 'portal_company_config': company_config, + 'portal_company_legal_name': company_config.legal_company_name or branding.company_name, + 'portal_company_street': company_config.street_address, + 'portal_company_postal_code': company_config.postal_code, + 'portal_company_city': company_config.city, + 'portal_company_country': company_config.country, + 'portal_company_website_url': company_config.website_url, + 'portal_company_imprint_url': company_config.imprint_url, + 'portal_company_privacy_url': company_config.privacy_url, + 'portal_company_hr_contact_email': company_config.hr_contact_email, + 'portal_company_it_contact_email': company_config.it_contact_email, + 'portal_company_operations_contact_email': company_config.operations_contact_email, + 'portal_company_phone_number': company_config.phone_number, + 'portal_company_vat_id': company_config.vat_id, + 'portal_company_registration_number': company_config.registration_number, + } + + +def get_branding_email_copy() -> dict[str, str]: + branding = get_portal_branding() + company_name = (branding.company_name or 'Workdock').strip() + portal_title = (branding.portal_title or f'{company_name} Portal').strip() + return { + 'company_name': company_name, + 'company_domain': get_company_email_domain(), + 'portal_title': portal_title, + 'support_email': (branding.support_email or '').strip(), + 'sender_display_name': (branding.sender_display_name or company_name).strip(), + } + + +def get_company_contact_copy() -> dict[str, str]: + branding = get_portal_branding() + company_config = get_portal_company_config() + company_name = (branding.company_name or 'Workdock').strip() + legal_name = (company_config.legal_company_name or company_name).strip() + domain = get_company_email_domain() + support_email = (branding.support_email or '').strip() + it_contact_email = (company_config.it_contact_email or support_email or f'it@{domain}').strip() + hr_contact_email = (company_config.hr_contact_email or support_email or f'hr@{domain}').strip() + operations_contact_email = (company_config.operations_contact_email or support_email or f'info@{domain}').strip() + address_parts = [ + (company_config.street_address or '').strip(), + ' '.join(part for part in [(company_config.postal_code or '').strip(), (company_config.city or '').strip()] if part).strip(), + (company_config.country or '').strip(), + ] + address = ', '.join(part for part in address_parts if part) + return { + 'company_name': company_name, + 'legal_company_name': legal_name, + 'support_email': support_email, + 'it_contact_email': it_contact_email, + 'hr_contact_email': hr_contact_email, + 'operations_contact_email': operations_contact_email, + 'phone_number': (company_config.phone_number or '').strip(), + 'website_url': (company_config.website_url or '').strip(), + 'imprint_url': (company_config.imprint_url or '').strip(), + 'privacy_url': (company_config.privacy_url or '').strip(), + 'address': address, + 'street_address': (company_config.street_address or '').strip(), + 'postal_code': (company_config.postal_code or '').strip(), + 'city': (company_config.city or '').strip(), + 'country': (company_config.country or '').strip(), + 'registration_number': (company_config.registration_number or '').strip(), + 'vat_id': (company_config.vat_id or '').strip(), + } + + +def get_branded_from_email(email_address: str | None) -> str | None: + address = (email_address or '').strip() + if not address: + return None + display_name = (get_branding_email_copy()['sender_display_name'] or '').strip() + if not display_name: + return address + return formataddr((display_name, address)) + + +def get_default_notification_templates() -> dict[str, dict[str, str]]: + from copy import deepcopy + + from .tasks import DEFAULT_NOTIFICATION_TEMPLATES + + templates = deepcopy(DEFAULT_NOTIFICATION_TEMPLATES) + branding_copy = get_branding_email_copy() + company_contact = get_company_contact_copy() + company_name = branding_copy['company_name'] + support_email = company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}" + welcome = templates.get('onboarding_welcome') + if welcome: + welcome['subject'] = f'Willkommen bei {company_name}, {{ VORNAME }}' + welcome['subject_en'] = f'Welcome to {company_name}, {{ VORNAME }}' + welcome['body'] = ( + 'Hallo {{ FULL_NAME }},\n\n' + f'herzlich willkommen bei {company_name}.\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' + f'Wenn du Fragen hast, melde dich gerne jederzeit unter {support_email}.\n\n' + 'Viele Grüße\n' + f'{company_name} IT' + ) + welcome['body_en'] = ( + 'Hello {{ FULL_NAME }},\n\n' + f'welcome to {company_name}.\n' + 'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n' + 'Your work email address is: {{ EMAIL }}.\n' + 'You will find your onboarding documents attached as a PDF.\n\n' + f'If you have any questions, feel free to contact {support_email}.\n\n' + 'Best regards,\n' + f'{company_name} IT' + ) + return templates diff --git a/backend/workflows/checks.py b/backend/workflows/checks.py new file mode 100644 index 0000000..39b4bfa --- /dev/null +++ b/backend/workflows/checks.py @@ -0,0 +1,57 @@ +import sys + +from django.conf import settings +from django.core.checks import Error, Warning, register + + +@register() +def security_settings_check(app_configs, **kwargs): + # Keep production checks strict in normal runtime, but avoid blocking the + # entire Django test runner before per-test overrides can take effect. + if 'test' in sys.argv and not settings.RUN_SECURITY_CHECKS_DURING_TESTS: + return [] + + issues = [] + + if not settings.DEBUG and settings.SECRET_KEY == 'unsafe-dev-key': + issues.append( + Error( + 'DJANGO_SECRET_KEY is using the development fallback while DEBUG is disabled.', + id='workdock.E001', + ) + ) + + if not settings.DEBUG and not settings.ALLOWED_HOSTS: + issues.append( + Error( + 'ALLOWED_HOSTS must be configured when DEBUG is disabled.', + id='workdock.E002', + ) + ) + + if not settings.DEBUG and not settings.SESSION_COOKIE_SECURE: + issues.append( + Error( + 'Secure session cookies must be enabled when DEBUG is disabled.', + id='workdock.E003', + ) + ) + + if not settings.DEBUG and not settings.CSRF_COOKIE_SECURE: + issues.append( + Error( + 'Secure CSRF cookies must be enabled when DEBUG is disabled.', + id='workdock.E004', + ) + ) + + if not settings.DEBUG and not settings.SECURE_SSL_REDIRECT: + issues.append( + Warning( + 'SECURE_SSL_REDIRECT is disabled while DEBUG is off.', + hint='Enable DJANGO_SECURE_SSL_REDIRECT=1 behind HTTPS-aware proxying.', + id='workdock.W001', + ) + ) + + return issues diff --git a/backend/workflows/context_processors.py b/backend/workflows/context_processors.py index 64bad7f..e31f0e4 100644 --- a/backend/workflows/context_processors.py +++ b/backend/workflows/context_processors.py @@ -1,5 +1,21 @@ +from .branding import get_branding_context, get_trial_context +from .models import UserNotification from .roles import template_role_context def role_context(request): - return template_role_context(getattr(request, 'user', None)) + context = template_role_context(getattr(request, 'user', None)) + context.update(get_branding_context()) + context.update(get_trial_context()) + user = getattr(request, 'user', None) + if getattr(user, 'is_authenticated', False): + notifications = list(UserNotification.objects.filter(user=user).order_by('-created_at')[:8]) + context.update( + { + 'header_notifications': notifications, + 'header_unread_notification_count': UserNotification.objects.filter(user=user, read_at__isnull=True).count(), + } + ) + else: + context.update({'header_notifications': [], 'header_unread_notification_count': 0}) + return context diff --git a/backend/workflows/email_workflows.py b/backend/workflows/email_workflows.py new file mode 100644 index 0000000..61c65df --- /dev/null +++ b/backend/workflows/email_workflows.py @@ -0,0 +1,188 @@ +from datetime import timedelta +from pathlib import Path + +from django.conf import settings +from django.utils import timezone +from jinja2 import Template + +from .branding import get_default_notification_templates +from .emailing import send_system_email +from .forms import OnboardingRequestForm +from .models import NotificationRule, NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .services import get_email_test_redirect, is_email_test_mode + + +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, language_code: str | None = None) -> tuple[str, str]: + lang = (language_code or 'de').split('-')[0] + db_template = NotificationTemplate.objects.filter(key=template_key, is_active=True).first() + if db_template: + subject_template = db_template.translated_subject_template(lang) + body_template = db_template.translated_body_template(lang) + else: + fallback = get_default_notification_templates()[template_key] + subject_template = fallback.get(f'subject_{lang}', '') or fallback['subject'] + body_template = fallback.get(f'body_{lang}', '') or 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 send_templated_email( + template_key: str, + to: list[str], + context: dict, + attachments: list[Path] | None = None, + from_email: str | None = None, + language_code: str | None = None, +) -> None: + subject, body = render_notification_template(template_key, context, language_code=language_code) + send_workflow_email(subject=subject, body=body, to=to, attachments=attachments, from_email=from_email) + + +def apply_notification_rules( + event_type: str, + request_obj, + context: dict, + pdf_path: Path | None = None, +) -> None: + language_code = (getattr(request_obj, 'preferred_language', '') or 'de').split('-')[0] + 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, + language_code=language_code, + ) + continue + + subject = rule.translated_custom_subject(language_code) + body = rule.translated_custom_body(language_code) + 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, *, send_scheduled_welcome_email_task) -> 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_task.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']) diff --git a/backend/workflows/emailing.py b/backend/workflows/emailing.py index 84620f9..68bba55 100644 --- a/backend/workflows/emailing.py +++ b/backend/workflows/emailing.py @@ -1,6 +1,7 @@ from django.conf import settings from django.core.mail import EmailMessage, get_connection +from .branding import get_branded_from_email from .models import SystemEmailConfig, WorkflowConfig @@ -66,7 +67,7 @@ def send_system_email( msg = EmailMessage( subject=subject, body=body, - from_email=(from_email or smtp['from_email']), + from_email=get_branded_from_email(from_email or smtp['from_email']) or (from_email or smtp['from_email']), to=to, connection=connection, ) diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index d7a03cf..8dffc01 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -1,192 +1,2 @@ -from collections import OrderedDict -from django.utils.translation import get_language - -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()) - language_code = get_language() - - for field_name, field in list(form.fields.items()): - cfg = configs.get(field_name) - if not cfg: - continue - - translated_label = cfg.translated_label_override(language_code) - if translated_label: - field.label = translated_label - - translated_help_text = cfg.translated_help_text_override(language_code) - if translated_help_text: - field.help_text = translated_help_text - - 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 - } +from .form_builder_config import * +from .form_builder_runtime import * diff --git a/backend/workflows/form_builder_config.py b/backend/workflows/form_builder_config.py new file mode 100644 index 0000000..4ed6262 --- /dev/null +++ b/backend/workflows/form_builder_config.py @@ -0,0 +1,117 @@ +from django.utils.translation import gettext_lazy as _ + +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'] +OFFBOARDING_PAGE_ORDER = ['mitarbeitende', 'austritt', 'abschluss'] +ONBOARDING_PAGE_LABELS = { + 'stammdaten': _('1. Stammdaten'), + 'vertrag': _('2. Vertrag'), + 'itsetup': _('3. IT-Setup'), + 'abschluss': _('4. Abschluss'), +} +OFFBOARDING_PAGE_LABELS = { + 'mitarbeitende': _('1. Mitarbeitende'), + 'austritt': _('2. Austritt'), + 'abschluss': _('3. Abschluss'), +} +CORE_SECTION_LABELS = {'onboarding': ONBOARDING_PAGE_LABELS, 'offboarding': OFFBOARDING_PAGE_LABELS} + +LOCKED_FIELD_RULES = { + 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, + 'offboarding': {'full_name', 'work_email', 'last_working_day'}, +} + +LOCKED_SECTION_RULES = { + 'onboarding': {'stammdaten', 'vertrag', 'abschluss'}, + 'offboarding': {'mitarbeitende', 'austritt'}, +} + +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', +} +OFFBOARDING_DEFAULT_PAGE = { + 'full_name': 'mitarbeitende', 'work_email': 'mitarbeitende', 'department': 'mitarbeitende', 'job_title': 'mitarbeitende', + 'last_working_day': 'austritt', 'notes': 'abschluss', +} + +DEFAULT_CONDITIONAL_RULES = { + 'onboarding': { + 'business-card-box': {'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}]}, + 'employment-end-box': {'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}]}, + 'group-mailboxes-box': {'clauses': [{'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'extra-hardware-box': {'clauses': [{'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'extra-software-box': {'clauses': [{'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'extra-access-box': {'clauses': [{'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'successor-box': {'clauses': [{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}]}, + }, +} + +FORM_PRESETS = { + 'onboarding': { + 'standard': {'label': _('Standard'), 'sections': {'stammdaten': True, 'vertrag': True, 'itsetup': True, 'abschluss': True}, 'fields': {}}, + 'lean': { + 'label': _('Lean'), + 'sections': {'stammdaten': True, 'vertrag': True, 'itsetup': False, 'abschluss': True}, + 'fields': { + 'gender': {'is_visible': False}, 'order_business_cards': {'is_visible': False}, 'business_card_name': {'is_visible': False}, + 'business_card_title': {'is_visible': False}, 'business_card_email': {'is_visible': False}, 'business_card_phone': {'is_visible': False}, + 'employment_end_date': {'is_visible': False}, 'group_mailboxes_required_choice': {'is_visible': False}, 'group_mailboxes': {'is_visible': False}, + 'additional_notes': {'is_required': False}, + }, + }, + 'it_heavy': { + 'label': _('IT-heavy'), + 'sections': {'stammdaten': True, 'vertrag': True, 'itsetup': True, 'abschluss': True}, + 'fields': { + 'needed_devices_multi': {'is_required': True}, 'needed_software_multi': {'is_required': True}, 'needed_accesses_multi': {'is_required': True}, + 'needed_workspace_groups_multi': {'is_required': True}, 'needed_resources_multi': {'is_required': True}, + 'additional_hardware_needed_choice': {'is_visible': True}, 'additional_software_needed_choice': {'is_visible': True}, + 'additional_access_needed_choice': {'is_visible': True}, 'successor_required_choice': {'is_visible': True}, + }, + }, + }, + 'offboarding': { + 'standard': {'label': _('Standard'), 'sections': {'mitarbeitende': True, 'austritt': True, 'abschluss': True}, 'fields': {}}, + 'lean': { + 'label': _('Lean'), + 'sections': {'mitarbeitende': True, 'austritt': True, 'abschluss': False}, + 'fields': {'department': {'is_visible': False}, 'job_title': {'is_visible': False}, 'notes': {'is_visible': False}}, + }, + 'hr_heavy': { + 'label': _('HR-heavy'), + 'sections': {'mitarbeitende': True, 'austritt': True, 'abschluss': True}, + 'fields': { + 'department': {'is_visible': True, 'is_required': True}, + 'job_title': {'is_visible': True, 'is_required': True}, + 'notes': {'is_visible': True, 'is_required': True}, + }, + }, + }, +} + +CUSTOM_FIELD_PREFIX = 'custom__' diff --git a/backend/workflows/form_builder_runtime.py b/backend/workflows/form_builder_runtime.py new file mode 100644 index 0000000..da4504f --- /dev/null +++ b/backend/workflows/form_builder_runtime.py @@ -0,0 +1,382 @@ +from collections import OrderedDict +from django import forms +from django.utils.text import slugify +from django.utils.translation import get_language + +from .model_forms import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig +from .form_builder_config import ( + CORE_SECTION_LABELS, + CUSTOM_FIELD_PREFIX, + DEFAULT_CONDITIONAL_RULES, + DEFAULT_FIELD_ORDER, + FORM_PRESETS, + LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + OFFBOARDING_DEFAULT_PAGE, + ONBOARDING_DEFAULT_PAGE, +) + + +def get_section_order(form_type: str) -> list[str]: + return [item['key'] for item in get_section_definitions(form_type)] + + +def get_section_labels(form_type: str) -> dict[str, str]: + return {item['key']: item['title'] for item in get_section_definitions(form_type)} + + +def get_default_page_map(form_type: str) -> dict[str, str]: + if form_type == 'onboarding': + return ONBOARDING_DEFAULT_PAGE + if form_type == 'offboarding': + return OFFBOARDING_DEFAULT_PAGE + return {} + + +def get_custom_section_configs(form_type: str, include_inactive: bool = False) -> list[FormCustomSectionConfig]: + qs = FormCustomSectionConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'section_key')) + + +def get_section_definitions(form_type: str, include_inactive_custom: bool = False) -> list[dict[str, object]]: + definitions: list[dict[str, object]] = [] + section_configs = ensure_form_section_configs(form_type) + for cfg in sorted(section_configs.values(), key=lambda item: (item.sort_order, item.section_key)): + label_map = CORE_SECTION_LABELS.get(form_type, {}) + definitions.append( + { + 'key': cfg.section_key, + 'title': label_map.get(cfg.section_key, cfg.section_key), + 'locked': cfg.section_key in LOCKED_SECTION_RULES.get(form_type, set()), + 'is_custom': False, + 'sort_order': cfg.sort_order, + } + ) + for cfg in get_custom_section_configs(form_type, include_inactive=include_inactive_custom): + definitions.append( + { + 'key': cfg.section_key, + 'title': cfg.translated_title(get_language()), + 'locked': False, + 'is_custom': True, + 'is_active': cfg.is_active, + 'sort_order': cfg.sort_order, + } + ) + definitions.sort(key=lambda item: (item.get('sort_order', 9999), item['key'])) + return definitions + + +def get_default_conditional_rules(form_type: str) -> dict[str, dict]: + return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) + + +def custom_field_target_key(field_key: str) -> str: + return f'custom__{field_key}' + + +def is_custom_field_target_key(target_key: str) -> bool: + return target_key.startswith(CUSTOM_FIELD_PREFIX) + + +def custom_field_form_name(field_key: str) -> str: + return f'{CUSTOM_FIELD_PREFIX}{field_key}' + + +def is_custom_field_name(field_name: str) -> bool: + return field_name.startswith(CUSTOM_FIELD_PREFIX) + + +def custom_field_key_from_name(field_name: str) -> str: + return field_name[len(CUSTOM_FIELD_PREFIX):] if is_custom_field_name(field_name) else field_name + + +def build_custom_field_key(label: str) -> str: + return slugify(label).replace('-', '_')[:60] or 'custom_field' + + +def get_custom_field_configs(form_type: str, include_inactive: bool = False): + qs = FormCustomFieldConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'field_key')) + + +def add_custom_form_fields(form_type: str, form, initial_values: dict | None = None) -> None: + language_code = get_language() + initial_values = initial_values or {} + field_page_keys = getattr(form, '_field_page_keys', {}) + sort_map = {} + for cfg in get_custom_field_configs(form_type): + field_name = custom_field_form_name(cfg.field_key) + initial = initial_values.get(cfg.field_key) + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_TEXTAREA: + field = forms.CharField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial, + widget=forms.Textarea(attrs={'rows': 3}), + ) + elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT: + field = forms.ChoiceField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial or '', + choices=[('', '--')] + cfg.translated_select_options(language_code), + ) + elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX: + field = forms.BooleanField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=False, + initial=bool(initial), + ) + else: + field = forms.CharField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial, + ) + form.fields[field_name] = field + field_page_keys[field_name] = cfg.section_key + sort_map[field_name] = cfg.sort_order + + if form.fields: + core_configs = ensure_form_field_configs(form_type, [name for name in form.fields.keys() if not is_custom_field_name(name)]) + for name in form.fields.keys(): + if is_custom_field_name(name): + continue + cfg = core_configs.get(name) + if cfg: + sort_map[name] = cfg.sort_order + form.fields = OrderedDict( + (name, form.fields[name]) + for name in sorted(form.fields.keys(), key=lambda name: (sort_map.get(name, 9999), name)) + ) + form._field_page_keys = field_page_keys + + +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=get_default_page_map(form_type).get(name, ''), + ) + 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 ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: + section_order = list(CORE_SECTION_LABELS.get(form_type, {}).keys()) + if not section_order: + return {} + existing = { + cfg.section_key: cfg + for cfg in FormSectionConfig.objects.filter(form_type=form_type) + } + missing = [key for key in section_order if key not in existing] + if missing: + FormSectionConfig.objects.bulk_create( + [ + FormSectionConfig( + form_type=form_type, + section_key=key, + sort_order=section_order.index(key), + is_visible=True, + ) + for key in missing + ], + ignore_conflicts=True, + ) + existing = { + cfg.section_key: cfg + for cfg in FormSectionConfig.objects.filter(form_type=form_type) + } + return existing + + +def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]: + defaults = get_default_conditional_rules(form_type) + if form_type != 'onboarding' and not defaults: + return {} + custom_targets = { + custom_field_target_key(cfg.field_key): {'clauses': []} + for cfg in get_custom_field_configs(form_type) + if form_type == 'onboarding' + } + target_defaults = dict(defaults) + target_defaults.update(custom_targets) + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + missing = [key for key in target_defaults.keys() if key not in existing] + if missing: + FormConditionalRuleConfig.objects.bulk_create( + [ + FormConditionalRuleConfig( + form_type=form_type, + target_key=key, + clauses=target_defaults[key].get('clauses', []), + is_active=bool(target_defaults[key].get('clauses')), + ) + for key in missing + ], + ignore_conflicts=True, + ) + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + return existing + + +def evaluate_conditional_clauses(cleaned_data: dict, clauses: list[dict]) -> bool: + def clause_result(clause: dict) -> bool: + field_name = (clause.get('field') or '').strip() + operator = (clause.get('operator') or '').strip() + if not field_name or not operator: + return False + value = cleaned_data.get(field_name) + if operator == 'checked': + return bool(value) is bool(clause.get('value')) + normalized = '' if value is None else str(value).strip() + expected = '' if clause.get('value') is None else str(clause.get('value')).strip() + if operator == 'equals': + return normalized == expected + if operator == 'not_equals': + return normalized != expected + return False + + active_clauses = [clause for clause in (clauses or []) if clause.get('field') and clause.get('operator')] + return bool(active_clauses) and all(clause_result(clause) for clause in active_clauses) + + +def hidden_custom_field_names(form_type: str, cleaned_data: dict) -> set[str]: + if form_type != 'onboarding': + return set() + hidden = set() + for target_key, cfg in ensure_form_conditional_rule_configs(form_type).items(): + if not cfg.is_active or not is_custom_field_target_key(target_key): + continue + if not evaluate_conditional_clauses(cleaned_data, list(cfg.clauses or [])): + hidden.add(target_key) + return hidden + + +def apply_form_field_config(form_type: str, form) -> None: + field_names = list(form.fields.keys()) + configs = _ensure_configs(form_type, field_names) + section_configs = ensure_form_section_configs(form_type) + locked = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + default_page_map = get_default_page_map(form_type) + language_code = get_language() + + for field_name, field in list(form.fields.items()): + cfg = configs.get(field_name) + if not cfg: + continue + + translated_label = cfg.translated_label_override(language_code) + if translated_label: + field.label = translated_label + + translated_help_text = cfg.translated_help_text_override(language_code) + if translated_help_text: + field.help_text = translated_help_text + + if field_name not in locked and cfg.is_required is not None: + field.required = cfg.is_required + + section_key = cfg.page_key or default_page_map.get(field_name, '') + section_hidden = ( + form_type in {'onboarding', 'offboarding'} + and section_key not in locked_sections + and section_key in section_configs + and not section_configs[section_key].is_visible + ) + if field_name not in locked and (not cfg.is_visible or section_hidden): + 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 in {'onboarding', 'offboarding'}: + form._field_page_keys = { + name: (configs[name].page_key or default_page_map.get(name, '')) + for name in form.fields.keys() + if name in configs + } + + +def apply_form_preset(form_type: str, preset_key: str) -> bool: + preset = FORM_PRESETS.get(form_type, {}).get(preset_key) + if not preset: + return False + + locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) + ensure_form_field_configs(form_type, default_names) + section_configs = ensure_form_section_configs(form_type) + + for section_key, is_visible in preset.get('sections', {}).items(): + cfg = section_configs.get(section_key) + if not cfg or section_key in locked_sections: + continue + cfg.is_visible = bool(is_visible) + cfg.save(update_fields=['is_visible']) + + for cfg in FormFieldConfig.objects.filter(form_type=form_type): + if cfg.field_name in locked_fields: + cfg.is_visible = True + cfg.is_required = None + else: + cfg.is_visible = True + cfg.is_required = None + override = preset.get('fields', {}).get(cfg.field_name, {}) + if 'is_visible' in override: + cfg.is_visible = bool(override['is_visible']) + if 'is_required' in override: + cfg.is_required = override['is_required'] + cfg.save(update_fields=['is_visible', 'is_required']) + + return True diff --git a/backend/workflows/form_builder_views.py b/backend/workflows/form_builder_views.py new file mode 100644 index 0000000..277c912 --- /dev/null +++ b/backend/workflows/form_builder_views.py @@ -0,0 +1,968 @@ +import json +import re + +from django.contrib import messages +from django.db import IntegrityError +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.utils.translation import get_language, gettext as _ + +from .forms import OffboardingRequestForm, OnboardingRequestForm +from .form_builder import ( + DEFAULT_FIELD_ORDER, + FORM_PRESETS, + LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + ONBOARDING_DEFAULT_PAGE, + apply_form_preset, + build_custom_field_key, + ensure_form_conditional_rule_configs, + ensure_form_field_configs, + ensure_form_section_configs, + get_custom_section_configs, + get_default_page_map, + get_section_definitions, + get_section_order, +) +from .models import ( + FormConditionalRuleConfig, + FormCustomFieldConfig, + FormCustomSectionConfig, + FormFieldConfig, + FormOption, +) +from .roles import ROLE_PLATFORM_OWNER, get_user_role_key + + +def form_builder_page_impl( + request, + *, + audit_fn, + translate_choice_list, + form_field_labels_fn, + field_rule_summary_fn, + conditional_rule_summary_fn, + onboarding_groups, + conditional_rule_operator_choices, +): + _audit = audit_fn + _translate_choice_list = translate_choice_list + _form_field_labels = form_field_labels_fn + _field_rule_summary = field_rule_summary_fn + _conditional_rule_summary = conditional_rule_summary_fn + ONBOARDING_GROUPS = onboarding_groups + CONDITIONAL_RULE_OPERATOR_CHOICES = conditional_rule_operator_choices + language_code = get_language() + form_type = request.GET.get('form_type', 'onboarding') + can_override_locked_builder_rules = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER + anchor = (request.GET.get('anchor') or '').strip() + active_panel = (request.GET.get('panel') or '').strip() + active_subpanel = (request.GET.get('subpanel') or '').strip() + active_rules_panel = (request.GET.get('rules_panel') or '').strip() + active_module = (request.GET.get('module') or '').strip() + active_structure_section = (request.GET.get('structure_section') or '').strip() + active_field_rules_section = ((request.POST.get('field_rules_section') if request.method == 'POST' else '') or request.GET.get('field_rules_section') or '').strip() + active_field_texts_section = ((request.POST.get('field_texts_section') if request.method == 'POST' else '') or request.GET.get('field_texts_section') or '').strip() + active_custom_fields_section = ((request.POST.get('custom_fields_section') if request.method == 'POST' else '') or request.GET.get('custom_fields_section') or '').strip() + active_section_rules_section = ((request.POST.get('section_rules_section') if request.method == 'POST' else '') or request.GET.get('section_rules_section') or '').strip() + active_conditional_target = ((request.POST.get('conditional_target') if request.method == 'POST' else '') or request.GET.get('conditional_target') or '').strip() + if form_type not in DEFAULT_FIELD_ORDER: + form_type = 'onboarding' + option_category = request.GET.get('option_category', 'department') + option_categories = [c[0] for c in FormOption.CATEGORY_CHOICES] + if option_category not in option_categories: + option_category = option_categories[0] + + valid_modules = { + 'structure', + 'section-rules', + 'field-rules', + 'conditional-rules', + 'options', + 'field-texts', + 'custom-sections', + 'custom-fields', + 'preview', + } + + if not active_module: + if active_panel == 'builder-structure': + active_module = 'structure' + elif active_panel == 'builder-rules': + active_module = active_rules_panel or 'section-rules' + elif active_panel == 'builder-content': + active_module = active_subpanel or 'options' + else: + active_module = 'structure' + if active_module not in valid_modules: + active_module = 'structure' + if form_type != 'onboarding' and active_module == 'custom-sections': + active_module = 'options' + if form_type != 'onboarding' and active_module == 'conditional-rules': + active_module = 'field-rules' + + if request.method == 'POST': + delete_option_id = request.POST.get('delete_option_id', '').strip() + delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip() + delete_custom_section_id = request.POST.get('delete_custom_section_id', '').strip() + if delete_option_id: + option = FormOption.objects.filter(id=delete_option_id).first() + if not option: + messages.error(request, _('Option nicht gefunden.')) + else: + option_category = option.category + deleted_label = option.label + deleted_id = option.id + option.delete() + _audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label) + messages.success(request, _('Option wurde gelöscht.')) + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=options") + if delete_custom_field_id: + custom_field = FormCustomFieldConfig.objects.filter(id=delete_custom_field_id, form_type=form_type).first() + if not custom_field: + messages.error(request, _('Benutzerdefiniertes Feld nicht gefunden.')) + else: + deleted_label = custom_field.label + deleted_id = custom_field.id + custom_field.delete() + _audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label) + messages.success(request, _('Benutzerdefiniertes Feld wurde gelöscht.')) + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-fields") + if delete_custom_section_id: + custom_section = FormCustomSectionConfig.objects.filter(id=delete_custom_section_id, form_type=form_type).first() + if not custom_section: + messages.error(request, _('Benutzerdefinierter Abschnitt nicht gefunden.')) + else: + deleted_label = custom_section.title + deleted_id = custom_section.id + section_key = custom_section.section_key + custom_fields = list(FormCustomFieldConfig.objects.filter(form_type=form_type, section_key=section_key)) + deleted_field_count = len(custom_fields) + if custom_fields: + field_keys = [item.field_key for item in custom_fields] + FormConditionalRuleConfig.objects.filter( + form_type=form_type, + target_key__in=[f'custom__{field_key}' for field_key in field_keys], + ).delete() + FormCustomFieldConfig.objects.filter(id__in=[item.id for item in custom_fields]).delete() + custom_section.delete() + _audit( + request, + 'form_custom_section_deleted', + target_type='form_custom_section', + target_id=deleted_id, + target_label=deleted_label, + details={'section_key': section_key, 'deleted_field_count': deleted_field_count}, + ) + messages.success(request, _('Benutzerdefinierter Abschnitt wurde gelöscht.')) + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-sections") + + action = request.POST.get('builder_action', '') + if action == 'add_option': + category = request.POST.get('category', '').strip() + label = request.POST.get('label', '').strip() + label_en = request.POST.get('label_en', '').strip() + value = request.POST.get('value', '').strip() + if category not in option_categories: + messages.error(request, _('Ungültige Kategorie.')) + elif not label: + messages.error(request, _('Bitte einen Namen für die Option angeben.')) + else: + next_sort = ( + FormOption.objects.filter(category=category).order_by('-sort_order').values_list('sort_order', flat=True).first() + ) + FormOption.objects.create( + # Global form option catalog entry + category=category, + label=label, + label_en=label_en, + value=value or label, + sort_order=(next_sort + 1) if next_sort is not None else 0, + is_active=True, + ) + _audit( + request, + 'form_option_added', + target_type='form_option', + target_label=label, + details={'category': category, 'label_en': label_en, 'value': value or label}, + ) + messages.success(request, _('Option wurde hinzugefügt.')) + option_category = category + + elif action == 'save_options': + option_ids = request.POST.getlist('option_ids') + for pos, raw_id in enumerate(option_ids): + option = FormOption.objects.filter(id=raw_id).first() + if not option: + continue + next_label = request.POST.get(f'label_{option.id}', '').strip() or option.label + option.label = next_label + option.label_en = request.POST.get(f'label_en_{option.id}', '').strip() + option.value = request.POST.get(f'value_{option.id}', '').strip() or next_label + option.is_active = request.POST.get(f'active_{option.id}') == 'on' + option.sort_order = pos + try: + option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order']) + except IntegrityError: + messages.error(request, _('Doppelte Bezeichnung in Kategorie: %(label)s') % {'label': next_label}) + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}&module=options") + option_category = option.category + _audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)}) + messages.success(request, _('Optionen wurden gespeichert.')) + + elif action == 'save_field_texts': + field_ids = request.POST.getlist('field_ids') + for raw_id in field_ids: + cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + cfg.label_override = (request.POST.get(f'label_override_{cfg.id}') or '').strip() + cfg.label_override_en = (request.POST.get(f'label_override_en_{cfg.id}') or '').strip() + cfg.help_text_override = (request.POST.get(f'help_text_override_{cfg.id}') or '').strip() + cfg.help_text_override_en = (request.POST.get(f'help_text_override_en_{cfg.id}') or '').strip() + cfg.save(update_fields=['label_override', 'label_override_en', 'help_text_override', 'help_text_override_en']) + _audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) + messages.success(request, _('Feldtexte wurden gespeichert.')) + + elif action == 'add_custom_section' and form_type == 'onboarding': + title = (request.POST.get('custom_section_title') or '').strip() + title_en = (request.POST.get('custom_section_title_en') or '').strip() + sort_order_raw = (request.POST.get('custom_section_sort_order') or '').strip() + if not title: + messages.error(request, _('Bitte einen Titel für den benutzerdefinierten Abschnitt angeben.')) + else: + section_key_base = build_custom_field_key(title) + section_key = section_key_base + suffix = 2 + while FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).exists(): + section_key = f'{section_key_base}_{suffix}' + suffix += 1 + try: + sort_order = int(sort_order_raw or 0) + except ValueError: + sort_order = 0 + FormCustomSectionConfig.objects.create( + form_type=form_type, + section_key=section_key, + sort_order=max(0, sort_order), + title=title, + title_en=title_en, + is_active=True, + ) + _audit(request, 'form_custom_section_added', target_type='form_custom_section', target_label=title, details={'form_type': form_type, 'section_key': section_key}) + messages.success(request, _('Benutzerdefinierter Abschnitt wurde hinzugefügt.')) + + elif action == 'save_custom_sections' and form_type == 'onboarding': + section_ids = request.POST.getlist('custom_section_ids') + updated = 0 + for raw_id in section_ids: + cfg = FormCustomSectionConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + try: + sort_order = int((request.POST.get(f'custom_section_sort_order_{cfg.id}') or '').strip() or cfg.sort_order) + except ValueError: + sort_order = cfg.sort_order + cfg.title = (request.POST.get(f'custom_section_title_{cfg.id}') or '').strip() or cfg.title + cfg.title_en = (request.POST.get(f'custom_section_title_en_{cfg.id}') or '').strip() + cfg.is_active = request.POST.get(f'custom_section_is_active_{cfg.id}') == 'on' + cfg.sort_order = max(0, sort_order) + cfg.save(update_fields=['title', 'title_en', 'is_active', 'sort_order']) + updated += 1 + _audit(request, 'form_custom_sections_saved', target_type='form_custom_section', target_label=form_type, details={'count': updated}) + messages.success(request, _('Benutzerdefinierte Abschnitte wurden gespeichert.')) + + elif action == 'add_custom_field': + label = (request.POST.get('custom_label') or '').strip() + label_en = (request.POST.get('custom_label_en') or '').strip() + section_key = (request.POST.get('custom_section_key') or '').strip() + field_type = (request.POST.get('custom_field_type') or '').strip() + sort_order_raw = (request.POST.get('custom_sort_order') or '').strip() + help_text = (request.POST.get('custom_help_text') or '').strip() + help_text_en = (request.POST.get('custom_help_text_en') or '').strip() + select_options = (request.POST.get('custom_select_options') or '').strip() + select_options_en = (request.POST.get('custom_select_options_en') or '').strip() + section_choices = {key for key in get_section_order(form_type)} + field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES} + if not label: + messages.error(request, _('Bitte eine Bezeichnung für das benutzerdefinierte Feld angeben.')) + elif section_key not in section_choices: + messages.error(request, _('Ungültiger Abschnitt für das benutzerdefinierte Feld.')) + elif field_type not in field_type_choices: + messages.error(request, _('Ungültiger Feldtyp.')) + elif field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not select_options: + messages.error(request, _('Auswahlfelder benötigen mindestens eine Option.')) + else: + field_key_base = build_custom_field_key(label) + field_key = field_key_base + suffix = 2 + while FormCustomFieldConfig.objects.filter(form_type=form_type, field_key=field_key).exists(): + field_key = f'{field_key_base}_{suffix}' + suffix += 1 + try: + sort_order = int(sort_order_raw or 0) + except ValueError: + sort_order = 0 + FormCustomFieldConfig.objects.create( + form_type=form_type, + field_key=field_key, + section_key=section_key, + sort_order=max(0, sort_order), + field_type=field_type, + is_active=True, + is_required=request.POST.get('custom_is_required') == 'on', + label=label, + label_en=label_en, + help_text=help_text, + help_text_en=help_text_en, + select_options=select_options, + select_options_en=select_options_en, + ) + _audit(request, 'form_custom_field_added', target_type='form_custom_field', target_label=label, details={'form_type': form_type, 'field_type': field_type, 'section_key': section_key}) + messages.success(request, _('Benutzerdefiniertes Feld wurde hinzugefügt.')) + + elif action == 'save_custom_fields': + custom_ids = request.POST.getlist('custom_field_ids') + updated = 0 + section_choices = {key for key in get_section_order(form_type)} + field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES} + for raw_id in custom_ids: + cfg = FormCustomFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + field_type = (request.POST.get(f'custom_field_type_{cfg.id}') or '').strip() + section_key = (request.POST.get(f'custom_section_key_{cfg.id}') or '').strip() + try: + sort_order = int((request.POST.get(f'custom_sort_order_{cfg.id}') or '').strip() or cfg.sort_order) + except ValueError: + sort_order = cfg.sort_order + cfg.label = (request.POST.get(f'custom_label_{cfg.id}') or '').strip() or cfg.label + cfg.label_en = (request.POST.get(f'custom_label_en_{cfg.id}') or '').strip() + cfg.help_text = (request.POST.get(f'custom_help_text_{cfg.id}') or '').strip() + cfg.help_text_en = (request.POST.get(f'custom_help_text_en_{cfg.id}') or '').strip() + cfg.is_required = request.POST.get(f'custom_is_required_{cfg.id}') == 'on' + cfg.is_active = request.POST.get(f'custom_is_active_{cfg.id}') == 'on' + if field_type in field_type_choices: + cfg.field_type = field_type + if section_key in section_choices: + cfg.section_key = section_key + cfg.sort_order = max(0, sort_order) + cfg.select_options = (request.POST.get(f'custom_select_options_{cfg.id}') or '').strip() + cfg.select_options_en = (request.POST.get(f'custom_select_options_en_{cfg.id}') or '').strip() + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not cfg.select_options: + messages.error(request, _('Auswahlfeld "%(label)s" benötigt mindestens eine Option.') % {'label': cfg.label}) + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-fields") + cfg.save() + updated += 1 + _audit(request, 'form_custom_fields_saved', target_type='form_custom_field', target_label=form_type, details={'count': updated}) + messages.success(request, _('Benutzerdefinierte Felder wurden gespeichert.')) + + elif action == 'save_field_rules': + field_ids = request.POST.getlist('field_rule_ids') + locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) + updated = 0 + for raw_id in field_ids: + cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + if cfg.field_name in locked_fields and not can_override_locked_builder_rules: + cfg.is_visible = True + cfg.is_required = None + else: + cfg.is_visible = request.POST.get(f'is_visible_{cfg.id}') == 'on' + required_mode = (request.POST.get(f'is_required_{cfg.id}') or '').strip() + cfg.is_required = True if required_mode == 'required' else False if required_mode == 'optional' else None + cfg.save(update_fields=['is_visible', 'is_required']) + updated += 1 + _audit(request, 'form_field_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) + messages.success(request, _('Feldregeln wurden gespeichert.')) + + elif action == 'save_section_rules' and form_type in {'onboarding', 'offboarding'}: + section_configs = ensure_form_section_configs(form_type) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + posted_order = request.POST.getlist('section_order') + next_sort_order = 0 + updated = 0 + for section_key in posted_order: + cfg = section_configs.get(section_key) + if cfg is not None: + if cfg.sort_order != next_sort_order: + cfg.sort_order = next_sort_order + cfg.save(update_fields=['sort_order']) + updated += 1 + next_sort_order += 1 + continue + if form_type == 'onboarding': + custom_cfg = FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).first() + if custom_cfg and custom_cfg.sort_order != next_sort_order: + custom_cfg.sort_order = next_sort_order + custom_cfg.save(update_fields=['sort_order']) + updated += 1 + next_sort_order += 1 + for section_key, cfg in section_configs.items(): + if section_key in locked_sections and not can_override_locked_builder_rules: + if not cfg.is_visible: + cfg.is_visible = True + cfg.save(update_fields=['is_visible']) + continue + cfg.is_visible = request.POST.get(f'section_visible_{section_key}') == 'on' + cfg.save(update_fields=['is_visible']) + updated += 1 + if form_type == 'onboarding': + for cfg in FormCustomSectionConfig.objects.filter(form_type=form_type): + cfg.is_active = request.POST.get(f'section_visible_{cfg.section_key}') == 'on' + cfg.save(update_fields=['is_active']) + updated += 1 + _audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) + messages.success(request, _('Abschnittsregeln wurden gespeichert.')) + + elif action == 'save_conditional_rules' and form_type == 'onboarding': + rule_configs = ensure_form_conditional_rule_configs(form_type) + updated = 0 + for target_key, cfg in rule_configs.items(): + cfg.is_active = request.POST.get(f'conditional_active_{target_key}') == 'on' + clauses = [] + clause_total = 2 + for index in range(clause_total): + field_name = (request.POST.get(f'conditional_field_{target_key}_{index}') or '').strip() + operator = (request.POST.get(f'conditional_operator_{target_key}_{index}') or '').strip() + value = (request.POST.get(f'conditional_value_{target_key}_{index}') or '').strip() + if not field_name or not operator: + continue + parsed_value = True if operator == 'checked' else value + clauses.append({'field': field_name, 'operator': operator, 'value': parsed_value}) + cfg.clauses = clauses + cfg.save(update_fields=['is_active', 'clauses']) + updated += 1 + _audit(request, 'form_conditional_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) + messages.success(request, _('Bedingte Logik wurde gespeichert.')) + + elif action == 'apply_preset': + preset_key = (request.POST.get('preset_key') or '').strip() + if apply_form_preset(form_type, preset_key): + active_module = 'preview' + _audit(request, 'form_preset_applied', target_type='form_config', target_label=form_type, details={'preset': preset_key}) + messages.success(request, _('Preset wurde angewendet.')) + else: + messages.error(request, _('Preset konnte nicht angewendet werden.')) + + if action in {'add_option', 'save_options'}: + active_module = 'options' + elif action == 'save_field_texts': + active_module = 'field-texts' + elif action in {'add_custom_field', 'save_custom_fields'}: + active_module = 'custom-fields' + elif action in {'add_custom_section', 'save_custom_sections'}: + active_module = 'custom-sections' + elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}: + active_module = 'section-rules' + if action == 'save_section_rules': + active_module = 'section-rules' + elif action == 'save_field_rules': + active_module = 'field-rules' + elif action == 'save_conditional_rules': + active_module = 'conditional-rules' + redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" + if active_module: + redirect_target += f"&module={active_module}" + if active_structure_section: + redirect_target += f"&structure_section={active_structure_section}" + if active_section_rules_section and active_module == 'section-rules': + redirect_target += f"§ion_rules_section={active_section_rules_section}" + if active_field_rules_section and active_module == 'field-rules': + redirect_target += f"&field_rules_section={active_field_rules_section}" + if active_conditional_target and active_module == 'conditional-rules': + redirect_target += f"&conditional_target={active_conditional_target}" + if active_field_texts_section and active_module == 'field-texts': + redirect_target += f"&field_texts_section={active_field_texts_section}" + if active_custom_fields_section and active_module == 'custom-fields': + redirect_target += f"&custom_fields_section={active_custom_fields_section}" + return redirect(redirect_target) + + default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) + existing_names = list( + OnboardingRequestForm.base_fields.keys() + if form_type == 'onboarding' + else OffboardingRequestForm.base_fields.keys() + ) + + for name in existing_names: + if name not in default_names: + default_names.append(name) + + ensure_form_field_configs(form_type, default_names) + section_configs = ensure_form_section_configs(form_type) + conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {} + section_definitions = get_section_definitions(form_type, include_inactive_custom=True) + section_order = [item['key'] for item in section_definitions] + section_labels = {item['key']: item['title'] for item in section_definitions} + default_page_map = get_default_page_map(form_type) + + configs = list( + FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name') + ) + labels = _form_field_labels(form_type) + locked = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key')) + custom_section_configs = get_custom_section_configs(form_type, include_inactive=True) + + if form_type == 'onboarding': + columns = [ + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': [], + } + for key in section_order + ] + column_by_key = {c['key']: c for c in columns} + fallback = 'abschluss' + for cfg in configs: + page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(cfg.field_name, fallback) + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': cfg.field_name, + 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), + 'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name), + 'label_en': cfg.label_override_en, + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked, + 'page_key': page_key, + 'is_custom': False, + 'sort_order': cfg.sort_order, + } + ) + for cfg in custom_field_configs: + page_key = cfg.section_key or fallback + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': f'custom__{cfg.field_key}', + 'label': cfg.translated_label(language_code), + 'label_de': cfg.label, + 'label_en': cfg.label_en, + 'is_visible': cfg.is_active, + 'is_required': cfg.is_required, + 'locked': False, + 'page_key': page_key, + 'is_custom': True, + 'sort_order': cfg.sort_order, + } + ) + for column in columns: + column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name'])) + else: + columns = [ + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': [], + } + for key in section_order + ] + column_by_key = {c['key']: c for c in columns} + fallback = section_order[-1] if section_order else 'all' + for cfg in configs: + page_key = cfg.page_key or default_page_map.get(cfg.field_name, fallback) + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': cfg.field_name, + 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), + 'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name), + 'label_en': cfg.label_override_en, + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked, + 'page_key': page_key, + 'is_custom': False, + 'sort_order': cfg.sort_order, + } + ) + for cfg in custom_field_configs: + page_key = cfg.section_key or fallback + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': f'custom__{cfg.field_key}', + 'label': cfg.translated_label(language_code), + 'label_de': cfg.label, + 'label_en': cfg.label_en, + 'is_visible': cfg.is_active, + 'is_required': cfg.is_required, + 'locked': False, + 'page_key': page_key, + 'is_custom': True, + 'sort_order': cfg.sort_order, + } + ) + for column in columns: + column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name'])) + + section_rule_items = [] + if section_order: + if active_structure_section not in section_order: + active_structure_section = section_order[0] + fallback_section = section_order[-1] if section_order else '' + custom_section_map = {cfg.section_key: cfg for cfg in custom_section_configs} + for key in section_order: + cfg = section_configs.get(key) + custom_cfg = custom_section_map.get(key) + is_custom = custom_cfg is not None + raw_title = str(section_labels.get(key, key)) + display_title = re.sub(r'^\d+\.\s*', '', raw_title) if not is_custom else raw_title + section_rule_items.append( + { + 'key': key, + 'title': raw_title, + 'display_title': display_title, + 'is_visible': bool(custom_cfg.is_active) if is_custom else (True if not cfg else cfg.is_visible), + 'locked': False if is_custom else (key in locked_sections and not can_override_locked_builder_rules), + 'is_custom': is_custom, + 'sort_order': custom_cfg.sort_order if is_custom else (cfg.sort_order if cfg else 0), + 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]) + len([c for c in custom_field_configs if c.section_key == key]), + } + ) + section_rule_keys = [item['key'] for item in section_rule_items] + if section_rule_keys and active_section_rules_section not in section_rule_keys: + active_section_rules_section = section_rule_keys[0] + + field_rule_items = [] + for cfg in configs: + page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '') + field_rule_items.append( + { + 'id': cfg.id, + 'field_name': cfg.field_name, + 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), + 'page_key': page_key, + 'page_label': section_labels.get(page_key, page_key) if section_order else '', + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked and not can_override_locked_builder_rules, + 'summary': _field_rule_summary( + is_visible=cfg.is_visible, + is_required=cfg.is_required, + locked=cfg.field_name in locked and not can_override_locked_builder_rules, + ), + } + ) + + custom_field_groups = [] + if section_order: + grouped_custom = {key: [] for key in section_order} + for cfg in custom_field_configs: + grouped_custom.setdefault(cfg.section_key, []).append(cfg) + for key in section_order: + custom_field_groups.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': grouped_custom.get(key, []), + } + ) + custom_section_field_counts: dict[str, int] = {} + for cfg in custom_field_configs: + custom_section_field_counts[cfg.section_key] = custom_section_field_counts.get(cfg.section_key, 0) + 1 + for cfg in custom_section_configs: + cfg.custom_field_count = custom_section_field_counts.get(cfg.section_key, 0) + + field_rule_groups = [] + if section_order: + grouped_rules = {key: [] for key in section_order} + for item in field_rule_items: + grouped_rules.setdefault(item['page_key'], []).append(item) + for key in section_order: + field_rule_groups.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': grouped_rules.get(key, []), + } + ) + field_rule_group_keys = [group['key'] for group in field_rule_groups] + if field_rule_group_keys and active_field_rules_section not in field_rule_group_keys: + active_field_rules_section = field_rule_group_keys[0] + + field_text_groups = [] + if section_order: + grouped_texts = {key: [] for key in section_order} + for cfg in configs: + page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '') + grouped_texts.setdefault(page_key, []).append(cfg) + for key in section_order: + field_text_groups.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': grouped_texts.get(key, []), + } + ) + field_text_group_keys = [group['key'] for group in field_text_groups] + if field_text_group_keys and active_field_texts_section not in field_text_group_keys: + active_field_texts_section = field_text_group_keys[0] + custom_field_group_keys = [group['key'] for group in custom_field_groups] + if custom_field_group_keys and active_custom_fields_section not in custom_field_group_keys: + active_custom_fields_section = custom_field_group_keys[0] + + conditional_rule_items = [] + if form_type == 'onboarding': + conditional_field_choices = [] + for field_name in [ + 'order_business_cards', + 'employment_type', + 'group_mailboxes_required_choice', + 'additional_hardware_needed_choice', + 'additional_software_needed_choice', + 'additional_access_needed_choice', + 'successor_required_choice', + 'inherit_phone_number_choice', + ]: + conditional_field_choices.append((field_name, labels.get(field_name, field_name))) + for cfg in custom_field_configs: + conditional_field_choices.append((f'custom__{cfg.field_key}', cfg.translated_label(language_code))) + conditional_field_label_map = {value: label for value, label in conditional_field_choices} + conditional_target_titles = { + 'business-card-box': _('Visitenkarten-Details'), + 'employment-end-box': _('Vertragsende'), + 'group-mailboxes-box': _('Gruppenpostfächer'), + 'extra-hardware-box': _('Zusätzliche Hardware'), + 'extra-software-box': _('Zusätzliche Software'), + 'extra-access-box': _('Zusätzliche Zugänge'), + 'successor-box': _('Nachfolge'), + } + conditional_target_descriptions = { + 'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'), + 'employment-end-box': _('Steuert das Enddatum bei befristeter Beschäftigung.'), + 'group-mailboxes-box': _('Steuert das Freitextfeld für Gruppenpostfächer.'), + 'extra-hardware-box': _('Steuert zusätzliche Hardware-Felder.'), + 'extra-software-box': _('Steuert zusätzliche Software-Felder.'), + 'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'), + 'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'), + } + for target_key, cfg in conditional_rule_configs.items(): + clauses = list(cfg.clauses or []) + while len(clauses) < 2: + clauses.append({'field': '', 'operator': 'equals', 'value': ''}) + if target_key.startswith('custom__'): + custom_field_key = target_key.replace('custom__', '', 1) + custom_field = next((item for item in custom_field_configs if item.field_key == custom_field_key), None) + target_title = custom_field.translated_label(language_code) if custom_field else target_key + target_description = _('Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes.') + target_fields = [target_title] + else: + target_title = conditional_target_titles.get(target_key, target_key) + target_description = conditional_target_descriptions.get(target_key, '') + target_fields = [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])] + conditional_rule_items.append( + { + 'target_key': target_key, + 'title': target_title, + 'description': target_description, + 'is_active': cfg.is_active, + 'clauses': clauses[:2], + 'summary': _conditional_rule_summary(clauses[:2], conditional_field_label_map), + 'field_choices': conditional_field_choices, + 'operator_choices': CONDITIONAL_RULE_OPERATOR_CHOICES, + 'target_fields': target_fields, + } + ) + conditional_rule_keys = [item['target_key'] for item in conditional_rule_items] + if conditional_rule_keys and active_conditional_target not in conditional_rule_keys: + active_conditional_target = conditional_rule_keys[0] + + preview_sections = [] + if section_order: + field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups} + for key in section_order: + section_cfg = section_configs.get(key) + section_locked = key in locked_sections + custom_section = next((cfg for cfg in custom_section_configs if cfg.section_key == key), None) + section_visible = bool(custom_section.is_active) if custom_section else (True if section_locked or not section_cfg else section_cfg.is_visible) + visible_items = [ + item for item in field_rule_group_map.get(key, []) + if item['locked'] or item['is_visible'] + ] + visible_items.extend( + [ + { + 'label': cfg.translated_label(language_code), + 'locked': False, + } + for cfg in custom_field_configs + if cfg.section_key == key and cfg.is_active + ] + ) + if section_visible: + preview_sections.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': visible_items, + } + ) + + locked_field_count = len([item for item in field_rule_items if item['locked']]) + hidden_field_count = len([item for item in field_rule_items if not item['is_visible']]) + configurable_field_count = len(field_rule_items) - locked_field_count + hidden_section_count = len([item for item in section_rule_items if not item['is_visible']]) if section_rule_items else 0 + builder_summary = { + 'locked_field_count': locked_field_count, + 'configurable_field_count': configurable_field_count, + 'hidden_field_count': hidden_field_count, + 'hidden_section_count': hidden_section_count, + 'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]), + 'custom_section_count': len([cfg for cfg in custom_section_configs if cfg.is_active]), + } + module_labels = { + 'structure': _('Struktur & Reihenfolge'), + 'section-rules': _('Abschnitte'), + 'field-rules': _('Feldregeln'), + 'conditional-rules': _('Bedingte Logik'), + 'options': _('Optionen'), + 'field-texts': _('Feldtexte'), + 'custom-sections': _('Eigene Abschnitte'), + 'custom-fields': _('Eigene Felder'), + 'preview': _('Vorschau'), + } + option_category_labels = dict(_translate_choice_list(FormOption.CATEGORY_CHOICES)) + form_type_labels = { + 'onboarding': _('Onboarding'), + 'offboarding': _('Offboarding'), + } + active_focus_label = '' + if active_module == 'structure' and active_structure_section: + active_focus_label = section_labels.get(active_structure_section, active_structure_section) + elif active_module == 'section-rules' and section_rule_items: + active_focus_label = _('Alle Abschnitte') + elif active_module == 'field-rules' and active_field_rules_section: + active_focus_label = section_labels.get(active_field_rules_section, active_field_rules_section) + elif active_module == 'conditional-rules' and active_conditional_target: + active_focus_label = next((item['title'] for item in conditional_rule_items if item['target_key'] == active_conditional_target), active_conditional_target) + elif active_module == 'options': + active_focus_label = option_category_labels.get(option_category, option_category) + elif active_module == 'field-texts' and active_field_texts_section: + active_focus_label = section_labels.get(active_field_texts_section, active_field_texts_section) + elif active_module == 'custom-sections': + active_focus_label = _('Onboarding') + elif active_module == 'custom-fields' and active_custom_fields_section: + active_focus_label = section_labels.get(active_custom_fields_section, active_custom_fields_section) + elif active_module == 'preview': + active_focus_label = _('Live-Vorschau') + + return render( + request, + 'workflows/form_builder.html', + { + 'form_type': form_type, + 'columns': columns, + 'form_types': [('onboarding', _('Onboarding')), ('offboarding', _('Offboarding'))], + 'option_categories': _translate_choice_list(FormOption.CATEGORY_CHOICES), + 'selected_option_category': option_category, + 'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'), + 'field_text_items': configs, + 'field_rule_items': field_rule_items, + 'field_rule_groups': field_rule_groups, + 'field_text_groups': field_text_groups, + 'preview_sections': preview_sections, + 'section_rule_items': section_rule_items, + 'builder_summary': builder_summary, + 'conditional_rule_items': conditional_rule_items, + 'custom_field_groups': custom_field_groups, + 'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES), + 'custom_section_items': custom_section_configs, + 'active_panel': active_panel, + 'active_subpanel': active_subpanel, + 'active_rules_panel': active_rules_panel, + 'active_module': active_module, + 'active_form_type_label': form_type_labels.get(form_type, form_type), + 'active_module_label': module_labels.get(active_module, active_module), + 'active_focus_label': active_focus_label, + 'active_structure_section': active_structure_section, + 'active_field_rules_section': active_field_rules_section, + 'active_field_texts_section': active_field_texts_section, + 'active_custom_fields_section': active_custom_fields_section, + 'active_section_rules_section': active_section_rules_section, + 'active_conditional_target': active_conditional_target, + 'available_presets': FORM_PRESETS.get(form_type, {}), + 'can_override_locked_builder_rules': can_override_locked_builder_rules, + }, + ) + + +def form_builder_save_order_impl(request, *, audit_fn): + try: + payload = json.loads(request.body.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError): + return JsonResponse({'ok': False, 'error': _('Ungültige JSON-Daten.')}, status=400) + + form_type = payload.get('form_type') + if form_type not in DEFAULT_FIELD_ORDER: + return JsonResponse({'ok': False, 'error': _('Ungültiger Formulartyp.')}, status=400) + default_page_map = get_default_page_map(form_type) + + columns = payload.get('columns') + if not isinstance(columns, dict): + return JsonResponse({'ok': False, 'error': _('Spalten-Daten fehlen.')}, status=400) + + configs = list(FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name')) + custom_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_key')) + allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs} + seen = set() + + allowed_columns = get_section_order(form_type) + fallback_section = allowed_columns[-1] if allowed_columns else '' + + name_to_cfg = {cfg.field_name: cfg for cfg in configs} + custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs} + sort_order = 0 + + for column_key in allowed_columns: + names = columns.get(column_key, []) + if not isinstance(names, list): + return JsonResponse({'ok': False, 'error': _('Ungültige Spalte: %(column)s') % {'column': column_key}}, status=400) + + for name in names: + if not isinstance(name, str): + continue + if name not in allowed_names or name in seen: + continue + seen.add(name) + if name in name_to_cfg: + cfg = name_to_cfg[name] + cfg.sort_order = sort_order + cfg.page_key = column_key + else: + cfg = custom_name_to_cfg[name] + cfg.sort_order = sort_order + cfg.section_key = column_key + sort_order += 1 + + missing = [cfg.field_name for cfg in configs if cfg.field_name not in seen] + for name in missing: + cfg = name_to_cfg[name] + cfg.sort_order = sort_order + sort_order += 1 + cfg.page_key = cfg.page_key or default_page_map.get(name, fallback_section) + + missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen] + for name in missing_custom: + cfg = custom_name_to_cfg[name] + cfg.sort_order = sort_order + sort_order += 1 + cfg.section_key = cfg.section_key or fallback_section + + FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) + if custom_configs: + FormCustomFieldConfig.objects.bulk_update(custom_configs, ['sort_order', 'section_key']) + saved_count = len(configs) + len(custom_configs) + audit_fn(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': saved_count}) + return JsonResponse({'ok': True, 'saved_count': saved_count}) diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 2853db5..837ef1c 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -1,14 +1,23 @@ from django import forms -from pathlib import Path from datetime import timedelta -from django.contrib.auth import get_user_model, password_validation -from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm +from django.contrib.auth import authenticate, get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm +from django.core.exceptions import ValidationError from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy -from .form_builder import apply_form_field_config -from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig -from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role +from .branding import get_company_email_domain +from .form_builder import add_custom_form_fields, apply_form_field_config, custom_field_key_from_name, hidden_custom_field_names, is_custom_field_name +from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig +from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability +from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token +from .upload_validation import ( + validate_avatar_upload, + validate_favicon_upload, + validate_logo_upload, + validate_pdf_upload, + validate_signature_upload, +) YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -99,9 +108,80 @@ 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 AppAuthenticationForm(AuthenticationForm): +class AppLoginForm(forms.Form): username = forms.CharField(label=gettext_lazy('Benutzername')) - password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'})) + password = forms.CharField( + label=gettext_lazy('Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + + error_messages = { + 'invalid_login': gettext_lazy('Benutzername oder Passwort sind nicht korrekt.'), + 'inactive': gettext_lazy('Dieses Konto ist deaktiviert.'), + } + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.user_cache = None + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + username = cleaned_data.get('username') + password = cleaned_data.get('password') + if username and password: + self.user_cache = authenticate(self.request, username=username, password=password) + if self.user_cache is None: + raise ValidationError(self.error_messages['invalid_login'], code='invalid_login') + if not self.user_cache.is_active: + raise ValidationError(self.error_messages['inactive'], code='inactive') + return cleaned_data + + def get_user(self): + return self.user_cache + + +class AppTOTPChallengeForm(forms.Form): + otp_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + required=False, + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + recovery_code = forms.CharField( + label=gettext_lazy('Recovery-Code'), + required=False, + max_length=32, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}), + ) + + error_messages = { + 'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'), + 'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'), + } + + def __init__(self, *args, profile=None, **kwargs): + self.profile = profile + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + profile = self.profile + if not profile or not profile.totp_enabled: + return cleaned_data + + otp_code = normalize_totp_token(cleaned_data.get('otp_code')) + recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) + if recovery_code: + if not profile.consume_recovery_code(recovery_code): + raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp') + return cleaned_data + if not otp_code: + raise ValidationError(self.error_messages['missing_otp'], code='missing_otp') + if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())): + raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp') + return cleaned_data class AppPasswordResetForm(PasswordResetForm): @@ -122,6 +202,274 @@ class AppSetPasswordForm(SetPasswordForm): ) +class AppPasswordChangeForm(PasswordChangeForm): + old_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + new_password1 = forms.CharField( + label=gettext_lazy('Neues Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + help_text=password_validation.password_validators_help_text_html(), + ) + new_password2 = forms.CharField( + label=gettext_lazy('Neues Passwort bestätigen'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + ) + + +class AccountAvatarForm(forms.ModelForm): + class Meta: + model = UserProfile + fields = ['avatar_image'] + labels = { + 'avatar_image': gettext_lazy('Profilbild'), + } + widgets = { + 'avatar_image': forms.ClearableFileInput( + attrs={ + 'accept': '.png,.jpg,.jpeg,.webp,.svg', + 'onchange': 'this.form.submit()', + } + ), + } + + def clean_avatar_image(self): + avatar = self.cleaned_data.get('avatar_image') + validate_avatar_upload(avatar) + return avatar + + +class AccountDetailsForm(forms.Form): + first_name = forms.CharField(label=gettext_lazy('Vorname'), max_length=150, required=False) + last_name = forms.CharField(label=gettext_lazy('Nachname'), max_length=150, required=False) + email = forms.EmailField(label=gettext_lazy('E-Mail-Adresse'), required=False) + phone_number = forms.CharField(label=gettext_lazy('Telefon'), max_length=80, required=False) + mobile_number = forms.CharField(label=gettext_lazy('Mobil'), max_length=80, required=False) + job_title = forms.CharField(label=gettext_lazy('Position'), max_length=255, required=False) + department = forms.CharField(label=gettext_lazy('Abteilung'), max_length=255, required=False) + location = forms.CharField(label=gettext_lazy('Standort'), max_length=255, required=False) + contact_notes = forms.CharField( + label=gettext_lazy('Hinweise'), + max_length=255, + required=False, + widget=forms.Textarea(attrs={'rows': 3}), + ) + + def __init__(self, *args, user=None, profile=None, **kwargs): + self.user = user + self.profile = profile + initial = kwargs.setdefault('initial', {}) + if user is not None and not args: + initial.setdefault('first_name', user.first_name) + initial.setdefault('last_name', user.last_name) + initial.setdefault('email', user.email) + if profile is not None and not args: + initial.setdefault('phone_number', profile.phone_number) + initial.setdefault('mobile_number', profile.mobile_number) + initial.setdefault('job_title', profile.job_title) + initial.setdefault('department', profile.department) + initial.setdefault('location', profile.location) + initial.setdefault('contact_notes', profile.contact_notes) + super().__init__(*args, **kwargs) + + def clean_email(self): + return (self.cleaned_data.get('email') or '').strip().lower() + + def save(self): + self.user.first_name = self.cleaned_data.get('first_name', '').strip() + self.user.last_name = self.cleaned_data.get('last_name', '').strip() + self.user.email = self.cleaned_data.get('email', '').strip() + self.user.save(update_fields=['first_name', 'last_name', 'email']) + + self.profile.phone_number = self.cleaned_data.get('phone_number', '').strip() + self.profile.mobile_number = self.cleaned_data.get('mobile_number', '').strip() + self.profile.job_title = self.cleaned_data.get('job_title', '').strip() + self.profile.department = self.cleaned_data.get('department', '').strip() + self.profile.location = self.cleaned_data.get('location', '').strip() + self.profile.contact_notes = self.cleaned_data.get('contact_notes', '').strip() + self.profile.save( + update_fields=['phone_number', 'mobile_number', 'job_title', 'department', 'location', 'contact_notes', 'updated_at'] + ) + return self.user, self.profile + + +class AccountTOTPEnableForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + def __init__(self, *args, user=None, secret: str = '', **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.secret = secret + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean_verification_code(self): + code = normalize_totp_token(self.cleaned_data.get('verification_code')) + if not code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) + if not self.secret or not verify_totp_token(self.secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return code + + +class AccountTOTPDisableForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + + def __init__(self, *args, user=None, profile=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.profile = profile + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean(self): + cleaned_data = super().clean() + if not self.profile or not self.profile.totp_enabled: + raise ValidationError(_('TOTP ist für dieses Konto nicht aktiv.')) + return cleaned_data + + +class AccountTOTPRegenerateRecoveryCodesForm(forms.Form): + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + required=False, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + recovery_code = forms.CharField( + label=gettext_lazy('Recovery-Code'), + max_length=32, + required=False, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}), + ) + + def __init__(self, *args, user=None, profile=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.profile = profile + + def clean(self): + cleaned_data = super().clean() + code = normalize_totp_token(cleaned_data.get('verification_code')) + recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) + if not code and not recovery_code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.')) + secret = getattr(self.profile, 'totp_secret', '') or '' + if code: + if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return cleaned_data + if not self.profile.consume_recovery_code(recovery_code): + raise ValidationError(_('Der Recovery-Code ist ungültig.')) + return cleaned_data + + +class AccountNotificationPreferencesForm(forms.Form): + onboarding_success = forms.BooleanField(label=gettext_lazy('Onboarding erfolgreich'), required=False) + onboarding_failure = forms.BooleanField(label=gettext_lazy('Onboarding fehlgeschlagen'), required=False) + offboarding_success = forms.BooleanField(label=gettext_lazy('Offboarding erfolgreich'), required=False) + offboarding_failure = forms.BooleanField(label=gettext_lazy('Offboarding fehlgeschlagen'), required=False) + backup_success = forms.BooleanField(label=gettext_lazy('Backup erfolgreich'), required=False) + backup_failure = forms.BooleanField(label=gettext_lazy('Backup fehlgeschlagen'), required=False) + welcome_email_success = forms.BooleanField(label=gettext_lazy('Welcome E-Mail erfolgreich'), required=False) + welcome_email_failure = forms.BooleanField(label=gettext_lazy('Welcome E-Mail fehlgeschlagen'), required=False) + trial_alerts = forms.BooleanField(label=gettext_lazy('Trial-Hinweise'), required=False) + system_alerts = forms.BooleanField(label=gettext_lazy('System-Hinweise'), required=False) + + FIELD_TO_EVENT = { + 'onboarding_success': UserProfile.NOTIFICATION_ONBOARDING_SUCCESS, + 'onboarding_failure': UserProfile.NOTIFICATION_ONBOARDING_FAILURE, + 'offboarding_success': UserProfile.NOTIFICATION_OFFBOARDING_SUCCESS, + 'offboarding_failure': UserProfile.NOTIFICATION_OFFBOARDING_FAILURE, + 'backup_success': UserProfile.NOTIFICATION_BACKUP_SUCCESS, + 'backup_failure': UserProfile.NOTIFICATION_BACKUP_FAILURE, + 'welcome_email_success': UserProfile.NOTIFICATION_WELCOME_EMAIL_SUCCESS, + 'welcome_email_failure': UserProfile.NOTIFICATION_WELCOME_EMAIL_FAILURE, + 'trial_alerts': UserProfile.NOTIFICATION_TRIAL_ALERTS, + 'system_alerts': UserProfile.NOTIFICATION_SYSTEM_ALERTS, + } + + GROUPS = [ + ('workflow', gettext_lazy('Workflow'), ['onboarding_success', 'onboarding_failure', 'offboarding_success', 'offboarding_failure']), + ('welcome', gettext_lazy('Welcome E-Mail'), ['welcome_email_success', 'welcome_email_failure']), + ('operations', gettext_lazy('Operations'), ['backup_success', 'backup_failure', 'system_alerts']), + ('platform', gettext_lazy('Platform'), ['trial_alerts']), + ] + + def __init__(self, *args, profile=None, user=None, **kwargs): + self.profile = profile + self.user = user + initial = kwargs.setdefault('initial', {}) + if profile is not None and not args: + prefs = profile.get_notification_preferences() + for field_name, event_key in self.FIELD_TO_EVENT.items(): + initial.setdefault(field_name, prefs.get(event_key, True)) + super().__init__(*args, **kwargs) + self.visible_field_names = self._compute_visible_field_names() + for field_name in list(self.fields.keys()): + if field_name not in self.visible_field_names: + self.fields.pop(field_name) + + def _compute_visible_field_names(self) -> list[str]: + visible = [ + 'onboarding_success', + 'onboarding_failure', + 'offboarding_success', + 'offboarding_failure', + 'welcome_email_success', + 'welcome_email_failure', + ] + if user_has_capability(self.user, 'manage_backups'): + visible.extend(['backup_success', 'backup_failure']) + if user_has_capability(self.user, 'manage_integrations'): + visible.append('system_alerts') + if user_has_capability(self.user, 'manage_trial_lifecycle'): + visible.append('trial_alerts') + return visible + + def grouped_fields(self): + groups = [] + for key, label, field_names in self.GROUPS: + rows = [self[name] for name in field_names if name in self.fields] + if rows: + groups.append({'key': key, 'label': label, 'fields': rows}) + return groups + + def save(self): + prefs = self.profile.get_notification_preferences() + for field_name in self.visible_field_names: + event_key = self.FIELD_TO_EVENT[field_name] + prefs[event_key] = bool(self.cleaned_data.get(field_name)) + self.profile.notification_preferences = prefs + self.profile.save(update_fields=['notification_preferences', 'updated_at']) + return self.profile + + class UserManagementCreateForm(forms.Form): first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) @@ -129,12 +477,13 @@ class UserManagementCreateForm(forms.Form): email = forms.EmailField(label=_('E-Mail-Adresse')) role_key = forms.ChoiceField(label=_('Rolle')) - def __init__(self, *args, **kwargs): + def __init__(self, *args, include_product_owner: bool = False, **kwargs): super().__init__(*args, **kwargs) - self.fields['role_key'].choices = [ - (role_key, str(ROLE_LABELS[role_key])) - for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF) - ] + self.include_product_owner = include_product_owner + role_order = [ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF] + if include_product_owner: + role_order = [ROLE_PLATFORM_OWNER] + role_order + self.fields['role_key'].choices = [(role_key, str(ROLE_LABELS[role_key])) for role_key in role_order] def clean_username(self): username = (self.cleaned_data.get('username') or '').strip() @@ -150,6 +499,8 @@ class UserManagementCreateForm(forms.Form): role_key = (self.cleaned_data.get('role_key') or '').strip() if role_key not in ROLE_GROUP_NAMES: raise forms.ValidationError(_('Ungültige Rolle.')) + if role_key == ROLE_PLATFORM_OWNER and not self.include_product_owner: + raise forms.ValidationError(_('Nur Platform Owner dürfen diese Rolle vergeben.')) return role_key def save(self): @@ -166,6 +517,153 @@ class UserManagementCreateForm(forms.Form): return user +class PortalBrandingForm(forms.ModelForm): + class Meta: + model = PortalBranding + fields = [ + 'portal_title', + 'company_name', + 'company_domain', + 'support_email', + 'sender_display_name', + 'login_subtitle', + 'footer_text', + 'footer_text_en', + 'legal_notice', + 'legal_notice_en', + 'default_language', + 'logo_image', + 'pdf_letterhead', + 'favicon_image', + 'primary_color', + 'secondary_color', + ] + labels = { + 'portal_title': gettext_lazy('Portal-Titel'), + 'company_name': gettext_lazy('Firmenname'), + 'company_domain': gettext_lazy('Firmen-Domain'), + 'support_email': gettext_lazy('Support-E-Mail'), + 'sender_display_name': gettext_lazy('Absender-Anzeigename'), + 'login_subtitle': gettext_lazy('Login-Untertitel'), + 'footer_text': gettext_lazy('Footer-Text DE'), + 'footer_text_en': gettext_lazy('Footer-Text EN'), + 'legal_notice': gettext_lazy('Rechtlicher Hinweis DE'), + 'legal_notice_en': gettext_lazy('Rechtlicher Hinweis EN'), + 'default_language': gettext_lazy('Standardsprache'), + 'logo_image': gettext_lazy('Logo'), + 'pdf_letterhead': gettext_lazy('PDF-Briefkopf'), + 'favicon_image': gettext_lazy('Favicon'), + 'primary_color': gettext_lazy('Primärfarbe'), + 'secondary_color': gettext_lazy('Sekundärfarbe'), + } + widgets = { + 'primary_color': forms.TextInput(attrs={'type': 'color'}), + 'secondary_color': forms.TextInput(attrs={'type': 'color'}), + 'logo_image': forms.ClearableFileInput(attrs={'accept': '.svg,.png,.jpg,.jpeg,.webp'}), + 'pdf_letterhead': forms.ClearableFileInput(attrs={'accept': '.pdf'}), + 'favicon_image': forms.ClearableFileInput(attrs={'accept': '.ico,.png,.svg,.webp'}), + 'legal_notice': forms.Textarea(attrs={'rows': 3}), + 'legal_notice_en': forms.Textarea(attrs={'rows': 3}), + } + + def clean_logo_image(self): + logo = self.cleaned_data.get('logo_image') + validate_logo_upload(logo) + return logo + + def clean_pdf_letterhead(self): + letterhead = self.cleaned_data.get('pdf_letterhead') + validate_pdf_upload(letterhead) + return letterhead + + def clean_favicon_image(self): + favicon = self.cleaned_data.get('favicon_image') + validate_favicon_upload(favicon) + return favicon + + +class PortalCompanyConfigForm(forms.ModelForm): + class Meta: + model = PortalCompanyConfig + fields = [ + 'legal_company_name', + 'street_address', + 'postal_code', + 'city', + 'country', + 'website_url', + 'imprint_url', + 'privacy_url', + 'hr_contact_email', + 'it_contact_email', + 'operations_contact_email', + 'phone_number', + 'vat_id', + 'registration_number', + ] + labels = { + 'legal_company_name': gettext_lazy('Rechtlicher Firmenname'), + 'street_address': gettext_lazy('Straße und Hausnummer'), + 'postal_code': gettext_lazy('Postleitzahl'), + 'city': gettext_lazy('Stadt'), + 'country': gettext_lazy('Land'), + 'website_url': gettext_lazy('Website'), + 'imprint_url': gettext_lazy('Impressum-URL'), + 'privacy_url': gettext_lazy('Datenschutz-URL'), + 'hr_contact_email': gettext_lazy('HR-Kontakt'), + 'it_contact_email': gettext_lazy('IT-Kontakt'), + 'operations_contact_email': gettext_lazy('Operations-Kontakt'), + 'phone_number': gettext_lazy('Zentrale Telefonnummer'), + 'vat_id': gettext_lazy('USt-IdNr.'), + 'registration_number': gettext_lazy('Register- oder Handelsnummer'), + } + + +class PortalTrialConfigForm(forms.ModelForm): + class Meta: + model = PortalTrialConfig + fields = [ + 'is_trial_mode', + 'trial_started_at', + 'trial_expires_at', + 'restrict_production_integrations', + 'auto_cleanup_enabled', + 'trial_banner_text', + 'trial_banner_text_en', + ] + labels = { + 'is_trial_mode': gettext_lazy('Trial-Modus aktiv'), + 'trial_started_at': gettext_lazy('Trial-Beginn'), + 'trial_expires_at': gettext_lazy('Trial-Ende'), + 'restrict_production_integrations': gettext_lazy('Produktive Integrationen begrenzen'), + 'auto_cleanup_enabled': gettext_lazy('Cleanup nach Ablauf zulassen'), + 'trial_banner_text': gettext_lazy('Banner-Text DE'), + 'trial_banner_text_en': gettext_lazy('Banner-Text EN'), + } + widgets = { + 'trial_started_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}), + 'trial_expires_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name in ('trial_started_at', 'trial_expires_at'): + field = self.fields[field_name] + if self.instance and getattr(self.instance, field_name): + field.initial = timezone.localtime(getattr(self.instance, field_name)).strftime('%Y-%m-%dT%H:%M') + field.input_formats = ['%Y-%m-%dT%H:%M'] + + def clean(self): + cleaned = super().clean() + started = cleaned.get('trial_started_at') + expires = cleaned.get('trial_expires_at') + if cleaned.get('is_trial_mode') and not expires: + self.add_error('trial_expires_at', _('Bitte ein Trial-Ende festlegen.')) + if started and expires and expires <= started: + self.add_error('trial_expires_at', _('Das Trial-Ende muss nach dem Trial-Beginn liegen.')) + return cleaned + + class OnboardingRequestForm(forms.ModelForm): first_name = forms.CharField(label='Vorname', required=False) last_name = forms.CharField(label='Nachname', required=False) @@ -174,7 +672,7 @@ class OnboardingRequestForm(forms.ModelForm): 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.', + help_text='', ) 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) @@ -225,7 +723,7 @@ class OnboardingRequestForm(forms.ModelForm): widget=forms.CheckboxSelectMultiple, ) phone_number_choice = forms.CharField( - label='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)', + label='Telefon-Direktwahl', required=False, widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}), ) @@ -293,6 +791,7 @@ class OnboardingRequestForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower() super().__init__(*args, **kwargs) + self.email_domain = get_company_email_domain() config = WorkflowConfig.objects.order_by('id').first() self.handover_lead_days = max(0, int(getattr(config, 'device_handover_lead_days', 5) or 5)) @@ -300,6 +799,7 @@ class OnboardingRequestForm(forms.ModelForm): self.fields['handover_date'].widget.attrs['min'] = minimum_handover_date.isoformat() self.fields['full_name'].label = 'Name' + self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain} 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() @@ -314,47 +814,20 @@ class OnboardingRequestForm(forms.ModelForm): 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) + add_custom_form_fields('onboarding', self, getattr(self.instance, 'custom_field_values', None)) 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.') + expected_suffix = f'@{self.email_domain}' + if self.email_domain and not value.endswith(expected_suffix): + raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain}) 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.') + validate_signature_upload(image) return image def clean(self): @@ -411,6 +884,11 @@ class OnboardingRequestForm(forms.ModelForm): }, ) + hidden_custom = hidden_custom_field_names('onboarding', cleaned) + for field_name in hidden_custom: + cleaned[field_name] = False if self.fields.get(field_name) and self.fields[field_name].widget.input_type == 'checkbox' else '' + self._errors.pop(field_name, None) + return cleaned def save(self, commit=True): @@ -450,6 +928,11 @@ class OnboardingRequestForm(forms.ModelForm): instance.agreement = 'accepted' if self.cleaned_data.get('agreement_confirm') else '' instance.onboarded_by_email = self.requester_email + instance.custom_field_values = { + custom_field_key_from_name(name): self.cleaned_data.get(name) + for name in self.fields.keys() + if is_custom_field_name(name) + } if commit: instance.save() @@ -481,11 +964,35 @@ class OffboardingRequestForm(forms.ModelForm): def __init__(self, *args, **kwargs): prefill_profile = kwargs.pop('prefill_profile', None) super().__init__(*args, **kwargs) + self.email_domain = get_company_email_domain() self.fields['full_name'].label = 'Vorname und Nachname' - self.fields['work_email'].help_text = '' + self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain} 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) + add_custom_form_fields('offboarding', self, getattr(self.instance, 'custom_field_values', None)) + + def clean_work_email(self): + value = (self.cleaned_data.get('work_email') or '').strip().lower() + if not value: + return value + expected_suffix = f'@{self.email_domain}' + if self.email_domain and not value.endswith(expected_suffix): + raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain}) + return value + + def save(self, commit=True): + instance = super().save(commit=False) + if not (instance.preferred_language or '').strip(): + instance.preferred_language = (get_language() or 'de').split('-')[0] + instance.custom_field_values = { + custom_field_key_from_name(name): self.cleaned_data.get(name) + for name in self.fields.keys() + if is_custom_field_name(name) + } + if commit: + instance.save() + return instance diff --git a/backend/workflows/integration_admin_views.py b/backend/workflows/integration_admin_views.py new file mode 100644 index 0000000..b849de2 --- /dev/null +++ b/backend/workflows/integration_admin_views.py @@ -0,0 +1,459 @@ +import json +from pathlib import Path +from tempfile import NamedTemporaryFile + +from django.conf import settings +from django.contrib import messages +from django.shortcuts import redirect, render +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .models import NotificationRule, NotificationTemplate, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig +from .notifications import notify_user +from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud +from .branding import get_default_notification_templates +from .emailing import send_system_email + + +def integrations_setup_page_impl(request): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + kind = (request.GET.get('kind') or 'nextcloud').strip().lower() + if kind not in {'nextcloud', 'mail', 'emails', 'rules', 'backup'}: + kind = 'nextcloud' + templates = list(NotificationTemplate.objects.all().order_by('key')) + system_email_config = ( + SystemEmailConfig.objects.filter(is_active=True).order_by('-updated_at').first() + or SystemEmailConfig.objects.filter(name='Default SMTP').first() + ) + return render( + request, + 'workflows/integrations_setup.html', + { + 'workflow_config': config, + 'system_email_config': system_email_config, + 'nextcloud_enabled': is_nextcloud_enabled(), + 'email_test_mode': is_email_test_mode(), + 'kind': kind, + 'templates': templates, + 'notification_rules': NotificationRule.objects.all().order_by('event_type', 'sort_order', 'id'), + 'rule_event_choices': NotificationRule.EVENT_CHOICES, + 'rule_operator_choices': NotificationRule.OPERATOR_CHOICES, + 'template_choices': NotificationTemplate.TEMPLATE_CHOICES, + 'remote_backup_target_choices': WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES, + }, + ) + + +def send_test_email_impl(request, *, audit_fn, redirect_back_fn): + mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF' + redirect_email = get_email_test_redirect() + try: + send_system_email( + subject=f'SMTP test from onboarding/offboarding v2 ({mode})', + body=( + 'This is a test email. If you see this, SMTP is configured correctly.\n' + f'EMAIL_TEST_MODE={is_email_test_mode()}\n' + f'EMAIL_TEST_REDIRECT={redirect_email}\n' + ), + to=[settings.TEST_NOTIFICATION_EMAIL], + ) + audit_fn(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()}) + notify_user( + user=request.user, + title=_('SMTP-Test erfolgreich'), + body=_('Die SMTP-Testmail wurde erfolgreich gesendet.'), + level=UserNotification.LEVEL_SUCCESS, + link_url='/admin-tools/integrations/', + event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS, + ) + messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).') + except Exception as exc: + notify_user( + user=request.user, + title=_('SMTP-Test fehlgeschlagen'), + body=str(exc), + level=UserNotification.LEVEL_ERROR, + link_url='/admin-tools/integrations/', + event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS, + ) + messages.error(request, _('SMTP-Testmail konnte nicht gesendet werden: %(error)s') % {'error': exc}) + return redirect_back_fn(request, 'home') + + +def nextcloud_test_upload_impl(request, *, audit_fn, redirect_back_fn): + filename = f"nextcloud_test_{timezone.now().strftime('%Y%m%d_%H%M%S')}.txt" + content = ( + "Nextcloud test upload from onboarding/offboarding system.\n" + f"Time: {timezone.now().isoformat()}\n" + f"User: {request.user.username}\n" + ) + + temp_path = None + try: + with NamedTemporaryFile('w', suffix='.txt', delete=False, encoding='utf-8') as tf: + tf.write(content) + temp_path = Path(tf.name) + + ok = upload_to_nextcloud(temp_path, filename) + if ok: + audit_fn(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'success'}) + notify_user( + user=request.user, + title=_('Nextcloud-Test erfolgreich'), + body=_('Der Testupload nach Nextcloud war erfolgreich.'), + level=UserNotification.LEVEL_SUCCESS, + link_url='/admin-tools/integrations/', + event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS, + ) + messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}') + else: + audit_fn(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'error'}) + notify_user( + user=request.user, + title=_('Nextcloud-Test fehlgeschlagen'), + body=_('Der Testupload nach Nextcloud ist fehlgeschlagen.'), + level=UserNotification.LEVEL_ERROR, + link_url='/admin-tools/integrations/', + event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS, + ) + messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.') + except Exception as exc: + notify_user( + user=request.user, + title=_('Nextcloud-Test fehlgeschlagen'), + body=str(exc), + level=UserNotification.LEVEL_ERROR, + link_url='/admin-tools/integrations/', + event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS, + ) + messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}') + finally: + if temp_path and temp_path.exists(): + temp_path.unlink(missing_ok=True) + + return redirect_back_fn(request, 'home') + + +def toggle_nextcloud_enabled_impl(request, *, audit_fn, redirect_back_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + currently_enabled = is_nextcloud_enabled() + config.nextcloud_enabled_override = not currently_enabled + config.save(update_fields=['nextcloud_enabled_override']) + audit_fn(request, 'nextcloud_mode_toggled', target_type='workflow_config', target_label='nextcloud', details={'enabled': config.nextcloud_enabled_override}) + + state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert' + messages.success(request, f'Nextcloud Upload wurde {state}.') + return redirect_back_fn(request, 'home') + + +def toggle_email_mode_impl(request, *, audit_fn, redirect_back_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + currently_test_mode = is_email_test_mode() + config.email_test_mode_override = not currently_test_mode + config.save(update_fields=['email_test_mode_override']) + audit_fn(request, 'email_mode_toggled', target_type='workflow_config', target_label='email_mode', details={'test_mode': config.email_test_mode_override}) + + state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus' + messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.') + return redirect_back_fn(request, 'home') + + +def save_integrations_settings_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + try: + sync_interval = int(request.POST.get('sync_interval_seconds', config.sync_interval_seconds or 60)) + smtp_port = int(request.POST.get('smtp_port', config.smtp_port or 465)) + except ValueError: + messages.error(request, 'Ungültige Zahl bei Sync-Intervall oder SMTP Port.') + return redirect('home') + + config.nextcloud_base_url_override = request.POST.get('nextcloud_base_url_override', '').strip() + config.nextcloud_username_override = request.POST.get('nextcloud_username_override', '').strip() + config.nextcloud_directory_override = request.POST.get('nextcloud_directory_override', '').strip() + config.sync_interval_seconds = max(10, sync_interval) + + config.imap_server = request.POST.get('imap_server', '').strip() + config.mailbox = request.POST.get('mailbox', '').strip() or 'INBOX' + config.smtp_server = request.POST.get('smtp_server', '').strip() + config.smtp_port = max(1, smtp_port) + config.email_account = request.POST.get('email_account', '').strip() + config.smtp_use_ssl = request.POST.get('smtp_use_ssl') == 'on' + config.smtp_use_tls = request.POST.get('smtp_use_tls') == 'on' + + nextcloud_password = request.POST.get('nextcloud_password_override', '').strip() + if nextcloud_password: + config.nextcloud_password_override = nextcloud_password + + email_password = request.POST.get('email_password', '').strip() + if email_password: + config.email_password = email_password + + config.save() + audit_fn(request, 'integrations_saved', target_type='workflow_config', target_label='all_integrations') + messages.success(request, 'Integrations-Einstellungen wurden gespeichert.') + return redirect('home') + + +def save_nextcloud_settings_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + try: + sync_interval = int(request.POST.get('sync_interval_seconds', config.sync_interval_seconds or 60)) + except ValueError: + messages.error(request, 'Ungültige Zahl beim Sync-Intervall.') + return redirect('home') + + config.nextcloud_base_url_override = request.POST.get('nextcloud_base_url_override', '').strip() + config.nextcloud_username_override = request.POST.get('nextcloud_username_override', '').strip() + config.nextcloud_directory_override = request.POST.get('nextcloud_directory_override', '').strip() + config.sync_interval_seconds = max(10, sync_interval) + + nextcloud_password = request.POST.get('nextcloud_password_override', '').strip() + if nextcloud_password: + config.nextcloud_password_override = nextcloud_password + + config.save() + audit_fn(request, 'nextcloud_settings_saved', target_type='workflow_config', target_label='nextcloud') + messages.success(request, 'Nextcloud-Einstellungen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=nextcloud') + + +def save_workflow_rules_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + try: + handover_lead_days = int( + request.POST.get( + 'device_handover_lead_days', + config.device_handover_lead_days or 5, + ) + ) + except ValueError: + messages.error(request, 'Ungültige Zahl beim Hardware-Vorlauf.') + return redirect('/admin-tools/integrations/?kind=rules') + + config.device_handover_lead_days = max(0, handover_lead_days) + config.save(update_fields=['device_handover_lead_days']) + audit_fn( + request, + 'workflow_rules_saved', + target_type='workflow_config', + target_label='workflow_rules', + details={ + 'device_handover_lead_days': config.device_handover_lead_days, + }, + ) + messages.success(request, 'Workflow-Regeln wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=rules') + + +def save_backup_settings_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + target_type = (request.POST.get('remote_backup_target_type') or config.remote_backup_target_type or 'nextcloud').strip().lower() + if target_type not in {choice for choice, _ in WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES}: + target_type = 'nextcloud' + remote_backup_enabled = request.POST.get('remote_backup_enabled') == 'on' + remote_backup_nextcloud_directory = request.POST.get('remote_backup_nextcloud_directory', '').strip() + primary_nextcloud_directory = ( + (config.nextcloud_directory_override or '').strip() + or settings.NEXTCLOUD_DIRECTORY.strip() + ).strip('/') + + if remote_backup_enabled and target_type == 'nextcloud': + if not remote_backup_nextcloud_directory: + messages.error(request, 'Bitte ein separates Nextcloud Backup-Verzeichnis angeben.') + return redirect('/admin-tools/integrations/?kind=backup') + if remote_backup_nextcloud_directory.strip('/') == primary_nextcloud_directory: + messages.error(request, 'Das Backup-Verzeichnis muss vom normalen Nextcloud Dokumentenordner getrennt sein.') + return redirect('/admin-tools/integrations/?kind=backup') + + config.remote_backup_enabled = remote_backup_enabled + config.remote_backup_target_type = target_type + config.remote_backup_nextcloud_directory = remote_backup_nextcloud_directory + config.remote_backup_s3_bucket = request.POST.get('remote_backup_s3_bucket', '').strip() + config.remote_backup_nfs_path = request.POST.get('remote_backup_nfs_path', '').strip() + config.save( + update_fields=[ + 'device_handover_lead_days', + 'remote_backup_enabled', + 'remote_backup_target_type', + 'remote_backup_nextcloud_directory', + 'remote_backup_s3_bucket', + 'remote_backup_nfs_path', + ] + ) + audit_fn( + request, + 'backup_settings_saved', + target_type='workflow_config', + target_label='backup_settings', + details={ + 'remote_backup_enabled': config.remote_backup_enabled, + 'remote_backup_target_type': config.remote_backup_target_type, + 'remote_backup_nextcloud_directory': config.remote_backup_nextcloud_directory, + }, + ) + messages.success(request, 'Backup-Einstellungen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=backup') + + +def save_mail_settings_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + try: + smtp_port = int(request.POST.get('smtp_port', config.smtp_port or 465)) + except ValueError: + messages.error(request, 'Ungültige Zahl beim SMTP Port.') + return redirect('home') + + config.imap_server = request.POST.get('imap_server', '').strip() + config.mailbox = request.POST.get('mailbox', '').strip() or 'INBOX' + config.smtp_server = request.POST.get('smtp_server', '').strip() + config.smtp_port = max(1, smtp_port) + config.email_account = request.POST.get('email_account', '').strip() + config.smtp_use_ssl = request.POST.get('smtp_use_ssl') == 'on' + config.smtp_use_tls = request.POST.get('smtp_use_tls') == 'on' + + email_password = request.POST.get('email_password', '').strip() + if email_password: + config.email_password = email_password + + config.save() + smtp_cfg, _ = SystemEmailConfig.objects.get_or_create(name='Default SMTP') + SystemEmailConfig.objects.exclude(id=smtp_cfg.id).update(is_active=False) + smtp_cfg.is_active = True + smtp_cfg.host = config.smtp_server + smtp_cfg.port = config.smtp_port + smtp_cfg.username = config.email_account + if email_password: + smtp_cfg.password = email_password + smtp_cfg.use_ssl = config.smtp_use_ssl + smtp_cfg.use_tls = config.smtp_use_tls + smtp_cfg.from_email = request.POST.get('from_email', '').strip() + smtp_cfg.save() + audit_fn(request, 'mail_settings_saved', target_type='workflow_config', target_label='mail') + messages.success(request, 'Mail-Einstellungen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=mail') + + +def save_email_routing_settings_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + config.it_onboarding_email = request.POST.get('it_onboarding_email', '').strip() + config.general_info_email = request.POST.get('general_info_email', '').strip() + config.business_card_email = request.POST.get('business_card_email', '').strip() + config.hr_works_email = request.POST.get('hr_works_email', '').strip() + config.key_notification_email = request.POST.get('key_notification_email', '').strip() + config.save( + update_fields=[ + 'it_onboarding_email', + 'general_info_email', + 'business_card_email', + 'hr_works_email', + 'key_notification_email', + ] + ) + + known_keys = {k for k, _ in NotificationTemplate.TEMPLATE_CHOICES} + for key in known_keys: + subject = request.POST.get(f'subject_{key}') + body = request.POST.get(f'body_{key}') + subject_en = request.POST.get(f'subject_en_{key}') + body_en = request.POST.get(f'body_en_{key}') + if subject is None and body is None and subject_en is None and body_en is None: + continue + subject = (subject or '').strip() + body = (body or '').strip() + subject_en = (subject_en or '').strip() + body_en = (body_en or '').strip() + if not subject and not body and not subject_en and not body_en: + continue + obj, _ = NotificationTemplate.objects.get_or_create( + key=key, + defaults={ + 'subject_template': subject or f'[{key}]', + 'body_template': body or '-', + 'subject_template_en': subject_en, + 'body_template_en': body_en, + 'is_active': True, + }, + ) + changed = [] + if subject and obj.subject_template != subject: + obj.subject_template = subject + changed.append('subject_template') + if body and obj.body_template != body: + obj.body_template = body + changed.append('body_template') + if obj.subject_template_en != subject_en: + obj.subject_template_en = subject_en + changed.append('subject_template_en') + if obj.body_template_en != body_en: + obj.body_template_en = body_en + changed.append('body_template_en') + if not obj.is_active: + obj.is_active = True + changed.append('is_active') + if changed: + obj.save(update_fields=changed) + + audit_fn(request, 'email_routing_saved', target_type='workflow_config', target_label='email_routing') + messages.success(request, 'E-Mail Routing und Vorlagen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=emails') + + +def save_notification_rules_impl(request, *, audit_fn): + rule_ids = request.POST.getlist('rule_ids') + for position, raw_id in enumerate(rule_ids): + rule = NotificationRule.objects.filter(id=raw_id).first() + if not rule: + continue + if request.POST.get(f'delete_{rule.id}') == 'on': + rule.delete() + continue + + rule.name = request.POST.get(f'name_{rule.id}', '').strip() or rule.name + event_type = request.POST.get(f'event_type_{rule.id}', '').strip() + if event_type in {'onboarding', 'offboarding'}: + rule.event_type = event_type + operator = request.POST.get(f'operator_{rule.id}', '').strip() + if operator in {x[0] for x in NotificationRule.OPERATOR_CHOICES}: + rule.operator = operator + rule.field_name = request.POST.get(f'field_name_{rule.id}', '').strip() + rule.expected_value = request.POST.get(f'expected_value_{rule.id}', '').strip() + rule.recipients = request.POST.get(f'recipients_{rule.id}', '').strip() + rule.template_key = request.POST.get(f'template_key_{rule.id}', '').strip() + rule.custom_subject = request.POST.get(f'custom_subject_{rule.id}', '').strip() + rule.custom_body = request.POST.get(f'custom_body_{rule.id}', '').strip() + rule.custom_subject_en = request.POST.get(f'custom_subject_en_{rule.id}', '').strip() + rule.custom_body_en = request.POST.get(f'custom_body_en_{rule.id}', '').strip() + rule.include_pdf_attachment = request.POST.get(f'include_pdf_{rule.id}') == 'on' + rule.is_active = request.POST.get(f'active_{rule.id}') == 'on' + rule.sort_order = position + rule.save() + + new_name = request.POST.get('new_name', '').strip() + new_recipients = request.POST.get('new_recipients', '').strip() + if new_name and new_recipients: + new_event = request.POST.get('new_event_type', 'onboarding').strip() + if new_event not in {'onboarding', 'offboarding'}: + new_event = 'onboarding' + new_operator = request.POST.get('new_operator', 'always').strip() + if new_operator not in {x[0] for x in NotificationRule.OPERATOR_CHOICES}: + new_operator = 'always' + NotificationRule.objects.create( + name=new_name, + event_type=new_event, + field_name=request.POST.get('new_field_name', '').strip(), + operator=new_operator, + expected_value=request.POST.get('new_expected_value', '').strip(), + recipients=new_recipients, + template_key=request.POST.get('new_template_key', '').strip(), + custom_subject=request.POST.get('new_custom_subject', '').strip(), + custom_body=request.POST.get('new_custom_body', '').strip(), + custom_subject_en=request.POST.get('new_custom_subject_en', '').strip(), + custom_body_en=request.POST.get('new_custom_body_en', '').strip(), + include_pdf_attachment=request.POST.get('new_include_pdf') == 'on', + is_active=True, + sort_order=NotificationRule.objects.filter(event_type=new_event).count() + 1, + ) + + audit_fn(request, 'notification_rules_saved', target_type='notification_rule') + messages.success(request, 'Benachrichtigungsregeln wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=emails') diff --git a/backend/workflows/integrations_views.py b/backend/workflows/integrations_views.py new file mode 100644 index 0000000..c1e04a7 --- /dev/null +++ b/backend/workflows/integrations_views.py @@ -0,0 +1,45 @@ +from .integration_admin_views import ( + integrations_setup_page_impl, + nextcloud_test_upload_impl, + save_backup_settings_impl, + save_email_routing_settings_impl, + save_integrations_settings_impl, + save_mail_settings_impl, + save_nextcloud_settings_impl, + save_notification_rules_impl, + save_workflow_rules_impl, + send_test_email_impl, + toggle_email_mode_impl, + toggle_nextcloud_enabled_impl, +) +from .welcome_email_views import ( + bulk_welcome_email_action_impl, + cancel_welcome_email_impl, + pause_welcome_email_impl, + resume_welcome_email_impl, + save_welcome_email_settings_impl, + trigger_welcome_email_now_impl, + welcome_emails_page_impl, +) + +__all__ = [ + 'integrations_setup_page_impl', + 'welcome_emails_page_impl', + 'trigger_welcome_email_now_impl', + 'save_welcome_email_settings_impl', + 'bulk_welcome_email_action_impl', + 'pause_welcome_email_impl', + 'resume_welcome_email_impl', + 'cancel_welcome_email_impl', + 'send_test_email_impl', + 'nextcloud_test_upload_impl', + 'toggle_nextcloud_enabled_impl', + 'toggle_email_mode_impl', + 'save_integrations_settings_impl', + 'save_nextcloud_settings_impl', + 'save_workflow_rules_impl', + 'save_backup_settings_impl', + 'save_mail_settings_impl', + 'save_email_routing_settings_impl', + 'save_notification_rules_impl', +] diff --git a/backend/workflows/intro_builder_views.py b/backend/workflows/intro_builder_views.py new file mode 100644 index 0000000..c9710e0 --- /dev/null +++ b/backend/workflows/intro_builder_views.py @@ -0,0 +1,134 @@ +from django.contrib import messages +from django.shortcuts import redirect, render + +from .models import IntroChecklistItem + + +def intro_builder_page_impl(request, *, audit_fn, translate_choice_list): + if request.method == 'POST': + delete_id = (request.POST.get('delete_item_id') or '').strip() + if delete_id: + item = IntroChecklistItem.objects.filter(id=delete_id).first() + if item: + deleted_label = item.label + deleted_id_int = item.id + item.delete() + audit_fn(request, 'intro_checklist_item_deleted', target_type='intro_checklist_item', target_id=deleted_id_int, target_label=deleted_label) + messages.success(request, 'Checklistenpunkt wurde gelöscht.') + else: + messages.error(request, 'Checklistenpunkt nicht gefunden.') + return redirect('intro_builder_page') + + action = (request.POST.get('builder_action') or '').strip() + if action == 'add_item': + section = (request.POST.get('section') or '').strip() + label = (request.POST.get('label') or '').strip() + label_en = (request.POST.get('label_en') or '').strip() + if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}: + messages.error(request, 'Ungültiger Abschnitt.') + return redirect('intro_builder_page') + if not label: + messages.error(request, 'Bitte eine Bezeichnung für den Checklistenpunkt angeben.') + return redirect('intro_builder_page') + next_sort = ( + IntroChecklistItem.objects.filter(section=section).order_by('-sort_order').values_list('sort_order', flat=True).first() + ) + IntroChecklistItem.objects.create( + section=section, + label=label, + label_en=label_en, + sort_order=(next_sort + 1) if next_sort is not None else 0, + is_active=True, + condition_operator='always', + ) + audit_fn(request, 'intro_checklist_item_added', target_type='intro_checklist_item', target_label=label, details={'section': section, 'label_en': label_en}) + messages.success(request, 'Checklistenpunkt wurde hinzugefügt.') + return redirect('intro_builder_page') + + if action == 'save_items': + item_ids = request.POST.getlist('item_ids') + valid_sections = {k for k, _ in IntroChecklistItem.SECTION_CHOICES} + valid_ops = {k for k, _ in IntroChecklistItem.OPERATOR_CHOICES} + for pos, raw_id in enumerate(item_ids): + item = IntroChecklistItem.objects.filter(id=raw_id).first() + if not item: + continue + section = (request.POST.get(f'section_{item.id}') or item.section).strip() + if section not in valid_sections: + section = item.section + operator = (request.POST.get(f'operator_{item.id}') or item.condition_operator).strip() + if operator not in valid_ops: + operator = 'always' + item.section = section + item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label + item.label_en = (request.POST.get(f'label_en_{item.id}') or '').strip() + item.is_active = request.POST.get(f'active_{item.id}') == 'on' + item.condition_field = (request.POST.get(f'field_{item.id}') or '').strip() + item.condition_operator = operator + item.condition_value = (request.POST.get(f'value_{item.id}') or '').strip() + item.sort_order = pos + item.save( + update_fields=[ + 'section', + 'label', + 'label_en', + 'is_active', + 'condition_field', + 'condition_operator', + 'condition_value', + 'sort_order', + ] + ) + audit_fn(request, 'intro_checklist_saved', target_type='intro_checklist_item', details={'count': len(item_ids)}) + messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.') + return redirect('intro_builder_page') + + condition_field_choices = [ + ('', 'Keine Bedingung'), + ('needed_devices', 'Benötigte Geräte und Gegenstände'), + ('needed_software', 'Benötigte Software'), + ('needed_accesses', 'Benötigte Zugänge'), + ('needed_workspace_groups', 'Benötigte Gruppen im Workspace'), + ('needed_resources', 'Benötigte Ressourcen'), + ('additional_hardware', 'Zusätzliche Hardware'), + ('additional_software', 'Zusätzliche Software'), + ('additional_access_text', 'Weitere Zugänge (Freitext)'), + ('group_mailboxes_required', 'Gruppenpostfächer erforderlich'), + ('order_business_cards', 'Visitenkarten bestellt'), + ('phone_number', 'Direktwahl vorhanden'), + ('successor_name', 'Nachfolge vorhanden'), + ('department', 'Abteilung'), + ] + + items = list(IntroChecklistItem.objects.all().order_by('section', 'sort_order', 'label')) + section_label_map = dict(translate_choice_list(IntroChecklistItem.SECTION_CHOICES)) + grouped_items = [] + for value, _label in IntroChecklistItem.SECTION_CHOICES: + section_items = [item for item in items if item.section == value] + grouped_items.append( + { + 'key': value, + 'label': section_label_map.get(value, value), + 'items': section_items, + 'count': len(section_items), + 'active_count': len([item for item in section_items if item.is_active]), + } + ) + + return render( + request, + 'workflows/intro_builder.html', + { + 'items': items, + 'grouped_items': grouped_items, + 'intro_summary': { + 'total_items': len(items), + 'active_items': len([item for item in items if item.is_active]), + 'conditional_items': len([item for item in items if item.condition_operator != 'always']), + 'section_count': len([group for group in grouped_items if group['count']]), + }, + 'section_choices': translate_choice_list(IntroChecklistItem.SECTION_CHOICES), + 'operator_choices': translate_choice_list(IntroChecklistItem.OPERATOR_CHOICES), + 'condition_field_choices': condition_field_choices, + }, + ) diff --git a/backend/workflows/logging_utils.py b/backend/workflows/logging_utils.py new file mode 100644 index 0000000..a3dd2c5 --- /dev/null +++ b/backend/workflows/logging_utils.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import contextvars +import json +import logging +from datetime import datetime, timezone + +from celery import current_task + +_request_id: contextvars.ContextVar[str] = contextvars.ContextVar('request_id', default='') + + +def set_request_id(value: str) -> None: + _request_id.set(value or '') + + +def get_request_id() -> str: + return _request_id.get('') + + +def clear_request_id() -> None: + _request_id.set('') + + +class RequestContextFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = get_request_id() + task = current_task + request = getattr(task, 'request', None) if task else None + record.task_id = getattr(request, 'id', '') if request else '' + record.task_name = getattr(task, 'name', '') if task else '' + return True + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + 'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + } + request_id = getattr(record, 'request_id', '') or '' + task_id = getattr(record, 'task_id', '') or '' + task_name = getattr(record, 'task_name', '') or '' + if request_id: + payload['request_id'] = request_id + if task_id: + payload['task_id'] = task_id + if task_name: + payload['task_name'] = task_name + if record.exc_info: + payload['exception'] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False) diff --git a/backend/workflows/management/commands/bootstrap_initial_users.py b/backend/workflows/management/commands/bootstrap_initial_users.py index 5adfb7f..a34d823 100644 --- a/backend/workflows/management/commands/bootstrap_initial_users.py +++ b/backend/workflows/management/commands/bootstrap_initial_users.py @@ -1,12 +1,12 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from workflows.roles import ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, ensure_role_groups +from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role, ensure_role_groups DEFAULT_USERS = [ { 'username': 'admin_test', - 'email': 'admin_test@tub.co', + 'email': 'admin_test@workdock.de', 'password': 'admin12345', 'first_name': 'Admin', 'last_name': 'Test', @@ -15,7 +15,7 @@ DEFAULT_USERS = [ }, { 'username': 'user_test', - 'email': 'user_test@tub.co', + 'email': 'user_test@workdock.de', 'password': 'user12345', 'first_name': 'Normal', 'last_name': 'User', @@ -45,7 +45,7 @@ class Command(BaseCommand): is_superuser=item['is_superuser'], ) ensure_role_groups() - assign_user_role(user, ROLE_SUPER_ADMIN if item['username'] == 'admin_test' else ROLE_STAFF) + assign_user_role(user, ROLE_PLATFORM_OWNER if item['username'] == 'admin_test' else ROLE_STAFF) self.stdout.write(f'created {user.username}') self.stdout.write(self.style.SUCCESS('initial users created')) diff --git a/backend/workflows/management/commands/cleanup_expired_trial_workspace.py b/backend/workflows/management/commands/cleanup_expired_trial_workspace.py new file mode 100644 index 0000000..05bc48e --- /dev/null +++ b/backend/workflows/management/commands/cleanup_expired_trial_workspace.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import gettext as _ + +from workflows.trial import cleanup_trial_workspace_data, trial_cleanup_is_due + + +class Command(BaseCommand): + help = 'Deletes operational trial data after trial expiry while keeping platform configuration.' + + def add_arguments(self, parser): + parser.add_argument('--force', action='store_true', help='Run cleanup even if the trial is not due yet.') + parser.add_argument('--yes-delete', action='store_true', help='Confirm destructive cleanup.') + + def handle(self, *args, **options): + if not options['yes_delete']: + raise CommandError(_('Bitte mit --yes-delete bestätigen.')) + if not options['force'] and not trial_cleanup_is_due(): + raise CommandError(_('Kein abgelaufener Trial mit aktiviertem Cleanup gefunden.')) + + result = cleanup_trial_workspace_data() + self.stdout.write(self.style.SUCCESS(_('Trial-Workspace bereinigt.'))) + for key, value in result.items(): + self.stdout.write(f'- {key}: {value}') diff --git a/backend/workflows/management/commands/run_staging_e2e_check.py b/backend/workflows/management/commands/run_staging_e2e_check.py index cb415fd..b58ed55 100644 --- a/backend/workflows/management/commands/run_staging_e2e_check.py +++ b/backend/workflows/management/commands/run_staging_e2e_check.py @@ -9,6 +9,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.utils import timezone +from workflows.branding import get_company_email_domain from workflows.models import EmployeeProfile, OffboardingRequest, OnboardingRequest from workflows.tasks import process_offboarding_request, process_onboarding_request @@ -100,8 +101,9 @@ class Command(BaseCommand): 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' + domain = get_company_email_domain() + work_email = f'e2e.{run_id}@{domain}' + requester_email = f'e2e.requester@{domain}' created_onboarding: OnboardingRequest | None = None created_offboarding: OffboardingRequest | None = None diff --git a/backend/workflows/management/commands/verify_latest_backup.py b/backend/workflows/management/commands/verify_latest_backup.py new file mode 100644 index 0000000..8b82f6a --- /dev/null +++ b/backend/workflows/management/commands/verify_latest_backup.py @@ -0,0 +1,30 @@ +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import gettext as _ + +from workflows.backup_ops import create_backup_bundle, list_backup_bundles, verify_backup_bundle + + +class Command(BaseCommand): + help = 'Verifies the latest backup bundle. Can create one first if no bundle exists yet.' + + def add_arguments(self, parser): + parser.add_argument( + '--create-if-missing', + action='store_true', + help='Create a new backup bundle first if none exists yet.', + ) + + def handle(self, *args, **options): + rows = list_backup_bundles() + if not rows: + if not options['create_if_missing']: + raise CommandError(_('Kein Backup-Bundle vorhanden.')) + created = create_backup_bundle() + backup_name = created['name'] + self.stdout.write(self.style.WARNING(_('Kein Backup gefunden. Neues Bundle erstellt: %(name)s') % {'name': backup_name})) + else: + backup_name = rows[0]['name'] + + result = verify_backup_bundle(backup_name) + self.stdout.write(self.style.SUCCESS(_('Backup erfolgreich verifiziert: %(name)s') % {'name': backup_name})) + self.stdout.write(result['summary']) diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py new file mode 100644 index 0000000..f378f73 --- /dev/null +++ b/backend/workflows/middleware.py @@ -0,0 +1,207 @@ +import uuid + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import logout +from django.contrib.messages.api import MessageFailure +from django.core.cache import cache +from django.http import HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .branding import is_trial_expired, is_trial_mode_enabled +from .logging_utils import clear_request_id, set_request_id +from .roles import ROLE_PLATFORM_OWNER, get_user_role_key + + +class RequestIDMiddleware: + HEADER_NAME = 'X-Request-ID' + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_id = ( + request.META.get('HTTP_X_REQUEST_ID') + or request.META.get('HTTP_X_CORRELATION_ID') + or uuid.uuid4().hex + ) + request.request_id = request_id + set_request_id(request_id) + try: + response = self.get_response(request) + finally: + clear_request_id() + response[self.HEADER_NAME] = request_id + return response + + +class RateLimitMiddleware: + LOGIN_PATHS = ('/accounts/login/', '/accounts/login/totp/') + PASSWORD_RESET_PATHS = ('/accounts/password_reset/',) + # Keep this list path-prefix based so new platform actions get protected + # without having to wire every single view into a second permission layer. + ADMIN_SENSITIVE_PREFIXES = ( + '/admin-tools/nextcloud/toggle/', + '/admin-tools/email-mode/toggle/', + '/admin-tools/integrations/save', + '/admin-tools/welcome-emails/', + '/admin-tools/branding/save/', + '/admin-tools/company/save/', + '/admin-tools/trial/save/', + '/admin-tools/apps/save/', + '/admin-tools/users/', + '/admin-tools/backups/', + '/requests/delete/', + '/requests/retry/', + ) + + def __init__(self, get_response): + self.get_response = get_response + + def _client_identifier(self, request) -> str: + user = getattr(request, 'user', None) + if getattr(user, 'is_authenticated', False): + return f'user:{user.pk}' + forwarded = (request.META.get('HTTP_X_FORWARDED_FOR') or '').split(',')[0].strip() + remote = forwarded or request.META.get('REMOTE_ADDR') or 'unknown' + return f'ip:{remote}' + + def _match_rule(self, path: str): + if any(path.startswith(prefix) for prefix in self.LOGIN_PATHS): + return ('login', settings.RATE_LIMIT_LOGIN_LIMIT, settings.RATE_LIMIT_LOGIN_WINDOW) + if any(path.startswith(prefix) for prefix in self.PASSWORD_RESET_PATHS): + return ('password_reset', settings.RATE_LIMIT_PASSWORD_RESET_LIMIT, settings.RATE_LIMIT_PASSWORD_RESET_WINDOW) + if any(path.startswith(prefix) for prefix in self.ADMIN_SENSITIVE_PREFIXES): + return ('admin_post', settings.RATE_LIMIT_ADMIN_ACTION_LIMIT, settings.RATE_LIMIT_ADMIN_ACTION_WINDOW) + return None + + def __call__(self, request): + if not settings.RATE_LIMIT_ENABLED or request.method != 'POST': + return self.get_response(request) + + rule = self._match_rule(request.path or '/') + if not rule: + return self.get_response(request) + + scope, limit, window = rule + identifier = self._client_identifier(request) + cache_key = f'ratelimit:{scope}:{identifier}' + added = cache.add(cache_key, 1, timeout=window) + current = 1 if added else cache.incr(cache_key) + if current > limit: + response = HttpResponse( + _('Zu viele Anfragen. Bitte versuchen Sie es in wenigen Minuten erneut.'), + status=429, + content_type='text/plain; charset=utf-8', + ) + retry_after = cache.ttl(cache_key) if hasattr(cache, 'ttl') else window + response['Retry-After'] = str(max(1, retry_after)) + return response + + return self.get_response(request) + + +class AuthSessionHardeningMiddleware: + EXEMPT_PREFIXES = ( + '/healthz/', + '/i18n/', + '/accounts/login/', + '/accounts/logout/', + '/accounts/password_reset/', + '/accounts/reset/', + '/static/', + '/media/', + ) + SENSITIVE_POST_PREFIXES = ( + '/admin-tools/users/', + '/admin-tools/backups/', + '/admin-tools/trial/save/', + '/admin-tools/apps/save/', + '/admin-tools/branding/save/', + '/admin-tools/company/save/', + '/admin-tools/integrations/save', + '/requests/delete/', + ) + + def __init__(self, get_response): + self.get_response = get_response + + def _is_exempt(self, path: str) -> bool: + return any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES) + + def _touch_session(self, request, now_ts: int) -> None: + request.session['last_activity_ts'] = now_ts + request.session.setdefault('auth_fresh_ts', now_ts) + + def _warn(self, request, message: str) -> None: + try: + messages.warning(request, message) + except MessageFailure: + return + + def __call__(self, request): + path = request.path or '/' + user = getattr(request, 'user', None) + if not getattr(user, 'is_authenticated', False) or self._is_exempt(path): + return self.get_response(request) + + now_ts = int(timezone.now().timestamp()) + idle_timeout = max(60, settings.SESSION_IDLE_TIMEOUT_SECONDS) + last_activity_ts = int(request.session.get('last_activity_ts') or now_ts) + if now_ts - last_activity_ts > idle_timeout: + logout(request) + self._warn(request, _('Ihre Sitzung ist wegen Inaktivität abgelaufen. Bitte melden Sie sich erneut an.')) + login_url = reverse('login') + return redirect(f'{login_url}?next={request.get_full_path()}') + + is_sensitive_post = request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES) + if request.method == 'POST' and path == '/account/': + account_form = (request.POST.get('account_form') or '').strip() + if account_form in {'totp_disable', 'totp_regenerate_codes'}: + is_sensitive_post = True + + if is_sensitive_post: + fresh_window = max(60, settings.SENSITIVE_ACTION_REAUTH_SECONDS) + auth_fresh_ts = int(request.session.get('auth_fresh_ts') or last_activity_ts) + if now_ts - auth_fresh_ts > fresh_window: + logout(request) + self._warn(request, _('Bitte bestätigen Sie Ihre Identität erneut, bevor Sie diese sensible Aktion ausführen.')) + login_url = reverse('login') + return redirect(f'{login_url}?next={request.get_full_path()}') + + response = self.get_response(request) + self._touch_session(request, now_ts) + return response + + +class TrialModeMiddleware: + EXEMPT_PREFIXES = ( + '/healthz/', + '/i18n/', + '/accounts/login/', + '/accounts/logout/', + '/accounts/password_reset/', + '/accounts/reset/', + '/static/', + '/media/', + ) + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not is_trial_mode_enabled() or not is_trial_expired(): + return self.get_response(request) + + path = request.path or '/' + if any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES): + return self.get_response(request) + + user = getattr(request, 'user', None) + if getattr(user, 'is_authenticated', False) and get_user_role_key(user) == ROLE_PLATFORM_OWNER: + return self.get_response(request) + + return render(request, 'workflows/trial_expired.html', status=403) diff --git a/backend/workflows/migrations/0036_portalbranding.py b/backend/workflows/migrations/0036_portalbranding.py new file mode 100644 index 0000000..a06b44b --- /dev/null +++ b/backend/workflows/migrations/0036_portalbranding.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0035_workflowconfig_remote_backup_enabled_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortalBranding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default', max_length=80, unique=True)), + ('portal_title', models.CharField(default='TUBCO Onboarding & Offboarding Portal', max_length=255)), + ('company_name', models.CharField(default='TUBCO', max_length=255)), + ('support_email', models.EmailField(blank=True, default='info@tub.co', max_length=254)), + ('default_language', models.CharField(choices=[('de', 'Deutsch'), ('en', 'English')], default='de', max_length=10)), + ('logo_image', models.ImageField(blank=True, null=True, upload_to='branding/')), + ('pdf_letterhead', models.FileField(blank=True, null=True, upload_to='branding/')), + ('primary_color', models.CharField(blank=True, default='#000078', max_length=20)), + ('secondary_color', models.CharField(blank=True, default='#c0002b', max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal Branding', + 'verbose_name_plural': 'Portal Branding', + }, + ), + ] diff --git a/backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py b/backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py new file mode 100644 index 0000000..0fb23eb --- /dev/null +++ b/backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:25 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0036_portalbranding'), + ] + + operations = [ + migrations.AlterField( + model_name='portalbranding', + name='logo_image', + field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['svg', 'png', 'jpg', 'jpeg', 'webp'])]), + ), + migrations.AlterField( + model_name='portalbranding', + name='pdf_letterhead', + field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])]), + ), + ] diff --git a/backend/workflows/migrations/0038_portalappconfig.py b/backend/workflows/migrations/0038_portalappconfig.py new file mode 100644 index 0000000..a2e0875 --- /dev/null +++ b/backend/workflows/migrations/0038_portalappconfig.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0037_alter_portalbranding_logo_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortalAppConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=80, unique=True)), + ('section', models.CharField(choices=[('app', 'Apps'), ('platform', 'Platform Apps'), ('admin', 'Admin Apps')], default='app', max_length=20)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_enabled', models.BooleanField(default=True)), + ('title_override', models.CharField(blank=True, max_length=255)), + ('title_override_en', models.CharField(blank=True, max_length=255)), + ('description_override', models.TextField(blank=True)), + ('description_override_en', models.TextField(blank=True)), + ('action_label_override', models.CharField(blank=True, max_length=255)), + ('action_label_override_en', models.CharField(blank=True, max_length=255)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal App', + 'verbose_name_plural': 'Portal Apps', + 'ordering': ['section', 'sort_order', 'key'], + }, + ), + ] diff --git a/backend/workflows/migrations/0039_portalbranding_company_domain.py b/backend/workflows/migrations/0039_portalbranding_company_domain.py new file mode 100644 index 0000000..de83381 --- /dev/null +++ b/backend/workflows/migrations/0039_portalbranding_company_domain.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0038_portalappconfig'), + ] + + operations = [ + migrations.AddField( + model_name='portalbranding', + name='company_domain', + field=models.CharField(blank=True, default='tub.co', max_length=120), + ), + ] diff --git a/backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py b/backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py new file mode 100644 index 0000000..4408dd2 --- /dev/null +++ b/backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.5 on 2026-03-26 11:02 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0039_portalbranding_company_domain'), + ] + + operations = [ + migrations.AddField( + model_name='portalbranding', + name='favicon_image', + field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['ico', 'png', 'svg', 'webp'])]), + ), + migrations.AddField( + model_name='portalbranding', + name='footer_text', + field=models.CharField(blank=True, default='TUBCO Onboarding & Offboarding Portal', max_length=255), + ), + migrations.AddField( + model_name='portalbranding', + name='footer_text_en', + field=models.CharField(blank=True, default='TUBCO Onboarding & Offboarding Portal', max_length=255), + ), + migrations.AddField( + model_name='portalbranding', + name='legal_notice', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='portalbranding', + name='legal_notice_en', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='portalbranding', + name='login_subtitle', + field=models.CharField(blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.', max_length=255), + ), + migrations.AddField( + model_name='portalbranding', + name='sender_display_name', + field=models.CharField(blank=True, default='TUBCO', max_length=255), + ), + ] diff --git a/backend/workflows/migrations/0041_portalappconfig_visible_to_admin_and_more.py b/backend/workflows/migrations/0041_portalappconfig_visible_to_admin_and_more.py new file mode 100644 index 0000000..037e7f3 --- /dev/null +++ b/backend/workflows/migrations/0041_portalappconfig_visible_to_admin_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-26 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0040_portalbranding_favicon_image_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='portalappconfig', + name='visible_to_admin', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='portalappconfig', + name='visible_to_it_staff', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='portalappconfig', + name='visible_to_staff', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='portalappconfig', + name='visible_to_super_admin', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/workflows/migrations/0042_portalcompanyconfig.py b/backend/workflows/migrations/0042_portalcompanyconfig.py new file mode 100644 index 0000000..a30276b --- /dev/null +++ b/backend/workflows/migrations/0042_portalcompanyconfig.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.5 on 2026-03-26 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0041_portalappconfig_visible_to_admin_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortalCompanyConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default', max_length=80, unique=True)), + ('legal_company_name', models.CharField(blank=True, default='', max_length=255)), + ('street_address', models.CharField(blank=True, default='', max_length=255)), + ('postal_code', models.CharField(blank=True, default='', max_length=50)), + ('city', models.CharField(blank=True, default='', max_length=120)), + ('country', models.CharField(blank=True, default='Deutschland', max_length=120)), + ('website_url', models.URLField(blank=True, default='')), + ('imprint_url', models.URLField(blank=True, default='')), + ('privacy_url', models.URLField(blank=True, default='')), + ('hr_contact_email', models.EmailField(blank=True, default='', max_length=254)), + ('it_contact_email', models.EmailField(blank=True, default='', max_length=254)), + ('operations_contact_email', models.EmailField(blank=True, default='', max_length=254)), + ('phone_number', models.CharField(blank=True, default='', max_length=80)), + ('vat_id', models.CharField(blank=True, default='', max_length=80)), + ('registration_number', models.CharField(blank=True, default='', max_length=120)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal Company Config', + 'verbose_name_plural': 'Portal Company Config', + }, + ), + ] diff --git a/backend/workflows/migrations/0043_portaltrialconfig.py b/backend/workflows/migrations/0043_portaltrialconfig.py new file mode 100644 index 0000000..7a0ab01 --- /dev/null +++ b/backend/workflows/migrations/0043_portaltrialconfig.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-26 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0042_portalcompanyconfig'), + ] + + operations = [ + migrations.CreateModel( + name='PortalTrialConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default', max_length=80, unique=True)), + ('is_trial_mode', models.BooleanField(default=False)), + ('trial_started_at', models.DateTimeField(blank=True, null=True)), + ('trial_expires_at', models.DateTimeField(blank=True, null=True)), + ('restrict_production_integrations', models.BooleanField(default=True)), + ('auto_cleanup_enabled', models.BooleanField(default=True)), + ('trial_banner_text', models.CharField(blank=True, default='', max_length=255)), + ('trial_banner_text_en', models.CharField(blank=True, default='', max_length=255)), + ('last_cleanup_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal Trial Config', + 'verbose_name_plural': 'Portal Trial Config', + }, + ), + ] diff --git a/backend/workflows/migrations/0044_asynctasklog.py b/backend/workflows/migrations/0044_asynctasklog.py new file mode 100644 index 0000000..fc105f7 --- /dev/null +++ b/backend/workflows/migrations/0044_asynctasklog.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-26 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0043_portaltrialconfig'), + ] + + operations = [ + migrations.CreateModel( + name='AsyncTaskLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_name', models.CharField(max_length=255)), + ('task_id', models.CharField(blank=True, max_length=255)), + ('target_type', models.CharField(blank=True, max_length=80)), + ('target_id', models.PositiveIntegerField(blank=True, null=True)), + ('target_label', models.CharField(blank=True, max_length=255)), + ('status', models.CharField(choices=[('started', 'Gestartet'), ('succeeded', 'Erfolgreich'), ('failed', 'Fehlgeschlagen')], default='started', max_length=20)), + ('error_message', models.TextField(blank=True)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Async Task Log', + 'verbose_name_plural': 'Async Task Logs', + 'ordering': ['-started_at', '-id'], + }, + ), + ] diff --git a/backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py b/backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py new file mode 100644 index 0000000..4c9b928 --- /dev/null +++ b/backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.5 on 2026-03-26 23:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0044_asynctasklog'), + ] + + operations = [ + migrations.AlterField( + model_name='portalbranding', + name='company_domain', + field=models.CharField(blank=True, default='workdock.de', max_length=120), + ), + migrations.AlterField( + model_name='portalbranding', + name='company_name', + field=models.CharField(default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='footer_text', + field=models.CharField(blank=True, default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='footer_text_en', + field=models.CharField(blank=True, default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='portal_title', + field=models.CharField(default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='sender_display_name', + field=models.CharField(blank=True, default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='support_email', + field=models.EmailField(blank=True, default='info@workdock.de', max_length=254), + ), + ] diff --git a/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py b/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py new file mode 100644 index 0000000..d00ea57 --- /dev/null +++ b/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-26 23:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0045_alter_portalbranding_company_domain_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='onboardingrequest', + name='phone_number', + field=models.CharField(blank=True, max_length=100, verbose_name='Telefon-Direktwahl'), + ), + ] diff --git a/backend/workflows/migrations/0048_userprofile.py b/backend/workflows/migrations/0048_userprofile.py new file mode 100644 index 0000000..a35af45 --- /dev/null +++ b/backend/workflows/migrations/0048_userprofile.py @@ -0,0 +1,42 @@ +from django.conf import settings +from django.core.validators import FileExtensionValidator +from django.db import migrations, models +import django.db.models.deletion + + +def create_profiles_for_existing_users(apps, schema_editor): + User = apps.get_model(*settings.AUTH_USER_MODEL.split('.')) + UserProfile = apps.get_model('workflows', 'UserProfile') + for user in User.objects.all().iterator(): + UserProfile.objects.get_or_create(user=user) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0046_alter_onboardingrequest_phone_number'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('avatar_image', models.FileField(blank=True, null=True, upload_to='profiles/', validators=[FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'webp', 'svg'])])), + ('phone_number', models.CharField(blank=True, default='', max_length=80)), + ('mobile_number', models.CharField(blank=True, default='', max_length=80)), + ('job_title', models.CharField(blank=True, default='', max_length=255)), + ('department', models.CharField(blank=True, default='', max_length=255)), + ('location', models.CharField(blank=True, default='', max_length=255)), + ('contact_notes', models.CharField(blank=True, default='', max_length=255)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User Profile', + 'verbose_name_plural': 'User Profiles', + }, + ), + migrations.RunPython(create_profiles_for_existing_users, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0049_userprofile_totp_fields.py b/backend/workflows/migrations/0049_userprofile_totp_fields.py new file mode 100644 index 0000000..c9f13eb --- /dev/null +++ b/backend/workflows/migrations/0049_userprofile_totp_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0048_userprofile'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='totp_confirmed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='userprofile', + name='totp_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userprofile', + name='totp_secret', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py b/backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py new file mode 100644 index 0000000..f1fd757 --- /dev/null +++ b/backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0049_userprofile_totp_fields'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='totp_recovery_codes', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/backend/workflows/migrations/0051_usernotification.py b/backend/workflows/migrations/0051_usernotification.py new file mode 100644 index 0000000..de26bc8 --- /dev/null +++ b/backend/workflows/migrations/0051_usernotification.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0050_userprofile_totp_recovery_codes'), + ] + + operations = [ + migrations.CreateModel( + name='UserNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('body', models.TextField(blank=True, default='')), + ('level', models.CharField(choices=[('info', 'Info'), ('success', 'Erfolg'), ('warning', 'Warnung'), ('error', 'Fehler')], default='info', max_length=20)), + ('link_url', models.CharField(blank=True, default='', max_length=500)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User Notification', + 'verbose_name_plural': 'User Notifications', + 'ordering': ['-created_at', '-id'], + }, + ), + ] diff --git a/backend/workflows/migrations/0052_userprofile_notification_preferences.py b/backend/workflows/migrations/0052_userprofile_notification_preferences.py new file mode 100644 index 0000000..33eda56 --- /dev/null +++ b/backend/workflows/migrations/0052_userprofile_notification_preferences.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0051_usernotification'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='notification_preferences', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/backend/workflows/migrations/0053_formsectionconfig.py b/backend/workflows/migrations/0053_formsectionconfig.py new file mode 100644 index 0000000..0a4c6ce --- /dev/null +++ b/backend/workflows/migrations/0053_formsectionconfig.py @@ -0,0 +1,37 @@ +from django.db import migrations, models + + +def seed_onboarding_sections(apps, schema_editor): + FormSectionConfig = apps.get_model('workflows', 'FormSectionConfig') + for section_key in ['stammdaten', 'vertrag', 'itsetup', 'abschluss']: + FormSectionConfig.objects.get_or_create( + form_type='onboarding', + section_key=section_key, + defaults={'is_visible': True}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0052_userprofile_notification_preferences'), + ] + + operations = [ + migrations.CreateModel( + name='FormSectionConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)), + ('section_key', models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss')], max_length=20)), + ('is_visible', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Formularabschnitt-Konfiguration', + 'verbose_name_plural': 'Formularabschnitt-Konfigurationen', + 'ordering': ['form_type', 'section_key'], + 'unique_together': {('form_type', 'section_key')}, + }, + ), + migrations.RunPython(seed_onboarding_sections, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0054_formconditionalruleconfig.py b/backend/workflows/migrations/0054_formconditionalruleconfig.py new file mode 100644 index 0000000..f1e705a --- /dev/null +++ b/backend/workflows/migrations/0054_formconditionalruleconfig.py @@ -0,0 +1,67 @@ +from django.db import migrations, models + + +DEFAULT_RULES = { + 'business-card-box': [ + {'field': 'order_business_cards', 'operator': 'checked', 'value': True}, + ], + 'employment-end-box': [ + {'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}, + ], + 'group-mailboxes-box': [ + {'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-hardware-box': [ + {'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-software-box': [ + {'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-access-box': [ + {'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'successor-box': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'phone-box': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, + ], +} + + +def seed_conditional_rules(apps, schema_editor): + FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig') + for target_key, clauses in DEFAULT_RULES.items(): + FormConditionalRuleConfig.objects.get_or_create( + form_type='onboarding', + target_key=target_key, + defaults={'clauses': clauses, 'is_active': True}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0053_formsectionconfig'), + ] + + operations = [ + migrations.CreateModel( + name='FormConditionalRuleConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)), + ('target_key', models.CharField(max_length=80)), + ('clauses', models.JSONField(blank=True, default=list)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Formular-Bedingungsregel', + 'verbose_name_plural': 'Formular-Bedingungsregeln', + 'ordering': ['form_type', 'target_key'], + 'unique_together': {('form_type', 'target_key')}, + }, + ), + migrations.RunPython(seed_conditional_rules, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py b/backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py new file mode 100644 index 0000000..c1cf8a1 --- /dev/null +++ b/backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.1.5 on 2026-03-27 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0054_formconditionalruleconfig'), + ] + + operations = [ + migrations.AddField( + model_name='offboardingrequest', + name='custom_field_values', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='onboardingrequest', + name='custom_field_values', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='formfieldconfig', + name='page_key', + field=models.CharField(blank=True, choices=[('', 'Automatisch'), ('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], default='', max_length=20), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='form_type', + field=models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='section_key', + field=models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], max_length=20), + ), + migrations.CreateModel( + name='FormCustomFieldConfig', + 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_key', models.SlugField(max_length=80)), + ('section_key', models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], max_length=20)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('field_type', models.CharField(choices=[('text', 'Text'), ('textarea', 'Mehrzeilig'), ('select', 'Auswahl'), ('checkbox', 'Checkbox')], default='text', max_length=20)), + ('is_active', models.BooleanField(default=True)), + ('is_required', models.BooleanField(default=False)), + ('label', models.CharField(max_length=255)), + ('label_en', models.CharField(blank=True, max_length=255)), + ('help_text', models.TextField(blank=True)), + ('help_text_en', models.TextField(blank=True)), + ('select_options', models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: wert|Label')), + ('select_options_en', models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: value|Label')), + ], + options={ + 'verbose_name': 'Benutzerdefiniertes Formularfeld', + 'verbose_name_plural': 'Benutzerdefinierte Formularfelder', + 'ordering': ['form_type', 'section_key', 'sort_order', 'field_key'], + 'unique_together': {('form_type', 'field_key')}, + }, + ), + ] diff --git a/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py b/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py new file mode 100644 index 0000000..b981014 --- /dev/null +++ b/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.5 on 2026-03-27 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0055_offboardingrequest_custom_field_values_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='formcustomfieldconfig', + name='section_key', + field=models.CharField(max_length=80), + ), + migrations.AlterField( + model_name='formfieldconfig', + name='page_key', + field=models.CharField(blank=True, default='', max_length=80), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='section_key', + field=models.CharField(max_length=80), + ), + migrations.CreateModel( + name='FormCustomSectionConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)), + ('section_key', models.SlugField(max_length=80)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('title', models.CharField(max_length=255)), + ('title_en', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Benutzerdefinierter Formularabschnitt', + 'verbose_name_plural': 'Benutzerdefinierte Formularabschnitte', + 'ordering': ['form_type', 'sort_order', 'section_key'], + 'unique_together': {('form_type', 'section_key')}, + }, + ), + ] diff --git a/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py b/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py new file mode 100644 index 0000000..d221eb2 --- /dev/null +++ b/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def remove_phone_box_rule(apps, schema_editor): + FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig') + FormConditionalRuleConfig.objects.filter( + form_type='onboarding', + target_key='phone-box', + ).update(is_active=False, clauses=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0056_alter_formcustomfieldconfig_section_key_and_more'), + ] + + operations = [ + migrations.RunPython(remove_phone_box_rule, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py b/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py new file mode 100644 index 0000000..d98d85f --- /dev/null +++ b/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2026-03-27 15:45 + +from django.db import migrations, models + + +def seed_section_sort_order(apps, schema_editor): + FormSectionConfig = apps.get_model('workflows', 'FormSectionConfig') + defaults = { + 'onboarding': ['stammdaten', 'vertrag', 'itsetup', 'abschluss'], + 'offboarding': ['mitarbeitende', 'austritt', 'abschluss'], + } + for form_type, section_keys in defaults.items(): + for index, section_key in enumerate(section_keys): + FormSectionConfig.objects.filter(form_type=form_type, section_key=section_key).update(sort_order=index) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0057_remove_phone_box_conditional_rule'), + ] + + operations = [ + migrations.AlterModelOptions( + name='formsectionconfig', + options={'ordering': ['form_type', 'sort_order', 'section_key'], 'verbose_name': 'Formularabschnitt-Konfiguration', 'verbose_name_plural': 'Formularabschnitt-Konfigurationen'}, + ), + migrations.AddField( + model_name='formsectionconfig', + name='sort_order', + field=models.PositiveIntegerField(default=0), + ), + migrations.RunPython(seed_section_sort_order, migrations.RunPython.noop), + ] diff --git a/backend/workflows/model_account.py b/backend/workflows/model_account.py new file mode 100644 index 0000000..7abd427 --- /dev/null +++ b/backend/workflows/model_account.py @@ -0,0 +1,152 @@ +from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password +from django.core.validators import FileExtensionValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +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 UserProfile(models.Model): + NOTIFICATION_ONBOARDING_SUCCESS = 'onboarding_success' + NOTIFICATION_ONBOARDING_FAILURE = 'onboarding_failure' + NOTIFICATION_OFFBOARDING_SUCCESS = 'offboarding_success' + NOTIFICATION_OFFBOARDING_FAILURE = 'offboarding_failure' + NOTIFICATION_BACKUP_SUCCESS = 'backup_success' + NOTIFICATION_BACKUP_FAILURE = 'backup_failure' + NOTIFICATION_WELCOME_EMAIL_SUCCESS = 'welcome_email_success' + NOTIFICATION_WELCOME_EMAIL_FAILURE = 'welcome_email_failure' + NOTIFICATION_TRIAL_ALERTS = 'trial_alerts' + NOTIFICATION_SYSTEM_ALERTS = 'system_alerts' + NOTIFICATION_PREFERENCE_DEFAULTS = { + NOTIFICATION_ONBOARDING_SUCCESS: True, + NOTIFICATION_ONBOARDING_FAILURE: True, + NOTIFICATION_OFFBOARDING_SUCCESS: True, + NOTIFICATION_OFFBOARDING_FAILURE: True, + NOTIFICATION_BACKUP_SUCCESS: True, + NOTIFICATION_BACKUP_FAILURE: True, + NOTIFICATION_WELCOME_EMAIL_SUCCESS: True, + NOTIFICATION_WELCOME_EMAIL_FAILURE: True, + NOTIFICATION_TRIAL_ALERTS: True, + NOTIFICATION_SYSTEM_ALERTS: True, + } + + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile') + avatar_image = models.FileField( + upload_to='profiles/', + blank=True, + null=True, + validators=[FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'webp', 'svg'])], + ) + phone_number = models.CharField(max_length=80, blank=True, default='') + mobile_number = models.CharField(max_length=80, blank=True, default='') + job_title = models.CharField(max_length=255, blank=True, default='') + department = models.CharField(max_length=255, blank=True, default='') + location = models.CharField(max_length=255, blank=True, default='') + contact_notes = models.CharField(max_length=255, blank=True, default='') + totp_secret = models.CharField(max_length=64, blank=True, default='') + totp_enabled = models.BooleanField(default=False) + totp_confirmed_at = models.DateTimeField(null=True, blank=True) + totp_recovery_codes = models.JSONField(default=list, blank=True) + notification_preferences = models.JSONField(default=dict, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'User Profile' + verbose_name_plural = 'User Profiles' + + def __str__(self) -> str: + return getattr(self.user, 'username', '') or str(self.user_id) + + def disable_totp(self) -> None: + self.totp_secret = '' + self.totp_enabled = False + self.totp_confirmed_at = None + self.totp_recovery_codes = [] + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at']) + + def enable_totp(self, secret: str, recovery_codes: list[str]) -> None: + self.totp_secret = secret + self.totp_enabled = True + self.totp_confirmed_at = timezone.now() + self.set_recovery_codes(recovery_codes) + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at']) + + def set_recovery_codes(self, recovery_codes: list[str]) -> None: + self.totp_recovery_codes = [make_password(code) for code in recovery_codes] + + def consume_recovery_code(self, raw_code: str) -> bool: + remaining_hashes = [] + matched = False + for hashed_code in self.totp_recovery_codes or []: + if not matched and check_password(raw_code, hashed_code): + matched = True + continue + remaining_hashes.append(hashed_code) + if matched: + self.totp_recovery_codes = remaining_hashes + self.save(update_fields=['totp_recovery_codes', 'updated_at']) + return matched + + def get_notification_preferences(self) -> dict[str, bool]: + current = self.notification_preferences or {} + prefs = dict(self.NOTIFICATION_PREFERENCE_DEFAULTS) + for key in prefs: + if key in current: + prefs[key] = bool(current[key]) + return prefs + + def notification_enabled(self, event_key: str) -> bool: + return bool(self.get_notification_preferences().get(event_key, True)) + + +class UserNotification(models.Model): + LEVEL_INFO = 'info' + LEVEL_SUCCESS = 'success' + LEVEL_WARNING = 'warning' + LEVEL_ERROR = 'error' + LEVEL_CHOICES = [ + (LEVEL_INFO, _('Info')), + (LEVEL_SUCCESS, _('Erfolg')), + (LEVEL_WARNING, _('Warnung')), + (LEVEL_ERROR, _('Fehler')), + ] + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications') + title = models.CharField(max_length=255) + body = models.TextField(blank=True, default='') + level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default=LEVEL_INFO) + link_url = models.CharField(max_length=500, blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + read_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at', '-id'] + verbose_name = 'User Notification' + verbose_name_plural = 'User Notifications' + + def __str__(self) -> str: + return f'{self.user_id} | {self.level} | {self.title}' + + @property + def is_unread(self) -> bool: + return self.read_at is None + + def mark_read(self) -> None: + if self.read_at is None: + self.read_at = timezone.now() + self.save(update_fields=['read_at']) diff --git a/backend/workflows/model_forms.py b/backend/workflows/model_forms.py new file mode 100644 index 0000000..9114278 --- /dev/null +++ b/backend/workflows/model_forms.py @@ -0,0 +1,202 @@ +from django.db import models +from django.utils.translation import get_language, gettext_lazy as _ + + +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) + label_en = models.CharField(max_length=255, blank=True) + 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}" + + def translated_label(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.label_en.strip(): + return self.label_en.strip() + return self.label.strip() + + +class FormFieldConfig(models.Model): + PAGE_CHOICES = [ + ('', _('Automatisch')), + ('stammdaten', _('Stammdaten')), + ('vertrag', _('Vertrag')), + ('itsetup', _('IT-Setup')), + ('abschluss', _('Abschluss')), + ('mitarbeitende', _('Mitarbeitende')), + ('austritt', _('Austritt')), + ] + 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=80, blank=True, default='') + label_override = models.CharField(max_length=255, blank=True) + label_override_en = models.CharField(max_length=255, blank=True) + help_text_override = models.TextField(blank=True) + help_text_override_en = 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}' + + def translated_label_override(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.label_override_en.strip(): + return self.label_override_en.strip() + return self.label_override.strip() + + def translated_help_text_override(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.help_text_override_en.strip(): + return self.help_text_override_en.strip() + return self.help_text_override.strip() + + +class FormSectionConfig(models.Model): + FORM_CHOICES = [('onboarding', _('Onboarding')), ('offboarding', _('Offboarding'))] + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + section_key = models.CharField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) + is_visible = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'sort_order', 'section_key'] + unique_together = ('form_type', 'section_key') + verbose_name = 'Formularabschnitt-Konfiguration' + verbose_name_plural = 'Formularabschnitt-Konfigurationen' + + def __str__(self) -> str: + return f'{self.form_type}: {self.section_key}' + + +class FormConditionalRuleConfig(models.Model): + FORM_CHOICES = [('onboarding', _('Onboarding'))] + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + target_key = models.CharField(max_length=80) + clauses = models.JSONField(default=list, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'target_key'] + unique_together = ('form_type', 'target_key') + verbose_name = 'Formular-Bedingungsregel' + verbose_name_plural = 'Formular-Bedingungsregeln' + + def __str__(self) -> str: + return f'{self.form_type}: {self.target_key}' + + +class FormCustomSectionConfig(models.Model): + FORM_CHOICES = [('onboarding', _('Onboarding'))] + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + section_key = models.SlugField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) + title = models.CharField(max_length=255) + title_en = models.CharField(max_length=255, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'sort_order', 'section_key'] + unique_together = ('form_type', 'section_key') + verbose_name = 'Benutzerdefinierter Formularabschnitt' + verbose_name_plural = 'Benutzerdefinierte Formularabschnitte' + + def __str__(self) -> str: + return f'{self.form_type}: {self.title}' + + def translated_title(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.title_en.strip(): + return self.title_en.strip() + return self.title.strip() + + +class FormCustomFieldConfig(models.Model): + FIELD_TYPE_TEXT = 'text' + FIELD_TYPE_TEXTAREA = 'textarea' + FIELD_TYPE_SELECT = 'select' + FIELD_TYPE_CHECKBOX = 'checkbox' + FIELD_TYPE_CHOICES = [ + (FIELD_TYPE_TEXT, _('Text')), + (FIELD_TYPE_TEXTAREA, _('Mehrzeilig')), + (FIELD_TYPE_SELECT, _('Auswahl')), + (FIELD_TYPE_CHECKBOX, _('Checkbox')), + ] + FORM_CHOICES = [('onboarding', _('Onboarding')), ('offboarding', _('Offboarding'))] + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + field_key = models.SlugField(max_length=80) + section_key = models.CharField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) + field_type = models.CharField(max_length=20, choices=FIELD_TYPE_CHOICES, default=FIELD_TYPE_TEXT) + is_active = models.BooleanField(default=True) + is_required = models.BooleanField(default=False) + label = models.CharField(max_length=255) + label_en = models.CharField(max_length=255, blank=True) + help_text = models.TextField(blank=True) + help_text_en = models.TextField(blank=True) + select_options = models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: wert|Label') + select_options_en = models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: value|Label') + + class Meta: + ordering = ['form_type', 'section_key', 'sort_order', 'field_key'] + unique_together = ('form_type', 'field_key') + verbose_name = 'Benutzerdefiniertes Formularfeld' + verbose_name_plural = 'Benutzerdefinierte Formularfelder' + + def __str__(self) -> str: + return f'{self.form_type}: {self.label}' + + def translated_label(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.label_en.strip(): + return self.label_en.strip() + return self.label.strip() + + def translated_help_text(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.help_text_en.strip(): + return self.help_text_en.strip() + return self.help_text.strip() + + def translated_select_options(self, language_code: str | None = None) -> list[tuple[str, str]]: + lang = (language_code or get_language() or 'de').split('-')[0] + raw = self.select_options_en if lang == 'en' and self.select_options_en.strip() else self.select_options + options = [] + for line in (raw or '').splitlines(): + line = line.strip() + if not line: + continue + if '|' in line: + value, label = [part.strip() for part in line.split('|', 1)] + else: + value = label = line + if value: + options.append((value, label or value)) + return options diff --git a/backend/workflows/model_notifications.py b/backend/workflows/model_notifications.py new file mode 100644 index 0000000..549a43f --- /dev/null +++ b/backend/workflows/model_notifications.py @@ -0,0 +1,89 @@ +from django.db import models +from django.utils.translation import get_language, gettext_lazy as _ + + +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) + subject_template_en = models.CharField(max_length=255, blank=True) + body_template = models.TextField() + body_template_en = models.TextField(blank=True) + 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() + + def translated_subject_template(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.subject_template_en.strip(): + return self.subject_template_en.strip() + return self.subject_template.strip() + + def translated_body_template(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.body_template_en.strip(): + return self.body_template_en.strip() + return self.body_template.strip() + + +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_subject_en = models.CharField(max_length=255, blank=True) + custom_body = models.TextField(blank=True) + custom_body_en = 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})' + + def translated_custom_subject(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.custom_subject_en.strip(): + return self.custom_subject_en.strip() + return self.custom_subject.strip() + + def translated_custom_body(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.custom_body_en.strip(): + return self.custom_body_en.strip() + return self.custom_body.strip() diff --git a/backend/workflows/model_ops.py b/backend/workflows/model_ops.py new file mode 100644 index 0000000..d797be8 --- /dev/null +++ b/backend/workflows/model_ops.py @@ -0,0 +1,49 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class AsyncTaskLog(models.Model): + STATUS_CHOICES = [ + ('started', _('Gestartet')), + ('succeeded', _('Erfolgreich')), + ('failed', _('Fehlgeschlagen')), + ] + + task_name = models.CharField(max_length=255) + task_id = models.CharField(max_length=255, blank=True) + target_type = models.CharField(max_length=80, blank=True) + target_id = models.PositiveIntegerField(null=True, blank=True) + target_label = models.CharField(max_length=255, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='started') + error_message = models.TextField(blank=True) + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-started_at', '-id'] + verbose_name = 'Async Task Log' + verbose_name_plural = 'Async Task Logs' + + def __str__(self) -> str: + return f'{self.task_name} | {self.status} | {self.target_label or self.target_type}' + + +class AdminAuditLog(models.Model): + actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='admin_audit_logs') + actor_display = models.CharField(max_length=255, blank=True) + action = models.CharField(max_length=120) + target_type = models.CharField(max_length=80, blank=True) + target_id = models.PositiveIntegerField(null=True, blank=True) + target_label = models.CharField(max_length=255, blank=True) + details = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at', '-id'] + verbose_name = 'Admin Audit Log' + verbose_name_plural = 'Admin Audit Logs' + + def __str__(self) -> str: + actor = self.actor_display or 'Unbekannt' + return f'{self.created_at:%Y-%m-%d %H:%M} | {actor} | {self.action}' diff --git a/backend/workflows/model_portal.py b/backend/workflows/model_portal.py new file mode 100644 index 0000000..18db518 --- /dev/null +++ b/backend/workflows/model_portal.py @@ -0,0 +1,129 @@ +from django.core.validators import FileExtensionValidator +from django.db import models +from django.utils.translation import get_language, gettext_lazy as _ + + +class PortalBranding(models.Model): + name = models.CharField(max_length=80, default='Default', unique=True) + portal_title = models.CharField(max_length=255, default='Workdock') + company_name = models.CharField(max_length=255, default='Workdock') + company_domain = models.CharField(max_length=120, blank=True, default='workdock.de') + support_email = models.EmailField(blank=True, default='info@workdock.de') + sender_display_name = models.CharField(max_length=255, blank=True, default='Workdock') + login_subtitle = models.CharField(max_length=255, blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.') + footer_text = models.CharField(max_length=255, blank=True, default='Workdock') + footer_text_en = models.CharField(max_length=255, blank=True, default='Workdock') + legal_notice = models.TextField(blank=True, default='') + legal_notice_en = models.TextField(blank=True, default='') + default_language = models.CharField(max_length=10, choices=[('de', 'Deutsch'), ('en', 'English')], default='de') + logo_image = models.FileField(upload_to='branding/', blank=True, null=True, validators=[FileExtensionValidator(allowed_extensions=['svg', 'png', 'jpg', 'jpeg', 'webp'])]) + pdf_letterhead = models.FileField(upload_to='branding/', blank=True, null=True, validators=[FileExtensionValidator(allowed_extensions=['pdf'])]) + favicon_image = models.FileField(upload_to='branding/', blank=True, null=True, validators=[FileExtensionValidator(allowed_extensions=['ico', 'png', 'svg', 'webp'])]) + primary_color = models.CharField(max_length=20, blank=True, default='#000078') + secondary_color = models.CharField(max_length=20, blank=True, default='#c0002b') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Portal Branding' + verbose_name_plural = 'Portal Branding' + + def __str__(self) -> str: + return self.portal_title or self.company_name or self.name + + +class PortalCompanyConfig(models.Model): + name = models.CharField(max_length=80, default='Default', unique=True) + legal_company_name = models.CharField(max_length=255, blank=True, default='') + street_address = models.CharField(max_length=255, blank=True, default='') + postal_code = models.CharField(max_length=50, blank=True, default='') + city = models.CharField(max_length=120, blank=True, default='') + country = models.CharField(max_length=120, blank=True, default='Deutschland') + website_url = models.URLField(blank=True, default='') + imprint_url = models.URLField(blank=True, default='') + privacy_url = models.URLField(blank=True, default='') + hr_contact_email = models.EmailField(blank=True, default='') + it_contact_email = models.EmailField(blank=True, default='') + operations_contact_email = models.EmailField(blank=True, default='') + phone_number = models.CharField(max_length=80, blank=True, default='') + vat_id = models.CharField(max_length=80, blank=True, default='') + registration_number = models.CharField(max_length=120, blank=True, default='') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Portal Company Config' + verbose_name_plural = 'Portal Company Config' + + def __str__(self) -> str: + return self.legal_company_name or self.name + + +class PortalTrialConfig(models.Model): + name = models.CharField(max_length=80, default='Default', unique=True) + is_trial_mode = models.BooleanField(default=False) + trial_started_at = models.DateTimeField(null=True, blank=True) + trial_expires_at = models.DateTimeField(null=True, blank=True) + restrict_production_integrations = models.BooleanField(default=True) + auto_cleanup_enabled = models.BooleanField(default=True) + trial_banner_text = models.CharField(max_length=255, blank=True, default='') + trial_banner_text_en = models.CharField(max_length=255, blank=True, default='') + last_cleanup_at = models.DateTimeField(null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Portal Trial Config' + verbose_name_plural = 'Portal Trial Config' + + def __str__(self) -> str: + return self.name + + +class PortalAppConfig(models.Model): + SECTION_APP = 'app' + SECTION_PLATFORM = 'platform' + SECTION_ADMIN = 'admin' + SECTION_CHOICES = [ + (SECTION_APP, _('Apps')), + (SECTION_PLATFORM, _('Platform Apps')), + (SECTION_ADMIN, _('Admin Apps')), + ] + + key = models.CharField(max_length=80, unique=True) + section = models.CharField(max_length=20, choices=SECTION_CHOICES, default=SECTION_APP) + sort_order = models.PositiveIntegerField(default=0) + is_enabled = models.BooleanField(default=True) + visible_to_super_admin = models.BooleanField(default=True) + visible_to_admin = models.BooleanField(default=True) + visible_to_it_staff = models.BooleanField(default=False) + visible_to_staff = models.BooleanField(default=False) + title_override = models.CharField(max_length=255, blank=True) + title_override_en = models.CharField(max_length=255, blank=True) + description_override = models.TextField(blank=True) + description_override_en = models.TextField(blank=True) + action_label_override = models.CharField(max_length=255, blank=True) + action_label_override_en = models.CharField(max_length=255, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['section', 'sort_order', 'key'] + verbose_name = 'Portal App' + verbose_name_plural = 'Portal Apps' + + def __str__(self) -> str: + return self.key + + def _translated_value(self, field_name: str, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en': + english_value = (getattr(self, f'{field_name}_en', '') or '').strip() + if english_value: + return english_value + return (getattr(self, field_name, '') or '').strip() + + def translated_title_override(self, language_code: str | None = None) -> str: + return self._translated_value('title_override', language_code) + + def translated_description_override(self, language_code: str | None = None) -> str: + return self._translated_value('description_override', language_code) + + def translated_action_label_override(self, language_code: str | None = None) -> str: + return self._translated_value('action_label_override', language_code) diff --git a/backend/workflows/model_requests.py b/backend/workflows/model_requests.py new file mode 100644 index 0000000..283a4a2 --- /dev/null +++ b/backend/workflows/model_requests.py @@ -0,0 +1,172 @@ +from django.db import models +from django.utils.translation import get_language, gettext_lazy as _ + +from .model_account import EmployeeProfile +from .model_shared import REQUEST_STATUS_CHOICES, normalized_language_code + + +class OnboardingRequest(models.Model): + STATUS_CHOICES = REQUEST_STATUS_CHOICES + + 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='Telefon-Direktwahl') + 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.') + custom_field_values = models.JSONField(default=dict, blank=True) + generated_pdf_path = models.CharField(max_length=500, blank=True) + intro_pdf_path = models.CharField(max_length=500, blank=True) + processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') + last_error = models.TextField(blank=True) + preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de') + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f'Onboarding #{self.id} - {self.full_name}' + + def save(self, *args, **kwargs): + self.preferred_language = normalized_language_code(self.preferred_language) + super().save(*args, **kwargs) + + +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 IntroChecklistItem(models.Model): + SECTION_CHOICES = [ + ('workplace', _('Geräte und Arbeitsplatz')), + ('accounts', _('Konten und Berechtigungen')), + ('software', _('Software und Tools')), + ('process', _('Prozesse und Hinweise')), + ] + OPERATOR_CHOICES = [ + ('always', _('Immer anzeigen')), + ('contains', _('Enthält')), + ('equals', _('Ist gleich')), + ('is_true', _('Ist Ja / aktiv')), + ('is_false', _('Ist Nein / inaktiv')), + ] + + section = models.CharField(max_length=30, choices=SECTION_CHOICES) + label = models.CharField(max_length=255) + label_en = models.CharField(max_length=255, blank=True) + sort_order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + condition_field = models.CharField(max_length=80, blank=True) + condition_operator = models.CharField(max_length=20, choices=OPERATOR_CHOICES, default='always') + condition_value = models.CharField(max_length=255, blank=True) + + class Meta: + ordering = ['section', 'sort_order', 'label'] + + def __str__(self) -> str: + return f'{self.get_section_display()}: {self.label}' + + def translated_label(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.label_en.strip(): + return self.label_en.strip() + return self.label.strip() + + +class OnboardingIntroductionSession(models.Model): + STATUS_CHOICES = [('draft', _('Entwurf')), ('completed', _('Abgeschlossen'))] + + onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE) + checklist_state = models.JSONField(default=dict, blank=True) + notes = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + completed_at = models.DateTimeField(null=True, blank=True) + completed_by_name = models.CharField(max_length=255, blank=True) + exported_pdf_path = models.CharField(max_length=500, blank=True) + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f'Einweisung #{self.id} | {self.onboarding_request.full_name} | {self.status}' + + +class OffboardingRequest(models.Model): + STATUS_CHOICES = REQUEST_STATUS_CHOICES + + 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') + preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de') + generated_pdf_path = models.CharField(max_length=500, blank=True) + custom_field_values = models.JSONField(default=dict, blank=True) + processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') + last_error = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f'Offboarding #{self.id} - {self.full_name}' + + def save(self, *args, **kwargs): + self.preferred_language = normalized_language_code(self.preferred_language) + super().save(*args, **kwargs) diff --git a/backend/workflows/model_shared.py b/backend/workflows/model_shared.py new file mode 100644 index 0000000..4bcf1b4 --- /dev/null +++ b/backend/workflows/model_shared.py @@ -0,0 +1,14 @@ +from django.utils.translation import gettext_lazy as _ + + +def normalized_language_code(value: str | None) -> str: + lang = (value or '').strip().split('-')[0].lower() + return lang or 'de' + + +REQUEST_STATUS_CHOICES = [ + ('submitted', _('Eingereicht')), + ('processing', _('In Bearbeitung')), + ('completed', _('Abgeschlossen')), + ('failed', _('Fehlgeschlagen')), +] diff --git a/backend/workflows/model_system.py b/backend/workflows/model_system.py new file mode 100644 index 0000000..a0e6c9e --- /dev/null +++ b/backend/workflows/model_system.py @@ -0,0 +1,62 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class WorkflowConfig(models.Model): + REMOTE_BACKUP_TARGET_CHOICES = [('nextcloud', _('Nextcloud')), ('s3', _('S3')), ('nfs', _('NFS'))] + + 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)') + device_handover_lead_days = models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)') + remote_backup_enabled = models.BooleanField(default=False, verbose_name='Remote Backup aktiviert') + remote_backup_target_type = models.CharField(max_length=20, choices=REMOTE_BACKUP_TARGET_CHOICES, default='nextcloud', verbose_name='Remote Backup Zieltyp') + remote_backup_nextcloud_directory = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Backup-Verzeichnis') + remote_backup_s3_bucket = models.CharField(max_length=255, blank=True, verbose_name='S3 Bucket (optional)') + remote_backup_nfs_path = models.CharField(max_length=255, blank=True, verbose_name='NFS Pfad (optional)') + 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})' diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 7f6498c..6577f21 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -1,501 +1,7 @@ -from django.conf import settings -from django.db import models -from django.utils.translation import get_language -from django.utils.translation import gettext_lazy as _ - - -def _normalized_language_code(value: str | None) -> str: - lang = (value or '').strip().split('-')[0].lower() - return lang or 'de' - - -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 AdminAuditLog(models.Model): - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='admin_audit_logs', - ) - actor_display = models.CharField(max_length=255, blank=True) - action = models.CharField(max_length=120) - target_type = models.CharField(max_length=80, blank=True) - target_id = models.PositiveIntegerField(null=True, blank=True) - target_label = models.CharField(max_length=255, blank=True) - details = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - ordering = ['-created_at', '-id'] - verbose_name = 'Admin Audit Log' - verbose_name_plural = 'Admin Audit Logs' - - def __str__(self) -> str: - actor = self.actor_display or 'Unbekannt' - return f'{self.created_at:%Y-%m-%d %H:%M} | {actor} | {self.action}' - - -class OnboardingRequest(models.Model): - STATUS_CHOICES = [ - ('submitted', _('Eingereicht')), - ('processing', _('In Bearbeitung')), - ('completed', _('Abgeschlossen')), - ('failed', _('Fehlgeschlagen')), - ] - - 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) - intro_pdf_path = models.CharField(max_length=500, blank=True) - processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') - last_error = models.TextField(blank=True) - preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de') - created_at = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return f"Onboarding #{self.id} - {self.full_name}" - - def save(self, *args, **kwargs): - self.preferred_language = _normalized_language_code(self.preferred_language) - super().save(*args, **kwargs) - - -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) - label_en = models.CharField(max_length=255, blank=True) - 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}" - - def translated_label(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.label_en.strip(): - return self.label_en.strip() - return self.label.strip() - - -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) - label_override_en = models.CharField(max_length=255, blank=True) - help_text_override = models.TextField(blank=True) - help_text_override_en = 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}' - - def translated_label_override(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.label_override_en.strip(): - return self.label_override_en.strip() - return self.label_override.strip() - - def translated_help_text_override(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.help_text_override_en.strip(): - return self.help_text_override_en.strip() - return self.help_text_override.strip() - - -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) - subject_template_en = models.CharField(max_length=255, blank=True) - body_template = models.TextField() - body_template_en = models.TextField(blank=True) - 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() - - def translated_subject_template(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.subject_template_en.strip(): - return self.subject_template_en.strip() - return self.subject_template.strip() - - def translated_body_template(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.body_template_en.strip(): - return self.body_template_en.strip() - return self.body_template.strip() - - -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_subject_en = models.CharField(max_length=255, blank=True) - custom_body = models.TextField(blank=True) - custom_body_en = 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})' - - def translated_custom_subject(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.custom_subject_en.strip(): - return self.custom_subject_en.strip() - return self.custom_subject.strip() - - def translated_custom_body(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.custom_body_en.strip(): - return self.custom_body_en.strip() - return self.custom_body.strip() - - -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 IntroChecklistItem(models.Model): - SECTION_CHOICES = [ - ('workplace', _('Geräte und Arbeitsplatz')), - ('accounts', _('Konten und Berechtigungen')), - ('software', _('Software und Tools')), - ('process', _('Prozesse und Hinweise')), - ] - OPERATOR_CHOICES = [ - ('always', _('Immer anzeigen')), - ('contains', _('Enthält')), - ('equals', _('Ist gleich')), - ('is_true', _('Ist Ja / aktiv')), - ('is_false', _('Ist Nein / inaktiv')), - ] - - section = models.CharField(max_length=30, choices=SECTION_CHOICES) - label = models.CharField(max_length=255) - label_en = models.CharField(max_length=255, blank=True) - sort_order = models.PositiveIntegerField(default=0) - is_active = models.BooleanField(default=True) - condition_field = models.CharField(max_length=80, blank=True) - condition_operator = models.CharField(max_length=20, choices=OPERATOR_CHOICES, default='always') - condition_value = models.CharField(max_length=255, blank=True) - - class Meta: - ordering = ['section', 'sort_order', 'label'] - - def __str__(self) -> str: - return f'{self.get_section_display()}: {self.label}' - - def translated_label(self, language_code: str | None = None) -> str: - lang = (language_code or get_language() or 'de').split('-')[0] - if lang == 'en' and self.label_en.strip(): - return self.label_en.strip() - return self.label.strip() - - -class OnboardingIntroductionSession(models.Model): - STATUS_CHOICES = [ - ('draft', _('Entwurf')), - ('completed', _('Abgeschlossen')), - ] - - onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE) - checklist_state = models.JSONField(default=dict, blank=True) - notes = models.TextField(blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') - completed_at = models.DateTimeField(null=True, blank=True) - completed_by_name = models.CharField(max_length=255, blank=True) - exported_pdf_path = models.CharField(max_length=500, blank=True) - updated_at = models.DateTimeField(auto_now=True) - created_at = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return f'Einweisung #{self.id} | {self.onboarding_request.full_name} | {self.status}' - - -class WorkflowConfig(models.Model): - REMOTE_BACKUP_TARGET_CHOICES = [ - ('nextcloud', _('Nextcloud')), - ('s3', _('S3')), - ('nfs', _('NFS')), - ] - - 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)') - device_handover_lead_days = models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)') - remote_backup_enabled = models.BooleanField(default=False, verbose_name='Remote Backup aktiviert') - remote_backup_target_type = models.CharField( - max_length=20, - choices=REMOTE_BACKUP_TARGET_CHOICES, - default='nextcloud', - verbose_name='Remote Backup Zieltyp', - ) - remote_backup_nextcloud_directory = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Backup-Verzeichnis') - remote_backup_s3_bucket = models.CharField(max_length=255, blank=True, verbose_name='S3 Bucket (optional)') - remote_backup_nfs_path = models.CharField(max_length=255, blank=True, verbose_name='NFS Pfad (optional)') - 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): - STATUS_CHOICES = OnboardingRequest.STATUS_CHOICES - - 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') - preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de') - generated_pdf_path = models.CharField(max_length=500, blank=True) - processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') - last_error = models.TextField(blank=True) - created_at = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return f"Offboarding #{self.id} - {self.full_name}" - - def save(self, *args, **kwargs): - self.preferred_language = _normalized_language_code(self.preferred_language) - super().save(*args, **kwargs) +from .model_account import * +from .model_portal import * +from .model_ops import * +from .model_forms import * +from .model_notifications import * +from .model_requests import * +from .model_system import * diff --git a/backend/workflows/notification_dispatch.py b/backend/workflows/notification_dispatch.py new file mode 100644 index 0000000..37a8837 --- /dev/null +++ b/backend/workflows/notification_dispatch.py @@ -0,0 +1,29 @@ +from django.utils.translation import gettext as _ + +from .notifications import notify_user_by_email + + +def notify_request_result(*, recipient_email: str, title: str, body: str, level: str, event_key: str) -> None: + notify_user_by_email( + email=recipient_email, + title=title, + body=body, + level=level, + link_url='/requests/', + event_key=event_key, + ) + + +def notify_welcome_email_result(*, recipient_email: str, full_name: str, body: str, level: str, event_key: str) -> None: + notify_user_by_email( + email=recipient_email, + title=( + _('Welcome E-Mail gesendet: %(name)s') % {'name': full_name} + if event_key == 'welcome_email_success' + else _('Welcome E-Mail fehlgeschlagen: %(name)s') % {'name': full_name} + ), + body=body, + level=level, + link_url='/admin-tools/welcome-emails/', + event_key=event_key, + ) diff --git a/backend/workflows/notifications.py b/backend/workflows/notifications.py new file mode 100644 index 0000000..e7f35b0 --- /dev/null +++ b/backend/workflows/notifications.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model + +from .models import UserNotification, UserProfile + + +def create_user_notification(*, user, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '') -> UserNotification: + return UserNotification.objects.create( + user=user, + title=(title or '').strip(), + body=(body or '').strip(), + level=level, + link_url=(link_url or '').strip(), + ) + + +def notify_user(*, user, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '', event_key: str = '') -> bool: + if not user or not getattr(user, 'is_authenticated', False): + return False + profile, _ = UserProfile.objects.get_or_create(user=user) + if event_key and not profile.notification_enabled(event_key): + return False + create_user_notification(user=user, title=title, body=body, level=level, link_url=link_url) + return True + + +def notify_user_by_email(*, email: str, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '', event_key: str = '') -> bool: + normalized_email = (email or '').strip().lower() + if not normalized_email: + return False + user = get_user_model().objects.filter(email__iexact=normalized_email).first() + if not user: + return False + return notify_user(user=user, title=title, body=body, level=level, link_url=link_url, event_key=event_key) diff --git a/backend/workflows/observability_views.py b/backend/workflows/observability_views.py new file mode 100644 index 0000000..91f4866 --- /dev/null +++ b/backend/workflows/observability_views.py @@ -0,0 +1,159 @@ +from datetime import timedelta + +from django.contrib import messages +from django.db.models import Count, Q +from django.shortcuts import redirect, render +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .backup_ops import create_backup_bundle, latest_backup_health_snapshot, list_backup_bundles, verify_backup_bundle +from .models import AdminAuditLog, AsyncTaskLog, UserNotification, UserProfile +from .roles import user_has_capability + + +def job_monitor_page_impl(request): + status_filter = (request.GET.get('status') or '').strip() + task_filter = (request.GET.get('task') or '').strip() + logs = AsyncTaskLog.objects.all() + if status_filter: + logs = logs.filter(status=status_filter) + if task_filter: + logs = logs.filter(task_name=task_filter) + logs = logs.order_by('-started_at', '-id')[:200] + task_names = list(AsyncTaskLog.objects.order_by('task_name').values_list('task_name', flat=True).distinct()) + since = timezone.now() - timedelta(hours=24) + recent_logs = AsyncTaskLog.objects.filter(started_at__gte=since) + counts = {row['status']: row['count'] for row in recent_logs.values('status').annotate(count=Count('id'))} + recent_failed = list(AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5]) + can_manage_backups = user_has_capability(request.user, 'manage_backups') + return render( + request, + 'workflows/job_monitor.html', + { + 'logs': logs, + 'status_filter': status_filter, + 'task_filter': task_filter, + 'task_names': task_names, + 'status_choices': [('started', _('Gestartet')), ('succeeded', _('Erfolgreich')), ('failed', _('Fehlgeschlagen'))], + 'job_summary': { + 'started_count_24h': counts.get('started', 0), + 'success_count_24h': counts.get('succeeded', 0), + 'failed_count_24h': counts.get('failed', 0), + 'recent_failed': recent_failed, + 'can_manage_backups': can_manage_backups, + 'backup_health': latest_backup_health_snapshot() if can_manage_backups else None, + }, + }, + ) + + +def audit_log_page_impl(request): + action = (request.GET.get('action') or '').strip() + user_query = (request.GET.get('user') or '').strip() + date_from = (request.GET.get('date_from') or '').strip() + date_to = (request.GET.get('date_to') or '').strip() + + rows_qs = AdminAuditLog.objects.select_related('actor').all() + if action: + rows_qs = rows_qs.filter(action=action) + if user_query: + rows_qs = rows_qs.filter( + Q(actor_display__icontains=user_query) + | Q(actor__username__icontains=user_query) + | Q(actor__email__icontains=user_query) + ) + if date_from: + rows_qs = rows_qs.filter(created_at__date__gte=date_from) + if date_to: + rows_qs = rows_qs.filter(created_at__date__lte=date_to) + + rows = list(rows_qs[:300]) + action_choices = AdminAuditLog.objects.order_by('action').values_list('action', flat=True).distinct() + return render( + request, + 'workflows/audit_log.html', + { + 'rows': rows, + 'action_choices': action_choices, + 'selected_action': action, + 'user_query': user_query, + 'date_from': date_from, + 'date_to': date_to, + }, + ) + + +def backup_recovery_page_impl(request): + rows = list_backup_bundles() + return render( + request, + 'workflows/backup_recovery.html', + { + 'rows': rows, + 'backup_health': latest_backup_health_snapshot(), + }, + ) + + +def create_backup_from_admin_impl(request, *, audit_fn, notify_user_fn, create_backup_bundle_fn): + try: + result = create_backup_bundle_fn() + audit_fn( + request, + 'backup_created', + target_type='backup_bundle', + target_label=result['name'], + details={'path': result['path']}, + ) + notify_user_fn( + user=request.user, + title=_('Backup erstellt: %(name)s') % {'name': result['name']}, + body=_('Das Backup-Bundle wurde erfolgreich erstellt.'), + level=UserNotification.LEVEL_SUCCESS, + link_url='/admin-tools/backups/', + event_key=UserProfile.NOTIFICATION_BACKUP_SUCCESS, + ) + messages.success(request, _('Backup wurde erstellt: %(name)s') % {'name': result['name']}) + except Exception as exc: + notify_user_fn( + user=request.user, + title=_('Backup fehlgeschlagen'), + body=str(exc), + level=UserNotification.LEVEL_ERROR, + link_url='/admin-tools/backups/', + event_key=UserProfile.NOTIFICATION_BACKUP_FAILURE, + ) + messages.error(request, _('Backup konnte nicht erstellt werden: %(error)s') % {'error': exc}) + return redirect('backup_recovery_page') + + +def verify_backup_from_admin_impl(request, backup_name: str, *, audit_fn, notify_user_fn, verify_backup_bundle_fn): + try: + result = verify_backup_bundle_fn(backup_name) + audit_fn( + request, + 'backup_verified', + target_type='backup_bundle', + target_label=backup_name, + details={'summary': result['summary']}, + ) + notify_user_fn( + user=request.user, + title=_('Backup verifiziert: %(name)s') % {'name': result['name']}, + body=result.get('summary') or _('Das Backup wurde erfolgreich verifiziert.'), + level=UserNotification.LEVEL_SUCCESS, + link_url='/admin-tools/backups/', + event_key=UserProfile.NOTIFICATION_BACKUP_SUCCESS, + ) + messages.success(request, _('Backup wurde verifiziert: %(name)s') % {'name': result['name']}) + except Exception as exc: + notify_user_fn( + user=request.user, + title=_('Backup-Verifikation fehlgeschlagen'), + body=str(exc), + level=UserNotification.LEVEL_ERROR, + link_url='/admin-tools/backups/', + event_key=UserProfile.NOTIFICATION_BACKUP_FAILURE, + ) + messages.error(request, _('Backup-Verifikation fehlgeschlagen: %(error)s') % {'error': exc}) + return redirect('backup_recovery_page') diff --git a/backend/workflows/pdf_rendering.py b/backend/workflows/pdf_rendering.py new file mode 100644 index 0000000..78ef070 --- /dev/null +++ b/backend/workflows/pdf_rendering.py @@ -0,0 +1,810 @@ +from pathlib import Path +import base64 +import mimetypes +import re + +from django.contrib.auth import get_user_model +from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext as _, get_language, override +from jinja2 import Template +from pypdf import PageObject, PdfReader, PdfWriter +from xhtml2pdf import pisa + +from .branding import get_branding_email_copy, get_company_contact_copy, get_portal_letterhead_path +from .models import IntroChecklistItem, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, WorkflowConfig +from .forms import ACCESS_CHOICES, DEVICE_CHOICES, HARDWARE_EXTRA_CHOICES, OnboardingRequestForm, RESOURCE_CHOICES, SOFTWARE_CHOICES, SOFTWARE_EXTRA_CHOICES, WORKSPACE_GROUP_CHOICES +from .pdf_sections import build_pdf_sections + + +MANUAL_ONBOARDING_FIELD_SECTIONS = [ + ( + 'Stammdaten', + [ + 'gender', + 'first_name', + 'last_name', + 'job_title', + 'department', + 'work_email', + 'order_business_cards', + 'business_card_name', + 'business_card_title', + 'business_card_email', + 'business_card_phone', + ], + ), + ( + 'Vertrag', + [ + 'contract_start', + 'employment_type', + 'employment_end_date', + 'handover_date', + ], + ), + ( + 'IT-Setup', + [ + 'group_mailboxes_required_choice', + 'group_mailboxes', + 'additional_hardware_needed_choice', + 'additional_hardware_other', + 'additional_software_needed_choice', + 'additional_software', + 'additional_access_needed_choice', + 'additional_access_text', + 'successor_required_choice', + 'successor_name', + 'inherit_phone_number_choice', + 'phone_number_choice', + ], + ), + ( + 'Abschluss', + [ + 'additional_notes', + 'signature_image', + 'agreement_confirm', + ], + ), +] + +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 _chunk_choice_labels(choices: list[tuple[str, str]], chunk_size: int = 3) -> list[list[str]]: + labels = [label for _, label in choices] + return _chunk_list(labels, chunk_size=chunk_size) + +def _normalized_lang(language_code: str | None) -> str: + return (language_code or 'de').split('-')[0].lower() or 'de' + +def _pdf_texts(language_code: str | None = None) -> dict[str, str]: + lang = _normalized_lang(language_code) + texts = { + 'de': { + 'lang': 'de', + 'not_available': 'Keine Angabe', + 'not_available_short': '-', + 'yes': 'Ja', + 'no': 'Nein', + 'onboarding_title': 'Onboarding-Unterlagen', + 'onboarding_staff_data': 'Personaldaten', + 'name': 'Name', + 'department': 'Abteilung', + 'job_title': 'Berufsbezeichnung', + 'work_email': 'Dienstliche E-Mail', + 'employment_type': 'Beschäftigungsverhältnis', + 'contract_start': 'Vertragsbeginn', + 'contract_end': 'Vertragsende', + 'handover_date': 'Übergabedatum', + 'equipment_access': 'Ausstattung und Zugänge', + 'devices': 'Benötigte Geräte und Gegenstände', + 'workspace_groups': 'Benötigte Gruppen im Workspace', + 'software': 'Benötigte Software', + 'accesses': 'Benötigte Zugänge', + 'resources': 'Benötigte Ressourcen', + 'group_mailboxes_required': 'Gruppenpostfächer erforderlich', + 'additional_hardware_needed': 'Darüber hinaus wird weitere Hardware benötigt', + 'additional_software_needed': 'Wird zusätzliche Software benötigt', + 'additional_access_needed': 'Darüber hinaus werden weitere Zugänge benötigt', + 'additional_details': 'Zusätzliche Angaben', + 'business_cards': 'Visitenkarten', + 'email': 'E-Mail', + 'phone': 'Telefon', + 'additional_hardware_other': 'Weitere Hardware (Freitext)', + 'successor_phone': 'Nachfolge und Telefon', + 'successor_of': 'Nachfolge von', + 'inherit_phone_number': 'Telefon von Vorgänger übernehmen', + 'direct_extension': 'Direktwahl', + 'notes': 'Notizen', + 'confirmation': 'Bestätigung', + 'requested_by_name': 'Angefordert von (Name)', + 'requested_by_email': 'Angefordert von (E-Mail)', + 'signature': 'Unterschrift', + 'signature_alt': 'Unterschrift', + 'onboarding_note': 'Hinweis: Dieses Formular dient als interne Prozessgrundlage für das Onboarding.', + 'offboarding_title': 'Offboarding-Unterlagen', + 'employee_info': 'Mitarbeitenden-Informationen', + 'last_working_day': 'Letzter Arbeitstag', + 'offboarding_requester': 'Offboarding-Anfordernde Person', + 'it_hardware_status': 'IT-Hardware-Status (aus Onboarding)', + 'hardware_check': 'Hardware-Check', + 'no_onboarding_hardware': 'Keine Onboarding-Hardwaredaten gefunden.', + 'manual_return_overview': 'Manuelle Rückgabeübersicht', + 'manual_return_note': 'Es wurden keine gespeicherten Onboarding-Daten zu dieser Person gefunden. Die folgenden Listen dienen als manuelle Rückgabe- und Prüfübersicht.', + 'returned_devices': 'Zurückgegebene Geräte und Artikel', + 'returned_software': 'Zurückgegebene bzw. deaktivierte Software', + 'removed_workspace_groups': 'Entfernte Gruppen im Workspace', + 'removed_accesses': 'Entfernte Zugänge', + 'returned_extra_it': 'Zurückgegebene zusätzliche Hardware / Software', + 'it_signatures': 'IT-Section: Signaturen', + 'it_checked_by': 'IT geprüft am durch:', + 'it_signature': 'IT-Unterschrift:', + 'return_complete': 'Rückgabe vollständig:', + 'offboarding_note': 'Hinweis: Dieses Formular dient als interne Prozessgrundlage für das Offboarding.', + 'intro_title': 'Einweisungs- und Übergabeprotokoll', + 'intro_sub': 'Gesprächsleitfaden für die persönliche Einführung neuer Mitarbeitender.', + 'base_data': 'Basisdaten', + 'start_date': 'Startdatum', + 'introduced_by': 'Einweisung durch', + 'intro_note': 'Dieses Dokument dient als Gesprächsleitfaden für die persönliche Einweisung. Die Felder können während des Termins manuell abgehakt und anschließend unterschrieben werden.', + 'employee_signature': 'Unterschrift Mitarbeitende Person:', + 'trainer_signature': 'Unterschrift Einweisende Person:', + 'intro_completed_at': 'Einweisung durchgeführt am:', + 'open_questions': 'Rückfragen offen / Nacharbeit erforderlich:', + 'live_intro_title': 'Einweisungsprotokoll', + 'live_intro_sub': 'Export des aktuellen Live-Status aus der webbasierten Einweisung.', + 'employment_start': 'Vertragsbeginn', + 'employee_signature_block': 'Unterschrift Mitarbeitende Person', + }, + 'en': { + 'lang': 'en', + 'not_available': 'Not provided', + 'not_available_short': '-', + 'yes': 'Yes', + 'no': 'No', + 'onboarding_title': 'Onboarding Documents', + 'onboarding_staff_data': 'Employee Details', + 'name': 'Name', + 'department': 'Department', + 'job_title': 'Job title', + 'work_email': 'Work email', + 'employment_type': 'Employment type', + 'contract_start': 'Contract start', + 'contract_end': 'Contract end', + 'handover_date': 'Handover date', + 'equipment_access': 'Equipment and access', + 'devices': 'Required devices and items', + 'workspace_groups': 'Required workspace groups', + 'software': 'Required software', + 'accesses': 'Required accesses', + 'resources': 'Required resources', + 'group_mailboxes_required': 'Group mailboxes required', + 'additional_hardware_needed': 'Additional hardware required', + 'additional_software_needed': 'Additional software required', + 'additional_access_needed': 'Additional accesses required', + 'additional_details': 'Additional details', + 'business_cards': 'Business cards', + 'email': 'Email', + 'phone': 'Phone', + 'additional_hardware_other': 'Additional hardware (free text)', + 'successor_phone': 'Successor and phone', + 'successor_of': 'Successor to', + 'inherit_phone_number': 'Take over predecessor phone number', + 'direct_extension': 'Direct extension', + 'notes': 'Notes', + 'confirmation': 'Confirmation', + 'requested_by_name': 'Requested by (name)', + 'requested_by_email': 'Requested by (email)', + 'signature': 'Signature', + 'signature_alt': 'Signature', + 'onboarding_note': 'Note: This form serves as the internal process basis for onboarding.', + 'offboarding_title': 'Offboarding Documents', + 'employee_info': 'Employee information', + 'last_working_day': 'Last working day', + 'offboarding_requester': 'Offboarding requester', + 'it_hardware_status': 'IT hardware status (from onboarding)', + 'hardware_check': 'Hardware check', + 'no_onboarding_hardware': 'No onboarding hardware data found.', + 'manual_return_overview': 'Manual return overview', + 'manual_return_note': 'No stored onboarding data was found for this person. The following lists serve as a manual return and review overview.', + 'returned_devices': 'Returned devices and items', + 'returned_software': 'Returned or disabled software', + 'removed_workspace_groups': 'Removed workspace groups', + 'removed_accesses': 'Removed accesses', + 'returned_extra_it': 'Returned additional hardware / software', + 'it_signatures': 'IT section: signatures', + 'it_checked_by': 'Checked by IT on:', + 'it_signature': 'IT signature:', + 'return_complete': 'Return complete:', + 'offboarding_note': 'Note: This form serves as the internal process basis for offboarding.', + 'intro_title': 'Introduction and Handover Protocol', + 'intro_sub': 'Conversation guide for the personal introduction of new employees.', + 'base_data': 'Basic data', + 'start_date': 'Start date', + 'introduced_by': 'Introduction by', + 'intro_note': 'This document serves as a conversation guide for the personal introduction. The fields can be checked manually during the meeting and signed afterwards.', + 'employee_signature': 'Employee signature:', + 'trainer_signature': 'Trainer signature:', + 'intro_completed_at': 'Introduction completed on:', + 'open_questions': 'Open questions / follow-up required:', + 'live_intro_title': 'Introduction Protocol', + 'live_intro_sub': 'Export of the current live status from the web-based introduction.', + 'employment_start': 'Contract start', + 'employee_signature_block': 'Employee signature', + }, + } + return texts.get(lang, texts['de']) + +def _manual_onboarding_field_sections() -> list[dict]: + fields = OnboardingRequestForm.base_fields + sections = [] + for title, field_names in MANUAL_ONBOARDING_FIELD_SECTIONS: + labels = [str(fields[name].label or name) for name in field_names if name in fields] + if not labels: + continue + sections.append({'title': title, 'rows': _chunk_list(labels, chunk_size=2)}) + return sections + +def _matches_intro_condition(request_obj: OnboardingRequest, item: IntroChecklistItem) -> bool: + operator = (item.condition_operator or 'always').strip() + field_name = (item.condition_field or '').strip() + expected = (item.condition_value or '').strip() + + if operator == 'always' or not field_name: + return True + + raw_value = getattr(request_obj, field_name, '') + if raw_value is None: + raw_value = '' + + if operator == 'is_true': + return bool(raw_value) + if operator == 'is_false': + return not bool(raw_value) + + text_value = str(raw_value).strip() + if operator == 'equals': + return text_value.lower() == expected.lower() + if operator == 'contains': + return expected.lower() in text_value.lower() + return True + +def _build_intro_sections_from_admin(request_obj: OnboardingRequest, language_code: str | None = None) -> dict[str, list[str]]: + items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label')) + if not items: + return {} + + section_map = {key: [] for key, _label in IntroChecklistItem.SECTION_CHOICES} + for item in items: + if item.section not in section_map: + continue + if _matches_intro_condition(request_obj, item): + section_map[item.section].append(item.translated_label(language_code)) + return {key: values for key, values in section_map.items() if values} + +def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]: + lang = _normalized_lang(language_code or get_language()) + with override(lang): + section_titles = { + 'workplace': _('Geräte und Arbeitsplatz'), + 'accounts': _('Konten und Berechtigungen'), + 'software': _('Software und Tools'), + 'process': _('Prozesse und Hinweise'), + } + 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) + extra_hardware = _split_multiline(request_obj.additional_hardware) + extra_software = _split_multiline(request_obj.additional_software) + group_mailboxes = _split_multiline(request_obj.group_mailboxes) + + workplace_items = [] + for item in devices: + workplace_items.append(_('%(item)s übergeben und Grundfunktionen erklärt') % {'item': item}) + for item in resources: + workplace_items.append(_('%(item)s gezeigt bzw. Nutzung erklärt') % {'item': item}) + if request_obj.phone_number: + workplace_items.append(_('Telefonnummer / Direktwahl erklärt: %(value)s') % {'value': request_obj.phone_number}) + if not workplace_items: + workplace_items.append(_('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen')) + + account_items = [_('%(item)s Zugang erklärt') % {'item': item} for item in accesses] + account_items.extend([_('%(item)s Gruppe / Berechtigung erläutert') % {'item': item} for item in groups]) + if request_obj.work_email: + account_items.insert(0, _('Dienstliche E-Mail-Adresse erläutert: %(value)s') % {'value': request_obj.work_email}) + if group_mailboxes: + account_items.extend([_('Gruppenpostfach erklärt: %(item)s') % {'item': item} for item in group_mailboxes]) + if not account_items: + account_items.append(_('Zugänge, Konten und Anmeldelogik besprochen')) + + software_items = [_('%(item)s Einführung durchgeführt') % {'item': item} for item in software] + software_items.extend([_('%(item)s zusätzlich besprochen') % {'item': item} for item in extra_software]) + if not software_items: + software_items.append(_('Benötigte Standardsoftware und tägliche Nutzung erklärt')) + + process_items = [ + _('Passwortregeln und sicherer Umgang besprochen'), + _('Dateiablage, Nextcloud und Freigaben erklärt'), + _('Kommunikationswege und Support-Prozess erklärt'), + ] + if extra_hardware: + process_items.extend([_('%(item)s als zusätzliche Ausstattung besprochen') % {'item': item} for item in extra_hardware]) + if request_obj.additional_access_text: + process_items.extend([_('Zusätzlicher Zugang besprochen: %(item)s') % {'item': item} for item in _split_multiline(request_obj.additional_access_text)]) + if request_obj.successor_name: + process_items.append(_('Übergabe-/Nachfolgekontext besprochen: %(value)s') % {'value': request_obj.successor_name}) + + custom_intro_items = _build_intro_sections_from_admin(request_obj, lang) + intro_sections_raw = [ + ('workplace', section_titles['workplace'], workplace_items), + ('accounts', section_titles['accounts'], account_items), + ('software', section_titles['software'], software_items), + ('process', section_titles['process'], process_items), + ] + + sections = [] + for key, title, default_items in intro_sections_raw: + merged_items = list(default_items) + merged_items.extend(custom_intro_items.get(key, [])) + section_items = [] + for idx, label in enumerate(merged_items, start=1): + section_items.append({'id': f'{key}_{idx}', 'label': label}) + if section_items: + sections.append({'key': key, 'title': title, 'items': section_items}) + return sections + +def _render_html(template_path: Path, context: dict) -> str: + with template_path.open('r', encoding='utf-8') as handle: + template = Template(handle.read()) + return template.render(context) + +def _generate_content_pdf(html_content: str, output_pdf: Path) -> None: + page_style = ( + '' + ) + if '' in html_content: + html_content = html_content.replace('', f'{page_style}', 1) + else: + html_content = page_style + html_content + + output_pdf.parent.mkdir(parents=True, exist_ok=True) + with output_pdf.open('wb') as fp: + result = pisa.CreatePDF( + src=html_content, + dest=fp, + encoding='utf-8', + ) + if result.err: + raise RuntimeError(f'Failed to render PDF content for {output_pdf.name}') + +def _overlay_with_letterhead(content_pdf: Path, letterhead_pdf: Path, output_pdf: Path) -> None: + letterhead_reader = PdfReader(str(letterhead_pdf)) + content_reader = PdfReader(str(content_pdf)) + writer = PdfWriter() + + letterhead_page = letterhead_reader.pages[0] + for page in content_reader.pages: + merged = PageObject.create_blank_page( + width=letterhead_page.mediabox.width, + height=letterhead_page.mediabox.height, + ) + merged.merge_page(letterhead_page) + merged.merge_page(page) + writer.add_page(merged) + + with output_pdf.open('wb') as fp: + writer.write(fp) + +def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path: + lang = _normalized_lang(request_obj.preferred_language) + t = _pdf_texts(lang) + 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 = get_portal_letterhead_path() + company_contact = get_company_contact_copy() + + 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 = t['not_available_short'] + 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 = 'Digital signature stored as image file.' if lang == 'en' else 'Digitale Signatur als Bilddatei hinterlegt.' + except Exception: + signature_src = '' + signature_note = request_obj.signature_url or t['not_available_short'] + elif request_obj.signature_url: + signature_note = request_obj.signature_url + + requester_email = request_obj.onboarded_by_email or t['not_available_short'] + requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or t['not_available_short'] + gender = (request_obj.get_gender_display() or t['not_available_short']).strip() or t['not_available_short'] + employment_type = (request_obj.employment_type or t['not_available_short']).strip() or t['not_available_short'] + employment_end = request_obj.employment_end_date or t['not_available_short'] + 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() + display_name = f"{gender} {first_name} {last_name}".strip() if gender and gender != '-' else f"{first_name} {last_name}".strip() + + pdf_sections = build_pdf_sections('onboarding', request_obj, lang) + pdf_section_map = {section['key']: section for section in pdf_sections if section.get('has_content')} + pdf_field_map = {} + for section in pdf_sections: + for field in section.get('render_fields', []): + pdf_field_map[field['name']] = field + + def _field_text(name: str, fallback): + field = pdf_field_map.get(name) + if not field: + return fallback + value = field.get('display_value') + if isinstance(value, list): + return ' | '.join(value) if value else fallback + return value or fallback + + def _field_list(name: str) -> list[str]: + field = pdf_field_map.get(name) + if not field: + return [] + value = field.get('display_value') + if isinstance(value, list): + return value + text = str(value or '').strip() + return [text] if text else [] + + devices_visible = _field_list('needed_devices_multi') + groups_visible = _field_list('needed_workspace_groups_multi') + software_visible = _field_list('needed_software_multi') + accesses_visible = _field_list('needed_accesses_multi') + resources_visible = _field_list('needed_resources_multi') + group_mailboxes_visible = _field_list('group_mailboxes') + additional_hardware_visible = _field_list('additional_hardware_multi') + additional_software_visible = _field_list('additional_software_multi') + additional_access_visible = _field_list('additional_access_text') + job_title_visible = 'job_title' in pdf_field_map + itsetup_visible = 'itsetup' in pdf_section_map + + context = { + 'T': t, + 'PDF_LANG': lang, + 'PDF_SECTIONS': pdf_sections, + 'VORNAME': first_name, + 'NACHNAME': last_name, + 'DISPLAY_NAME': display_name or request_obj.full_name, + 'ANREDE': _field_text('gender', gender), + 'JOB_TITLE_VISIBLE': job_title_visible, + 'BERUFSBEZEICHNUNG': _field_text('job_title', t['not_available']), + 'ABTEILUNG': _field_text('department', t['not_available']), + 'EMAIL': _field_text('work_email', t['not_available']), + 'VERTRAGSBEGINN': _field_text('contract_start', request_obj.contract_start), + 'BESCHAEFTIGUNG': _field_text('employment_type', employment_type), + 'VERTRAGSENDE': _field_text('employment_end_date', employment_end), + 'UEBERGABEDATUM': _field_text('handover_date', request_obj.handover_date or t['not_available_short']), + 'ARBEITSGERAETE_TEXT': ' | '.join(devices_visible) if devices_visible else t['not_available'], + 'WORKSPACE_GROUPS_TEXT': ' | '.join(groups_visible) if groups_visible else t['not_available'], + 'SOFTWARE_TEXT': ' | '.join(software_visible) if software_visible else t['not_available'], + 'ZUGAENGE_TEXT': ' | '.join(accesses_visible) if accesses_visible else t['not_available'], + 'RESSOURCEN_TEXT': ' | '.join(resources_visible) if resources_visible else t['not_available'], + 'VISITENKARTE_BESTELLT': order_business_cards, + 'HAS_VISITENKARTE_DATEN': order_business_cards and ('business_card_name' in pdf_field_map or 'business_card_title' in pdf_field_map or 'business_card_email' in pdf_field_map or 'business_card_phone' in pdf_field_map) and any( + [ + _field_text('business_card_name', '').strip(), + _field_text('business_card_title', '').strip(), + _field_text('business_card_email', '').strip(), + _field_text('business_card_phone', '').strip(), + ] + ), + 'VISITENKARTE_NAME': _field_text('business_card_name', t['not_available_short']), + 'VISITENKARTE_TITEL': _field_text('business_card_title', t['not_available_short']), + 'VISITENKARTE_EMAIL': _field_text('business_card_email', t['not_available_short']), + 'VISITENKARTE_TELEFON': _field_text('business_card_phone', t['not_available_short']), + 'GROUP_MAILBOXES': _field_text('group_mailboxes', group_mailboxes or t['not_available']), + 'ADDITIONAL_HARDWARE_OTHER': _field_text('additional_hardware_other', additional_hardware_other or t['not_available']), + 'ADDITIONAL_HARDWARE': _field_text('additional_hardware_other', additional_hardware or t['not_available']), + 'ADDITIONAL_SOFTWARE': _field_text('additional_software', additional_software or t['not_available']), + 'ADDITIONAL_ACCESS_TEXT': _field_text('additional_access_text', additional_access_text or t['not_available']), + 'SUCCESSOR_NAME': _field_text('successor_name', successor_name or t['not_available']), + 'PHONE_NUMBER': _field_text('phone_number_choice', phone_number or t['not_available_short']), + 'INHERIT_PHONE_NUMBER': _field_text('inherit_phone_number_choice', t['yes'] if request_obj.inherit_phone_number else t['no']), + 'ADDITIONAL_NOTES': _field_text('additional_notes', additional_notes or t['not_available']), + 'GROUP_MAILBOXES_REQUIRED': 'group_mailboxes_required_choice' in pdf_field_map and bool(group_mailboxes_visible), + 'ADDITIONAL_HARDWARE_NEEDED': 'additional_hardware_needed_choice' in pdf_field_map and bool(additional_hardware_visible), + 'ADDITIONAL_SOFTWARE_NEEDED': 'additional_software_needed_choice' in pdf_field_map and bool(additional_software_visible), + 'ADDITIONAL_ACCESS_NEEDED': 'additional_access_needed_choice' in pdf_field_map and bool(additional_access_visible), + 'HAS_DEVICES': itsetup_visible and bool(devices_visible), + 'HAS_GROUPS': itsetup_visible and bool(groups_visible), + 'HAS_SOFTWARE': itsetup_visible and bool(software_visible), + 'HAS_ACCESSES': itsetup_visible and bool(accesses_visible), + 'HAS_RESOURCES': itsetup_visible and bool(resources_visible), + 'HAS_GROUP_MAILBOXES': bool(group_mailboxes_visible), + 'HAS_ADDITIONAL_HARDWARE': bool(additional_hardware_visible), + 'HAS_ADDITIONAL_SOFTWARE': bool(additional_software_visible), + 'HAS_ADDITIONAL_ACCESS': bool(additional_access_visible), + 'HAS_ADDITIONAL_HARDWARE_OTHER': bool(_field_text('additional_hardware_other', '').strip()), + 'HAS_SUCCESSOR_INFO': bool(_field_text('successor_name', '').strip()) or 'inherit_phone_number_choice' in pdf_field_map or bool(_field_text('phone_number_choice', '').strip()), + 'HAS_ADDITIONAL_NOTES': bool(_field_text('additional_notes', '').strip()), + 'GROUP_MAILBOXES_LIST': _chunk_list(group_mailboxes_visible), + 'ADDITIONAL_HARDWARE_LIST': _chunk_list(additional_hardware_visible), + 'ADDITIONAL_SOFTWARE_LIST': _chunk_list(additional_software_visible), + 'ADDITIONAL_ACCESS_LIST': _chunk_list(additional_access_visible), + 'ZUGAENGE_LIST': _chunk_list(groups_visible), + 'ARBEITSGERÄTE_LIST': _chunk_list(devices_visible), + 'SOFTWARE_LIST': _chunk_list(software_visible), + 'ACCOUNT_LIST': _chunk_list(accesses_visible), + 'STANDARD_RESSOURCEN': _chunk_list(resources_visible), + 'UNTERSCHRIFT': signature_src, + 'UNTERSCHRIFT_HINWEIS': signature_note, + 'REQUESTED_BY_NAME': requester_name, + 'REQUESTED_BY_EMAIL': requester_email, + 'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'], + 'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'], + 'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'], + 'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'], + 'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'], + } + + 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_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code: str | None = None) -> Path: + lang = _normalized_lang(language_code or request_obj.preferred_language) + t = _pdf_texts(lang) + safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_{request_obj.id}') + output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_{safe_name}.pdf' + temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf' + + template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html' + letterhead_path = get_portal_letterhead_path() + company_contact = get_company_contact_copy() + + salutation = (request_obj.get_gender_display() or '').strip() + display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name + intro_sections = [ + { + 'title': section['title'], + 'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2), + } + for section in build_intro_sections_for_request(request_obj, language_code=language_code) + ] + + 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 '-' + + context = { + 'T': t, + 'DISPLAY_NAME': display_name, + 'ABTEILUNG': request_obj.department or t['not_available_short'], + 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available_short'], + 'VERTRAGSBEGINN': request_obj.contract_start, + 'EMAIL': request_obj.work_email or t['not_available_short'], + 'REQUESTED_BY_NAME': requester_name, + 'REQUESTED_BY_EMAIL': requester_email, + 'INTRO_SECTIONS': intro_sections, + 'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'], + 'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'], + 'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'], + 'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'], + 'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'], + } + + 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_onboarding_intro_session_pdf( + session: OnboardingIntroductionSession, + admin_signature_name: str = '-', + language_code: str | None = None, +) -> Path: + request_obj = session.onboarding_request + lang = _normalized_lang(language_code or request_obj.preferred_language) + t = _pdf_texts(lang) + safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_session_{request_obj.id}') + version = timezone.now().strftime('%Y%m%d%H%M%S') + output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_session_{safe_name}_{version}.pdf' + temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf' + + template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html' + letterhead_path = get_portal_letterhead_path() + company_contact = get_company_contact_copy() + + salutation = (request_obj.get_gender_display() or '').strip() + display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name + + raw_sections = build_intro_sections_for_request(request_obj, language_code=language_code) + checked_map = session.checklist_state or {} + exported_sections = [] + checked_count = 0 + total_count = 0 + for section in raw_sections: + checked_items = [] + for item in section['items']: + checked = bool(checked_map.get(item['id'])) + total_count += 1 + if checked: + checked_count += 1 + checked_items.append({'label': item['label']}) + if checked_items: + exported_sections.append({ + 'title': section['title'], + 'rows': [checked_items[i:i + 2] for i in range(0, len(checked_items), 2)], + }) + + 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 '-' + + context = { + 'T': t, + 'DISPLAY_NAME': display_name, + 'ABTEILUNG': request_obj.department or t['not_available_short'], + 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available_short'], + 'VERTRAGSBEGINN': request_obj.contract_start, + 'EMAIL': request_obj.work_email or t['not_available_short'], + 'REQUESTED_BY_NAME': requester_name, + 'REQUESTED_BY_EMAIL': requester_email, + 'SESSION_STATUS': session.get_status_display(), + 'SESSION_COMPLETED_BY': session.completed_by_name or '-', + 'SESSION_COMPLETED_AT': session.completed_at or '-', + 'SESSION_UPDATED_AT': session.updated_at, + 'SESSION_NOTES': session.notes or t['not_available_short'], + 'INTRO_SECTIONS': exported_sections, + 'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'], + 'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'], + 'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'], + 'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'], + 'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'], + } + + 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: + lang = _normalized_lang(request_obj.preferred_language) + t = _pdf_texts(lang) + 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 = get_portal_letterhead_path() + company_contact = get_company_contact_copy() + latest_onboarding = ( + OnboardingRequest.objects.filter(work_email=request_obj.work_email) + .order_by('-created_at') + .first() + ) + has_onboarding_data = latest_onboarding is not None + 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 t['not_available_short'] + requester_name = request_obj.requested_by_name or _resolve_user_display_name(request_obj.requested_by_email) or t['not_available_short'] + + context = { + 'T': t, + 'PDF_LANG': lang, + 'PDF_SECTIONS': build_pdf_sections('offboarding', request_obj, lang), + 'FULL_NAME': request_obj.full_name, + 'EMAIL': request_obj.work_email, + 'DEPARTMENT': request_obj.department or t['not_available_short'], + 'JOB_TITLE': request_obj.job_title or t['not_available_short'], + 'LAST_WORKING_DAY': request_obj.last_working_day, + 'NOTES': request_obj.notes or t['not_available_short'], + 'REQUESTED_BY': requester_email, + 'REQUESTED_BY_NAME': requester_name, + 'HAS_ONBOARDING_DATA': has_onboarding_data, + 'ONBOARDING_HARDWARE': onboarding_hardware, + 'HARDWARE_CHECKLIST': checklist, + 'MANUAL_FIELD_SECTIONS': _manual_onboarding_field_sections(), + 'MANUAL_DEVICES': _chunk_choice_labels(DEVICE_CHOICES), + 'MANUAL_SOFTWARE': _chunk_choice_labels(SOFTWARE_CHOICES), + 'MANUAL_ACCESSES': _chunk_choice_labels(ACCESS_CHOICES), + 'MANUAL_WORKSPACE_GROUPS': _chunk_choice_labels(WORKSPACE_GROUP_CHOICES), + 'MANUAL_RESOURCES': _chunk_choice_labels(RESOURCE_CHOICES), + 'MANUAL_EXTRA_HARDWARE': _chunk_choice_labels(HARDWARE_EXTRA_CHOICES), + 'MANUAL_EXTRA_SOFTWARE': _chunk_choice_labels(SOFTWARE_EXTRA_CHOICES), + 'COMPANY_LEGAL_NAME': company_contact['legal_company_name'] or company_contact['company_name'], + 'COMPANY_ADDRESS': company_contact['address'] or t['not_available_short'], + 'COMPANY_IT_CONTACT': company_contact['it_contact_email'] or t['not_available_short'], + 'COMPANY_HR_CONTACT': company_contact['hr_contact_email'] or t['not_available_short'], + 'COMPANY_PHONE': company_contact['phone_number'] or t['not_available_short'], + } + + 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 diff --git a/backend/workflows/pdf_sections.py b/backend/workflows/pdf_sections.py new file mode 100644 index 0000000..cff2221 --- /dev/null +++ b/backend/workflows/pdf_sections.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +from collections import OrderedDict +from datetime import date, datetime + +from django.utils import formats +from django.utils.translation import override + +from .form_builder import ( + LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + get_custom_field_configs, + ensure_form_field_configs, + ensure_form_section_configs, + get_default_page_map, + get_section_labels, + get_section_order, +) +from .forms import OffboardingRequestForm, OnboardingRequestForm +from .models import FormCustomFieldConfig + +PDF_SECTION_TITLES = { + "onboarding": { + "stammdaten": "Stammdaten", + "vertrag": "Vertrag", + "itsetup": "IT-Setup", + "abschluss": "Abschluss", + }, + "offboarding": { + "mitarbeitende": "Mitarbeitende", + "austritt": "Austritt", + "abschluss": "Abschluss", + }, +} + +PDF_FIELD_LABELS = { + "onboarding": { + "full_name": {"de": "Name", "en": "Name"}, + }, + "offboarding": { + "full_name": {"de": "Name", "en": "Name"}, + }, +} + +PDF_EXCLUDED_FIELDS = { + "onboarding": { + "first_name", + "last_name", + "onboarded_by_email", + "agreement_confirm", + "signature_url", + "signature_image", + }, + "offboarding": set(), +} + +PDF_BOOLEAN_CONTROL_FIELDS = { + "onboarding": { + "order_business_cards", + "group_mailboxes_required_choice", + "additional_hardware_needed_choice", + "additional_software_needed_choice", + "additional_access_needed_choice", + "successor_required_choice", + "inherit_phone_number_choice", + }, + "offboarding": set(), +} + + +def _normalized_lang(language_code: str | None) -> str: + return (language_code or "de").split("-")[0].lower() or "de" + + +def _yes_no_text(language_code: str | None) -> tuple[str, str]: + lang = _normalized_lang(language_code) + if lang == "en": + return "Yes", "No" + return "Ja", "Nein" + + +def _not_available_text(language_code: str | None) -> str: + return "Not provided" if _normalized_lang(language_code) == "en" else "Keine Angabe" + + +def _split_name(full_name: str) -> tuple[str, str]: + parts = (full_name or "").split() + if not parts: + return "", "" + return parts[0], " ".join(parts[1:]) + + +def _split_multiline(value: str) -> list[str]: + return [line.strip() for line in (value or "").splitlines() if line.strip()] + + +def _format_date(value, language_code: str | None) -> str: + if not value: + return "" + if isinstance(value, datetime): + with override(_normalized_lang(language_code)): + return formats.date_format(value, "DATETIME_FORMAT", use_l10n=True) + if isinstance(value, date): + with override(_normalized_lang(language_code)): + return formats.date_format(value, "DATE_FORMAT", use_l10n=True) + return str(value) + + +def _coerce_text(value) -> str: + if value is None: + return "" + if isinstance(value, (date, datetime)): + return str(value) + return str(value).strip() + + +def _field_value_from_request(form_type: str, request_obj, field_name: str, language_code: str | None): + yes_text, no_text = _yes_no_text(language_code) + first_name, last_name = _split_name(getattr(request_obj, "full_name", "")) + + onboarding_map = { + "first_name": first_name, + "last_name": last_name, + "full_name": getattr(request_obj, "full_name", ""), + "gender": getattr(request_obj, "get_gender_display", lambda: "")() or getattr(request_obj, "gender", ""), + "job_title": getattr(request_obj, "job_title", ""), + "department": getattr(request_obj, "department", ""), + "work_email": getattr(request_obj, "work_email", ""), + "order_business_cards": yes_text if getattr(request_obj, "order_business_cards", False) else no_text, + "business_card_name": getattr(request_obj, "business_card_name", ""), + "business_card_title": getattr(request_obj, "business_card_title", ""), + "business_card_email": getattr(request_obj, "business_card_email", ""), + "business_card_phone": getattr(request_obj, "business_card_phone", ""), + "contract_start": _format_date(getattr(request_obj, "contract_start", None), language_code), + "employment_type": getattr(request_obj, "get_employment_type_display", lambda: "")() or getattr(request_obj, "employment_type", ""), + "employment_end_date": _format_date(getattr(request_obj, "employment_end_date", None), language_code), + "handover_date": _format_date(getattr(request_obj, "handover_date", None), language_code), + "group_mailboxes_required_choice": yes_text if getattr(request_obj, "group_mailboxes_required", False) else no_text, + "group_mailboxes": _split_multiline(getattr(request_obj, "group_mailboxes", "")), + "needed_devices_multi": _split_multiline(getattr(request_obj, "needed_devices", "")), + "additional_hardware_needed_choice": yes_text if getattr(request_obj, "additional_hardware_needed", False) else no_text, + "additional_hardware_multi": _split_multiline(getattr(request_obj, "additional_hardware", "")), + "additional_hardware_other": getattr(request_obj, "additional_hardware_other", ""), + "needed_software_multi": _split_multiline(getattr(request_obj, "needed_software", "")), + "additional_software_needed_choice": yes_text if getattr(request_obj, "additional_software_needed", False) else no_text, + "additional_software_multi": _split_multiline(getattr(request_obj, "additional_software", "")), + "additional_software": getattr(request_obj, "additional_software", ""), + "needed_accesses_multi": _split_multiline(getattr(request_obj, "needed_accesses", "")), + "additional_access_needed_choice": yes_text if getattr(request_obj, "additional_access_needed", False) else no_text, + "additional_access_text": _split_multiline(getattr(request_obj, "additional_access_text", "")), + "needed_workspace_groups_multi": _split_multiline(getattr(request_obj, "needed_workspace_groups", "")), + "needed_resources_multi": _split_multiline(getattr(request_obj, "needed_resources", "")), + "successor_required_choice": yes_text if getattr(request_obj, "successor_required", False) else no_text, + "successor_name": getattr(request_obj, "successor_name", ""), + "inherit_phone_number_choice": yes_text if getattr(request_obj, "inherit_phone_number", False) else no_text, + "phone_number_choice": getattr(request_obj, "phone_number", ""), + "additional_notes": getattr(request_obj, "additional_notes", ""), + "signature_url": getattr(request_obj, "signature_url", ""), + "signature_image": getattr(getattr(request_obj, "signature_image", None), "name", "") or "", + "onboarded_by_email": getattr(request_obj, "onboarded_by_email", ""), + "agreement_confirm": yes_text if _coerce_text(getattr(request_obj, "agreement", "")) else no_text, + } + offboarding_map = { + "full_name": getattr(request_obj, "full_name", ""), + "work_email": getattr(request_obj, "work_email", ""), + "department": getattr(request_obj, "department", ""), + "job_title": getattr(request_obj, "job_title", ""), + "last_working_day": _format_date(getattr(request_obj, "last_working_day", None), language_code), + "notes": getattr(request_obj, "notes", ""), + } + value_map = onboarding_map if form_type == "onboarding" else offboarding_map + return value_map.get(field_name, "") + + +def _field_kind(value) -> str: + if isinstance(value, list): + return "list" + return "text" + + +def _is_empty_value(value) -> bool: + if isinstance(value, list): + return len(value) == 0 + return _coerce_text(value) == "" + + +def _field_meta(form_type: str, field_name: str, language_code: str | None) -> tuple[str, str]: + custom_label = PDF_FIELD_LABELS.get(form_type, {}).get(field_name, {}) + if custom_label: + return custom_label.get(_normalized_lang(language_code), field_name), "" + form_class = OnboardingRequestForm if form_type == "onboarding" else OffboardingRequestForm + base_field = form_class.base_fields.get(field_name) + if not base_field: + return field_name, "" + with override(_normalized_lang(language_code)): + label = str(base_field.label or field_name) + help_text = str(base_field.help_text or "").strip() + return label, help_text + + +def build_pdf_sections(form_type: str, request_obj, language_code: str | None = None) -> list[dict]: + language_code = _normalized_lang(language_code or getattr(request_obj, "preferred_language", None)) + default_page_map = get_default_page_map(form_type) + section_order = get_section_order(form_type) + section_labels = get_section_labels(form_type) + field_names = list(default_page_map.keys()) + configs = ensure_form_field_configs(form_type, field_names) + section_configs = ensure_form_section_configs(form_type) + locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + + sections: OrderedDict[str, dict] = OrderedDict() + for key in section_order: + section_cfg = section_configs.get(key) + is_visible = True + if key not in locked_sections and section_cfg is not None: + is_visible = bool(section_cfg.is_visible) + if not is_visible: + continue + sections[key] = { + "key": key, + "title": PDF_SECTION_TITLES.get(form_type, {}).get(key, section_labels.get(key, key)), + "fields": [], + } + + ordered_configs = sorted( + configs.values(), + key=lambda cfg: (cfg.sort_order, cfg.field_name), + ) + for cfg in ordered_configs: + field_name = cfg.field_name + section_key = cfg.page_key or default_page_map.get(field_name, "") + if section_key not in sections: + continue + if field_name in PDF_EXCLUDED_FIELDS.get(form_type, set()): + continue + if field_name not in locked_fields and not cfg.is_visible: + continue + + base_label, base_help_text = _field_meta(form_type, field_name, language_code) + label = cfg.translated_label_override(language_code) or base_label + help_text = cfg.translated_help_text_override(language_code) or base_help_text + raw_value = _field_value_from_request(form_type, request_obj, field_name, language_code) + sections[section_key]["fields"].append( + { + "name": field_name, + "label": label, + "help_text": help_text, + "kind": _field_kind(raw_value), + "value": raw_value, + "is_empty": _is_empty_value(raw_value), + "is_locked": field_name in locked_fields, + } + ) + + custom_values = getattr(request_obj, 'custom_field_values', {}) or {} + for cfg in get_custom_field_configs(form_type): + if cfg.section_key not in sections: + continue + raw_value = custom_values.get(cfg.field_key) + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX: + raw_value = _yes_no_text(language_code)[0] if raw_value else '' + sections[cfg.section_key]["fields"].append( + { + "name": f"custom__{cfg.field_key}", + "label": cfg.translated_label(language_code), + "help_text": cfg.translated_help_text(language_code), + "kind": _field_kind(raw_value), + "value": raw_value, + "is_empty": _is_empty_value(raw_value), + "is_locked": False, + } + ) + + not_available = _not_available_text(language_code) + result = [] + for section in sections.values(): + visible_fields = [] + for field in section["fields"]: + if ( + field["name"] in PDF_BOOLEAN_CONTROL_FIELDS.get(form_type, set()) + and _coerce_text(field["value"]).lower() in {"nein", "no"} + ): + continue + display_value = field["value"] if field["kind"] == "list" else (_coerce_text(field["value"]) or not_available) + visible_fields.append( + { + **field, + "display_value": display_value, + } + ) + render_fields = [field for field in visible_fields if not field["is_empty"]] + scalar_fields = [field for field in render_fields if field["kind"] != "list"] + list_fields = [field for field in render_fields if field["kind"] == "list"] + scalar_rows = [scalar_fields[index:index + 2] for index in range(0, len(scalar_fields), 2)] + for row in scalar_rows: + if len(row) < 2: + row.append(None) + result.append( + { + "key": section["key"], + "title": section["title"], + "fields": visible_fields, + "render_fields": render_fields, + "scalar_fields": scalar_fields, + "list_fields": list_fields, + "scalar_rows": scalar_rows, + "has_content": bool(render_fields), + } + ) + return result diff --git a/backend/workflows/request_views.py b/backend/workflows/request_views.py new file mode 100644 index 0000000..55be88b --- /dev/null +++ b/backend/workflows/request_views.py @@ -0,0 +1,650 @@ +from datetime import timedelta +from pathlib import Path + +from django.conf import settings +from django.contrib import messages +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.utils.translation import get_language, gettext as _ + +from .branding import get_company_email_domain +from .form_builder import LOCKED_SECTION_RULES, OFFBOARDING_PAGE_ORDER, ensure_form_section_configs, get_section_definitions +from .forms import OffboardingRequestForm, OnboardingRequestForm +from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .roles import user_has_capability +from .tasks import _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, build_intro_sections_for_request, process_offboarding_request, process_onboarding_request + + +def request_timeline_page_impl(request, kind: str, request_id: int, *, request_target_label_fn, request_custom_field_details_fn, audit_action_label_fn): + if kind == 'onboarding': + obj = get_object_or_404(OnboardingRequest, id=request_id) + elif kind == 'offboarding': + obj = get_object_or_404(OffboardingRequest, id=request_id) + else: + messages.error(request, f'Unbekannter Typ: {kind}') + return redirect('requests_dashboard') + + request_label = request_target_label_fn(obj, kind) + custom_field_details = request_custom_field_details_fn(obj, kind, getattr(request, 'LANGUAGE_CODE', None)) + audit_rows = list( + AdminAuditLog.objects.select_related('actor') + .filter(target_type__in=[kind, 'request']) + .filter(Q(target_id=request_id) | Q(target_label__icontains=(obj.full_name or '').strip())) + .order_by('-created_at', '-id')[:200] + ) + + timeline_rows = [ + { + 'created_at': obj.created_at, + 'kind': 'system', + 'title': _('Anfrage erstellt'), + 'summary': request_label, + 'meta': _('Status: %(status)s') % {'status': obj.get_processing_status_display()}, + 'details': {item['label']: item['value'] for item in custom_field_details}, + } + ] + + contract_start = getattr(obj, 'contract_start', None) + if contract_start: + timeline_rows.append( + { + 'created_at': timezone.make_aware(timezone.datetime.combine(contract_start, timezone.datetime.min.time())), + 'kind': 'milestone', + 'title': _('Vertragsbeginn'), + 'summary': str(contract_start), + 'meta': _('Geplanter Start'), + } + ) + + handover_date = getattr(obj, 'handover_date', None) + if handover_date: + timeline_rows.append( + { + 'created_at': timezone.make_aware(timezone.datetime.combine(handover_date, timezone.datetime.min.time())), + 'kind': 'milestone', + 'title': _('Geräteübergabe / Hardware-Abholung'), + 'summary': str(handover_date), + 'meta': _('Geplanter Hardware-Termin'), + } + ) + + if getattr(obj, 'generated_pdf_path', ''): + timeline_rows.append( + { + 'created_at': obj.created_at, + 'kind': 'document', + 'title': _('PDF verfügbar'), + 'summary': Path(obj.generated_pdf_path).name, + 'meta': '', + 'url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}", + } + ) + + for row in audit_rows: + timeline_rows.append( + { + 'created_at': row.created_at, + 'kind': 'audit', + 'title': audit_action_label_fn(row.action), + 'summary': row.target_label or row.target_type or '-', + 'meta': row.actor_display or '-', + 'details': row.details, + } + ) + + if kind == 'onboarding': + intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first() + if intro_session: + timeline_rows.append( + { + 'created_at': intro_session.updated_at, + 'kind': 'session', + 'title': _('Einweisungssitzung'), + 'summary': intro_session.get_status_display(), + 'meta': intro_session.completed_by_name or '-', + 'url': (f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" if intro_session.exported_pdf_path else ''), + } + ) + welcome_email = ScheduledWelcomeEmail.objects.filter(onboarding_request=obj).first() + if welcome_email: + timeline_rows.append( + { + 'created_at': welcome_email.updated_at, + 'kind': 'email', + 'title': _('Welcome E-Mail'), + 'summary': welcome_email.get_status_display(), + 'meta': welcome_email.recipient_email, + } + ) + + timeline_rows.sort(key=lambda item: item['created_at']) + + return render( + request, + 'workflows/request_timeline.html', + { + 'request_kind': kind, + 'request_obj': obj, + 'request_label': request_label, + 'timeline_rows': timeline_rows, + 'custom_field_details': custom_field_details, + 'contract_start': getattr(obj, 'contract_start', None), + 'handover_date': getattr(obj, 'handover_date', None), + }, + ) + +def requests_dashboard_impl(request, *, audit_fn, request_target_label_fn, request_status_label_fn): + if not user_has_capability(request.user, 'access_requests_dashboard'): + messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) + return redirect('home') + + if request.method == 'POST': + if not user_has_capability(request.user, 'delete_requests'): + messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) + return redirect('requests_dashboard') + + selected = request.POST.getlist('selected_requests') + single_delete = (request.POST.get('single_delete') or '').strip() + if single_delete: + selected = [single_delete] + + if not selected: + messages.warning(request, _('Keine Einträge ausgewählt.')) + return redirect('requests_dashboard') + + deleted_count = 0 + invalid_count = 0 + deleted_labels = [] + for token in selected: + try: + kind, raw_id = token.split(':', 1) + request_id = int(raw_id) + except (ValueError, TypeError): + invalid_count += 1 + continue + + model = None + if kind == 'onboarding': + model = OnboardingRequest + elif kind == 'offboarding': + model = OffboardingRequest + else: + invalid_count += 1 + continue + + obj = model.objects.filter(id=request_id).first() + if not obj: + continue + deleted_labels.append(request_target_label_fn(obj, kind)) + obj.delete() + deleted_count += 1 + + if deleted_count: + audit_fn( + request, + 'requests_deleted', + target_type='request', + target_label='Dashboard bulk/single delete', + details={ + 'deleted_count': deleted_count, + 'invalid_count': invalid_count, + 'selected': selected, + 'request_labels': deleted_labels, + }, + ) + messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count}) + if invalid_count: + messages.warning(request, _('%(count)s Auswahl(en) konnten nicht verarbeitet werden.') % {'count': invalid_count}) + if not deleted_count and not invalid_count: + messages.info(request, _('Keine passenden Einträge gefunden.')) + return redirect('requests_dashboard') + + search_query = request.GET.get('q', '').strip() + type_filter = (request.GET.get('type') or '').strip().lower() + status_filter = (request.GET.get('status') or '').strip().lower() + department_filter = (request.GET.get('department') or '').strip() + date_from = (request.GET.get('date_from') or '').strip() + date_to = (request.GET.get('date_to') or '').strip() + + onboarding_qs = OnboardingRequest.objects.order_by('-created_at') + offboarding_qs = OffboardingRequest.objects.order_by('-created_at') + all_onboarding = OnboardingRequest.objects.all() + all_offboarding = OffboardingRequest.objects.all() + + if search_query: + onboarding_qs = onboarding_qs.filter(Q(full_name__icontains=search_query) | Q(work_email__icontains=search_query)) + offboarding_qs = offboarding_qs.filter(Q(full_name__icontains=search_query) | Q(work_email__icontains=search_query)) + if status_filter in {'submitted', 'processing', 'completed', 'failed'}: + onboarding_qs = onboarding_qs.filter(processing_status=status_filter) + offboarding_qs = offboarding_qs.filter(processing_status=status_filter) + if department_filter: + onboarding_qs = onboarding_qs.filter(department=department_filter) + offboarding_qs = offboarding_qs.filter(department=department_filter) + if date_from: + onboarding_qs = onboarding_qs.filter(created_at__date__gte=date_from) + offboarding_qs = offboarding_qs.filter(created_at__date__gte=date_from) + if date_to: + onboarding_qs = onboarding_qs.filter(created_at__date__lte=date_to) + offboarding_qs = offboarding_qs.filter(created_at__date__lte=date_to) + + if type_filter == 'onboarding': + offboarding_qs = offboarding_qs.none() + elif type_filter == 'offboarding': + onboarding_qs = onboarding_qs.none() + + onboarding_items = onboarding_qs[:50] + offboarding_items = offboarding_qs[:50] + language_code = ( + request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME) + or getattr(request, 'LANGUAGE_CODE', '') + or get_language() + or 'de' + ).split('-')[0].lower() + + rows = [] + for obj in onboarding_items: + intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first() + if intro_session and intro_session.exported_pdf_path: + intro_session.exported_pdf_url = f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" + rows.append( + { + 'id': obj.id, + 'kind': 'Onboarding', + 'kind_slug': 'onboarding', + 'name': obj.full_name, + 'work_email': obj.work_email, + 'created_at': obj.created_at, + 'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None, + 'intro_pdf_url': f"/media/pdfs/{Path(obj.intro_pdf_path).name}" if obj.intro_pdf_path else None, + 'intro_session': intro_session, + 'status': request_status_label_fn(obj.processing_status, language_code), + 'status_key': obj.processing_status, + 'last_error': obj.last_error, + } + ) + for obj in offboarding_items: + rows.append( + { + 'id': obj.id, + 'kind': 'Offboarding', + 'kind_slug': 'offboarding', + 'name': obj.full_name, + 'work_email': obj.work_email, + 'created_at': obj.created_at, + 'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None, + 'intro_pdf_url': None, + 'intro_session': None, + 'status': request_status_label_fn(obj.processing_status, language_code), + 'status_key': obj.processing_status, + 'last_error': obj.last_error, + } + ) + + rows.sort(key=lambda x: x['created_at'], reverse=True) + + today = timezone.localdate() + start_date = today - timedelta(days=13) + onboarding_daily = {} + offboarding_daily = {} + for i in range(14): + day = start_date + timedelta(days=i) + onboarding_daily[day] = 0 + offboarding_daily[day] = 0 + + for dt in onboarding_qs.filter(created_at__date__gte=start_date).values_list('created_at', flat=True): + onboarding_daily[timezone.localtime(dt).date()] += 1 + for dt in offboarding_qs.filter(created_at__date__gte=start_date).values_list('created_at', flat=True): + offboarding_daily[timezone.localtime(dt).date()] += 1 + + chart_points = [] + max_total = 1 + for i in range(14): + day = start_date + timedelta(days=i) + on_count = onboarding_daily[day] + off_count = offboarding_daily[day] + total = on_count + off_count + max_total = max(max_total, total) + chart_points.append( + { + 'label': day.strftime('%d.%m'), + 'onboarding': on_count, + 'offboarding': off_count, + 'total': total, + } + ) + + for point in chart_points: + point['height'] = max(8, int((point['total'] / max_total) * 84)) + + onboarding_total = onboarding_qs.count() + offboarding_total = offboarding_qs.count() + departments = sorted( + { + value.strip() + for value in list(all_onboarding.exclude(department='').values_list('department', flat=True)) + + list(all_offboarding.exclude(department='').values_list('department', flat=True)) + if value and value.strip() + }, + key=str.lower, + ) + status_choices = [ + {'value': 'submitted', 'label': request_status_label_fn('submitted', language_code)}, + {'value': 'processing', 'label': request_status_label_fn('processing', language_code)}, + {'value': 'completed', 'label': request_status_label_fn('completed', language_code)}, + {'value': 'failed', 'label': request_status_label_fn('failed', language_code)}, + ] + has_filters = any([search_query, type_filter, status_filter, department_filter, date_from, date_to]) + column_count = 4 + if user_has_capability(request.user, 'delete_requests'): + column_count += 1 + if user_has_capability(request.user, 'run_intro_session') or user_has_capability(request.user, 'generate_intro_pdfs'): + column_count += 1 + if user_has_capability(request.user, 'access_requests_dashboard'): + column_count += 1 + return render( + request, + 'workflows/requests_dashboard.html', + { + 'rows': rows[:60], + 'search_query': search_query, + 'selected_type': type_filter, + 'selected_status': status_filter, + 'selected_department': department_filter, + 'date_from': date_from, + 'date_to': date_to, + 'departments': departments, + 'status_choices': status_choices, + 'has_filters': has_filters, + 'column_count': column_count, + 'onboarding_total': onboarding_total, + 'offboarding_total': offboarding_total, + 'combined_total': onboarding_total + offboarding_total, + 'chart_points': chart_points, + }, + ) + +def onboarding_create_impl( + request, + *, + build_onboarding_layout_fn, + build_onboarding_sections_fn, + normalized_conditional_rule_payload_fn, + display_user_name_fn, + onboarding_inline_checks, + onboarding_checkbox_lists, +): + config = WorkflowConfig.objects.order_by('id').first() + legal_text = ( + config.legal_text + if config and config.legal_text + else 'Eine Ausrüstungsvereinbarung erlaubt es einem Mitarbeitenden, die Ausrüstung des Unternehmens im Außendienst oder zu Hause zu nutzen und mitzunehmen.' + ) + + if request.method == 'POST': + form = OnboardingRequestForm(request.POST, request.FILES, requester_email=request.user.email) + if form.is_valid(): + obj = form.save() + obj.onboarded_by_name = display_user_name_fn(request.user) + obj.preferred_language = ((getattr(request, 'LANGUAGE_CODE', '') or get_language() or 'de').split('-')[0]) + obj.save(update_fields=['onboarded_by_name', 'preferred_language']) + process_onboarding_request.delay(obj.id) + return redirect(f"/onboarding/new/?saved=1&id={obj.id}") + else: + form = OnboardingRequestForm(requester_email=request.user.email) + + onboarding_blocks = build_onboarding_layout_fn(form) + field_pages = getattr(form, '_field_page_keys', {}) + section_configs = ensure_form_section_configs('onboarding') + visible_section_keys = set() + for section in get_section_definitions('onboarding'): + key = section['key'] + if section.get('is_custom'): + if section.get('is_active', True): + visible_section_keys.add(key) + elif key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible: + visible_section_keys.add(key) + onboarding_sections = build_onboarding_sections_fn(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys) + onboarding_conditional_rules = normalized_conditional_rule_payload_fn('onboarding') + + return render( + request, + 'workflows/onboarding_form.html', + { + 'form': form, + 'onboarding_blocks': onboarding_blocks, + 'onboarding_sections': onboarding_sections, + 'onboarding_inline_checks': onboarding_inline_checks, + 'onboarding_checkbox_lists': onboarding_checkbox_lists, + 'onboarding_conditional_rules': onboarding_conditional_rules, + 'legal_text': legal_text, + 'saved': request.GET.get('saved') == '1', + 'saved_request_id': request.GET.get('id', ''), + 'portal_email_domain': get_company_email_domain(), + }, + ) + +def onboarding_success_impl(request, request_id: int): + obj = get_object_or_404(OnboardingRequest, id=request_id) + pdf_url = None + if obj.generated_pdf_path: + pdf_url = f"/media/pdfs/{Path(obj.generated_pdf_path).name}" + return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) + +def generate_onboarding_intro_pdf_impl(request, request_id: int, *, audit_fn): + obj = get_object_or_404(OnboardingRequest, id=request_id) + pdf_path = _generate_onboarding_intro_pdf(obj, language_code=get_language()) + obj.intro_pdf_path = str(pdf_path) + obj.save(update_fields=['intro_pdf_path']) + audit_fn(request, 'intro_pdf_generated', target_type='onboarding', target_id=obj.id, target_label=obj.full_name) + messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.')) + return redirect('requests_dashboard') + +def generate_onboarding_intro_session_pdf_impl(request, request_id: int, *, audit_fn, display_user_name_fn): + onboarding = get_object_or_404(OnboardingRequest, id=request_id) + session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) + pdf_path = _generate_onboarding_intro_session_pdf( + session, + admin_signature_name=display_user_name_fn(request.user), + language_code=get_language(), + ) + session.exported_pdf_path = str(pdf_path) + session.save(update_fields=['exported_pdf_path']) + audit_fn(request, 'intro_live_pdf_generated', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name) + messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.')) + return redirect('onboarding_intro_session_page', request_id=request_id) + +def onboarding_intro_session_page_impl(request, request_id: int, *, audit_fn, display_user_name_fn): + onboarding = get_object_or_404(OnboardingRequest, id=request_id) + session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) + sections = build_intro_sections_for_request(onboarding, language_code=get_language()) + + if request.method == 'POST': + checked_ids = set(request.POST.getlist('checked_items')) + checklist_state = {} + for section in sections: + for item in section['items']: + checklist_state[item['id']] = item['id'] in checked_ids + + action = (request.POST.get('session_action') or 'save').strip() + session.checklist_state = checklist_state + session.notes = (request.POST.get('notes') or '').strip() + if action == 'reset': + session.checklist_state = {} + session.notes = '' + session.status = 'draft' + session.completed_at = None + session.completed_by_name = '' + session.exported_pdf_path = '' + session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path']) + audit_fn(request, 'intro_session_reset', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name) + messages.success(request, _('Einweisung wurde zurückgesetzt.')) + return redirect('onboarding_intro_session_page', request_id=request_id) + if action == 'complete': + session.status = 'completed' + session.completed_at = timezone.now() + session.completed_by_name = display_user_name_fn(request.user) + audit_fn( + request, + 'intro_session_completed', + target_type='onboarding', + target_id=onboarding.id, + target_label=onboarding.full_name, + details={'checked_count': len([value for value in checklist_state.values() if value])}, + ) + messages.success(request, _('Einweisung wurde als abgeschlossen gespeichert.')) + else: + session.status = 'draft' + session.completed_at = None + session.completed_by_name = '' + audit_fn( + request, + 'intro_session_saved', + target_type='onboarding', + target_id=onboarding.id, + target_label=onboarding.full_name, + details={'checked_count': len([value for value in checklist_state.values() if value])}, + ) + messages.success(request, _('Einweisung wurde als Entwurf gespeichert.')) + session.save() + return redirect('onboarding_intro_session_page', request_id=request_id) + + checked_map = session.checklist_state or {} + checked_count = 0 + total_count = 0 + for section in sections: + for item in section['items']: + item['checked'] = bool(checked_map.get(item['id'])) + total_count += 1 + if item['checked']: + checked_count += 1 + + salutation = (onboarding.get_gender_display() or '').strip() + display_name = f"{salutation} {onboarding.full_name}".strip() if salutation else onboarding.full_name + progress_percent = int((checked_count / total_count) * 100) if total_count else 0 + + return render( + request, + 'workflows/onboarding_intro_session.html', + { + 'onboarding': onboarding, + 'session': session, + 'display_name': display_name, + 'sections': sections, + 'checked_count': checked_count, + 'total_count': total_count, + 'progress_percent': progress_percent, + 'session_pdf_url': f"/media/pdfs/{Path(session.exported_pdf_path).name}" if session.exported_pdf_path else None, + }, + ) + +def offboarding_create_impl(request, *, build_offboarding_sections_fn, display_user_name_fn): + profile_id = request.GET.get('profile') + search_query = request.GET.get('q', '').strip() + selected_profile = None + + if profile_id: + selected_profile = EmployeeProfile.objects.filter(id=profile_id).first() + + search_results = [] + if search_query: + search_results = list( + EmployeeProfile.objects.filter(full_name__icontains=search_query)[:10] + ) + list( + EmployeeProfile.objects.filter(work_email__icontains=search_query)[:10] + ) + # preserve order while removing duplicates + seen = set() + unique = [] + for r in search_results: + if r.id not in seen: + unique.append(r) + seen.add(r.id) + search_results = unique[:10] + + if request.method == 'POST': + form = OffboardingRequestForm(request.POST, prefill_profile=selected_profile) + if form.is_valid(): + obj = form.save(commit=False) + if selected_profile: + obj.employee_profile = selected_profile + requester_email = (request.user.email or '').strip().lower() + company_suffix = f"@{get_company_email_domain()}" + if requester_email and requester_email.endswith(company_suffix): + obj.requested_by_email = requester_email + else: + obj.requested_by_email = settings.DEFAULT_FROM_EMAIL + obj.requested_by_name = display_user_name_fn(request.user) + obj.preferred_language = ((getattr(request, 'LANGUAGE_CODE', '') or get_language() or 'de').split('-')[0]) + obj.save() + process_offboarding_request.delay(obj.id) + return redirect(f"/offboarding/new/?saved=1&id={obj.id}") + else: + form = OffboardingRequestForm(prefill_profile=selected_profile, initial={'search_query': search_query}) + + field_pages = getattr(form, '_field_page_keys', {}) + section_configs = ensure_form_section_configs('offboarding') + visible_section_keys = { + key for key in OFFBOARDING_PAGE_ORDER + if key in LOCKED_SECTION_RULES.get('offboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible + } + offboarding_sections = build_offboarding_sections_fn(form, visible_section_keys=visible_section_keys) + + return render( + request, + 'workflows/offboarding_form.html', + { + 'form': form, + 'search_results': search_results, + 'selected_profile': selected_profile, + 'search_query': search_query, + 'saved': request.GET.get('saved') == '1', + 'saved_request_id': request.GET.get('id', ''), + 'portal_email_domain': get_company_email_domain(), + 'offboarding_sections': offboarding_sections, + }, + ) + +def offboarding_success_impl(request, request_id: int): + obj = get_object_or_404(OffboardingRequest, id=request_id) + pdf_url = None + if obj.generated_pdf_path: + pdf_url = f"/media/pdfs/{Path(obj.generated_pdf_path).name}" + return render(request, 'workflows/offboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) + +def delete_request_from_dashboard_impl(request, kind: str, request_id: int, *, audit_fn, request_target_label_fn): + if kind == 'onboarding': + obj = get_object_or_404(OnboardingRequest, id=request_id) + elif kind == 'offboarding': + obj = get_object_or_404(OffboardingRequest, id=request_id) + else: + messages.error(request, f'Unbekannter Typ: {kind}') + return redirect('requests_dashboard') + + target_label = request_target_label_fn(obj, kind) + obj.delete() + audit_fn(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=target_label) + messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.') + return redirect('requests_dashboard') + +def retry_request_from_dashboard_impl(request, kind: str, request_id: int, *, audit_fn, request_target_label_fn): + if kind == 'onboarding': + obj = get_object_or_404(OnboardingRequest, id=request_id) + obj.processing_status = 'submitted' + obj.last_error = '' + obj.save(update_fields=['processing_status', 'last_error']) + process_onboarding_request.delay(obj.id) + audit_fn(request, 'request_retried', target_type='onboarding', target_id=obj.id, target_label=request_target_label_fn(obj, 'onboarding')) + elif kind == 'offboarding': + obj = get_object_or_404(OffboardingRequest, id=request_id) + obj.processing_status = 'submitted' + obj.last_error = '' + obj.save(update_fields=['processing_status', 'last_error']) + process_offboarding_request.delay(obj.id) + audit_fn(request, 'request_retried', target_type='offboarding', target_id=obj.id, target_label=request_target_label_fn(obj, 'offboarding')) + else: + messages.error(request, f'Unbekannter Typ: {kind}') + return redirect('requests_dashboard') + + messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde erneut angestoßen.') + return redirect('requests_dashboard') diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py index f487490..9686f3d 100644 --- a/backend/workflows/roles.py +++ b/backend/workflows/roles.py @@ -4,12 +4,18 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ +# Product-level and company-level roles intentionally coexist here. +# Workdock uses capability checks as the long-term contract so app-registry +# visibility can stay a presentation concern instead of an authorization layer. + +ROLE_PLATFORM_OWNER = 'platform_owner' ROLE_SUPER_ADMIN = 'super_admin' ROLE_ADMIN = 'admin' ROLE_IT_STAFF = 'it_staff' ROLE_STAFF = 'staff' ROLE_GROUP_NAMES = { + ROLE_PLATFORM_OWNER: 'Platform Owner', ROLE_SUPER_ADMIN: 'Super Admin', ROLE_ADMIN: 'Admin', ROLE_IT_STAFF: 'IT Staff', @@ -17,6 +23,7 @@ ROLE_GROUP_NAMES = { } ROLE_LABELS = { + ROLE_PLATFORM_OWNER: _('Platform Owner'), ROLE_SUPER_ADMIN: _('Super Admin'), ROLE_ADMIN: _('Admin'), ROLE_IT_STAFF: _('IT Staff'), @@ -24,19 +31,26 @@ ROLE_LABELS = { } CAPABILITIES = { - 'manage_users': {ROLE_SUPER_ADMIN}, - 'access_requests_dashboard': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'run_intro_session': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'generate_intro_pdfs': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'retry_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'delete_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_integrations': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_welcome_emails': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_builders': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'view_audit_log': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_backups': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'view_docs': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'access_django_admin_link': {ROLE_SUPER_ADMIN}, + # Platform-only capabilities stay above any customer-company admin role. + 'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN}, + 'manage_product_branding': {ROLE_PLATFORM_OWNER}, + 'manage_company_config': {ROLE_PLATFORM_OWNER}, + 'manage_trial_lifecycle': {ROLE_PLATFORM_OWNER}, + 'manage_app_registry': {ROLE_PLATFORM_OWNER}, + 'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF}, + 'view_request_timeline': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'run_intro_session': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'generate_intro_pdfs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'retry_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'delete_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_integrations': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_welcome_emails': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_builders': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'view_job_monitor': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'view_audit_log': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_backups': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'view_docs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'access_django_admin_link': {ROLE_PLATFORM_OWNER}, } @@ -54,16 +68,17 @@ def assign_user_role(user, role_key: str) -> None: user.groups.remove(*role_groups) user.groups.add(Group.objects.get(name=ROLE_GROUP_NAMES[role_key])) + is_product_owner = role_key == ROLE_PLATFORM_OWNER is_super_admin = role_key == ROLE_SUPER_ADMIN - user.is_staff = is_super_admin - user.is_superuser = is_super_admin + user.is_staff = is_product_owner or is_super_admin + user.is_superuser = is_product_owner user.save(update_fields=['is_staff', 'is_superuser']) def ensure_bootstrap_role_assignments() -> None: user_model = get_user_model() bootstrap_roles = { - 'admin_test': ROLE_SUPER_ADMIN, + 'admin_test': ROLE_PLATFORM_OWNER, 'user_test': ROLE_STAFF, } role_group_names = set(ROLE_GROUP_NAMES.values()) @@ -72,24 +87,32 @@ def ensure_bootstrap_role_assignments() -> None: user = user_model.objects.get(username=username) except user_model.DoesNotExist: continue + if role_key == ROLE_PLATFORM_OWNER and not any( + get_user_role_key(existing_user) == ROLE_PLATFORM_OWNER + for existing_user in user_model.objects.all() + ): + assign_user_role(user, ROLE_PLATFORM_OWNER) + continue if user.groups.filter(name__in=role_group_names).exists(): continue assign_user_role(user, role_key) def get_user_role_key(user) -> str: + # Keep a conservative fallback for legacy staff users until a later + # dedicated cleanup phase removes the remaining historical assumptions. if not getattr(user, 'is_authenticated', False): return ROLE_STAFF if getattr(user, 'is_superuser', False): - return ROLE_SUPER_ADMIN + return ROLE_PLATFORM_OWNER group_names = set(user.groups.values_list('name', flat=True)) - for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF): + for role_key in (ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF): if ROLE_GROUP_NAMES[role_key] in group_names: return role_key if getattr(user, 'is_staff', False): - return ROLE_ADMIN + return ROLE_SUPER_ADMIN return ROLE_STAFF @@ -108,11 +131,23 @@ def user_has_capability(user, capability: str) -> bool: def template_role_context(user) -> dict[str, object]: role_key = get_user_role_key(user) + avatar_url = '' + if getattr(user, 'is_authenticated', False): + profile = getattr(user, 'profile', None) + avatar = getattr(profile, 'avatar_image', None) + if avatar: + avatar_url = getattr(avatar, 'url', '') or '' return { 'role_key': role_key, 'role_label': str(ROLE_LABELS[role_key]), + 'user_avatar_url': avatar_url, + 'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'), + 'can_manage_company_config': user_has_capability(user, 'manage_company_config'), + 'can_manage_trial_lifecycle': user_has_capability(user, 'manage_trial_lifecycle'), + 'can_manage_app_registry': user_has_capability(user, 'manage_app_registry'), 'can_manage_users': user_has_capability(user, 'manage_users'), 'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'), + 'can_view_request_timeline': user_has_capability(user, 'view_request_timeline'), 'can_run_intro_session': user_has_capability(user, 'run_intro_session'), 'can_generate_intro_pdfs': user_has_capability(user, 'generate_intro_pdfs'), 'can_retry_requests': user_has_capability(user, 'retry_requests'), @@ -120,6 +155,7 @@ def template_role_context(user) -> dict[str, object]: 'can_manage_integrations': user_has_capability(user, 'manage_integrations'), 'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'), 'can_manage_builders': user_has_capability(user, 'manage_builders'), + 'can_view_job_monitor': user_has_capability(user, 'view_job_monitor'), 'can_view_audit_log': user_has_capability(user, 'view_audit_log'), 'can_manage_backups': user_has_capability(user, 'manage_backups'), 'can_view_docs': user_has_capability(user, 'view_docs'), diff --git a/backend/workflows/services.py b/backend/workflows/services.py index dfd8f8a..559e8de 100644 --- a/backend/workflows/services.py +++ b/backend/workflows/services.py @@ -5,6 +5,7 @@ import time import requests from django.conf import settings +from .branding import should_restrict_trial_integrations from .models import WorkflowConfig logger = logging.getLogger(__name__) @@ -15,6 +16,8 @@ def _active_workflow_config() -> WorkflowConfig | None: def is_nextcloud_enabled() -> bool: + if should_restrict_trial_integrations(): + return False config = _active_workflow_config() if config and config.nextcloud_enabled_override is not None: return bool(config.nextcloud_enabled_override) @@ -22,6 +25,8 @@ def is_nextcloud_enabled() -> bool: def is_email_test_mode() -> bool: + if should_restrict_trial_integrations(): + return True config = _active_workflow_config() if config and config.email_test_mode_override is not None: return bool(config.email_test_mode_override) @@ -73,12 +78,16 @@ def _ensure_nextcloud_directory(base_url: str, directory: str, auth: tuple[str, current_parts: list[str] = [] for part in [p for p in directory.split('/') if p]: current_parts.append(part) - response = requests.request( - 'MKCOL', - f"{base_url}/{'/'.join(current_parts)}", - auth=auth, - timeout=timeout, - ) + try: + response = requests.request( + 'MKCOL', + f"{base_url}/{'/'.join(current_parts)}", + auth=auth, + timeout=timeout, + ) + except requests.RequestException as exc: + logger.warning('Nextcloud directory ensure error for %s: %s', '/'.join(current_parts), exc) + return False if response.status_code in (201, 301, 405): continue logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts)) diff --git a/backend/workflows/signals.py b/backend/workflows/signals.py index d761dbb..ad2d5bc 100644 --- a/backend/workflows/signals.py +++ b/backend/workflows/signals.py @@ -1,6 +1,8 @@ -from django.db.models.signals import post_migrate +from django.conf import settings +from django.db.models.signals import post_migrate, post_save from django.dispatch import receiver +from .models import UserProfile from .roles import ensure_bootstrap_role_assignments, ensure_role_groups @@ -10,3 +12,9 @@ def workflows_post_migrate(sender, **kwargs): return ensure_role_groups() ensure_bootstrap_role_assignments() + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def ensure_user_profile(sender, instance, created, **kwargs): + if created: + UserProfile.objects.get_or_create(user=instance) diff --git a/backend/workflows/static/workflows/css/account.css b/backend/workflows/static/workflows/css/account.css new file mode 100644 index 0000000..dd7fa18 --- /dev/null +++ b/backend/workflows/static/workflows/css/account.css @@ -0,0 +1,812 @@ +.account-shell-body { + padding: 24px; + background: + radial-gradient(90% 120% at 10% 0%, rgba(31, 79, 214, 0.06), rgba(31, 79, 214, 0)), + linear-gradient(180deg, rgba(255,255,255,0.72), rgba(248,251,255,0.48)); +} + +.account-page { + width: min(1120px, 100%); + margin: 0 auto; + display: grid; + gap: 18px; +} + +.account-hero { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + padding: 22px 24px; + border: 1px solid #d9e3f0; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.1), transparent 24%), + linear-gradient(135deg, rgba(255,255,255,0.96), rgba(244,248,255,0.9)); + box-shadow: 0 14px 34px rgba(28, 45, 79, 0.08); + animation: accountFadeUp 320ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-kicker { + display: inline-flex; + margin-bottom: 10px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(0, 0, 120, 0.08); + color: #203b74; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.account-hero h1 { + margin: 0 0 8px; + font-size: 32px; + color: #132238; +} + +.account-hero p { + margin: 0; + max-width: 620px; + color: #617389; + line-height: 1.55; +} + +.account-hero-submeta { + margin-top: 14px !important; + color: #6a7a8f; + font-size: 13px; +} + +.account-hero-submeta strong { + color: #132238; +} + +.account-hero-badges { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.account-chip { + display: inline-flex; + align-items: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + background: #0f2e8a; + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.account-chip-muted { + background: #edf3ff; + color: #35507e; + border: 1px solid #d5deee; +} + +.account-layout { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + gap: 22px; + align-items: start; +} + +.account-profile-card, +.account-panel { + border: 1px solid #d9e3f0; + border-radius: 22px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 14px 32px rgba(28, 45, 79, 0.09); + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-profile-card { + padding: 20px; + position: sticky; + top: 24px; + animation: accountFadeUp 360ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-profile-card:hover, +.account-panel:hover { + transform: translateY(-1px); + box-shadow: 0 18px 34px rgba(28, 45, 79, 0.10); +} + +.account-avatar-form { + display: grid; + gap: 10px; +} + +.account-avatar-wrap { + position: relative; + display: inline-flex; + width: fit-content; + cursor: pointer; +} + +.account-avatar { + width: 84px; + height: 84px; + border-radius: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #000078, #2943b6); + color: #fff; + font-size: 28px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.22); +} + +.account-avatar-image { + width: 84px; + height: 84px; + object-fit: cover; + display: block; + border-radius: 24px; + border: 1px solid #dce5f2; + box-shadow: 0 12px 24px rgba(18, 34, 56, 0.12); +} + +.account-avatar-edit { + position: absolute; + right: -4px; + bottom: -4px; + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: #132d8a; + color: #fff; + font-size: 14px; + font-weight: 800; + border: 2px solid #fff; + box-shadow: 0 8px 18px rgba(18, 34, 56, 0.16); +} + +.account-avatar-hint { + margin: 0; + color: #617389; + font-size: 12px; + line-height: 1.45; +} + +.account-avatar-error { + color: #ab1e1e; + font-size: 12px; + line-height: 1.4; +} + +.account-profile-copy { + margin-top: 18px; +} + +.account-profile-copy h2 { + margin: 0 0 6px; + font-size: 24px; + color: #132238; +} + +.account-profile-copy p { + margin: 0; + color: #617389; + line-height: 1.45; + word-break: break-word; +} + +.account-nav { + display: grid; + gap: 10px; + margin-top: 22px; +} + +.account-nav-item { + width: 100%; + padding: 14px 16px; + text-align: left; + border-radius: 16px; + border: 1px solid #dce6f2; + background: #f7faff; + color: #17345e; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-nav-item:hover { + border-color: #c5d5ea; + background: #f3f8ff; +} + +.account-nav-item.is-active { + border-color: rgba(0, 0, 120, 0.18); + background: linear-gradient(180deg, rgba(238,243,255,0.95), rgba(231,239,255,0.92)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.7); + transform: translateX(3px); +} + +.account-main { + display: grid; + gap: 22px; +} + +.account-notification-pref-grid { + display: grid; + gap: 12px; +} + +.account-notification-group + .account-notification-group { + margin-top: 18px; +} + +.account-notification-group h3 { + margin: 0 0 10px; + color: #17345e; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.account-notification-pref-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid #dce6f2; + border-radius: 16px; + background: #f7faff; +} + +.account-notification-pref-item span { + color: #17345e; + font-weight: 700; +} + +.account-notification-pref-item strong { + color: #617389; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.account-notification-pref-grid-edit { + gap: 14px; +} + +.account-notification-pref-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 16px; + border: 1px solid #dce6f2; + border-radius: 16px; + background: #f7faff; +} + +.account-notification-pref-copy { + display: grid; + gap: 4px; +} + +.account-notification-pref-copy strong { + color: #17345e; + font-size: 14px; +} + +.account-notification-pref-copy small { + color: #617389; + font-size: 12px; + line-height: 1.45; +} + +.account-toggle-control { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} + +.account-toggle-control input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.account-toggle-slider { + position: relative; + width: 50px; + height: 30px; + border-radius: 999px; + background: #d7e1ee; + transition: background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-toggle-slider::after { + content: ""; + position: absolute; + top: 4px; + left: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 4px 10px rgba(18, 34, 56, 0.12); + transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-toggle-control input:checked + .account-toggle-slider { + background: #0f8f57; +} + +.account-toggle-control input:checked + .account-toggle-slider::after { + transform: translateX(20px); +} + +.account-panel { + padding: 24px; + animation: accountFadeUp 380ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-panel.is-entering { + animation: accountPanelSwap 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-panel-head { + margin-bottom: 18px; + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; +} + +.account-panel-head h2 { + margin: 0 0 6px; + font-size: 20px; + color: #132238; +} + +.account-panel-head p { + margin: 0; + color: #617389; + line-height: 1.5; +} + +.account-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.account-detail-wide { + grid-column: 1 / -1; +} + +.account-detail { + padding: 14px 16px; + border-radius: 16px; + background: #f9fbff; + border: 1px solid #dbe5f2; +} + +.account-detail span { + display: block; + margin-bottom: 6px; + color: #6b7a90; + font-size: 12px; +} + +.account-detail strong { + color: #132238; + font-size: 14px; + line-height: 1.45; + word-break: break-word; +} + +.account-security-overview { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.account-security-item { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.06), transparent 26%), + linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.9)); +} + +.account-security-item-active { + border-color: rgba(34, 139, 86, 0.26); + background: + radial-gradient(circle at top right, rgba(34, 139, 86, 0.10), transparent 26%), + linear-gradient(180deg, rgba(242,255,247,0.96), rgba(236,251,242,0.92)); +} + +.account-security-item span { + display: block; + margin-bottom: 6px; + color: #6b7a90; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.account-security-item strong { + display: block; + color: #132238; + font-size: 20px; + line-height: 1.2; +} + +.account-security-item p { + margin: 8px 0 0; + color: #617389; + font-size: 13px; + line-height: 1.5; +} + +.account-totp-card { + margin-bottom: 18px; + padding: 16px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%), + #f9fbff; +} + +.account-totp-card h3 { + margin: 0 0 6px; + color: #132238; + font-size: 18px; +} + +.account-secret { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.55; + word-break: break-all; +} + +.account-totp-form { + margin-top: 12px; +} + +.account-totp-action-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.account-totp-action-copy strong { + display: block; + color: #132238; + font-size: 15px; +} + +.account-totp-action-copy p { + margin: 6px 0 0; + color: #617389; + font-size: 13px; + line-height: 1.45; +} + +.account-totp-toggle-form { + margin-top: 12px; +} + +.account-totp-status-row { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: center; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: rgba(255,255,255,0.88); +} + +.account-totp-status-copy strong { + display: block; + color: #132238; + font-size: 16px; +} + +.account-totp-status-copy p { + margin: 6px 0 0; + color: #55718f; + font-size: 13px; + line-height: 1.45; +} + +.account-qr-card, +.account-recovery-card { + margin-top: 14px; + padding: 16px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: rgba(255, 255, 255, 0.82); +} + +.account-qr-card svg { + display: block; + width: min(220px, 100%); + height: auto; + margin: 0 auto; +} + +.account-secret-panel { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #dbe5f2; +} + +.account-secret-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.account-secret-head span { + display: block; + margin-bottom: 4px; + color: #6b7a90; + font-size: 12px; +} + +.account-secret-head strong { + color: #132238; + font-size: 14px; +} + +.account-secret-toggle { + min-width: 48px; + padding-left: 0; + padding-right: 0; +} + +.account-secret-body { + margin-top: 12px; +} + +.account-secret-body.is-hidden { + display: none; +} + +.account-recovery-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.account-recovery-code { + padding: 12px 14px; + border-radius: 14px; + border: 1px dashed #c8d7ea; + background: #f7faff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + color: #17345e; +} + +.account-inline-view.is-hidden, +.account-inline-form.is-hidden { + display: none; +} + +.account-inline-edit-trigger { + min-width: 112px; +} + +.account-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.account-form-field { + display: grid; + gap: 6px; +} + +.account-form-field-wide { + grid-column: 1 / -1; +} + +.account-form-field.is-hidden { + display: none; +} + +.account-totp-form.is-hidden { + display: none; +} + +.account-form-grid.is-hidden, +.account-inline-actions.is-hidden { + display: none; +} + +.account-form-field label { + color: #132238; + font-size: 13px; + font-weight: 700; +} + +.account-form-field input, +.account-form-field textarea { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 12px; + min-height: 44px; + font: inherit; + background: #fff; + transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-form-field textarea { + min-height: 104px; + resize: vertical; +} + +.account-form-field input:focus, +.account-form-field textarea:focus { + outline: none; + border-color: rgba(0, 0, 120, 0.3); + box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08); +} + +.account-form-field.has-error input, +.account-form-field.has-error textarea { + border-color: #e3a3a3; + background: #fffafa; + box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.06); +} + +.account-form-error { + color: #ab1e1e; + font-size: 12px; + line-height: 1.4; +} + +.account-inline-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.account-recovery-toggle { + width: auto; +} + +@keyframes accountFadeUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes accountPanelSwap { + from { + opacity: 0; + transform: translateY(10px) scale(0.995); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 980px) { + .account-layout { + grid-template-columns: 1fr; + } + + .account-profile-card { + position: static; + } +} + +@media (max-width: 760px) { + body { + padding: 14px; + } + + .account-shell-body { + padding: 16px; + } + + .account-hero, + .account-panel, + .account-profile-card { + padding: 18px; + border-radius: 18px; + } + + .account-hero { + flex-direction: column; + } + + .account-hero h1 { + font-size: 26px; + } + + .account-detail-grid, + .account-security-overview, + .account-form-grid, + .account-recovery-grid { + grid-template-columns: 1fr; + } + + .account-inline-actions { + flex-direction: column; + } + + .account-panel-head { + flex-direction: column; + } + + .account-totp-status-card { + flex-direction: column; + } + + .account-totp-status-row { + flex-direction: column; + align-items: stretch; + } + + .account-totp-action-row { + flex-direction: column; + align-items: stretch; + } +} + +@media (prefers-reduced-motion: reduce) { + .account-hero, + .account-profile-card, + .account-panel, + .account-panel.is-entering { + animation: none; + } + + .account-profile-card, + .account-panel, + .account-nav-item { + transition: none; + } + + .account-profile-card:hover, + .account-panel:hover, + .account-nav-item.is-active { + transform: none; + } +} diff --git a/backend/workflows/static/workflows/css/admin_tools.css b/backend/workflows/static/workflows/css/admin_tools.css index 014198d..ec0389a 100644 --- a/backend/workflows/static/workflows/css/admin_tools.css +++ b/backend/workflows/static/workflows/css/admin_tools.css @@ -1,13 +1,122 @@ -body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; } [hidden] { display: none !important; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } -.shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; } -h1 { margin: 12px 0 6px; color: #000078; } -.sub { margin: 0 0 12px; color: #54657c; } .app-messages { margin-bottom: 12px; } -.card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; margin-bottom: 14px; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } +.card { padding: 14px; margin-bottom: 14px; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 10px; } +.branding-sections { display: grid; gap: 14px; } +.branding-block { border: 1px solid #dce5f1; border-radius: 16px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.94)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); } +.branding-block-head { margin-bottom: 12px; } +.branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; } +.branding-block-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; } +.branding-inline-head, .company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; } +.branding-inline-trigger, .company-inline-trigger { min-width: 112px; } +.branding-inline-view.is-hidden, .branding-inline-form.is-hidden, .company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; } +.branding-inline-value, .company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; } +.branding-inline-actions, .company-inline-actions { display: flex; gap: 10px; margin-top: 14px; } +.branding-inline-error, .company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; } +.company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; } +.company-inline-trigger { min-width: 112px; } +.company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; } +.company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; } +.company-inline-actions { display: flex; gap: 10px; margin-top: 14px; } +.company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; } +.field.has-error input, .field.has-error select, .field.has-error textarea { border-color: #e3a3a3; background: #fffafa; box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.06); } +.lang-pairs { align-items: start; } +.lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; } +.lang-block h3 { margin: 0 0 10px; color: #223b63; font-size: 15px; } +.branding-preview { max-width: 460px; margin-left: auto; border: 1px solid #dce5f1; border-radius: 18px; background: + radial-gradient(circle at top right, rgba(59,112,234,0.10), transparent 30%), + linear-gradient(180deg, #f9fbff, #eef4ff); + padding: 10px; } +.branding-preview-shell { border: 1px solid rgba(210, 221, 236, 0.95); border-radius: 18px; overflow: hidden; background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(247,250,255,0.96)); box-shadow: 0 8px 22px rgba(16, 32, 57, 0.05); } +.branding-preview-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(217, 227, 238, 0.9); } +.branding-preview-logo { width: 64px; max-width: 100%; height: auto; display: block; object-fit: contain; filter: saturate(1.02); } +.branding-preview-copy { display: grid; gap: 2px; min-width: 0; } +.branding-preview-copy strong { color: #18335b; font-size: 13px; line-height: 1.2; } +.branding-preview-copy span { color: #61738d; font-size: 12px; line-height: 1.3; } +.branding-preview-band { display: flex; gap: 8px; padding: 10px 12px; } +.branding-preview-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 104px; padding: 5px 10px; border-radius: 999px; color: #fff; font-size: 10px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: #000078; box-shadow: inset 0 1px 0 rgba(255,255,255,0.16); } +.branding-preview-chip-secondary { background: #c0002b; } +.branding-preview-footer { padding: 0 12px 12px; } +.branding-preview-footer-main { color: #20385f; font-size: 11px; font-weight: 700; line-height: 1.35; } +.branding-preview-footer-legal { margin-top: 4px; color: #6c7f99; font-size: 10px; line-height: 1.4; } .backup-grid { grid-template-columns: minmax(280px, 720px); } +.trial-overview { padding-bottom: 12px; } +.trial-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; } +.trial-summary-card { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.86); padding: 12px; display: grid; gap: 6px; } +.trial-summary-label { color: #60738d; font-size: 12px; font-weight: 700; } +.trial-summary-value { color: #17345e; font-size: 16px; line-height: 1.2; } +.trial-summary-value.is-active { color: #166534; } +.trial-summary-value.is-warn { color: #8a5a00; } +.trial-summary-value.is-inactive { color: #7a1f1f; } +.trial-summary-value.is-expired { color: #9f1d1d; } +.trial-expired-shell { padding: 28px 24px 36px; } +.trial-expired-card { + max-width: 900px; + margin: 0 auto; + border: 1px solid #e7d1d1; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(201, 68, 68, 0.12), transparent 24%), + linear-gradient(180deg, rgba(255,255,255,0.99), rgba(255,247,247,0.96)); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.94), + 0 18px 36px rgba(16, 32, 57, 0.08); + padding: 24px; +} +.trial-expired-card h1 { margin: 10px 0 8px; color: #7f1d1d; } +.trial-expired-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 11px; + border-radius: 999px; + border: 1px solid rgba(159, 29, 29, 0.14); + background: rgba(255,255,255,0.75); + color: #9f1d1d; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.trial-expired-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} +.trial-expired-panel { + border: 1px solid #ead9d9; + border-radius: 16px; + background: rgba(255,255,255,0.82); + padding: 14px; + display: grid; + gap: 6px; +} +.trial-expired-label { + color: #8e5a5a; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.trial-expired-panel strong { + color: #6f1d1d; + font-size: 15px; + line-height: 1.25; +} +.trial-expired-panel p { + margin: 0; + color: #805c5c; + font-size: 13px; + line-height: 1.5; +} +.trial-expired-contact { + margin-top: 14px; + color: #7a5252; + font-size: 13px; + font-weight: 700; +} label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; } input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } @@ -25,9 +134,6 @@ textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, .hint { margin-top: 6px; color: #64748b; font-size: 12px; } .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } .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; transition: background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } -.switch .tab.active { background: #000078; color: #fff; border-color: #000078; } -.switch .tab:hover { transform: translateY(-1px); box-shadow: 0 8px 16px rgba(16, 32, 57, 0.06); } .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; } @@ -35,6 +141,40 @@ textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, 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; color: #334155; } +.app-registry-wrap textarea { min-height: 86px; } +.app-registry-table input[type="text"], +.app-registry-table input[type="number"], +.app-registry-table select, +.app-registry-table textarea { min-width: 160px; } +.app-registry-table td { background: rgba(255,255,255,0.9); } +.app-registry-cards { display: grid; gap: 14px; } +.app-registry-filters { display: grid; grid-template-columns: minmax(260px, 1.5fr) repeat(2, minmax(180px, 0.7fr)); gap: 12px; margin-bottom: 14px; } +.app-registry-card { border: 1px solid #d9e4f1; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } +.app-registry-card:hover { transform: translateY(-1px); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.06); border-color: #c9d8eb; } +.app-registry-card.is-disabled { opacity: 0.84; } +.app-registry-card.is-dragging { opacity: 0.55; transform: rotate(0.4deg); box-shadow: 0 18px 28px rgba(16, 32, 57, 0.14); } +.app-registry-card[hidden] { display: none !important; } +.app-registry-card-head { display: flex; justify-content: space-between; align-items: start; gap: 14px; margin-bottom: 14px; } +.app-registry-card-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } +.app-registry-card-title-row h2 { margin: 0; color: #17345e; font-size: 19px; } +.app-registry-card-copy { margin: 8px 0 0; color: #60738d; max-width: 760px; } +.app-registry-summary { display: grid; grid-template-columns: 28px minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; } +.app-registry-summary::-webkit-details-marker { display: none; } +.app-registry-summary::marker { display: none; } +.app-registry-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 28px; min-height: 42px; border-radius: 10px; border: 1px dashed #cbd7e6; background: #f8fbff; color: #5f6f85; font-size: 15px; letter-spacing: 0.04em; cursor: grab; user-select: none; } +.app-registry-card.is-dragging .app-registry-drag-handle { cursor: grabbing; } +.app-registry-card.drag-disabled .app-registry-drag-handle { opacity: 0.4; cursor: not-allowed; border-style: solid; } +.app-registry-summary-main { min-width: 0; } +.app-registry-summary-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; align-items: center; } +.app-registry-card-grid { display: grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap: 12px; align-items: start; } +.app-registry-card .app-registry-card-grid { margin-top: 14px; padding-top: 14px; border-top: 1px solid #dce6f2; } +.app-registry-panel { border: 1px solid #dce6f2; border-radius: 14px; background: rgba(255,255,255,0.86); padding: 12px; } +.app-registry-panel h3 { margin: 0 0 10px; color: #213a61; font-size: 15px; } +.app-registry-panel h4 { margin: 0 0 10px; color: #223b63; font-size: 14px; } +.app-registry-checks { gap: 10px 14px; } +.app-registry-checks label { min-width: 130px; padding: 8px 10px; border: 1px solid #d7e2ef; border-radius: 12px; background: #f8fbff; } +.app-registry-copy-panel { grid-column: 1 / -1; } +.app-registry-savebar { position: sticky; bottom: 14px; z-index: 5; display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; padding: 12px 14px; border: 1px solid #cad8ea; border-radius: 16px; background: rgba(255,255,255,0.95); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.08); backdrop-filter: blur(8px); } .template-block { border: 1px solid #d8e3f0; border-radius: 10px; background: #fff; padding: 10px; margin-top: 10px; } .template-title, .rule-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; } @@ -50,8 +190,71 @@ th { background: #f6f9ff; color: #334155; } .bulk-note { color: #64748b; font-size: 12px; } .field label { display: block; font-weight: 600; margin-bottom: 6px; } .field input, .field select { min-height: 40px; } +.field-full { grid-column: 1 / -1; } .mini { color: #64748b; font-size: 12px; } .table-controls input[type="text"], .table-controls select { width: 100%; min-height: 36px; padding: 7px 9px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; } .table-controls input[type="checkbox"] { transform: scale(1.1); width: auto; } .actions { white-space: nowrap; } -@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } } +@media (max-width: 760px) { + .grid { grid-template-columns: 1fr; } + .branding-inline-head, .company-inline-head { flex-direction: column; } + .branding-inline-actions, .company-inline-actions { flex-direction: column; } + .trial-summary-grid { grid-template-columns: 1fr 1fr; } + .trial-expired-shell { padding: 20px 16px 28px; } + .trial-expired-card { padding: 18px; } + .trial-expired-grid { grid-template-columns: 1fr; } + .branding-preview-header { flex-direction: column; align-items: flex-start; } + .branding-preview-band { flex-wrap: wrap; } + .app-registry-filters { grid-template-columns: 1fr; } + .app-registry-summary { grid-template-columns: 1fr; } + .app-registry-summary-meta { justify-content: flex-start; } + .app-registry-card-grid { grid-template-columns: 1fr; } + .app-registry-copy-panel { grid-column: auto; } + .app-registry-savebar { align-items: stretch; flex-direction: column; } +} +.app-registry-groups { display: grid; gap: 18px; } +.app-registry-group { border: 1px solid #d7e3f0; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,255,0.95)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); } +.app-registry-group-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; } +.app-registry-group-head h2 { margin: 0; color: #17345e; font-size: 18px; } +.app-registry-group-body { display: grid; gap: 14px; } +.app-registry-group[hidden] { display: none !important; } + +.intro-builder-shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 18px; align-items: start; } +.intro-builder-sidebar { position: sticky; top: 18px; display: grid; gap: 14px; } +.intro-side-card { padding: 16px; } +.intro-eyebrow { margin-bottom: 10px; } +.intro-side-stats { display: grid; gap: 12px; } +.intro-side-stat { display: grid; gap: 2px; } +.intro-side-stat strong { font-size: 22px; line-height: 1; color: #163566; } +.intro-side-stat span { color: #60738d; font-size: 12px; font-weight: 700; } +.intro-section-nav { display: grid; gap: 8px; } +.intro-section-link { justify-content: flex-start; } +.intro-builder-main { min-width: 0; display: grid; gap: 14px; } +.intro-builder-form-card, .intro-builder-list-card { padding: 16px; } +.intro-surface-head { margin-bottom: 14px; } +.intro-surface-head h2 { margin: 0; color: #17345e; font-size: 20px; } +.intro-surface-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; } +.intro-builder-actions { display: flex; gap: 10px; align-items: center; justify-content: space-between; flex-wrap: wrap; margin-top: 12px; } +.intro-builder-actions-sticky { position: sticky; bottom: 14px; z-index: 4; padding-top: 12px; border-top: 1px solid #dbe5f2; background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.98)); } +.intro-group-stack { display: grid; gap: 16px; } +.intro-group-card { border: 1px solid #d7e3f0; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,255,0.95)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); } +.intro-group-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; } +.intro-group-head h3 { margin: 0; color: #17345e; font-size: 18px; } +.intro-item-list { display: grid; gap: 12px; } +.intro-item-card { border: 1px solid #dce6f2; border-radius: 16px; background: rgba(255,255,255,0.88); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); } +.intro-item-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 12px; } +.intro-item-head strong { color: #17345e; font-size: 15px; line-height: 1.35; } +.intro-item-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.intro-inline-check { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; font-weight: 700; color: #334155; } +.intro-inline-check input[type="checkbox"] { width: auto; } +.intro-item-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; } +.intro-item-field { margin: 0; } +.intro-item-field > span { display: block; margin-bottom: 6px; font-size: 12px; color: #334155; font-weight: 700; } +.intro-item-field-wide { grid-column: 1 / -1; } +@media (max-width: 760px) { + .intro-builder-shell { grid-template-columns: 1fr; } + .intro-builder-sidebar { position: static; } + .intro-item-head { flex-direction: column; } + .intro-item-grid { grid-template-columns: 1fr; } + .intro-builder-actions { align-items: stretch; flex-direction: column; } +} diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index 3129592..48271f0 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -1,13 +1,13 @@ :root { - --app-shell-width: 1380px; - --app-line: #d9e3ee; - --app-brand-blue: #000078; - --app-panel: rgba(255, 255, 255, 0.9); - --app-shadow: 0 22px 48px rgba(18, 34, 56, 0.14); - --motion-fast: 160ms; - --motion-base: 200ms; + --app-shell-width: var(--ds-shell-width); + --app-line: var(--ds-line); + --app-brand-blue: var(--ds-brand); + --app-panel: var(--ds-surface); + --app-shadow: var(--ds-shadow-shell); + --motion-fast: var(--ds-motion-fast); + --motion-base: var(--ds-motion-base); --motion-slow: 260ms; - --motion-ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --motion-ease: var(--ds-ease); } .app-header { @@ -29,6 +29,110 @@ background-color var(--motion-base) var(--motion-ease); } +.app-trial-banner { + width: min(var(--app-shell-width), 100%); + margin: 0 auto 12px; + padding: 0 10px; +} + +.app-trial-banner-inner { + display: flex; + gap: 14px; + align-items: center; + flex-wrap: wrap; + padding: 10px 12px; + border: 1px solid #e9d4a2; + border-radius: 18px; + background: + radial-gradient(circle at top right, rgba(255, 206, 112, 0.22), transparent 28%), + linear-gradient(180deg, rgba(255,251,243,0.98), rgba(255,244,222,0.94)); + color: #875400; + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.88), + 0 10px 20px rgba(16, 32, 57, 0.05); +} + +.app-trial-banner.is-expired .app-trial-banner-inner { + border-color: #efc2c2; + background: + radial-gradient(circle at top right, rgba(222, 92, 92, 0.16), transparent 28%), + linear-gradient(180deg, rgba(255,248,248,0.98), rgba(255,238,238,0.94)); + color: #9f1d1d; +} + +.app-trial-banner-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 11px; + border-radius: 999px; + border: 1px solid rgba(135, 84, 0, 0.16); + background: rgba(255,255,255,0.72); + color: inherit; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; +} + +.app-trial-banner.is-expired .app-trial-banner-chip { + border-color: rgba(159, 29, 29, 0.18); +} + +.app-trial-banner-copy { + display: grid; + gap: 2px; + flex: 1 1 440px; + min-width: 240px; +} + +.app-trial-banner-title { + color: #4b3710; + font-size: 13px; + line-height: 1.2; +} + +.app-trial-banner.is-expired .app-trial-banner-title { + color: #7f1d1d; +} + +.app-trial-banner-text { + color: #7f6540; + font-size: 12px; + line-height: 1.45; +} + +.app-trial-banner.is-expired .app-trial-banner-text { + color: #8f3a3a; +} + +.app-trial-banner-meta { + display: grid; + gap: 2px; + padding: 6px 10px; + border-left: 1px solid rgba(135, 84, 0, 0.14); + min-width: 120px; +} + +.app-trial-banner.is-expired .app-trial-banner-meta { + border-left-color: rgba(159, 29, 29, 0.16); +} + +.app-trial-banner-meta-label { + color: #8a6c42; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.app-trial-banner-meta strong { + color: inherit; + font-size: 12px; + line-height: 1.3; +} + .app-header-in-shell { box-sizing: border-box; width: 100%; @@ -76,6 +180,386 @@ align-items: center; } +.app-notification-menu, +.app-user-menu { + position: relative; +} + +.app-notification-trigger { + list-style: none; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid var(--app-line); + border-radius: 999px; + background: rgba(248, 251, 255, 0.92); + color: #1f3a5f; + cursor: pointer; + transition: + border-color var(--motion-fast) var(--motion-ease), + background-color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease), + box-shadow var(--motion-fast) var(--motion-ease); +} + +.app-notification-trigger::-webkit-details-marker { + display: none; +} + +.app-notification-trigger:hover { + transform: translateY(-1px); +} + +.app-notification-menu[open] .app-notification-trigger { + border-color: rgba(0, 0, 120, 0.22); + box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08); +} + +.app-notification-bell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: #28446e; +} + +.app-notification-bell svg { + width: 18px; + height: 18px; + display: block; +} + +.app-notification-count { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #c0002b; + color: #fff; + font-size: 10px; + font-weight: 800; + line-height: 18px; + text-align: center; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.98); +} + +.app-notification-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + width: min(380px, calc(100vw - 32px)); + max-height: min(70vh, 520px); + padding: 10px; + border: 1px solid rgba(217, 227, 238, 0.96); + border-radius: 18px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16); + display: flex; + flex-direction: column; + gap: 10px; + z-index: 45; + overflow: hidden; +} + +.app-notification-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 4px 6px 8px; + border-bottom: 1px solid rgba(217, 227, 238, 0.85); +} + +.app-notification-panel-head strong { + color: #132238; + font-size: 13px; + line-height: 1.2; +} + +.app-notification-panel-head form { + margin: 0; +} + +.app-notification-panel-head button { + border: 0; + background: transparent; + color: var(--app-brand-blue); + font-size: 12px; + font-weight: 700; + cursor: pointer; + padding: 0; +} + +.app-notification-list { + display: grid; + gap: 8px; + overflow: auto; + padding-right: 2px; +} + +.app-notification-item { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(217, 227, 238, 0.92); + border-radius: 14px; + background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92)); +} + +.app-notification-item.is-unread { + border-color: rgba(0, 0, 120, 0.22); + box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9); +} + +.app-notification-success { + background: linear-gradient(180deg, #f4fcf7, #edf9f1); + border-color: #cce9d5; +} + +.app-notification-error { + background: linear-gradient(180deg, #fff7f7, #fff1f1); + border-color: #f0c8c8; +} + +.app-notification-warning { + background: linear-gradient(180deg, #fffaf0, #fff4dd); + border-color: #f3d9a7; +} + +.app-notification-copy { + display: grid; + gap: 4px; +} + +.app-notification-copy strong { + color: #132238; + font-size: 13px; + line-height: 1.35; +} + +.app-notification-copy p { + margin: 0; + color: #51657f; + font-size: 12px; + line-height: 1.45; +} + +.app-notification-copy span { + color: #7a8ca3; + font-size: 11px; + line-height: 1.3; +} + +.app-notification-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.app-notification-actions a, +.app-notification-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid rgba(217, 227, 238, 0.92); + background: rgba(255, 255, 255, 0.82); + color: #1f3a5f; + font: inherit; + font-size: 12px; + font-weight: 700; + line-height: 1; + text-decoration: none; + cursor: pointer; + transition: + background-color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); +} + +.app-notification-actions a:hover, +.app-notification-actions button:hover, +.app-notification-panel-head button:hover { + color: var(--app-brand-blue); +} + +.app-notification-empty { + padding: 14px 8px 8px; + color: #64748b; + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.app-user-trigger { + list-style: none; + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 6px 10px 6px 8px; + border: 1px solid var(--app-line); + border-radius: 999px; + background: rgba(248, 251, 255, 0.92); + color: #1f3a5f; + cursor: pointer; + transition: + border-color var(--motion-fast) var(--motion-ease), + background-color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease), + box-shadow var(--motion-fast) var(--motion-ease); +} + +.app-user-trigger::-webkit-details-marker { + display: none; +} + +.app-user-trigger:hover { + transform: translateY(-1px); +} + +.app-user-menu[open] .app-user-trigger { + border-color: rgba(0, 0, 120, 0.22); + box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08); +} + +.app-user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, var(--app-brand-blue), #1d3ca8); + color: #fff; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + overflow: hidden; + flex: 0 0 28px; +} + +.app-user-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.app-user-copy { + display: grid; + gap: 1px; + text-align: left; +} + +.app-user-copy strong { + font-size: 13px; + line-height: 1.2; +} + +.app-user-copy span { + color: #64748b; + font-size: 11px; + line-height: 1.2; +} + +.app-user-caret { + color: #64748b; + font-size: 12px; +} + +.app-user-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + min-width: 220px; + padding: 10px; + border: 1px solid rgba(217, 227, 238, 0.96); + border-radius: 18px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16); + display: flex; + flex-direction: column; + gap: 2px; + z-index: 40; + overflow: hidden; +} + +.app-user-panel-head { + display: grid; + gap: 2px; + padding: 4px 6px 6px; + margin-bottom: 2px; + border-bottom: 1px solid rgba(217, 227, 238, 0.85); +} + +.app-user-panel-head strong { + color: #132238; + font-size: 13px; + line-height: 1.2; +} + +.app-user-panel-head span { + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.app-user-panel a, +.app-user-panel button { + display: flex; + align-items: center; + width: 100%; + min-height: 0; + border: 0; + border-radius: 12px; + background: transparent; + color: #1f3a5f; + font: inherit; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + text-align: left; + text-decoration: none; + padding: 8px 12px; + cursor: pointer; + transition: + background-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); +} + +.app-user-panel a:hover, +.app-user-panel button:hover { + background: #f4f8ff; + color: var(--app-brand-blue); +} + +.app-user-panel a:focus-visible, +.app-user-panel button:focus-visible, +.app-notification-trigger:focus-visible, +.app-notification-actions a:focus-visible, +.app-notification-actions button:focus-visible, +.app-notification-panel-head button:focus-visible, +.app-user-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.10); +} + +.app-user-panel form { + margin: 0; +} + .app-lang-switch { display: flex; gap: 6px; @@ -122,6 +606,48 @@ margin-right: auto !important; } +.app-site-footer { + width: min(var(--app-shell-width), 100%); + margin: 14px auto 0; + padding: 0 10px 18px; + color: #5f728d; + text-align: center; +} + +.app-site-footer-main { + font-size: 13px; + font-weight: 700; +} + +.app-site-footer-legal { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; +} + +.app-site-footer-links { + margin-top: 8px; + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.app-site-footer-links a { + color: #1f3a5f; + font-size: 12px; + font-weight: 700; + text-decoration: none; + transition: + color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease); +} + +.app-site-footer-links a:hover { + color: var(--app-brand-blue); + transform: translateY(-1px); +} + @media (max-width: 900px) { .app-header, .app-header-in-shell { diff --git a/backend/workflows/static/workflows/css/buttons.css b/backend/workflows/static/workflows/css/buttons.css index be716fb..a8f641f 100644 --- a/backend/workflows/static/workflows/css/buttons.css +++ b/backend/workflows/static/workflows/css/buttons.css @@ -1,12 +1,3 @@ -: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; @@ -44,9 +35,9 @@ } .btn-primary { - background: var(--btn-primary-bg); - border-color: var(--btn-primary-border); - color: var(--btn-primary-text); + background: var(--ds-brand); + border-color: var(--ds-brand); + color: #fff; } .btn-primary:hover:not(:disabled) { @@ -56,9 +47,9 @@ } .btn-secondary { - background: var(--btn-secondary-bg); - border-color: var(--btn-secondary-border); - color: var(--btn-secondary-text); + background: rgba(255, 255, 255, 0.96); + border-color: var(--ds-line-strong); + color: var(--ds-brand); } .btn-secondary:hover:not(:disabled) { diff --git a/backend/workflows/static/workflows/css/design_system.css b/backend/workflows/static/workflows/css/design_system.css new file mode 100644 index 0000000..ab73c31 --- /dev/null +++ b/backend/workflows/static/workflows/css/design_system.css @@ -0,0 +1,602 @@ +:root { + --ds-shell-width: 1380px; + --ds-font-sans: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --ds-ink: #152338; + --ds-ink-strong: #0f1b2d; + --ds-muted: #607086; + --ds-line: #d8e2ef; + --ds-line-strong: #c8d5e5; + --ds-surface: rgba(255, 255, 255, 0.88); + --ds-surface-strong: rgba(255, 255, 255, 0.96); + --ds-surface-soft: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); + --ds-surface-soft-blue: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(247,250,255,0.96)); + --ds-brand: #000078; + --ds-brand-strong: #173f8d; + --ds-accent: #1f4fd6; + --ds-danger: #a32020; + --ds-success: #1f7a3f; + --ds-warning: #9a6400; + --ds-radius-shell: 28px; + --ds-radius-xl: 24px; + --ds-radius-lg: 20px; + --ds-radius-md: 16px; + --ds-radius-sm: 12px; + --ds-shadow-shell: 0 22px 56px rgba(18, 34, 56, 0.14); + --ds-shadow-card: 0 14px 28px rgba(15, 23, 42, 0.06); + --ds-shadow-hover: 0 18px 32px rgba(15, 23, 42, 0.08); + --ds-motion-fast: 160ms; + --ds-motion-base: 220ms; + --ds-ease: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + padding: 24px; + font-family: var(--ds-font-sans); + color: var(--ds-ink); + background: + radial-gradient(72% 90% at 8% 8%, rgba(0, 0, 120, 0.12), rgba(0, 0, 120, 0)), + radial-gradient(60% 82% at 92% 88%, rgba(163, 32, 32, 0.08), rgba(163, 32, 32, 0)), + linear-gradient(160deg, #eef3ff 0%, #f9fbff 48%, #edf4ff 100%); +} + +.shell { + width: min(var(--ds-shell-width), 100%); + margin: 0 auto; + background: rgba(255, 255, 255, 0.78); + backdrop-filter: blur(12px); + border: 1px solid rgba(216, 226, 239, 0.9); + border-radius: var(--ds-radius-shell); + box-shadow: var(--ds-shadow-shell); + overflow: hidden; +} + +.page-stack { + display: grid; + gap: 18px; + padding: 24px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 18px; + flex-wrap: wrap; +} + +.page-header-copy { + min-width: 0; + max-width: 760px; +} + +.page-eyebrow { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 120, 0.12); + background: rgba(0, 0, 120, 0.05); + color: var(--ds-brand); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.page-header h1, +.page-title { + margin: 10px 0 0; + color: var(--ds-ink-strong); + font-size: clamp(30px, 4vw, 42px); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.page-subtitle, +.sub { + margin: 8px 0 0; + color: var(--ds-muted); + font-size: 14px; + line-height: 1.55; +} + +.page-section, +.card, +.surface-card { + border: 1px solid rgba(216, 226, 239, 0.94); + border-radius: var(--ds-radius-lg); + background: + radial-gradient(120% 120% at 100% 0%, rgba(31, 79, 214, 0.05), rgba(31, 79, 214, 0)), + var(--ds-surface-soft); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.94), var(--ds-shadow-card); +} + +.page-section { + padding: 18px; +} + +.page-section-head, +.surface-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.page-section-head h2, +.surface-head h2 { + margin: 0; + color: #17345e; + font-size: 20px; +} + +.page-section-head p, +.surface-head p { + margin: 4px 0 0; + color: var(--ds-muted); + font-size: 13px; + line-height: 1.5; +} + +.surface-grid { + display: grid; + gap: 14px; +} + +.app-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 18px; + align-items: start; + padding: 24px; +} + +.app-sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 14px; +} + +.app-editor-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.app-editor-sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 14px; +} + +.app-editor-main { + min-width: 0; + display: grid; + gap: 14px; +} + +.app-sidebar-card { + padding: 16px; + border: 1px solid rgba(216, 226, 239, 0.94); + border-radius: 18px; + background: + radial-gradient(120% 120% at 100% 0%, rgba(31, 79, 214, 0.05), rgba(31, 79, 214, 0)), + var(--ds-surface-soft); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.94), var(--ds-shadow-card); +} + +.app-sidebar-card h1, +.app-sidebar-card h2 { + margin: 6px 0 0; + color: var(--ds-ink-strong); + line-height: 1.08; +} + +.app-sidebar-card p { + margin: 8px 0 0; + color: var(--ds-muted); + font-size: 13px; + line-height: 1.55; +} + +.app-sidebar-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: rgba(0, 0, 120, 0.07); + color: var(--ds-brand-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.app-side-nav { + display: grid; + gap: 8px; +} + +.app-side-link { + display: grid; + gap: 4px; + padding: 14px 16px; + border: 1px solid rgba(216, 226, 239, 0.94); + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #ffffff); + color: var(--ds-ink-strong); + text-decoration: none; + transition: + transform var(--ds-motion-fast) var(--ds-ease), + border-color var(--ds-motion-fast) var(--ds-ease), + box-shadow var(--ds-motion-fast) var(--ds-ease), + background var(--ds-motion-fast) var(--ds-ease); +} + +.app-side-link:hover { + transform: translateY(-1px); + border-color: #b8cae0; + box-shadow: var(--ds-shadow-hover); +} + +.app-side-link.is-active { + border-color: #9eb6d8; + background: linear-gradient(180deg, #eef5ff, #ffffff); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); +} + +.app-side-link-title { + font-size: 14px; + font-weight: 800; +} + +.app-side-link-meta { + color: var(--ds-muted); + font-size: 12px; + font-weight: 700; +} + +.app-sidebar-stats { + display: grid; + gap: 12px; +} + +.app-stat-grid { + display: grid; + gap: 12px; +} + +.app-stat-card { + display: grid; + gap: 2px; + padding: 14px; + border: 1px solid rgba(216, 226, 239, 0.94); + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #ffffff); +} + +.app-stat-card strong { + font-size: 22px; + line-height: 1; + color: #163566; +} + +.app-stat-card span { + color: var(--ds-muted); + font-size: 12px; + font-weight: 700; +} + +.app-side-stat { + display: grid; + gap: 2px; +} + +.app-side-stat strong { + font-size: 22px; + line-height: 1; + color: #163566; +} + +.app-side-stat span { + color: var(--ds-muted); + font-size: 12px; + font-weight: 700; +} + +.app-flow-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.app-flow-item { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px; + border: 1px solid rgba(216, 226, 239, 0.94); + border-radius: 14px; + background: linear-gradient(160deg, #f8faff, #fcfdff); + transition: + border-color var(--ds-motion-fast) var(--ds-ease), + transform var(--ds-motion-fast) var(--ds-ease), + box-shadow var(--ds-motion-fast) var(--ds-ease); +} + +.app-flow-item.is-active { + border-color: #9db4ff; + background: linear-gradient(160deg, #eaf0ff, #f4f7ff); + box-shadow: 0 6px 16px rgba(0, 0, 120, 0.08); +} + +.app-flow-item:hover { + border-color: #b2c3ff; +} + +.app-flow-dot { + width: 24px; + height: 24px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 120, 0.06); + border: 1px solid rgba(0, 0, 120, 0.14); + color: var(--ds-brand); + font-size: 12px; + font-weight: 700; + flex: 0 0 auto; +} + +.app-flow-title { + font-weight: 700; + color: #1d2c68; + margin-bottom: 2px; +} + +.app-flow-sub { + font-size: 12px; + color: var(--ds-muted); +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.metric-card { + border: 1px solid var(--ds-line); + border-radius: var(--ds-radius-md); + background: rgba(255,255,255,0.86); + padding: 14px; + display: grid; + gap: 6px; +} + +.metric-card strong { + color: #17345e; + font-size: 24px; + line-height: 1.05; +} + +.metric-card span, +.mini, +.hint { + color: var(--ds-muted); + font-size: 12px; + line-height: 1.45; +} + +.app-table-wrap, +.table-wrap, +.option-table-wrap { + overflow-x: auto; +} + +.app-table-shell { + border: 1px solid rgba(216, 226, 239, 0.94); + border-radius: var(--ds-radius-md); + background: rgba(255,255,255,0.86); + overflow: hidden; +} + +.app-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.app-table th, +.app-table td, +table th, +table td { + border: 1px solid #dce5f1; + padding: 10px; + text-align: left; + vertical-align: top; +} + +.app-table th, +table th { + background: #f6f9ff; + color: #334155; +} + +.app-table tr:last-child td { + border-bottom: 0; +} + +.table-actions, +.inline-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.app-module-nav { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.app-action-row { + margin-top: 12px; + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.app-action-row.is-sticky { + position: sticky; + bottom: 14px; + z-index: 4; + padding-top: 12px; + border-top: 1px solid #dbe5f2; + background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.98)); +} + +.app-note-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 18px; +} + +.app-module-link, +.tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 14px; + border: 1px solid #c6d1e1; + border-radius: 999px; + text-decoration: none; + color: #1c2a41; + background: #f8fbff; + font-weight: 700; + transition: + transform var(--ds-motion-fast) var(--ds-ease), + border-color var(--ds-motion-fast) var(--ds-ease), + background var(--ds-motion-fast) var(--ds-ease), + box-shadow var(--ds-motion-fast) var(--ds-ease); +} + +.app-module-link:hover, +.tab:hover { + transform: translateY(-1px); + border-color: #9db4d2; + box-shadow: 0 8px 16px rgba(16, 32, 57, 0.06); +} + +.app-module-link.is-active, +.tab.active { + background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%); + color: #fff; + border-color: #1759b8; +} + +.inline-action-form { + display: inline; +} + +.inline-flex-label { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; +} + +.u-mt-12 { + margin-top: 12px; +} + +.u-mt-16 { + margin-top: 16px; +} + +.u-align-end { + align-items: end; +} + +.audit-request-list { + margin: 6px 0 10px 18px; + padding: 0; +} + +.status-note-error { + margin-top: 8px; + color: #8e1e1e; +} + +@media (max-width: 980px) { + .app-workspace { + grid-template-columns: 1fr; + padding: 18px; + } + + .app-sidebar { + position: static; + } +} + +.status-note-error + .status-note-error { + margin-top: 6px; +} + +@media (max-width: 900px) { + body { + padding: 18px; + } + + .page-stack { + padding: 18px; + } + + .app-editor-shell { + grid-template-columns: 1fr; + } + + .app-editor-sidebar { + position: static; + } + + .app-action-row { + align-items: stretch; + flex-direction: column; + } +} + +@media (max-width: 760px) { + body { + padding: 12px; + } + + .shell { + border-radius: 22px; + } + + .page-stack { + padding: 16px; + } +} diff --git a/backend/workflows/static/workflows/css/docs_pages.css b/backend/workflows/static/workflows/css/docs_pages.css index 02b429d..7c8cfdb 100644 --- a/backend/workflows/static/workflows/css/docs_pages.css +++ b/backend/workflows/static/workflows/css/docs_pages.css @@ -1,5 +1,4 @@ -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; } +.page-stack { width: min(1280px, 100%); margin: 0 auto; } .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; } @@ -14,8 +13,8 @@ code { background: #f1f5fb; border: 1px solid #dce6f3; border-radius: 6px; paddi pre { background: #f7fbff; border: 1px solid #dce6f3; border-radius: 10px; padding: 10px; overflow-x: auto; } .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; } -.grid { display: grid; grid-template-columns: repeat(3, minmax(260px, 1fr)); gap: 14px; } -.card { border: 1px solid #d7e0ea; border-radius: 14px; background: #fcfdff; padding: 16px; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; align-items: stretch; } +.card { border: 1px solid #d7e0ea; border-radius: 14px; background: #fcfdff; padding: 18px; height: 100%; } .eyebrow { display: inline-block; padding: 5px 10px; border-radius: 999px; background: #eef4ff; color: #244a8f; border: 1px solid #d5e2f9; font-size: 12px; font-weight: 700; margin-bottom: 10px; } p { margin: 0 0 14px; color: #5f6f85; } .actions { display: flex; gap: 8px; flex-wrap: wrap; } diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index 8de88a6..d259085 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -1,72 +1,253 @@ -body { - margin: 0; - font-family: Arial, sans-serif; - background: #f4f7fb; - color: #1f2937; +.builder-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 18px; + align-items: start; + padding: 24px; } -.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); +.builder-sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 14px; } -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; +.builder-main { + min-width: 0; } -.brand-logo { - width: 190px; - max-width: 100%; - height: auto; - display: block; +.builder-sidebar-card { + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, rgba(248, 251, 255, 0.98), rgba(255, 255, 255, 0.98)); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05); } -.header h1 { - margin: 0; - font-size: 28px; -} - -.header p { +.builder-sidebar-card h2 { margin: 6px 0 0; - color: #64748b; + font-size: 20px; + color: #142033; } -.toolbar { - margin-top: 14px; +.builder-sidebar-card p { + margin: 0; + color: #5f7089; + font-size: 13px; + line-height: 1.6; +} + +.builder-sidebar-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eaf1ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.builder-side-nav { + display: grid; + gap: 8px; +} + +.builder-side-link { + display: grid; + gap: 4px; + padding: 14px 16px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #ffffff); + color: #142033; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.builder-side-link:hover { + transform: translateY(-1px); + border-color: #b8cae0; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06); +} + +.builder-side-link.is-active { + border-color: #9eb6d8; + background: linear-gradient(180deg, #eef5ff, #ffffff); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); +} + +.builder-side-link-title { + font-size: 14px; + font-weight: 800; +} + +.builder-side-link-meta { + color: #607086; + font-size: 12px; + font-weight: 700; +} + +.builder-sidebar-stats { + display: grid; + gap: 12px; +} + +.builder-side-stat { + display: grid; + gap: 2px; +} + +.builder-side-stat strong { + font-size: 22px; + line-height: 1; + color: #163566; +} + +.builder-side-stat span { + color: #607086; + font-size: 12px; + font-weight: 700; +} + +.builder-hero, +.builder-panel, +.builder-stat-card, +.section-rule-card, +.field-card, +.options-panel { + animation: builderFadeIn 0.32s ease; +} + +.builder-hero { display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + padding: 4px 0 6px; +} + +.builder-hero-copy { + max-width: 760px; +} + +.builder-hero-sub { + margin: 8px 0 0; + max-width: 620px; + color: #5c6d87; + font-size: 14px; + line-height: 1.6; +} + +.builder-eyebrow { + display: inline-flex; align-items: center; gap: 8px; + margin-bottom: 10px; + padding: 6px 11px; + border-radius: 999px; + background: #e9f2ff; + color: #174ea6; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.builder-hero h1 { + margin: 0; + font-size: clamp(30px, 4vw, 40px); + line-height: 1.02; +} + +.builder-hero-actions { + display: flex; + align-items: center; + gap: 10px; flex-wrap: wrap; + justify-content: flex-end; +} + +.builder-lang-switch { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid #c6d1e1; + border-radius: 999px; + background: #f8fbff; +} + +.builder-lang-btn { + min-width: 38px; + min-height: 34px; + border: 0; + border-radius: 999px; + background: transparent; + color: #304159; + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: background-color 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +.builder-lang-btn:hover { + transform: translateY(-1px); +} + +.builder-lang-btn.active { + background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%); + color: #fff; +} + +.builder-main .btn, +.builder-main .builder-module-link, +.builder-main .builder-lang-btn { + min-height: 38px; +} + +.builder-main .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 14px; + border-radius: 12px; + font-weight: 800; } .tab { - border: 1px solid #cbd5e1; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 14px; + border: 1px solid #c6d1e1; border-radius: 999px; - padding: 8px 14px; text-decoration: none; - color: #1f2937; - background: #f8fafc; - font-weight: 600; + color: #1c2a41; + background: #f8fbff; + font-weight: 700; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} + +.tab:hover { + transform: translateY(-1px); + border-color: #9db4d2; } .tab.active { - background: #000078; + background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%); color: #ffffff; - border-color: #000078; + border-color: #1759b8; } .status { min-height: 22px; - margin: 10px 0 8px; + margin: 14px 0 10px; color: #334155; font-size: 14px; } @@ -83,58 +264,925 @@ body { color: #166534; } -.columns { - margin-top: 8px; - display: grid; - grid-template-columns: repeat(4, minmax(220px, 1fr)); +.builder-summary-strip { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.builder-summary-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 12px; + border: 1px solid #d5dfec; + border-radius: 999px; + background: #f8fbff; + color: #304159; + font-size: 13px; + font-weight: 700; +} + +.builder-summary-pill strong { + color: #101c30; +} + +.builder-panel, +.options-panel { + border: 1px solid rgba(201, 212, 226, 0.95); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.98)); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); +} + +.builder-module-surface { + overflow: hidden; +} + +.builder-module-hidden { + display: none; +} + +.builder-module-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.builder-module-link { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border: 1px solid #d1dbea; + border-radius: 999px; + background: #f7fbff; + color: #304159; + font-size: 13px; + font-weight: 800; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease; +} + +.builder-module-link:hover { + transform: translateY(-1px); + border-color: #adc2dd; + background: #ffffff; + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06); +} + +.builder-module-link.is-active { + border-color: #164a99; + background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%); + color: #ffffff; +} + +.builder-panel-head h2, +.options-head h2 { + margin: 0; + color: #142033; +} + +.builder-panel-copy, +.options-copy { + min-width: 0; +} + +.builder-panel-sub, +.options-sub { + margin: 6px 0 0; + color: #61718a; + font-size: 13px; + line-height: 1.5; +} + +.builder-panel-meta { + display: flex; + align-items: center; gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.builder-panel-count { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid #d7e1ee; + border-radius: 999px; + background: #f8fbff; + color: #294567; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.mini { + color: #61718a; + font-size: 13px; + line-height: 1.55; +} + +.builder-quicknav { + margin-top: 12px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.builder-quicknav a { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border: 1px solid #d1dbea; + border-radius: 999px; + background: #f7fbff; + color: #304159; + font-size: 13px; + font-weight: 700; + text-decoration: none; + transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease; +} + +.builder-quicknav a:hover { + transform: translateY(-1px); + border-color: #adc2dd; + background: #ffffff; + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06); +} + +.builder-preset-bar { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.builder-preset-form { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.builder-preset-label { + color: #61718a; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.builder-preset-form select { + min-height: 36px; + padding: 0 12px; + border: 1px solid #cfdbeb; + border-radius: 999px; + background: linear-gradient(180deg, #ffffff, #f6faff); + color: #24405f; + font-size: 13px; + font-weight: 700; +} + +.builder-panel { + margin-top: 14px; + padding: 16px; +} + +.builder-panel-head, +.options-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.options-head-inline { + margin-bottom: 14px; +} + +.module-card-body { + display: grid; + gap: 14px; +} + +.builder-panel-body { + padding: 14px 16px 16px; +} + +.builder-panel-body-static { + display: grid; + gap: 16px; +} + +.builder-rule-layout { + display: grid; + grid-template-columns: minmax(300px, 0.85fr) minmax(0, 1.15fr); + gap: 14px; +} + +.builder-stack-layout { + display: grid; + gap: 14px; +} + +.builder-module-card { + overflow: hidden; +} + +.preview-shell { + display: grid; + gap: 12px; +} + +.preview-shell-compact .preview-section { + border-radius: 12px; +} + +.preview-shell-compact .preview-section-head { + padding: 10px 12px; +} + +.preview-shell-compact .preview-chip-list { + padding: 12px; +} + +.preview-section { + border: 1px solid #d7e0ec; + border-radius: 14px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + overflow: hidden; +} + +.preview-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #dfe7f1; + background: #f2f7ff; +} + +.preview-section-head h3 { + margin: 0; + font-size: 15px; + color: #142033; +} + +.preview-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 14px; +} + +.preview-chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 10px; + border: 1px solid #d8e1ec; + border-radius: 999px; + background: #ffffff; + color: #304159; + font-size: 13px; + font-weight: 700; +} + +.preview-chip.is-locked { + background: #eef2ff; + border-color: #c7d2fe; + color: #3730a3; +} + +.field-rule-groups { + display: grid; + gap: 12px; +} + +.field-rule-group { + border: 1px solid #d7e0ec; + border-radius: 14px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.field-rule-group:hover { + transform: translateY(-1px); + border-color: #bfd0e4; + box-shadow: 0 14px 26px rgba(15, 23, 42, 0.06); +} + +.field-rule-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #dfe7f1; + background: #f2f7ff; +} + +.field-rule-group-head h3 { + margin: 0; + font-size: 15px; + color: #142033; +} + +.field-rule-list { + display: grid; + gap: 10px; + padding: 10px; +} + +.field-rule-row { + display: grid; + grid-template-columns: minmax(240px, 1.5fr) minmax(120px, 0.55fr) minmax(170px, 0.7fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid #e7edf6; + border-radius: 14px; + background: rgba(255, 255, 255, 0.96); + transition: background-color 0.18s ease; +} + +.field-rule-row:first-child { + border-top: 1px solid #e7edf6; +} + +.field-rule-row:hover { + background: rgba(246, 250, 255, 0.96); +} + +.field-rule-main strong { + display: block; + color: #162133; + font-size: 14px; +} + +.field-rule-summary { + margin-top: 4px; + color: #526379; + font-size: 12px; + line-height: 1.5; +} + +.field-rule-meta { + margin-top: 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.field-rule-control { + display: grid; + gap: 6px; + color: #5f7089; + font-size: 12px; + font-weight: 700; +} + +.field-rule-settings { + display: flex; + align-items: end; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.field-rule-control-compact { + min-width: 120px; +} + +.field-rule-control input[type='checkbox'] { + width: 16px; + height: 16px; +} + +.field-rule-status { + display: flex; + justify-content: flex-end; +} + +.field-rule-status-inline { + justify-content: flex-start; +} + +.conditional-rule-grid { + display: grid; + gap: 10px; +} + +.conditional-rule-card { + border: 1px solid #d7e0ec; + border-radius: 14px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + padding: 12px; + display: grid; + gap: 10px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.conditional-rule-card:hover { + transform: translateY(-1px); + border-color: #bfd0e4; + box-shadow: 0 14px 26px rgba(15, 23, 42, 0.06); +} + +.conditional-rule-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #e6edf6; +} + +.conditional-rule-head-main { + min-width: 0; + display: grid; + gap: 8px; +} + +.conditional-rule-title-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.conditional-rule-head h3 { + margin: 0; + font-size: 15px; + color: #142033; +} + +.conditional-rule-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eef4ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.conditional-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + color: #5f7089; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.conditional-toggle input[type='checkbox'] { + width: 15px; + height: 15px; +} + +.conditional-rule-target-inline { + display: grid; + gap: 6px; +} + +.conditional-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.conditional-targets { + display: grid; + gap: 6px; + padding: 10px 12px; + border: 1px solid #e7edf6; + border-radius: 12px; + background: #f9fbff; +} + +.conditional-rule-summary { + display: grid; + gap: 6px; + padding: 10px 12px; + border: 1px solid #dbe6f5; + border-radius: 12px; + background: linear-gradient(180deg, #f6faff, #ffffff); +} + +.conditional-summary-text { + color: #21354f; + font-size: 13px; + line-height: 1.55; +} + +.conditional-rule-state { + display: grid; + align-content: start; + gap: 4px; + padding: 10px 12px; + border: 1px solid #e7edf6; + border-radius: 12px; + background: #f9fbff; +} + +.conditional-rule-state strong { + color: #142033; + font-size: 14px; +} + +.conditional-summary-prefix { + color: #294567; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-summary-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.conditional-target-label { + color: #5f7089; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-target-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.conditional-sentence-builder { + display: grid; + gap: 8px; +} + +.conditional-sentence-row { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1.3fr) minmax(160px, 0.9fr) minmax(160px, 0.9fr); + gap: 8px; + align-items: end; + padding: 10px 12px; + border: 1px solid #e5ebf3; + border-radius: 12px; + background: #f8fbff; +} + +.conditional-sentence-label { + color: #33506f; + font-size: 11px; + font-weight: 800; + line-height: 1.45; + letter-spacing: 0.01em; +} + +.conditional-sentence-row select, +.conditional-sentence-row input[type='text'] { + width: 100%; + min-height: 38px; + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 7px 9px; + box-sizing: border-box; + background: #fff; +} + +.conditional-clause-row { + display: grid; + grid-template-columns: 56px minmax(220px, 1.35fr) minmax(180px, 0.8fr) minmax(180px, 0.85fr); + gap: 10px; + align-items: end; + padding: 12px; + border: 1px solid #e5ebf3; + border-radius: 14px; + background: #f8fbff; +} + +.conditional-clause-index { + display: inline-flex; + align-items: center; + min-height: 38px; + color: #33506f; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-clause-control { + display: grid; + gap: 6px; +} + +.conditional-clause-control span { + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.conditional-clause-control select, +.conditional-clause-control input[type='text'] { + width: 100%; + min-height: 40px; +} + +.conditional-extra-clause { + border: 1px dashed #d7e0ec; + border-radius: 12px; + background: #fbfdff; +} + +.conditional-extra-clause summary { + list-style: none; + cursor: pointer; + padding: 8px 12px; + color: #35506f; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.conditional-extra-clause summary::-webkit-details-marker { + display: none; +} + +.conditional-extra-clause[open] summary { + border-bottom: 1px solid #e6edf6; +} + +.conditional-extra-clause .conditional-clause-row { + border: 0; + border-radius: 0 0 14px 14px; + background: transparent; +} + +.columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + min-width: 0; +} + +.structure-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; + min-width: 0; +} + +.structure-canvas { + min-width: 0; + overflow: visible; + padding-bottom: 0; + display: grid; + gap: 12px; +} + +.structure-section-nav { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.structure-section-pill { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 0 14px 0 10px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + color: #142033; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.structure-section-pill:hover { + transform: translateY(-1px); + border-color: #bfd0e4; + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06); +} + +.structure-section-pill.is-active { + border-color: #9eb6d8; + background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%); + box-shadow: 0 12px 22px rgba(15, 23, 42, 0.08); +} + +.structure-section-pill-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 999px; + background: #eaf1ff; + color: #214d99; + font-size: 12px; + font-weight: 800; +} + +.structure-section-pill-copy { + display: grid; + gap: 1px; +} + +.structure-section-pill-copy strong { + font-size: 14px; + line-height: 1.2; +} + +.structure-section-pill-copy span { + color: #607086; + font-size: 11px; + font-weight: 700; +} + +.structure-card { + display: grid; + gap: 8px; + padding: 14px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05); +} + +.structure-card h3 { + margin: 0; + font-size: 17px; + color: #142033; +} + +.structure-card p { + margin: 0; + color: #5f7089; + font-size: 13px; + line-height: 1.6; +} + +.structure-card-muted { + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); +} + +.structure-card-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eaf1ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.structure-stat { + display: grid; + gap: 2px; +} + +.structure-stat strong { + font-size: 22px; + line-height: 1; + color: #163566; +} + +.structure-stat span { + color: #607086; + font-size: 12px; + font-weight: 700; } .columns.single { grid-template-columns: minmax(320px, 1fr); } +.structure-columns-single { + grid-template-columns: 1fr; +} + .column { - border: 1px solid #d4dce7; - border-radius: 12px; - background: #f9fbff; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #f7faff 0%, #fdfefe 100%); display: flex; flex-direction: column; min-height: 460px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; } -.column h2 { +.column.is-collapsed { + min-height: 0; +} + +.column:hover { + transform: translateY(-2px); + border-color: #bfd0e4; + box-shadow: 0 16px 28px rgba(15, 23, 42, 0.07); +} + +.column-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 13px 14px; + border-bottom: 1px solid #deE7f1; + background: linear-gradient(180deg, #eef5ff, #f7fbff); +} + +.column-head h3 { margin: 0; - padding: 10px 12px; - border-bottom: 1px solid #dbe3ee; font-size: 16px; - color: #0f172a; - background: #edf3fb; - border-radius: 12px 12px 0 0; +} + +.column-count { + color: #5c6d87; + font-size: 12px; + font-weight: 700; } .dropzone { - padding: 10px; + padding: 12px; display: grid; - gap: 8px; + gap: 10px; align-content: start; min-height: 140px; flex: 1; } +.column.is-collapsed .dropzone { + display: none; +} + +.btn-compact { + min-height: 34px; + padding: 0 12px; +} + .dropzone.drag-over { background: #ecf5ff; } +.structure-empty { + padding: 14px; + border: 1px dashed #cbd7e6; + border-radius: 14px; + background: #f8fbff; + color: #61718a; + font-size: 13px; + font-weight: 700; + text-align: center; +} + .field-card { - background: #ffffff; - border: 1px solid #d3dbe8; - border-radius: 10px; - padding: 9px 10px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid #d7dfeb; + border-radius: 14px; + padding: 11px 12px; display: flex; justify-content: space-between; - gap: 8px; + align-items: flex-start; + gap: 10px; cursor: move; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.field-card:hover { + transform: translateY(-1px); + border-color: #b9cadf; + box-shadow: 0 12px 20px rgba(15, 23, 42, 0.06); } .field-card.dragging { @@ -145,13 +1193,20 @@ body { font-weight: 700; font-size: 14px; color: #0f172a; + overflow-wrap: anywhere; } .field-name { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: #64748b; - margin-top: 2px; + margin-top: 3px; + overflow-wrap: anywhere; +} + +.field-main { + min-width: 0; + flex: 1 1 auto; } .badges { @@ -159,13 +1214,15 @@ body { gap: 6px; align-items: center; flex-wrap: wrap; + justify-content: flex-end; + flex: 0 0 auto; } .badge { font-size: 11px; border-radius: 999px; border: 1px solid #d1d5db; - padding: 2px 7px; + padding: 3px 8px; background: #f8fafc; color: #334155; } @@ -189,24 +1246,7 @@ body { } .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; + padding: 14px; } .category-switch { @@ -215,27 +1255,23 @@ body { gap: 8px; } -.category-switch select { +.category-switch select, +.add-option-form input, +.option-card input[type='text'], +.option-table select, +.option-table input[type='text'] { border: 1px solid #cbd5e1; - border-radius: 8px; - padding: 6px 8px; + border-radius: 10px; + padding: 8px 10px; + box-sizing: border-box; + background: #fff; } .add-option-form { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 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; + margin-bottom: 12px; } .option-table { @@ -246,30 +1282,25 @@ body { .option-table th, .option-table td { border: 1px solid #e2e8f0; - padding: 7px 8px; + padding: 8px 9px; text-align: left; vertical-align: top; } .option-table th { - background: #f8fafc; + background: #f8fbff; + color: #3d4c63; + position: sticky; + top: 0; + z-index: 1; } -.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; +.option-table-group-row th { + background: #eef5ff; + color: #17335e; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; } .drag-handle { @@ -279,7 +1310,7 @@ body { width: 28px; height: 28px; border: 1px solid #cbd5e1; - border-radius: 6px; + border-radius: 8px; background: #f8fafc; color: #475569; font-size: 14px; @@ -287,16 +1318,616 @@ body { user-select: none; } +.option-card-list { + display: grid; + gap: 12px; +} + +.option-card { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); + cursor: grab; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; +} + +.option-card:hover { + transform: translateY(-1px); + border-color: #c4d4e7; + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.06); +} + +.option-row.dragging { + opacity: 0.55; + background: #ecf5ff; + box-shadow: none; +} + +.option-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.option-card-order { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.option-card-title-block { + display: grid; + gap: 4px; + min-width: 0; +} + +.option-card-title-block strong { + color: #142033; + font-size: 14px; + line-height: 1.35; +} + +.option-card-meta { + color: #61718a; + font-size: 12px; + font-weight: 700; +} + +.option-card-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.option-card-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 12px; + border: 1px solid #d7e1ee; + border-radius: 999px; + background: #f8fbff; + color: #294567; + font-size: 12px; + font-weight: 800; +} + +.option-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.option-empty-state { + padding: 18px; + border: 1px dashed #c8d5e7; + border-radius: 16px; + background: #f8fbff; + color: #61718a; + font-size: 14px; + font-weight: 700; + text-align: center; +} + .options-actions { - margin-top: 10px; + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.options-actions-sticky { + position: sticky; + bottom: 0; + z-index: 2; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #e5ecf5; + background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.98) 32%); +} + +.field-text-card-list { + display: grid; + gap: 12px; +} + +.field-text-card { + display: grid; + gap: 12px; + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); +} + +.field-text-card-head strong { + color: #142033; + font-size: 15px; +} + +.field-text-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.custom-fields-surface .builder-entity-form { + gap: 8px; + padding: 12px; +} + +.custom-fields-surface .builder-entity-grid { + gap: 8px; +} + +.custom-fields-surface .builder-group-stack { + gap: 8px; +} + +.custom-fields-surface .builder-group-card { + border-radius: 16px; +} + +.custom-fields-surface .builder-group-head { + padding: 8px 12px; +} + +.custom-fields-surface .builder-entity-card { + padding: 14px; + gap: 10px; + border-radius: 16px; + border: 1px solid #d7e0ec; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); +} + +.custom-fields-surface .builder-entity-card-head strong { + font-size: 14px; +} + +.custom-fields-surface .builder-entity-card-head { + gap: 10px; +} + +.custom-fields-surface .builder-switch-stack { + gap: 6px; +} + +.custom-fields-surface .builder-entity-control span { + font-size: 11px; +} + +.custom-fields-surface .builder-entity-control input[type='text'], +.custom-fields-surface .builder-entity-control input[type='number'], +.custom-fields-surface .builder-entity-control select, +.custom-fields-surface .builder-entity-control textarea { + min-height: 38px; + padding: 7px 9px; +} + +.custom-fields-surface .builder-entity-grid { + gap: 10px; +} + +.custom-fields-surface .builder-entity-card:hover { + transform: translateY(-1px); + border-color: #c4d4e7; + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.06); +} + +.builder-entity-form { + display: grid; + gap: 12px; + margin-bottom: 14px; + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.builder-entity-head h3, +.builder-group-head h3 { + margin: 0; + font-size: 16px; + color: #142033; +} + +.builder-entity-head .mini { + margin-top: 4px; +} + +.builder-entity-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.builder-entity-control { + display: grid; + gap: 6px; +} + +.builder-entity-control span { + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.builder-entity-control input[type='text'] { + width: 100%; +} + +.builder-entity-control-narrow { + max-width: 180px; +} + +.builder-entity-control-full { + grid-column: 1 / -1; +} + +.builder-card-list, +.builder-group-stack { + display: grid; + gap: 12px; +} + +.builder-group-card { + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + overflow: hidden; + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04); +} + +.builder-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #dfe7f1; + background: #f2f7ff; +} + +.builder-entity-card { + padding: 16px; + border: 1px solid #e5ebf3; + border-radius: 16px; + background: #ffffff; + display: grid; + gap: 12px; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04); +} + +.builder-entity-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.builder-card-head-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.builder-entity-card-head strong { + color: #142033; + font-size: 15px; +} + +.entity-meta { + margin-top: 4px; + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + overflow-wrap: anywhere; +} + +.builder-inline-meta { + margin-top: 6px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.builder-meta-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid #dbe5f1; + border-radius: 999px; + background: #f8fbff; + color: #526379; + font-size: 11px; + font-weight: 700; +} + +.builder-switch, +.builder-switch-inline { + display: inline-flex; + align-items: center; + gap: 8px; + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.builder-switch-stack { + display: grid; + gap: 8px; + justify-items: end; +} + +.builder-switch input[type='checkbox'], +.builder-switch-inline input[type='checkbox'] { + width: 16px; + height: 16px; +} + +.builder-entity-card-actions { display: flex; justify-content: flex-end; } -@media (max-width: 1120px) { +.builder-empty-state { + padding: 14px; + border: 1px dashed #d7e0ec; + border-radius: 14px; + background: #fbfdff; + color: #64748b; + font-size: 13px; +} + +.section-rule-grid { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.section-rule-card { + display: grid; + grid-template-columns: 30px auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid #d6e0ec; + border-radius: 12px; + background: linear-gradient(180deg, #fbfdff, #ffffff); + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; + width: 100%; + box-sizing: border-box; + max-width: 100%; +} + +.section-rule-card:hover { + transform: translateY(-1px); + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.07); + border-color: #b2c6df; +} + +.section-rule-card.is-locked { + background: linear-gradient(180deg, #f5f8fc, #fbfdff); +} + +.section-rule-actions { + display: inline-flex; + align-items: center; + gap: 4px; + padding-right: 0; +} + +.section-rule-order { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 999px; + background: #eaf1ff; + color: #23457a; + font-size: 12px; + font-weight: 800; + box-shadow: inset 0 0 0 1px #cdddff; +} + +.section-move-btn { + width: 30px; + height: 30px; + border: 1px solid #cdd9e8; + border-radius: 9px; + background: linear-gradient(180deg, #ffffff, #f5f9ff); + color: #274264; + font-size: 14px; + font-weight: 700; + line-height: 1; + cursor: pointer; + box-shadow: 0 6px 12px rgba(15, 23, 42, 0.04); + transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease; +} + +.section-move-btn:hover { + transform: translateY(-1px); + border-color: #9db4d2; + background: linear-gradient(180deg, #ffffff, #eef5ff); + box-shadow: 0 10px 16px rgba(15, 23, 42, 0.07); +} + +.section-move-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.section-rule-copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.section-rule-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.section-rule-copy span, +.mini { + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.section-rule-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + justify-self: end; + justify-content: flex-end; + white-space: nowrap; +} + +.section-rule-checkbox { + display: inline-flex; + align-items: center; +} + +@keyframes builderFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes builderReveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .builder-hero, + .builder-panel, + .builder-stat-card, + .section-rule-card, + .field-card, + .options-panel { + animation: none; + } + + .field-card, + .tab, + .builder-stat-card, + .builder-quicknav a, + .builder-panel-summary, + .field-rule-group, + .field-rule-row, + .column { + transition: none; + } + +} + +@media (max-width: 1220px) { + .builder-rule-layout, .columns { grid-template-columns: repeat(2, minmax(220px, 1fr)); } + + .structure-workspace { + grid-template-columns: 1fr; + } + + .structure-sidebar { + position: static; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + } + + .structure-canvas { + overflow-x: visible; + } +} + +@media (max-width: 900px) { + .builder-workspace { + grid-template-columns: 1fr; + } + + .builder-sidebar { + position: static; + } + + .builder-hero, + .builder-panel-head, + .options-head, + .conditional-rule-head { + flex-direction: column; + align-items: flex-start; + } + + .builder-hero-actions { + justify-content: flex-start; + } + + .builder-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .builder-toolbar-note { + max-width: none; + text-align: left; + } + + .builder-panel-meta { + justify-content: flex-start; + } + + .builder-rule-layout { + grid-template-columns: 1fr; + } + + .conditional-meta-grid { + grid-template-columns: 1fr; + } + + .conditional-rule-title-row { + align-items: flex-start; + } + + .builder-entity-card-head { + flex-direction: column; + align-items: flex-start; + } } @media (max-width: 760px) { @@ -304,11 +1935,49 @@ body { grid-template-columns: 1fr; } + .structure-sidebar { + grid-template-columns: 1fr; + } + + .field-rule-row, + .conditional-clause-row, + .conditional-sentence-row, + .field-text-card-grid { + grid-template-columns: 1fr; + } + + .section-rule-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .section-rule-toggle { + grid-column: 1 / -1; + justify-content: flex-start; + } + + .field-rule-status { + justify-content: flex-start; + } + + .conditional-clause-index { + min-height: auto; + } + .add-option-form { grid-template-columns: 1fr; } - .options-head { + .builder-entity-grid { + grid-template-columns: 1fr; + } + + .builder-entity-control-narrow, + .builder-entity-control-full { + max-width: none; + grid-column: auto; + } + + .builder-group-head { flex-direction: column; align-items: flex-start; } diff --git a/backend/workflows/static/workflows/css/home.css b/backend/workflows/static/workflows/css/home.css index a9976a1..f60f832 100644 --- a/backend/workflows/static/workflows/css/home.css +++ b/backend/workflows/static/workflows/css/home.css @@ -1,9 +1,9 @@ :root { - --brand-blue: #000078; + --brand-blue: var(--ds-brand); --brand-red: #8c1d1d; - --ink: #102039; - --muted: #5f6f85; - --line: #d8e1ee; + --ink: var(--ds-ink); + --muted: var(--ds-muted); + --line: var(--ds-line); --panel: #ffffff; --bg-soft: #eff4ff; --ok-bg: #effaf2; @@ -12,68 +12,6 @@ --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(1380px, 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-wrap { - display: flex; - flex-direction: column; - gap: 8px; - flex: 0 0 auto; - min-width: 0; - } - - .brand-logo { - width: 210px; - max-width: 100%; - height: auto; - display: block; - margin: 0; - flex: 0 0 auto; - } - - .quick-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - } - .lang-switch { display:flex; gap:6px; } - .lang-btn { border:1px solid var(--line); background:#f8fbff; color:#1f3a5f; border-radius:999px; padding:6px 10px; font-size:12px; font-weight:700; cursor:pointer; } - .lang-btn.active { background:var(--brand-blue); border-color:var(--brand-blue); color:#fff; } - .hero { padding: 24px; border-bottom: 1px solid var(--line); @@ -149,6 +87,114 @@ line-height: 1.55; } + .ops-overview-card { + margin-bottom: 20px; + border: 1px solid var(--line); + border-radius: 18px; + background: + radial-gradient(120% 120% at 100% 0%, rgba(0, 0, 120, 0.08), rgba(0, 0, 120, 0)), + linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); + padding: 18px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); + } + + .ops-overview-head h2 { + margin: 0; + color: #17345e; + font-size: 20px; + } + + .ops-overview-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 14px; + } + + .ops-overview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: 12px; + margin-top: 14px; + } + + .ops-stat-card { + border: 1px solid #dce6f2; + border-radius: 16px; + background: rgba(255,255,255,0.86); + padding: 14px; + display: grid; + gap: 6px; + } + + .ops-stat-label { + color: #60738d; + font-size: 12px; + font-weight: 700; + } + + .ops-stat-card strong { + color: #17345e; + font-size: 22px; + line-height: 1.1; + } + + .ops-stat-card strong.is-error { + color: #a32020; + } + + .ops-failure-list { + margin-top: 16px; + display: grid; + gap: 10px; + } + + .ops-failure-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .ops-failure-head h3 { + margin: 0; + color: #17345e; + font-size: 16px; + } + + .ops-failure-items { + display: grid; + gap: 10px; + } + + .ops-failure-item { + border: 1px solid #ead9d9; + border-radius: 14px; + background: rgba(255,248,248,0.92); + padding: 12px; + display: grid; + gap: 4px; + } + + .ops-failure-item strong { + color: #7f1d1d; + font-size: 14px; + } + + .ops-failure-item span { + color: #6f5b5b; + font-size: 13px; + } + + .ops-failure-item code { + color: #6a1f1f; + background: rgba(255,255,255,0.6); + border-radius: 8px; + padding: 6px 8px; + font-size: 12px; + overflow-wrap: anywhere; + } + .status-row { margin-top: 16px; display: flex; @@ -362,6 +408,8 @@ gap: 10px; align-items: flex-end; flex-wrap: wrap; + position: relative; + padding-left: 16px; } .section-head h2 { @@ -376,6 +424,32 @@ font-size: 13px; } + .section-divider { + height: 1px; + margin: 24px 0 14px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(0, 0, 120, 0.18), rgba(0, 0, 120, 0.05) 40%, rgba(140, 29, 29, 0.10)); + } + + .section-head::before { + content: ""; + position: absolute; + left: 0; + top: 2px; + width: 4px; + height: 34px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(0, 0, 120, 0.95), rgba(0, 0, 120, 0.30)); + } + + .section-head-platform::before { + background: linear-gradient(180deg, rgba(140, 29, 29, 0.90), rgba(140, 29, 29, 0.28)); + } + + .section-head-admin::before { + background: linear-gradient(180deg, rgba(159, 118, 33, 0.92), rgba(159, 118, 33, 0.28)); + } + .apps-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -577,6 +651,7 @@ @media (max-width: 1080px) { .hero-grid { grid-template-columns: 1fr; } + .ops-overview-grid { grid-template-columns: 1fr 1fr; } .apps-grid { grid-template-columns: 1fr 1fr; } .admin-grid { grid-template-columns: 1fr 1fr; } } diff --git a/backend/workflows/static/workflows/css/login.css b/backend/workflows/static/workflows/css/login.css index 5efb371..5ea0f3b 100644 --- a/backend/workflows/static/workflows/css/login.css +++ b/backend/workflows/static/workflows/css/login.css @@ -1,23 +1,3 @@ -body { - margin: 0; - min-height: 100vh; - font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; - background: - radial-gradient(70% 90% at 8% 8%, rgba(0, 0, 120, 0.14), rgba(0, 0, 120, 0)), - radial-gradient(60% 85% at 92% 88%, rgba(163, 32, 32, 0.12), rgba(163, 32, 32, 0)), - linear-gradient(160deg, #eef3ff 0%, #f9fbff 48%, #edf4ff 100%); - padding: 24px; -} - -.shell { - background: rgba(255, 255, 255, 0.78); - backdrop-filter: blur(12px); - border: 1px solid rgba(217, 227, 238, 0.9); - border-radius: 28px; - box-shadow: 0 22px 48px rgba(18, 34, 56, 0.14); - overflow: hidden; -} - .login-shell-body { padding: 24px; background: @@ -47,6 +27,78 @@ body { line-height: 1.45; } +.login-step-caption { + display: grid; + gap: 2px; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid #d9e3f0; + border-radius: 14px; + background: #f7faff; +} + +.login-step-caption strong { + color: #132238; + font-size: 14px; +} + +.login-step-caption span { + color: #607086; + font-size: 13px; +} + +.account-card { + width: min(560px, 100%); +} + +.account-grid { + display: grid; + gap: 10px; + margin: 0 0 16px; +} + +.account-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: baseline; + padding: 10px 12px; + border: 1px solid #d9e3f0; + border-radius: 12px; + background: #f9fbff; +} + +.account-row span { + color: #607086; + font-size: 13px; +} + +.account-row strong { + color: #132238; + font-size: 14px; + text-align: right; +} + +.account-actions { + display: flex; + gap: 10px; +} + +.account-actions .btn { + width: auto; +} + +.account-actions form { + margin: 0; +} + +.hint { + margin-top: 6px; + color: #8a5a00; + font-size: 12px; + line-height: 1.4; +} + .field { margin-bottom: 12px; } @@ -86,6 +138,34 @@ body { width: 100%; } +.btn-inline-toggle { + width: auto; + min-width: 0; +} + +.login-recovery-toggle-row { + display: flex; + justify-content: flex-start; + margin: -2px 0 12px; +} + +.login-recovery-box.is-hidden { + display: none; +} + +.login-back-link { + display: inline-flex; + align-items: center; + margin-top: 14px; + color: #36506e; + font-size: 14px; + text-decoration: none; +} + +.login-back-link:hover { + text-decoration: underline; +} + .login-card .app-alert { margin: 0 0 12px; } @@ -114,4 +194,21 @@ body { padding: 18px; border-radius: 16px; } + + .account-row { + flex-direction: column; + align-items: flex-start; + } + + .account-row strong { + text-align: left; + } + + .account-actions { + flex-direction: column; + } + + .account-actions .btn { + width: 100%; + } } diff --git a/backend/workflows/static/workflows/css/offboarding_form.css b/backend/workflows/static/workflows/css/offboarding_form.css index 34a1a12..d88e5e9 100644 --- a/backend/workflows/static/workflows/css/offboarding_form.css +++ b/backend/workflows/static/workflows/css/offboarding_form.css @@ -1,31 +1,206 @@ -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; +.offboarding-main, +.offboarding-search-card, +.offboarding-section-card { + background: #ffffff; + border: 1px solid #d7dfeb; + box-shadow: 0 12px 28px rgba(30, 52, 87, 0.08); +} + +.offboarding-search-card, +.offboarding-section-card { + border-radius: 16px; +} + +.workflow-sidebar-card h1 { + margin: 0 0 8px; + font-size: 28px; + letter-spacing: -0.02em; +} + +.offboarding-sub { + margin: 0 0 16px; + font-size: 14px; + line-height: 1.55; +} + +.offboarding-main form { + margin: 0; +} + +.workflow-form-main.offboarding-main { + padding: 22px; + border-radius: 16px; + background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); +} + +.offboarding-search-card { + padding: 14px; + margin-bottom: 16px; + background: linear-gradient(160deg, #ffffff, #fbfcff); +} + +.offboarding-section-card { + overflow: hidden; + background: linear-gradient(160deg, #ffffff, #fbfcff); +} + +.offboarding-sections { + display: grid; + gap: 14px; + margin-bottom: 14px; +} + +.offboarding-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid #d5e2f9; + background: #eef4ff; +} + +.offboarding-search-head { + margin: -14px -14px 14px; + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +.offboarding-section-head h2 { + margin: 0; + font-size: 20px; + color: #1f376b; +} + +.offboarding-section-head p { + margin: 4px 0 0; + color: #5e7088; + font-size: 13px; +} + +.offboarding-section-card .grid { + padding: 16px; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.field { + margin-bottom: 10px; +} + +.field-full { + grid-column: 1 / -1; +} + +label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +input, +textarea, +select { + 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; +} + +.inline-check { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px; + border: 1px solid #d7e0ea; + border-radius: 14px; + background: #ffffff; +} + +.inline-check input[type="checkbox"] { + width: auto; + min-height: 0; + margin-top: 2px; +} + +.hint { + color: #64748b; + font-size: 12px; + margin-top: 4px; +} + +.results { + margin-top: 10px; +} + +.results a { + display: inline-block; + margin: 4px 8px 4px 0; + padding: 6px 8px; + border: 1px solid #d4dbf7; + border-radius: 8px; + text-decoration: none; + color: #000078; + background: #f7f8ff; +} + +.offboarding-prefill-note { + margin-top: 10px; + color: #2563eb; +} + +.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; + } } -.wrap { width: min(var(--app-shell-width), 100%); margin: 0 auto; background: #ffffff; border: 1px solid #d8e1ee; border-radius: 20px; box-shadow: 0 20px 44px rgba(16, 32, 57, 0.13); overflow: hidden; } -.wrap-body { padding: 18px; } -.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); } -.wrap-body .card:last-child { margin-bottom: 0; } -h1 { margin-top: 0; color: #000078; } -.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.field { margin-bottom: 12px; } -.field-full { grid-column: 1 / -1; } -label { display: block; font-weight: 600; margin-bottom: 6px; } -input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; box-sizing: border-box; border: 1px solid #d4dbf7; border-radius: 10px; background: #fff; } -textarea { min-height: 120px; resize: vertical; } -.hint { color: #64748b; font-size: 12px; margin-top: 4px; } -.results a { display: inline-block; margin: 4px 8px 4px 0; padding: 6px 8px; border: 1px solid #d4dbf7; border-radius: 6px; text-decoration: none; color: #000078; background: #f7f8ff; } -.errorlist { color: #b91c1c; margin: 4px 0; } -.popup-backdrop { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.38); display: none; align-items: center; justify-content: center; z-index: 1000; } -.popup-backdrop.show { display: flex; } -.popup { background: #fff; border: 1px solid #d8e0ec; border-radius: 12px; padding: 18px; width: min(460px, calc(100% - 28px)); box-shadow: 0 18px 40px rgba(2, 6, 23, 0.25); } -.popup h3 { margin: 0 0 8px; color: #000078; } -.popup p { margin: 0 0 14px; color: #475569; } -@media (max-width: 820px) { .grid { grid-template-columns: 1fr; } } diff --git a/backend/workflows/static/workflows/css/onboarding_form.css b/backend/workflows/static/workflows/css/onboarding_form.css index a787442..a69ce71 100644 --- a/backend/workflows/static/workflows/css/onboarding_form.css +++ b/backend/workflows/static/workflows/css/onboarding_form.css @@ -1,83 +1,22 @@ -: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 { - width: min(var(--app-shell-width), 100%); - margin: 0 auto; - background: var(--card); - border: 1px solid var(--line); - border-radius: 20px; - box-shadow: 0 20px 44px rgba(16, 32, 57, 0.13); - overflow: hidden; -} - -.shell-body { - display: grid; - grid-template-columns: 290px 1fr; - gap: 16px; - padding: 18px; -} - -.top-wrap { - width: min(var(--app-shell-width), 100%); - 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 { +.workflow-sidebar-card h1 { margin: 0 0 8px; font-size: 28px; letter-spacing: -0.02em; } -.sub { +.workflow-sidebar-card .sub { margin: 0 0 16px; - color: var(--muted); font-size: 14px; } +.workflow-form-main { + padding: 22px; + border: 1px solid #d7dfeb; + border-radius: 16px; + box-shadow: 0 12px 28px rgba(30, 52, 87, 0.08); + background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); +} + .brand-logo { width: 180px; max-width: 100%; @@ -90,66 +29,15 @@ h1 { 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); +.app-flow-item { 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 { +.app-flow-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; } @@ -192,6 +80,10 @@ h1 { } .section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; border-bottom: 1px dashed #dde4f1; padding-bottom: 8px; @@ -210,6 +102,9 @@ h1 { } .section-itsetup .section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; margin: -14px -14px 16px; padding: 12px 14px; border-bottom: 1px solid #d5e2f9; @@ -252,6 +147,36 @@ h1 { margin-bottom: 0; } +.section-stammdaten .field:not(.field-full):not(.inline-check), +.section-vertrag .field:not(.field-full):not(.inline-check) { + border: 1px solid #d7e0ea; + border-radius: 14px; + background: #ffffff; + padding: 12px; + min-height: 118px; + display: flex; + flex-direction: column; + box-shadow: 0 4px 14px rgba(17, 52, 95, 0.04); +} + +.section-stammdaten .field:not(.field-full):not(.inline-check) input, +.section-stammdaten .field:not(.field-full):not(.inline-check) select, +.section-stammdaten .field:not(.field-full):not(.inline-check) textarea, +.section-vertrag .field:not(.field-full):not(.inline-check) input, +.section-vertrag .field:not(.field-full):not(.inline-check) select, +.section-vertrag .field:not(.field-full):not(.inline-check) textarea { + margin-top: auto; +} + +.section-stammdaten .field.field-full:not(.inline-check):not(.checkbox-list):not(.empty-step), +.section-vertrag .field.field-full:not(.inline-check):not(.checkbox-list):not(.empty-step) { + border: 1px solid #d7e0ea; + border-radius: 14px; + background: #ffffff; + padding: 12px; + box-shadow: 0 4px 14px rgba(17, 52, 95, 0.04); +} + .section-itsetup .field-group { border: 1px solid #d7e0ea; border-radius: 14px; @@ -348,6 +273,11 @@ h1 { transform: translateY(1px); } +.section-toggle-btn { + flex-shrink: 0; + margin-left: auto; +} + .itsetup-checklist-body { padding: 0; background: #ffffff; @@ -382,11 +312,11 @@ textarea { width: 100%; min-height: 44px; padding: 10px 12px; - border: 1px solid var(--line); + border: 1px solid #d4dbf7; border-radius: 10px; box-sizing: border-box; font-size: 14px; - background: #fff; + background: #f8fbff; color: var(--ink); } @@ -642,12 +572,8 @@ select:focus { } @media (max-width: 920px) { - .shell { - grid-template-columns: 1fr; - } - - .panel { - position: static; + .workflow-form-main { + padding: 18px; } .grid-2 { diff --git a/backend/workflows/static/workflows/css/release_checklist.css b/backend/workflows/static/workflows/css/release_checklist.css index 5011299..2e2ce5f 100644 --- a/backend/workflows/static/workflows/css/release_checklist.css +++ b/backend/workflows/static/workflows/css/release_checklist.css @@ -1,5 +1,4 @@ -body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #1b2b43; padding: 20px; } - .shell { max-width: 1120px; margin: 0 auto; } + .page-stack { width: min(1280px, 100%); margin: 0 auto; } .brand-logo { width: 190px; max-width: 100%; height: auto; margin: 0 0 10px; display: block; } .hero, .panel { background: #fff; border: 1px solid #d7e0ea; border-radius: 16px; box-shadow: 0 20px 40px rgba(17, 58, 116, 0.08); } .hero { padding: 20px; margin-bottom: 18px; } diff --git a/backend/workflows/static/workflows/css/request_timeline.css b/backend/workflows/static/workflows/css/request_timeline.css new file mode 100644 index 0000000..6f78f1e --- /dev/null +++ b/backend/workflows/static/workflows/css/request_timeline.css @@ -0,0 +1,40 @@ +.timeline-summary-grid { display:grid; grid-template-columns:repeat(5, minmax(0,1fr)); gap:16px; margin-bottom:20px; } +.timeline-stat { border:1px solid #d9e3f8; border-radius:20px; padding:16px 18px; background:linear-gradient(180deg,#ffffff 0%,#f7faff 100%); box-shadow:0 18px 40px rgba(23,39,90,.08); } +.timeline-stat label { display:block; margin-bottom:6px; font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#7081a5; } +.timeline-stat strong { display:block; font-size:18px; line-height:1.35; color:#20345f; } +.timeline-list { position:relative; display:grid; gap:18px; padding-left:28px; } +.timeline-list::before { content:""; position:absolute; top:6px; bottom:6px; left:11px; width:2px; background:linear-gradient(180deg,#cad8f8 0%,#e8eefc 100%); } +.timeline-item { position:relative; border:1px solid #d9e3f8; border-radius:20px; padding:18px 20px 18px 22px; background:#fff; box-shadow:0 18px 40px rgba(23,39,90,.08); } +.timeline-item::before { content:""; position:absolute; top:22px; left:-24px; width:16px; height:16px; border-radius:999px; background:#1e2bb8; box-shadow:0 0 0 4px #eef3ff; } +.timeline-item[data-kind="document"]::before { background:#1f7a45; } +.timeline-item[data-kind="audit"]::before { background:#6a5acd; } +.timeline-item[data-kind="session"]::before { background:#b86b12; } +.timeline-item[data-kind="email"]::before { background:#c2354e; } +.timeline-item[data-kind="milestone"]::before { background:#0d7c88; } +.timeline-head { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:8px; } +.timeline-title-wrap { display:grid; gap:8px; } +.timeline-kind { display:inline-flex; align-items:center; width:max-content; padding:6px 10px; border-radius:999px; font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; background:#eef3ff; color:#27407a; } +.timeline-item[data-kind="document"] .timeline-kind { background:#edf8f0; color:#20623c; } +.timeline-item[data-kind="audit"] .timeline-kind { background:#f2efff; color:#5b49aa; } +.timeline-item[data-kind="session"] .timeline-kind { background:#fff2df; color:#8a560a; } +.timeline-item[data-kind="email"] .timeline-kind { background:#ffe8ee; color:#9f2749; } +.timeline-item[data-kind="milestone"] .timeline-kind { background:#e8fbfd; color:#0e6a72; } +.timeline-stamp { font-size:13px; color:#6b7a9b; white-space:nowrap; } +.timeline-title { margin:0; font-size:20px; color:#20345f; } +.timeline-summary { margin:0; font-size:15px; color:#22324d; line-height:1.55; } +.timeline-meta { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; } +.timeline-chip { display:inline-flex; align-items:center; padding:6px 10px; border-radius:999px; background:#f7faff; border:1px solid #d8e1f5; color:#4d6087; font-size:12px; } +.timeline-actions { margin-top:14px; } +.timeline-details { margin-top:14px; padding-top:14px; border-top:1px dashed #d7e0f5; display:grid; gap:8px; } +.timeline-detail-row { display:grid; grid-template-columns:160px 1fr; gap:12px; font-size:13px; } +.timeline-detail-row strong { color:#566886; } +.timeline-detail-list { margin:0; padding-left:18px; color:#4f617f; } +.timeline-custom-fields { margin:0 0 20px; padding:18px 20px; border:1px solid #d9e3f8; border-radius:20px; background:linear-gradient(180deg,#ffffff 0%,#f7faff 100%); box-shadow:0 18px 40px rgba(23,39,90,.08); } +.timeline-custom-fields h2 { margin:0 0 14px; font-size:18px; color:#20345f; } +.timeline-custom-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:12px 16px; } +.timeline-custom-item { padding:12px 14px; border:1px solid #d8e1f5; border-radius:16px; background:#fff; } +.timeline-custom-item strong { display:block; margin-bottom:4px; color:#566886; font-size:12px; letter-spacing:.05em; text-transform:uppercase; } +.timeline-custom-item span { color:#22324d; font-size:14px; line-height:1.45; } +@media (max-width: 1160px) { .timeline-summary-grid { grid-template-columns:repeat(3, minmax(0,1fr)); } } +@media (max-width: 820px) { .timeline-summary-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } } +@media (max-width: 700px) { .timeline-summary-grid { grid-template-columns:1fr; } .timeline-custom-grid { grid-template-columns:1fr; } .timeline-head { flex-direction:column; } .timeline-stamp { white-space:normal; } .timeline-detail-row { grid-template-columns:1fr; } } diff --git a/backend/workflows/static/workflows/css/requests_dashboard.css b/backend/workflows/static/workflows/css/requests_dashboard.css index ca6f26a..e523c58 100644 --- a/backend/workflows/static/workflows/css/requests_dashboard.css +++ b/backend/workflows/static/workflows/css/requests_dashboard.css @@ -1,11 +1,11 @@ :root { - --brand-blue: #000078; + --brand-blue: var(--ds-brand); --brand-blue-soft: #1f4fd6; --brand-red: #a32020; - --ink: #132238; - --muted: #607086; - --line: #d9e3ee; - --line-strong: #c8d5e5; + --ink: var(--ds-ink); + --muted: var(--ds-muted); + --line: var(--ds-line); + --line-strong: var(--ds-line-strong); --panel: rgba(255, 255, 255, 0.9); --panel-strong: #ffffff; --bg-soft: #eef3ff; @@ -15,69 +15,9 @@ --warn-ink: #9a6400; --danger-bg: #fff1f1; --danger-ink: #982222; - --shadow: 0 22px 48px rgba(18, 34, 56, 0.14); + --shadow: var(--ds-shadow-shell); } - * { box-sizing: border-box; } - - body { - margin: 0; - font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; - color: var(--ink); - min-height: 100vh; - background: - radial-gradient(70% 90% at 8% 8%, rgba(0, 0, 120, 0.14), rgba(0, 0, 120, 0)), - radial-gradient(60% 85% at 92% 88%, rgba(163, 32, 32, 0.12), rgba(163, 32, 32, 0)), - linear-gradient(160deg, #eef3ff 0%, #f9fbff 48%, #edf4ff 100%); - padding: 24px; - } - - .shell { - width: min(1380px, 100%); - margin: 0 auto; - background: rgba(255, 255, 255, 0.78); - backdrop-filter: blur(12px); - border: 1px solid rgba(217, 227, 238, 0.9); - border-radius: 28px; - box-shadow: var(--shadow); - overflow: hidden; - } - - .topbar { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 18px; - padding: 22px 24px 18px; - border-bottom: 1px solid rgba(217, 227, 238, 0.9); - background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(248,251,255,0.84)); - } - - .brand-wrap { - display: flex; - flex-direction: column; - gap: 12px; - } - - .brand-logo { - width: 212px; - max-width: 100%; - height: auto; - display: block; - margin: 0; - } - - .quick-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - } - .lang-switch { display:flex; gap:6px; } - .lang-btn { border:1px solid var(--line); background:#f8fbff; color:#1f3a5f; border-radius:999px; padding:6px 10px; font-size:12px; font-weight:700; cursor:pointer; } - .lang-btn.active { background:var(--brand-blue); border-color:var(--brand-blue); color:#fff; } - .hero { padding: 0; border-bottom: 0; diff --git a/backend/workflows/static/workflows/css/success_pages.css b/backend/workflows/static/workflows/css/success_pages.css index bbb1e02..b611d08 100644 --- a/backend/workflows/static/workflows/css/success_pages.css +++ b/backend/workflows/static/workflows/css/success_pages.css @@ -1,5 +1,5 @@ -body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #1b2b43; padding: 24px; } -.shell { background: #fff; border: 1px solid #d7e0ea; border-radius: 16px; padding: 20px; box-shadow: 0 16px 36px rgba(18,34,56,0.10); } +.page-stack { max-width: 920px; } +.success-card { padding: 20px; } h1 { margin: 0 0 10px; color: #000078; } p { margin: 0 0 10px; } code { background: #f4f6fa; padding: 2px 6px; border-radius: 6px; } diff --git a/backend/workflows/static/workflows/js/form_builder.js b/backend/workflows/static/workflows/js/form_builder.js index bb3b7c4..87bd42c 100644 --- a/backend/workflows/static/workflows/js/form_builder.js +++ b/backend/workflows/static/workflows/js/form_builder.js @@ -114,14 +114,14 @@ let draggingRow = null; function getOptionInsertBeforeNode(mouseY) { - const rows = Array.from(optionTableBody.querySelectorAll('tr.option-row:not(.dragging)')); + const rows = Array.from(optionTableBody.querySelectorAll('.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) => { + optionTableBody.querySelectorAll('.option-row').forEach((row) => { row.addEventListener('dragstart', (event) => { draggingRow = row; row.classList.add('dragging'); @@ -145,4 +145,39 @@ } }); } + + const sectionRuleGrid = document.getElementById('section-rule-grid'); + if (sectionRuleGrid) { + function updateSectionMoveButtons() { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card')); + cards.forEach((card, index) => { + const upBtn = card.querySelector('[data-move-section="up"]'); + const downBtn = card.querySelector('[data-move-section="down"]'); + if (upBtn) upBtn.disabled = index === 0; + if (downBtn) downBtn.disabled = index === cards.length - 1; + }); + } + + sectionRuleGrid.addEventListener('click', (event) => { + const button = event.target.closest('[data-move-section]'); + if (!button) return; + const card = button.closest('.section-rule-card'); + if (!card) return; + const direction = button.dataset.moveSection; + if (direction === 'up') { + const previousCard = card.previousElementSibling; + if (previousCard) { + sectionRuleGrid.insertBefore(card, previousCard); + } + } else if (direction === 'down') { + const nextCard = card.nextElementSibling; + if (nextCard) { + sectionRuleGrid.insertBefore(nextCard, card); + } + } + updateSectionMoveButtons(); + }); + + updateSectionMoveButtons(); + } })(); diff --git a/backend/workflows/static/workflows/js/offboarding_form.js b/backend/workflows/static/workflows/js/offboarding_form.js index c7dec47..d62db6e 100644 --- a/backend/workflows/static/workflows/js/offboarding_form.js +++ b/backend/workflows/static/workflows/js/offboarding_form.js @@ -22,6 +22,8 @@ const fullName = byName('full_name'); const workEmail = byName('work_email'); + const form = fullName ? fullName.closest('form') : null; + const emailDomain = (((form && form.dataset.emailDomain) || 'workdock.de') + '').replace(/^@+/, '').trim(); if (!fullName || !workEmail) return; let lastSuggested = ''; @@ -31,7 +33,7 @@ const lastName = extractLastName(fullName.value); const slug = slugifyForEmail(lastName); if (!slug) return; - const suggestion = slug + '@tub.co'; + const suggestion = slug + '@' + emailDomain; const current = (workEmail.value || '').trim(); if (!userEditedEmail || current === '' || current === lastSuggested) { workEmail.value = suggestion; diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index 8ae6960..ee2ecce 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -1,10 +1,13 @@ (function () { const pages = Array.from(document.querySelectorAll('.page')); - const navItems = Array.from(document.querySelectorAll('.step-item')); + const navItems = Array.from(document.querySelectorAll('.app-flow-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'); + const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim(); + const conditionalRulesNode = document.getElementById('onboarding-conditional-rules'); + const conditionalRules = conditionalRulesNode ? JSON.parse(conditionalRulesNode.textContent || '{}') : {}; let current = 0; form.setAttribute('novalidate', 'novalidate'); @@ -13,34 +16,39 @@ const el = document.getElementById(id); if (!el) return; el.classList.toggle('hidden', !state); + el.setAttribute('aria-hidden', state ? 'false' : 'true'); + } + + function fieldState(name) { + const field = byName(name); + if (!field) return { exists: false, value: '', checked: false }; + return { + exists: true, + value: (field.value || '').trim(), + checked: !!field.checked, + }; + } + + function evaluateClause(clause) { + const state = fieldState(clause.field); + if (!state.exists) return false; + if (clause.operator === 'checked') return state.checked === !!clause.value; + if (clause.operator === 'equals') return state.value === String(clause.value); + if (clause.operator === 'not_equals') return state.value !== String(clause.value); + return false; + } + + function evaluateRule(rule) { + const all = Array.isArray(rule.all) ? rule.all : []; + return all.every(evaluateClause); } 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); + Object.entries(conditionalRules).forEach(function (entry) { + const targetId = entry[0]; + const rule = entry[1] || {}; + toggle(targetId, evaluateRule(rule)); + }); // Hidden conditional groups must not block submit with invisible required fields. document.querySelectorAll('.field-group').forEach(function (group) { @@ -59,6 +67,22 @@ }); } + function setupConditionalBindings() { + const watched = new Set(); + Object.values(conditionalRules).forEach(function (rule) { + const all = Array.isArray(rule.all) ? rule.all : []; + all.forEach(function (clause) { + if (clause.field) watched.add(clause.field); + }); + }); + watched.forEach(function (name) { + const field = byName(name); + if (!field) return; + field.addEventListener('change', syncConditionals); + field.addEventListener('input', syncConditionals); + }); + } + function slugifyForEmail(value) { const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' }; const lower = (value || '').toLowerCase(); @@ -82,7 +106,7 @@ function suggestEmail() { const slug = slugifyForEmail(lastName.value); if (!slug) return; - const suggestion = slug + '@tub.co'; + const suggestion = slug + '@' + emailDomain; if (!userEditedEmail || workEmail.value === '' || workEmail.value === lastSuggested) { workEmail.value = suggestion; lastSuggested = suggestion; @@ -198,6 +222,45 @@ }); } + function setupSectionCheckboxToggles() { + document.querySelectorAll('[data-section-checkbox-toggle]').forEach(function (button) { + const sectionCard = button.closest('.section-card'); + if (!sectionCard) return; + + const getCheckboxes = function () { + return Array.from(sectionCard.querySelectorAll('.custom-section-checkbox input[type="checkbox"]')); + }; + + const refreshButtonLabel = function () { + const checkboxes = getCheckboxes(); + if (!checkboxes.length) { + button.classList.add('hidden'); + return; + } + button.classList.remove('hidden'); + const allChecked = checkboxes.every(function (box) { return box.checked; }); + button.textContent = allChecked ? (button.dataset.labelClear || 'Auswahl aufheben') : (button.dataset.labelSelect || 'Alle auswählen'); + }; + + button.addEventListener('click', function () { + const checkboxes = getCheckboxes(); + if (!checkboxes.length) return; + const shouldCheck = checkboxes.some(function (box) { return !box.checked; }); + checkboxes.forEach(function (box) { + box.checked = shouldCheck; + box.dispatchEvent(new Event('change', { bubbles: true })); + }); + refreshButtonLabel(); + }); + + getCheckboxes().forEach(function (box) { + box.addEventListener('change', refreshButtonLabel); + }); + + refreshButtonLabel(); + }); + } + function setupChecklistColumns() { document.querySelectorAll('.itsetup-checklist-body > [id^="id_"]').forEach(function (container) { const itemCount = container.querySelectorAll(':scope > div').length; @@ -207,7 +270,7 @@ function updateStep() { pages.forEach((p, i) => p.classList.toggle('active', i === current)); - navItems.forEach((n, i) => n.classList.toggle('active', i === current)); + navItems.forEach((n, i) => n.classList.toggle('is-active', i === current)); btnPrev.disabled = current === 0; const last = current === pages.length - 1; btnNext.classList.toggle('hidden', last); @@ -223,7 +286,6 @@ 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) { @@ -244,9 +306,11 @@ }); syncConditionals(); + setupConditionalBindings(); setupWorkEmailAutofill(); setupBusinessCardAutofill(); setupChecklistToggles(); + setupSectionCheckboxToggles(); setupChecklistColumns(); jumpToFirstErrorPage(); updateStep(); diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 56c305d..008e197 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -4,7 +4,7 @@ import base64 import mimetypes import re -from celery import shared_task +from celery import current_task, shared_task from django.contrib.auth import get_user_model from django.conf import settings from django.utils import timezone @@ -13,8 +13,9 @@ from jinja2 import Template from pypdf import PageObject, PdfReader, PdfWriter from xhtml2pdf import pisa -from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig -from .emailing import send_system_email +from .branding import get_branding_email_copy, get_company_contact_copy, get_portal_letterhead_path +from . import email_workflows, notification_dispatch, pdf_rendering +from .models import AsyncTaskLog, EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .services import upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode from .forms import ( @@ -27,7 +28,11 @@ from .forms import ( SOFTWARE_EXTRA_CHOICES, WORKSPACE_GROUP_CHOICES, ) +from .pdf_sections import build_pdf_sections +# These templates are the product-level defaults for fresh deployments. +# Runtime branding and company config can override the company-facing identity +# without changing the workflow/task logic itself. DEFAULT_NOTIFICATION_TEMPLATES = { 'onboarding_it': { @@ -100,7 +105,7 @@ DEFAULT_NOTIFICATION_TEMPLATES = { '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' + 'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei {{ SUPPORT_EMAIL }} melden!\n\n' 'Vielen Dank und schöne Grüße,\n' 'Die IT.' ), @@ -113,7 +118,7 @@ DEFAULT_NOTIFICATION_TEMPLATES = { 'Contract start: {{ CONTRACT_START }}\n' 'Email address: {{ EMAIL }}\n\n' '{% if PDF_LINK %}You will find the employee PDF here in about 2 minutes: {{ PDF_LINK }}\n\n{% endif %}' - 'If you need any other information, please contact it@tub.co.\n\n' + 'If you need any other information, please contact {{ SUPPORT_EMAIL }}.\n\n' 'Thank you and best regards,\n' 'IT' ), @@ -157,27 +162,27 @@ DEFAULT_NOTIFICATION_TEMPLATES = { ), }, 'onboarding_welcome': { - 'subject': 'Willkommen bei TUB/CO, {{ VORNAME }}', - 'subject_en': 'Welcome to TUB/CO, {{ VORNAME }}', + 'subject': 'Willkommen bei {{ COMPANY_NAME }}, {{ VORNAME }}', + 'subject_en': 'Welcome to {{ COMPANY_NAME }}, {{ VORNAME }}', 'body': ( 'Hallo {{ FULL_NAME }},\n\n' - 'herzlich willkommen bei TUB/CO.\n' + 'herzlich willkommen bei {{ COMPANY_NAME }}.\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' + '{{ COMPANY_NAME }} IT' ), 'body_en': ( 'Hello {{ FULL_NAME }},\n\n' - 'welcome to TUB/CO.\n' + 'welcome to {{ COMPANY_NAME }}.\n' 'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n' 'Your work email address is: {{ EMAIL }}.\n' 'You will find your onboarding documents attached as a PDF.\n\n' 'If you have any questions, feel free to contact us anytime.\n\n' 'Best regards,\n' - 'TUB/CO IT' + '{{ COMPANY_NAME }} IT' ), }, 'offboarding_it': { @@ -246,212 +251,77 @@ DEFAULT_NOTIFICATION_TEMPLATES = { }, } + +def _notify_request_result(*, recipient_email: str, title: str, body: str, level: str, event_key: str) -> None: + return notification_dispatch.notify_request_result( + recipient_email=recipient_email, + title=title, + body=body, + level=level, + event_key=event_key, + ) + + +def _notify_welcome_email_result(*, recipient_email: str, full_name: str, body: str, level: str, event_key: str) -> None: + return notification_dispatch.notify_welcome_email_result( + recipient_email=recipient_email, + full_name=full_name, + body=body, + level=level, + event_key=event_key, + ) + + +def _start_task_log(task_name: str, *, target_type: str = '', target_id: int | None = None, target_label: str = '') -> AsyncTaskLog: + task_request = getattr(current_task, 'request', None) + return AsyncTaskLog.objects.create( + task_name=task_name, + task_id=getattr(task_request, 'id', '') or '', + target_type=target_type, + target_id=target_id, + target_label=target_label, + status='started', + ) + + +def _finish_task_log(task_log: AsyncTaskLog | None, *, status: str, error_message: str = '') -> None: + if not task_log: + return + task_log.status = status + task_log.error_message = error_message + task_log.finished_at = timezone.now() + task_log.save(update_fields=['status', 'error_message', 'finished_at']) + def _split_name(full_name: str) -> tuple[str, str]: - parts = full_name.split() - if not parts: - return '', '' - return parts[0], ' '.join(parts[1:]) + return pdf_rendering._split_name(full_name) 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 + return pdf_rendering._safe_filename_fragment(text, fallback=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() + return pdf_rendering._resolve_user_display_name(email) 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 + return pdf_rendering._chunk_list(data_list, chunk_size=chunk_size) def _split_multiline(text: str) -> list[str]: - return [line.strip() for line in (text or '').split('\n') if line.strip()] + return pdf_rendering._split_multiline(text) def _chunk_choice_labels(choices: list[tuple[str, str]], chunk_size: int = 3) -> list[list[str]]: - labels = [label for _, label in choices] - return _chunk_list(labels, chunk_size=chunk_size) + return pdf_rendering._chunk_choice_labels(choices, chunk_size=chunk_size) def _normalized_lang(language_code: str | None) -> str: - return (language_code or 'de').split('-')[0].lower() or 'de' + return pdf_rendering._normalized_lang(language_code) def _pdf_texts(language_code: str | None = None) -> dict[str, str]: - lang = _normalized_lang(language_code) - texts = { - 'de': { - 'lang': 'de', - 'not_available': 'Keine Angabe', - 'not_available_short': '-', - 'yes': 'Ja', - 'no': 'Nein', - 'onboarding_title': 'Onboarding-Unterlagen', - 'onboarding_staff_data': 'Personaldaten', - 'name': 'Name', - 'department': 'Abteilung', - 'job_title': 'Berufsbezeichnung', - 'work_email': 'Dienstliche E-Mail', - 'employment_type': 'Beschäftigungsverhältnis', - 'contract_start': 'Vertragsbeginn', - 'contract_end': 'Vertragsende', - 'handover_date': 'Übergabedatum', - 'equipment_access': 'Ausstattung und Zugänge', - 'devices': 'Benötigte Geräte und Gegenstände', - 'workspace_groups': 'Benötigte Gruppen im Workspace', - 'software': 'Benötigte Software', - 'accesses': 'Benötigte Zugänge', - 'resources': 'Benötigte Ressourcen', - 'group_mailboxes_required': 'Gruppenpostfächer erforderlich', - 'additional_hardware_needed': 'Darüber hinaus wird weitere Hardware benötigt', - 'additional_software_needed': 'Wird zusätzliche Software benötigt', - 'additional_access_needed': 'Darüber hinaus werden weitere Zugänge benötigt', - 'additional_details': 'Zusätzliche Angaben', - 'business_cards': 'Visitenkarten', - 'email': 'E-Mail', - 'phone': 'Telefon', - 'additional_hardware_other': 'Weitere Hardware (Freitext)', - 'successor_phone': 'Nachfolge und Telefon', - 'successor_of': 'Nachfolge von', - 'inherit_phone_number': 'Telefon von Vorgänger übernehmen', - 'direct_extension': 'Direktwahl', - 'notes': 'Notizen', - 'confirmation': 'Bestätigung', - 'requested_by_name': 'Angefordert von (Name)', - 'requested_by_email': 'Angefordert von (E-Mail)', - 'signature': 'Unterschrift', - 'signature_alt': 'Unterschrift', - 'onboarding_note': 'Hinweis: Dieses Formular dient als interne Prozessgrundlage für das Onboarding.', - 'offboarding_title': 'Offboarding-Unterlagen', - 'employee_info': 'Mitarbeitenden-Informationen', - 'last_working_day': 'Letzter Arbeitstag', - 'offboarding_requester': 'Offboarding-Anfordernde Person', - 'it_hardware_status': 'IT-Hardware-Status (aus Onboarding)', - 'hardware_check': 'Hardware-Check', - 'no_onboarding_hardware': 'Keine Onboarding-Hardwaredaten gefunden.', - 'manual_return_overview': 'Manuelle Rückgabeübersicht', - 'manual_return_note': 'Es wurden keine gespeicherten Onboarding-Daten zu dieser Person gefunden. Die folgenden Listen dienen als manuelle Rückgabe- und Prüfübersicht.', - 'returned_devices': 'Zurückgegebene Geräte und Artikel', - 'returned_software': 'Zurückgegebene bzw. deaktivierte Software', - 'removed_workspace_groups': 'Entfernte Gruppen im Workspace', - 'removed_accesses': 'Entfernte Zugänge', - 'returned_extra_it': 'Zurückgegebene zusätzliche Hardware / Software', - 'it_signatures': 'IT-Section: Signaturen', - 'it_checked_by': 'IT geprüft am durch:', - 'it_signature': 'IT-Unterschrift:', - 'return_complete': 'Rückgabe vollständig:', - 'offboarding_note': 'Hinweis: Dieses Formular dient als interne Prozessgrundlage für das Offboarding.', - 'intro_title': 'Einweisungs- und Übergabeprotokoll', - 'intro_sub': 'Gesprächsleitfaden für die persönliche Einführung neuer Mitarbeitender.', - 'base_data': 'Basisdaten', - 'start_date': 'Startdatum', - 'introduced_by': 'Einweisung durch', - 'intro_note': 'Dieses Dokument dient als Gesprächsleitfaden für die persönliche Einweisung. Die Felder können während des Termins manuell abgehakt und anschließend unterschrieben werden.', - 'employee_signature': 'Unterschrift Mitarbeitende Person:', - 'trainer_signature': 'Unterschrift Einweisende Person:', - 'intro_completed_at': 'Einweisung durchgeführt am:', - 'open_questions': 'Rückfragen offen / Nacharbeit erforderlich:', - 'live_intro_title': 'Einweisungsprotokoll', - 'live_intro_sub': 'Export des aktuellen Live-Status aus der webbasierten Einweisung.', - 'employment_start': 'Vertragsbeginn', - 'employee_signature_block': 'Unterschrift Mitarbeitende Person', - }, - 'en': { - 'lang': 'en', - 'not_available': 'Not provided', - 'not_available_short': '-', - 'yes': 'Yes', - 'no': 'No', - 'onboarding_title': 'Onboarding Documents', - 'onboarding_staff_data': 'Employee Details', - 'name': 'Name', - 'department': 'Department', - 'job_title': 'Job title', - 'work_email': 'Work email', - 'employment_type': 'Employment type', - 'contract_start': 'Contract start', - 'contract_end': 'Contract end', - 'handover_date': 'Handover date', - 'equipment_access': 'Equipment and access', - 'devices': 'Required devices and items', - 'workspace_groups': 'Required workspace groups', - 'software': 'Required software', - 'accesses': 'Required accesses', - 'resources': 'Required resources', - 'group_mailboxes_required': 'Group mailboxes required', - 'additional_hardware_needed': 'Additional hardware required', - 'additional_software_needed': 'Additional software required', - 'additional_access_needed': 'Additional accesses required', - 'additional_details': 'Additional details', - 'business_cards': 'Business cards', - 'email': 'Email', - 'phone': 'Phone', - 'additional_hardware_other': 'Additional hardware (free text)', - 'successor_phone': 'Successor and phone', - 'successor_of': 'Successor to', - 'inherit_phone_number': 'Take over predecessor phone number', - 'direct_extension': 'Direct extension', - 'notes': 'Notes', - 'confirmation': 'Confirmation', - 'requested_by_name': 'Requested by (name)', - 'requested_by_email': 'Requested by (email)', - 'signature': 'Signature', - 'signature_alt': 'Signature', - 'onboarding_note': 'Note: This form serves as the internal process basis for onboarding.', - 'offboarding_title': 'Offboarding Documents', - 'employee_info': 'Employee information', - 'last_working_day': 'Last working day', - 'offboarding_requester': 'Offboarding requester', - 'it_hardware_status': 'IT hardware status (from onboarding)', - 'hardware_check': 'Hardware check', - 'no_onboarding_hardware': 'No onboarding hardware data found.', - 'manual_return_overview': 'Manual return overview', - 'manual_return_note': 'No stored onboarding data was found for this person. The following lists serve as a manual return and review overview.', - 'returned_devices': 'Returned devices and items', - 'returned_software': 'Returned or disabled software', - 'removed_workspace_groups': 'Removed workspace groups', - 'removed_accesses': 'Removed accesses', - 'returned_extra_it': 'Returned additional hardware / software', - 'it_signatures': 'IT section: signatures', - 'it_checked_by': 'Checked by IT on:', - 'it_signature': 'IT signature:', - 'return_complete': 'Return complete:', - 'offboarding_note': 'Note: This form serves as the internal process basis for offboarding.', - 'intro_title': 'Introduction and Handover Protocol', - 'intro_sub': 'Conversation guide for the personal introduction of new employees.', - 'base_data': 'Basic data', - 'start_date': 'Start date', - 'introduced_by': 'Introduction by', - 'intro_note': 'This document serves as a conversation guide for the personal introduction. The fields can be checked manually during the meeting and signed afterwards.', - 'employee_signature': 'Employee signature:', - 'trainer_signature': 'Trainer signature:', - 'intro_completed_at': 'Introduction completed on:', - 'open_questions': 'Open questions / follow-up required:', - 'live_intro_title': 'Introduction Protocol', - 'live_intro_sub': 'Export of the current live status from the web-based introduction.', - 'employment_start': 'Contract start', - 'employee_signature_block': 'Employee signature', - }, - } - return texts.get(lang, texts['de']) + return pdf_rendering._pdf_texts(language_code) MANUAL_ONBOARDING_FIELD_SECTIONS = [ @@ -509,137 +379,23 @@ MANUAL_ONBOARDING_FIELD_SECTIONS = [ def _manual_onboarding_field_sections() -> list[dict]: - fields = OnboardingRequestForm.base_fields - sections = [] - for title, field_names in MANUAL_ONBOARDING_FIELD_SECTIONS: - labels = [str(fields[name].label or name) for name in field_names if name in fields] - if not labels: - continue - sections.append({'title': title, 'rows': _chunk_list(labels, chunk_size=2)}) - return sections + return pdf_rendering._manual_onboarding_field_sections() 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 + return email_workflows.resolve_workflow_emails() def _matches_intro_condition(request_obj: OnboardingRequest, item: IntroChecklistItem) -> bool: - operator = (item.condition_operator or 'always').strip() - field_name = (item.condition_field or '').strip() - expected = (item.condition_value or '').strip() - - if operator == 'always' or not field_name: - return True - - raw_value = getattr(request_obj, field_name, '') - if raw_value is None: - raw_value = '' - - if operator == 'is_true': - return bool(raw_value) - if operator == 'is_false': - return not bool(raw_value) - - text_value = str(raw_value).strip() - if operator == 'equals': - return text_value.lower() == expected.lower() - if operator == 'contains': - return expected.lower() in text_value.lower() - return True + return pdf_rendering._matches_intro_condition(request_obj, item) def _build_intro_sections_from_admin(request_obj: OnboardingRequest, language_code: str | None = None) -> dict[str, list[str]]: - items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label')) - if not items: - return {} - - section_map = {key: [] for key, _label in IntroChecklistItem.SECTION_CHOICES} - for item in items: - if item.section not in section_map: - continue - if _matches_intro_condition(request_obj, item): - section_map[item.section].append(item.translated_label(language_code)) - return {key: values for key, values in section_map.items() if values} + return pdf_rendering._build_intro_sections_from_admin(request_obj, language_code=language_code) def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]: - lang = _normalized_lang(language_code or get_language()) - with override(lang): - section_titles = { - 'workplace': _('Geräte und Arbeitsplatz'), - 'accounts': _('Konten und Berechtigungen'), - 'software': _('Software und Tools'), - 'process': _('Prozesse und Hinweise'), - } - 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) - extra_hardware = _split_multiline(request_obj.additional_hardware) - extra_software = _split_multiline(request_obj.additional_software) - group_mailboxes = _split_multiline(request_obj.group_mailboxes) - - workplace_items = [] - for item in devices: - workplace_items.append(_('%(item)s übergeben und Grundfunktionen erklärt') % {'item': item}) - for item in resources: - workplace_items.append(_('%(item)s gezeigt bzw. Nutzung erklärt') % {'item': item}) - if request_obj.phone_number: - workplace_items.append(_('Telefonnummer / Direktwahl erklärt: %(value)s') % {'value': request_obj.phone_number}) - if not workplace_items: - workplace_items.append(_('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen')) - - account_items = [_('%(item)s Zugang erklärt') % {'item': item} for item in accesses] - account_items.extend([_('%(item)s Gruppe / Berechtigung erläutert') % {'item': item} for item in groups]) - if request_obj.work_email: - account_items.insert(0, _('Dienstliche E-Mail-Adresse erläutert: %(value)s') % {'value': request_obj.work_email}) - if group_mailboxes: - account_items.extend([_('Gruppenpostfach erklärt: %(item)s') % {'item': item} for item in group_mailboxes]) - if not account_items: - account_items.append(_('Zugänge, Konten und Anmeldelogik besprochen')) - - software_items = [_('%(item)s Einführung durchgeführt') % {'item': item} for item in software] - software_items.extend([_('%(item)s zusätzlich besprochen') % {'item': item} for item in extra_software]) - if not software_items: - software_items.append(_('Benötigte Standardsoftware und tägliche Nutzung erklärt')) - - process_items = [ - _('Passwortregeln und sicherer Umgang besprochen'), - _('Dateiablage, Nextcloud und Freigaben erklärt'), - _('Kommunikationswege und Support-Prozess erklärt'), - ] - if extra_hardware: - process_items.extend([_('%(item)s als zusätzliche Ausstattung besprochen') % {'item': item} for item in extra_hardware]) - if request_obj.additional_access_text: - process_items.extend([_('Zusätzlicher Zugang besprochen: %(item)s') % {'item': item} for item in _split_multiline(request_obj.additional_access_text)]) - if request_obj.successor_name: - process_items.append(_('Übergabe-/Nachfolgekontext besprochen: %(value)s') % {'value': request_obj.successor_name}) - - custom_intro_items = _build_intro_sections_from_admin(request_obj, lang) - intro_sections_raw = [ - ('workplace', section_titles['workplace'], workplace_items), - ('accounts', section_titles['accounts'], account_items), - ('software', section_titles['software'], software_items), - ('process', section_titles['process'], process_items), - ] - - sections = [] - for key, title, default_items in intro_sections_raw: - merged_items = list(default_items) - merged_items.extend(custom_intro_items.get(key, [])) - section_items = [] - for idx, label in enumerate(merged_items, start=1): - section_items.append({'id': f'{key}_{idx}', 'label': label}) - if section_items: - sections.append({'key': key, 'title': title, 'items': section_items}) - return sections + return pdf_rendering.build_intro_sections_for_request(request_obj, language_code=language_code) def _send_workflow_email( @@ -649,77 +405,33 @@ def _send_workflow_email( 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( + return email_workflows.send_workflow_email( subject=subject, - body=effective_body, - to=effective_to, - attachments=[str(a) for a in (attachments or [])], + body=body, + to=to, + attachments=attachments, from_email=from_email, ) def _render_notification_template(template_key: str, context: dict, language_code: str | None = None) -> tuple[str, str]: - lang = (language_code or 'de').split('-')[0] - db_template = NotificationTemplate.objects.filter(key=template_key, is_active=True).first() - if db_template: - subject_template = db_template.translated_subject_template(lang) - body_template = db_template.translated_body_template(lang) - else: - fallback = DEFAULT_NOTIFICATION_TEMPLATES[template_key] - subject_template = fallback.get(f'subject_{lang}', '') or fallback['subject'] - body_template = fallback.get(f'body_{lang}', '') or fallback['body'] - - subject = Template(subject_template).render(context).strip() - body = Template(body_template).render(context).strip() - return subject, body + return email_workflows.render_notification_template( + template_key, + context, + language_code=language_code, + ) 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()] + return email_workflows.parse_recipients(raw) 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'} + return email_workflows.as_bool(value) 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 + return email_workflows.rule_matches(rule, request_obj) def _apply_notification_rules( @@ -768,32 +480,10 @@ def _apply_notification_rules( 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, - }, + return email_workflows.schedule_welcome_email( + request_obj, + send_scheduled_welcome_email_task=send_scheduled_welcome_email, ) - 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( @@ -809,229 +499,23 @@ def _send_templated_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) + return pdf_rendering._render_html(template_path, context) def _generate_content_pdf(html_content: str, output_pdf: Path) -> None: - page_style = ( - '' - ) - if '' in html_content: - html_content = html_content.replace('', f'{page_style}', 1) - else: - html_content = page_style + html_content - - output_pdf.parent.mkdir(parents=True, exist_ok=True) - with output_pdf.open('wb') as fp: - result = pisa.CreatePDF( - src=html_content, - dest=fp, - encoding='utf-8', - ) - if result.err: - raise RuntimeError(f'Failed to render PDF content for {output_pdf.name}') + return pdf_rendering._generate_content_pdf(html_content, output_pdf) 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) + return pdf_rendering._overlay_with_letterhead(content_pdf, letterhead_pdf, output_pdf) def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path: - lang = _normalized_lang(request_obj.preferred_language) - t = _pdf_texts(lang) - 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 = t['not_available_short'] - 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 = 'Digital signature stored as image file.' if lang == 'en' else 'Digitale Signatur als Bilddatei hinterlegt.' - except Exception: - signature_src = '' - signature_note = request_obj.signature_url or t['not_available_short'] - elif request_obj.signature_url: - signature_note = request_obj.signature_url - - requester_email = request_obj.onboarded_by_email or t['not_available_short'] - requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or t['not_available_short'] - gender = (request_obj.get_gender_display() or t['not_available_short']).strip() or t['not_available_short'] - employment_type = (request_obj.employment_type or t['not_available_short']).strip() or t['not_available_short'] - employment_end = request_obj.employment_end_date or t['not_available_short'] - 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() - display_name = f"{gender} {first_name} {last_name}".strip() if gender and gender != '-' else f"{first_name} {last_name}".strip() - - context = { - 'T': t, - 'PDF_LANG': lang, - 'VORNAME': first_name, - 'NACHNAME': last_name, - 'DISPLAY_NAME': display_name or request_obj.full_name, - 'ANREDE': gender, - 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available'], - 'ABTEILUNG': request_obj.department or t['not_available'], - 'EMAIL': request_obj.work_email or t['not_available'], - 'VERTRAGSBEGINN': request_obj.contract_start, - 'BESCHAEFTIGUNG': employment_type, - 'VERTRAGSENDE': employment_end, - 'UEBERGABEDATUM': request_obj.handover_date or t['not_available_short'], - 'ARBEITSGERAETE_TEXT': ' | '.join(devices) if devices else t['not_available'], - 'WORKSPACE_GROUPS_TEXT': ' | '.join(groups) if groups else t['not_available'], - 'SOFTWARE_TEXT': ' | '.join(software) if software else t['not_available'], - 'ZUGAENGE_TEXT': ' | '.join(accesses) if accesses else t['not_available'], - 'RESSOURCEN_TEXT': ' | '.join(resources) if resources else t['not_available'], - '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 t['not_available_short'], - 'VISITENKARTE_TITEL': request_obj.business_card_title or t['not_available_short'], - 'VISITENKARTE_EMAIL': request_obj.business_card_email or t['not_available_short'], - 'VISITENKARTE_TELEFON': request_obj.business_card_phone or t['not_available_short'], - 'GROUP_MAILBOXES': group_mailboxes or t['not_available'], - 'ADDITIONAL_HARDWARE_OTHER': additional_hardware_other or t['not_available'], - 'ADDITIONAL_HARDWARE': additional_hardware or t['not_available'], - 'ADDITIONAL_SOFTWARE': additional_software or t['not_available'], - 'ADDITIONAL_ACCESS_TEXT': additional_access_text or t['not_available'], - 'SUCCESSOR_NAME': successor_name or t['not_available'], - 'PHONE_NUMBER': phone_number or t['not_available_short'], - 'INHERIT_PHONE_NUMBER': t['yes'] if request_obj.inherit_phone_number else t['no'], - 'ADDITIONAL_NOTES': additional_notes or t['not_available'], - '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 + return pdf_rendering._generate_onboarding_pdf(request_obj) def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code: str | None = None) -> Path: - lang = _normalized_lang(language_code or request_obj.preferred_language) - t = _pdf_texts(lang) - safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_{request_obj.id}') - output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_{safe_name}.pdf' - temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf' - - template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html' - letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' - - salutation = (request_obj.get_gender_display() or '').strip() - display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name - intro_sections = [ - { - 'title': section['title'], - 'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2), - } - for section in build_intro_sections_for_request(request_obj, language_code=language_code) - ] - - 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 '-' - - context = { - 'T': t, - 'DISPLAY_NAME': display_name, - 'ABTEILUNG': request_obj.department or t['not_available_short'], - 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available_short'], - 'VERTRAGSBEGINN': request_obj.contract_start, - 'EMAIL': request_obj.work_email or t['not_available_short'], - 'REQUESTED_BY_NAME': requester_name, - 'REQUESTED_BY_EMAIL': requester_email, - 'INTRO_SECTIONS': intro_sections, - } - - 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 + return pdf_rendering._generate_onboarding_intro_pdf(request_obj, language_code=language_code) def _generate_onboarding_intro_session_pdf( @@ -1039,142 +523,32 @@ def _generate_onboarding_intro_session_pdf( admin_signature_name: str = '-', language_code: str | None = None, ) -> Path: - request_obj = session.onboarding_request - lang = _normalized_lang(language_code or request_obj.preferred_language) - t = _pdf_texts(lang) - safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_session_{request_obj.id}') - version = timezone.now().strftime('%Y%m%d%H%M%S') - output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_session_{safe_name}_{version}.pdf' - temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf' - - template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html' - letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' - - salutation = (request_obj.get_gender_display() or '').strip() - display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name - - raw_sections = build_intro_sections_for_request(request_obj, language_code=language_code) - checked_map = session.checklist_state or {} - exported_sections = [] - checked_count = 0 - total_count = 0 - for section in raw_sections: - checked_items = [] - for item in section['items']: - checked = bool(checked_map.get(item['id'])) - total_count += 1 - if checked: - checked_count += 1 - checked_items.append({'label': item['label']}) - if checked_items: - exported_sections.append({ - 'title': section['title'], - 'rows': [checked_items[i:i + 2] for i in range(0, len(checked_items), 2)], - }) - - 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 '-' - - context = { - 'T': t, - 'DISPLAY_NAME': display_name, - 'ABTEILUNG': request_obj.department or t['not_available_short'], - 'BERUFSBEZEICHNUNG': request_obj.job_title or t['not_available_short'], - 'VERTRAGSBEGINN': request_obj.contract_start, - 'EMAIL': request_obj.work_email or t['not_available_short'], - 'REQUESTED_BY_NAME': requester_name, - 'REQUESTED_BY_EMAIL': requester_email, - 'SESSION_STATUS': session.get_status_display(), - 'SESSION_COMPLETED_BY': session.completed_by_name or '-', - 'SESSION_COMPLETED_AT': session.completed_at or '-', - 'SESSION_UPDATED_AT': session.updated_at, - 'SESSION_NOTES': session.notes or t['not_available_short'], - 'INTRO_SECTIONS': exported_sections, - } - - 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 + return pdf_rendering._generate_onboarding_intro_session_pdf( + session, + admin_signature_name=admin_signature_name, + language_code=language_code, + ) def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path: - lang = _normalized_lang(request_obj.preferred_language) - t = _pdf_texts(lang) - 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() - ) - has_onboarding_data = latest_onboarding is not None - 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 t['not_available_short'] - requester_name = request_obj.requested_by_name or _resolve_user_display_name(request_obj.requested_by_email) or t['not_available_short'] - - context = { - 'T': t, - 'FULL_NAME': request_obj.full_name, - 'EMAIL': request_obj.work_email, - 'DEPARTMENT': request_obj.department or t['not_available_short'], - 'JOB_TITLE': request_obj.job_title or t['not_available_short'], - 'LAST_WORKING_DAY': request_obj.last_working_day, - 'NOTES': request_obj.notes or t['not_available_short'], - 'REQUESTED_BY': requester_email, - 'REQUESTED_BY_NAME': requester_name, - 'HAS_ONBOARDING_DATA': has_onboarding_data, - 'ONBOARDING_HARDWARE': onboarding_hardware, - 'HARDWARE_CHECKLIST': checklist, - 'MANUAL_FIELD_SECTIONS': _manual_onboarding_field_sections(), - 'MANUAL_DEVICES': _chunk_choice_labels(DEVICE_CHOICES), - 'MANUAL_SOFTWARE': _chunk_choice_labels(SOFTWARE_CHOICES), - 'MANUAL_ACCESSES': _chunk_choice_labels(ACCESS_CHOICES), - 'MANUAL_WORKSPACE_GROUPS': _chunk_choice_labels(WORKSPACE_GROUP_CHOICES), - 'MANUAL_RESOURCES': _chunk_choice_labels(RESOURCE_CHOICES), - 'MANUAL_EXTRA_HARDWARE': _chunk_choice_labels(HARDWARE_EXTRA_CHOICES), - 'MANUAL_EXTRA_SOFTWARE': _chunk_choice_labels(SOFTWARE_EXTRA_CHOICES), - } - - 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 + return pdf_rendering._generate_offboarding_pdf(request_obj) @shared_task def process_onboarding_request(onboarding_request_id: int) -> None: request_obj = OnboardingRequest.objects.get(id=onboarding_request_id) + task_log = _start_task_log( + 'process_onboarding_request', + target_type='onboarding_request', + target_id=request_obj.id, + target_label=request_obj.full_name, + ) request_obj.processing_status = 'processing' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) try: + branding_copy = get_branding_email_copy() + company_contact = get_company_contact_copy() 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() @@ -1203,6 +577,10 @@ def process_onboarding_request(onboarding_request_id: int) -> None: 'CONTRACT_START': request_obj.contract_start, 'EMAIL': request_obj.work_email, 'REQUESTED_BY': request_obj.onboarded_by_email or '-', + 'SUPPORT_EMAIL': company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}", + 'IT_CONTACT_EMAIL': company_contact['it_contact_email'], + 'HR_CONTACT_EMAIL': company_contact['hr_contact_email'], + 'OPERATIONS_CONTACT_EMAIL': company_contact['operations_contact_email'], '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, @@ -1270,21 +648,45 @@ def process_onboarding_request(onboarding_request_id: int) -> None: request_obj.processing_status = 'completed' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.onboarded_by_email, + title=_('Onboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name}, + body=_('Die Onboarding-Anfrage wurde erfolgreich verarbeitet.'), + level='success', + event_key='onboarding_success', + ) + _finish_task_log(task_log, status='succeeded') except Exception as exc: request_obj.processing_status = 'failed' request_obj.last_error = str(exc) request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.onboarded_by_email, + title=_('Onboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name}, + body=str(exc), + level='error', + event_key='onboarding_failure', + ) + _finish_task_log(task_log, status='failed', error_message=str(exc)) raise @shared_task def process_offboarding_request(offboarding_request_id: int) -> None: request_obj = OffboardingRequest.objects.get(id=offboarding_request_id) + task_log = _start_task_log( + 'process_offboarding_request', + target_type='offboarding_request', + target_id=request_obj.id, + target_label=request_obj.full_name, + ) request_obj.processing_status = 'processing' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) try: - it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails() + branding_copy = get_branding_email_copy() + company_contact = get_company_contact_copy() + it_email, general_info_email, business_card_email_unused, hr_works_email, key_email_unused = _resolve_workflow_emails() pdf_path = _generate_offboarding_pdf(request_obj) request_obj.generated_pdf_path = str(pdf_path) @@ -1296,6 +698,10 @@ def process_offboarding_request(offboarding_request_id: int) -> None: 'LAST_WORKING_DAY': request_obj.last_working_day, 'REQUESTED_BY': request_obj.requested_by_email, 'EMAIL': request_obj.work_email, + 'SUPPORT_EMAIL': company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}", + 'IT_CONTACT_EMAIL': company_contact['it_contact_email'], + 'HR_CONTACT_EMAIL': company_contact['hr_contact_email'], + 'OPERATIONS_CONTACT_EMAIL': company_contact['operations_contact_email'], } _send_templated_email( @@ -1343,10 +749,26 @@ def process_offboarding_request(offboarding_request_id: int) -> None: request_obj.processing_status = 'completed' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.requested_by_email, + title=_('Offboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name}, + body=_('Die Offboarding-Anfrage wurde erfolgreich verarbeitet.'), + level='success', + event_key='offboarding_success', + ) + _finish_task_log(task_log, status='succeeded') except Exception as exc: request_obj.processing_status = 'failed' request_obj.last_error = str(exc) request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.requested_by_email, + title=_('Offboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name}, + body=str(exc), + level='error', + event_key='offboarding_failure', + ) + _finish_task_log(task_log, status='failed', error_message=str(exc)) raise @@ -1355,15 +777,24 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals scheduled = ScheduledWelcomeEmail.objects.select_related('onboarding_request').filter(id=scheduled_email_id).first() if not scheduled: return + task_log = _start_task_log( + 'send_scheduled_welcome_email', + target_type='scheduled_welcome_email', + target_id=scheduled.id, + target_label=scheduled.recipient_email, + ) if scheduled.status in {'sent', 'cancelled'} and not force_now: + _finish_task_log(task_log, status='succeeded') return if scheduled.status == 'paused' and not force_now: + _finish_task_log(task_log, status='succeeded') 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']) + _finish_task_log(task_log, status='succeeded') return request_obj = scheduled.onboarding_request @@ -1404,9 +835,25 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals scheduled.status = 'sent' scheduled.sent_at = timezone.now() scheduled.last_error = '' + _notify_welcome_email_result( + recipient_email=request_obj.onboarded_by_email, + full_name=request_obj.full_name, + body=_('Die geplante Welcome E-Mail wurde erfolgreich versendet.'), + level='success', + event_key='welcome_email_success', + ) + _finish_task_log(task_log, status='succeeded') except Exception as exc: scheduled.status = 'failed' scheduled.last_error = str(exc) + _notify_welcome_email_result( + recipient_email=request_obj.onboarded_by_email, + full_name=request_obj.full_name, + body=str(exc), + level='error', + event_key='welcome_email_failure', + ) + _finish_task_log(task_log, status='failed', error_message=str(exc)) raise finally: scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at']) diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html new file mode 100644 index 0000000..12919a8 --- /dev/null +++ b/backend/workflows/templates/workflows/account_profile.html @@ -0,0 +1,610 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Profil" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/backend/workflows/templates/workflows/app_registry.html b/backend/workflows/templates/workflows/app_registry.html new file mode 100644 index 0000000..55f4f49 --- /dev/null +++ b/backend/workflows/templates/workflows/app_registry.html @@ -0,0 +1,415 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} +{% trans "Ungespeicherte Änderungen" as dirty_state_label %} +{% trans "Sortierung" as sort_label %} + +{% block title %}{% trans "App Registry" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +
+ + +{% include 'workflows/includes/messages.html' %} + +
+
+
{% trans "Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." %}
+ {% trans "Produktkern" %} +
+
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ + +
+
+
{% trans "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." %}
+
+ {% for section_key, section_label in section_choices %} +
+
+
+

{{ section_label }}

+

+ {% if section_key == 'platform' %} + {% trans "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." %} + {% elif section_key == 'admin' %} + {% trans "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." %} + {% else %} + {% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %} + {% endif %} +

+
+ {{ section_label }} +
+
+ {% for row in rows %} + {% if row.config.section == section_key %} +
+ + +
+
+

{{ row.definition.title }}

+ {% if row.config.is_enabled %} + {% trans "Aktiv" %} + {% else %} + {% trans "Deaktiviert" %} + {% endif %} +
+
{{ row.config.key }}
+

{{ row.definition.description }}

+

{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}

+
+
+ + {% if row.config.section == 'platform' %} + {% trans "Platform Apps" %} + {% elif row.config.section == 'admin' %} + {% trans "Admin Apps" %} + {% else %} + {% trans "Apps" %} + {% endif %} + + {% trans "Sortierung" %}: {{ row.config.sort_order }} + {% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %} + {% trans "Platform only" %} + {% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %} + {% trans "Alle Firmenrollen" %} + {% else %} + + {% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %} + {% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %} + {% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %} + {% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %} + + {% endif %} +
+
+ +
+
+

{% trans "Verfügbarkeit" %}

+
+ +
+

{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}

+
+ +
+

{% trans "Sichtbarkeit nach Rolle" %}

+
+ + + + +
+

{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}

+
+ +
+

{% trans "Platzierung" %}

+
+
+ + +
+
+ + +
{% trans "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." %}
+
+
+
+ +
+

{% trans "Bezeichnungen & Texte" %}

+
+
+

{% trans "Deutsch" %}

+
+ + +
+
+ + +
+
+ + +
+
+
+

{% trans "English" %}

+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+
+
{% trans "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter Apps oder Admin Apps einordnen." %}
+
{% trans "Keine ungespeicherten Änderungen" %}
+
+ +
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/backend/workflows/templates/workflows/audit_log.html b/backend/workflows/templates/workflows/audit_log.html index c1903ab..a86e8b6 100644 --- a/backend/workflows/templates/workflows/audit_log.html +++ b/backend/workflows/templates/workflows/audit_log.html @@ -3,22 +3,26 @@ {% block title %}{% trans "Audit Log" %}{% endblock %} +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} +{% endblock %} + {% block extra_css %} {% endblock %} {% block shell_body %} -{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} - -
-
+
+
+
-
+
@@ -25,15 +36,54 @@ {% if form.errors %} {% endif %} -
{{ form.username.label_tag }}{{ form.username }}
-
{{ form.password.label_tag }}{{ form.password }}
+ {% if login_step == 'totp' %} +
+ {{ form.otp_code.label_tag }}{{ form.otp_code }} +
+ + + + + {% else %} +
{{ form.username.label_tag }}{{ form.username }}
+
{{ form.password.label_tag }}{{ form.password }}
+ {% endif %}
+ {% endblock %} diff --git a/backend/workflows/templates/workflows/auth/password_change_done.html b/backend/workflows/templates/workflows/auth/password_change_done.html new file mode 100644 index 0000000..ce900ed --- /dev/null +++ b/backend/workflows/templates/workflows/auth/password_change_done.html @@ -0,0 +1,22 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Passwort geändert" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} + +{% endblock %} diff --git a/backend/workflows/templates/workflows/auth/password_change_form.html b/backend/workflows/templates/workflows/auth/password_change_form.html new file mode 100644 index 0000000..d042d95 --- /dev/null +++ b/backend/workflows/templates/workflows/auth/password_change_form.html @@ -0,0 +1,37 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Passwort ändern" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} + +{% endblock %} diff --git a/backend/workflows/templates/workflows/backup_recovery.html b/backend/workflows/templates/workflows/backup_recovery.html index 9edda46..a869e26 100644 --- a/backend/workflows/templates/workflows/backup_recovery.html +++ b/backend/workflows/templates/workflows/backup_recovery.html @@ -3,22 +3,57 @@ {% block title %}{% trans "Backup & Recovery" %}{% endblock %} +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + {% block extra_css %} {% endblock %} {% block shell_body %} -{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} -

{% trans "Backup & Recovery" %}

-

{% trans "Datenbank- und Media-Backups erstellen und vorhandene Bundles sicher verifizieren." %}

+
+ {% include 'workflows/includes/messages.html' %}
-
+
-

{% trans "Aktionen" %}

-
{% trans "Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst CLI-only." %}
+

{% trans "Backup-Status" %}

+

{{ backup_health.summary }}

+
+
+ {% if backup_health.status == 'healthy' %} + {{ backup_health.label }} + {% elif backup_health.status == 'stale' %} + {{ backup_health.label }} + {% elif backup_health.status == 'unverified' %} + {{ backup_health.label }} + {% else %} + {{ backup_health.label }} + {% endif %} + {% if backup_health.bundle_name %} + {{ backup_health.bundle_name }} + {% endif %} + {% if backup_health.verified_at %} + {% trans "Zuletzt verifiziert:" %} {{ backup_health.verified_at|slice:":16"|cut:"T" }} + {% endif %} +
+
+
+ +
+
+
+

{% trans "Aktionen" %}

+

{% trans "Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst CLI-only." %}

{% csrf_token %} @@ -28,10 +63,24 @@
-

{% trans "Verfügbare Backup-Bundles" %}

+
+
+

{% trans "Automation" %}

+

{% trans "Für einen geplanten Verify-Run außerhalb der UI:" %}

+
+
+
docker compose exec -T web python manage.py verify_latest_backup --create-if-missing
+
+ +
+
+
+

{% trans "Verfügbare Backup-Bundles" %}

+
+
{% if rows %} -
- +
+
@@ -111,4 +160,5 @@
{% trans "Noch keine Backup-Bundles vorhanden." %}
{% endif %} + {% endblock %} diff --git a/backend/workflows/templates/workflows/base_shell.html b/backend/workflows/templates/workflows/base_shell.html index a5d167b..adf0265 100644 --- a/backend/workflows/templates/workflows/base_shell.html +++ b/backend/workflows/templates/workflows/base_shell.html @@ -6,6 +6,8 @@ {% block title %}{% endblock %} + + {% block extra_css %}{% endblock %} @@ -13,10 +15,60 @@ {% block pre_shell %}{% endblock %} + {% if portal_trial_enabled %} +
+
+
+ {% if portal_trial_expired %}{% trans "Trial abgelaufen" %}{% else %}{% trans "Trial-Modus" %}{% endif %} +
+
+ + {% if portal_trial_expired %} + {% trans "Zugriff für Testnutzer gesperrt" %} + {% else %} + {% trans "Kontrollierte Testumgebung aktiv" %} + {% endif %} + + + {% if portal_trial_banner_text %} + {{ portal_trial_banner_text }} + {% elif portal_trial_expires_at %} + {% if portal_trial_expired %} + {% blocktrans with expires=portal_trial_expires_at|date:"d.m.Y H:i" %}Diese Testumgebung ist seit {{ expires }} abgelaufen.{% endblocktrans %} + {% else %} + {% blocktrans with expires=portal_trial_expires_at|date:"d.m.Y H:i" %}Diese Testumgebung ist bis {{ expires }} aktiv.{% endblocktrans %} + {% endif %} + {% else %} + {% trans "Diese Umgebung läuft im Trial-Modus." %} + {% endif %} + +
+ {% if portal_trial_expires_at %} +
+ {% trans "Ende" %} + {{ portal_trial_expires_at|date:"d.m.Y H:i" }} +
+ {% endif %} +
+
+ {% endif %}
{% block shell_header %}{% endblock %} {% block shell_body %}{% endblock %}
+ {% if portal_footer_text or portal_legal_notice %} + + {% endif %}
{% trans "Bundle" %}
- - - - - - - - - - - - {% for item in option_items %} - - - - - - - - - {% empty %} - - {% endfor %} - -
{% trans "Sortierung" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}Value{% trans "Aktiv" %}{% trans "Löschen" %}
- - ⋮⋮ - - -
{% trans "Keine Optionen in dieser Kategorie." %}
-
-
- -
- -
- -
-
-

{% trans "Feldtexte verwalten" %}

-
-
- {% csrf_token %} -
- - - - - - - - - - - - {% for item in field_text_items %} - - - - - - - - {% empty %} - - {% endfor %} - -
{% trans "Feld" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Hilfetext (DE)" %}{% trans "Hilfetext (EN)" %}
- - {{ item.field_name }} -
{% trans "Keine Feldkonfigurationen verfügbar." %}
-
-
- -
-
-
+{% endblock %} + +{% block extra_scripts %} + {% endblock %} diff --git a/backend/workflows/templates/workflows/handbook.html b/backend/workflows/templates/workflows/handbook.html index decb080..3e8f56f 100644 --- a/backend/workflows/templates/workflows/handbook.html +++ b/backend/workflows/templates/workflows/handbook.html @@ -11,6 +11,7 @@ {% block shell_body %} {% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} +

{% trans "Handbook" %}

@@ -40,7 +41,7 @@
  • {% trans "repository and service structure" %}
  • {% trans "Docker and migration workflow" %}
  • {% trans "translation and builder architecture" %}
  • -
  • {% trans "deployment, security, and maintenance notes" %}
  • +
  • {% trans "CI/CD, deployment, security, and maintenance notes" %}
  • +
    {% endblock %} diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 568ed9a..6329e7e 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -1,39 +1,24 @@ {% extends 'workflows/base_shell.html' %} {% load static i18n %} -{% block title %}{% trans "TUBCO Onboarding & Offboarding Portal" %}{% endblock %} - +{% block title %}{{ portal_title }}{% endblock %} +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} {% block extra_css %} {% endblock %} {% block shell_body %} -
    -
    - -
    -
    -
    - {% csrf_token %} - - - -
    -
    - {% csrf_token %} - -
    -
    -
    -
    {% trans "Operations Console" %} -

    {% trans "TUBCO Onboarding & Offboarding Portal" %}

    +

    {{ portal_title }}

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

    + {% if can_manage_integrations %}
    {% trans "Rolle:" %} {{ role_label }} @@ -44,6 +29,7 @@ {% trans "PDF + E-Mail Workflow bereit" %}
    + {% endif %}
    @@ -51,131 +37,44 @@
    {% include 'workflows/includes/messages.html' %} -
    -

    {% trans "Apps" %}

    -

    {% trans "Wählen Sie den gewünschten Prozess." %}

    -
    -
    -
    -
    -
    ON
    -

    {% trans "Onboarding" %}

    -

    {% trans "Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen." %}

    -
    -{% trans "Mehrschritt-Formular" %} - PDF -{% trans "E-Mail Routing" %} -
    -
    - -
    - -
    -
    -
    OFF
    -

    {% trans "Offboarding" %}

    -

    {% trans "Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten." %}

    -
    -{% trans "Profile-Suche" %} -{% trans "Hardware-Liste" %} -{% trans "IT-Rückgabe" %} -
    -
    - -
    - - {% if can_access_requests_dashboard %} -
    -
    -
    APP
    -

    {% trans "Anfragen Dashboard" %}

    -

    {% trans "Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen." %}

    -
    -{% trans "Suche" %} -{% trans "Status" %} -{% trans "PDF Zugriff" %} -
    -
    - -
    - {% endif %} -
    - - {% if can_manage_users or can_manage_integrations or can_view_audit_log or can_manage_backups or can_manage_welcome_emails or can_manage_builders or can_view_docs or can_access_django_admin_link %} -
    -

    {% trans "Admin Apps" %}

    -

    {% trans "Konfiguration, Tests und Steuerung." %}

    -
    -
    - {% if can_manage_integrations %} -
    -

    {% trans "Integrationen" %}

    -

    {% trans "Nextcloud- und E-Mail-Setup." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_manage_users %} -
    -

    {% trans "Benutzer & Rollen" %}

    -

    {% trans "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_view_audit_log %} -
    -

    {% trans "Audit Log" %}

    -

    {% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_manage_backups %} -
    -

    {% trans "Backup & Recovery" %}

    -

    {% trans "Backups erstellen und sicher verifizieren." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_manage_welcome_emails %} -
    -

    {% trans "Welcome E-Mails" %}

    -

    {% trans "Geplante Welcome Mails verwalten." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_manage_builders %} -
    -

    {% trans "Form Builder" %}

    -

    {% trans "Felder, Schritte und Optionen verwalten." %}

    -{% trans "Öffnen" %} -
    -
    -

    {% trans "Einweisungs-Builder" %}

    -

    {% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_view_docs %} -
    -

    {% trans "Handbook" %}

    -

    {% trans "Project wiki and developer documentation in one place." %}

    -{% trans "Öffnen" %} -
    - {% endif %} - {% if can_access_django_admin_link %} -
    -

    {% trans "Django Admin" %}

    -

    {% trans "Vollständige Datenverwaltung." %}

    -{% trans "Öffnen" %} -
    - {% endif %} -
    + {% for section in portal_app_sections %} + {% if not forloop.first %} + {% endif %} +
    +

    {{ section.title }}

    +

    {{ section.subtitle }}

    +
    +
    + {% for app in section.apps %} + {% if section.key == 'app' %} +
    +
    +
    {{ app.accent }}
    +

    {{ app.title }}

    +

    {{ app.description }}

    + {% if app.tags %} +
    + {% for tag in app.tags %} + {{ tag }} + {% endfor %} +
    + {% endif %} +
    + +
    + {% else %} +
    +

    {{ app.title }}

    +

    {{ app.description }}

    +{{ app.action_label }} +
    + {% endif %} + {% endfor %} +
    + {% endfor %}
    -
    -
    {% endblock %} {% block extra_scripts %} {% endblock %} - diff --git a/backend/workflows/templates/workflows/offboarding_success.html b/backend/workflows/templates/workflows/offboarding_success.html index cbffd51..8842451 100644 --- a/backend/workflows/templates/workflows/offboarding_success.html +++ b/backend/workflows/templates/workflows/offboarding_success.html @@ -6,7 +6,7 @@ {% block shell_header %} -{% include 'workflows/includes/app_header.html' with header_show_home=1 %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} {% endblock %} {% block extra_css %} @@ -14,6 +14,8 @@ {% endblock %} {% block shell_body %} +
    +

    {% trans "Offboarding gespeichert" %}

    {% trans "Vorgangs-ID:" %} {{ obj.id }}

    {% trans "Name:" %} {{ obj.full_name }}

    @@ -29,5 +31,6 @@ {% trans "Neue Offboarding-Anfrage erfassen" %} {% trans "Zum Dashboard" %}
    + + {% endblock %} - diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index b6d7edd..97a2ef8 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -20,37 +20,51 @@ {% block shell_body %} {% include 'workflows/includes/app_header.html' with header_show_lang=1 header_show_home=1 header_inside_shell=1 %} -
    -
    {% endblock %} diff --git a/backend/workflows/templates/workflows/request_timeline.html b/backend/workflows/templates/workflows/request_timeline.html index a0c0dc0..b16836e 100644 --- a/backend/workflows/templates/workflows/request_timeline.html +++ b/backend/workflows/templates/workflows/request_timeline.html @@ -5,47 +5,13 @@ {% block extra_css %} - + {% endblock %} {% block shell_body %} {% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_dashboard=1 header_inside_shell=1 %} +

    {% trans "Request Timeline" %}

    @@ -80,6 +46,20 @@
    + {% if custom_field_details %} +
    +

    {% trans "Benutzerdefinierte Felder" %}

    +
    + {% for item in custom_field_details %} +
    + {{ item.label }} + {{ item.value }} +
    + {% endfor %} +
    +
    + {% endif %} +
    {% for row in timeline_rows %}
    @@ -131,4 +111,5 @@ {% endfor %}
    + {% endblock %} diff --git a/backend/workflows/templates/workflows/requests_dashboard.html b/backend/workflows/templates/workflows/requests_dashboard.html index ec06e1c..0e6a7ce 100644 --- a/backend/workflows/templates/workflows/requests_dashboard.html +++ b/backend/workflows/templates/workflows/requests_dashboard.html @@ -3,6 +3,10 @@ {% block title %}{% trans "Anfragen Dashboard" %}{% endblock %} +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + {% block extra_css %} @@ -10,21 +14,7 @@ {% endblock %} {% block shell_body %} -
    -
    - -
    -
    - - {% csrf_token %} - - - - - {% trans "Zur Startseite" %} -
    -
    - +
    @@ -180,8 +170,8 @@ {% endif %}
    -
    - +
    +
    {% if can_delete_requests %}{% endif %} @@ -190,7 +180,7 @@ {% if can_run_intro_session or can_generate_intro_pdfs %}{% endif %} -{% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}{% endif %} +{% if can_view_request_timeline or can_retry_requests or can_delete_requests %}{% endif %} @@ -219,9 +209,9 @@ {% trans "Noch nicht verfügbar" %} {% endif %} {% if row.status_key == 'failed' %} -
    {% trans "Fehlgeschlagen" %}
    +
    {% trans "Fehlgeschlagen" %}
    {% if row.last_error %} -
    {{ row.last_error|truncatechars:140 }}
    +
    {{ row.last_error|truncatechars:140 }}
    {% endif %} {% endif %} @@ -271,9 +261,11 @@ {% endif %} {% endif %} - {% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %} + {% if can_view_request_timeline or can_retry_requests or can_delete_requests %}
    {% trans "E-Mail" %} {% trans "Dokument" %}{% trans "Einweisung" %}{% trans "Aktion" %}{% trans "Aktion" %}
    + {% if can_view_request_timeline %} {% trans "Timeline" %} + {% endif %} {% if can_retry_requests and row.status_key == 'failed' %}
    {% csrf_token %} @@ -300,8 +292,9 @@ + {% endblock %} {% block extra_scripts %} diff --git a/backend/workflows/templates/workflows/trial_expired.html b/backend/workflows/templates/workflows/trial_expired.html new file mode 100644 index 0000000..7dbcf9e --- /dev/null +++ b/backend/workflows/templates/workflows/trial_expired.html @@ -0,0 +1,46 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Trial abgelaufen" %}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +{% include 'workflows/includes/app_header.html' with header_show_home=0 header_show_lang=1 header_inside_shell=1 %} +
    +
    +
    {% trans "Trial expired" %}
    +

    {% trans "Trial abgelaufen" %}

    +

    {% trans "Diese Testumgebung ist nicht mehr aktiv. Bitte wenden Sie sich für eine Verlängerung oder ein Produktiv-Setup an den Plattformbetreiber." %}

    + +
    +
    + {% trans "Status" %} + {% trans "Zugriff gesperrt" %} +

    {% trans "Nicht-Platform-Nutzer können diese Umgebung nach Ablauf nicht mehr verwenden." %}

    +
    +
    + {% trans "Nächster Schritt" %} + {% trans "Verlängern oder Produktiv-Setup" %} +

    {% trans "Ein Platform Owner kann den Trial verlängern oder das Setup in einen regulären Betrieb überführen." %}

    +
    + {% if portal_trial_expires_at %} +
    + {% trans "Ablaufzeit" %} + {{ portal_trial_expires_at|date:"d.m.Y H:i" }} +

    {% trans "Das ist der im System hinterlegte Endzeitpunkt der Testumgebung." %}

    +
    + {% endif %} +
    + + + {% if portal_support_email %} +
    {% blocktrans with email=portal_support_email %}Kontakt: {{ email }}{% endblocktrans %}
    + {% endif %} +
    +
    +{% endblock %} diff --git a/backend/workflows/templates/workflows/trial_management.html b/backend/workflows/templates/workflows/trial_management.html new file mode 100644 index 0000000..437b53d --- /dev/null +++ b/backend/workflows/templates/workflows/trial_management.html @@ -0,0 +1,137 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Trial Management" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +
    + + +{% include 'workflows/includes/messages.html' %} + +
    +
    +
    +

    {% trans "Übersicht" %}

    +

    {% trans "Aktueller Trial-Status und die daraus resultierende Systemwirkung." %}

    +
    +
    +
    + {% trans "Status" %} + + {% if portal_trial_enabled %} + {% if portal_trial_expired %}{% trans "Abgelaufen" %}{% else %}{% trans "Aktiv" %}{% endif %} + {% else %} + {% trans "Deaktiviert" %} + {% endif %} + +
    +
    + {% trans "Ende" %} + + {% if portal_trial_expires_at %}{{ portal_trial_expires_at|date:"d.m.Y H:i" }}{% else %}{% trans "Nicht gesetzt" %}{% endif %} + +
    +
    + {% trans "Nextcloud effektiv" %} + + {% if portal_trial_restrict_integrations and portal_trial_enabled %}{% trans "Deaktiviert" %}{% else %}{% trans "Unverändert" %}{% endif %} + +
    +
    + {% trans "E-Mail effektiv" %} + + {% if portal_trial_restrict_integrations and portal_trial_enabled %}{% trans "Testmodus" %}{% else %}{% trans "Unverändert" %}{% endif %} + +
    +
    +
    + {% trans "Zum Deaktivieren des Trial-Modus entfernen Sie den Haken bei „Trial-Modus aktiv“ und speichern Sie die Seite." %} +
    +
    + + + {% csrf_token %} + +
    +
    +

    {% trans "Trial-Status" %}

    +

    {% trans "Aktivieren Sie den Trial-Modus und definieren Sie die gültige Laufzeit." %}

    +
    +
    +
    + +
    + +
    +
    {% trans "Sobald dieser Schalter deaktiviert ist, verschwindet das Trial-Banner und die normalen Integrationsregeln greifen wieder." %}
    +
    +
    + + {{ form.trial_started_at }} +
    +
    + + {{ form.trial_expires_at }} + {% if trial_is_expired %}
    {% trans "Der konfigurierte Trial ist derzeit abgelaufen." %}
    {% endif %} +
    +
    +
    + +
    +
    +

    {% trans "Sicherheitsregeln" %}

    +

    {% trans "Testumgebungen sollen keine produktiven Integrationen verwenden." %}

    +
    +
    + + +
    +
    {% trans "Wenn diese Regel aktiv ist, bleiben produktive Integrationen technisch gesperrt, auch wenn lokale Overrides anders gesetzt sind." %}
    +
    + +
    +
    +

    {% trans "Banner" %}

    +

    {% trans "Optionaler Hinweistext für die Shell. Ohne Text wird ein Standardhinweis mit Enddatum verwendet." %}

    +
    +
    +
    +

    {% trans "Deutsch" %}

    +
    + + {{ form.trial_banner_text }} +
    +
    +
    +

    {% trans "English" %}

    +
    + + {{ form.trial_banner_text_en }} +
    +
    +
    +
    + +
    +
    {% trans "Die eigentliche Datenbereinigung läuft bewusst nicht über die Web-UI. Nutzen Sie dafür den Cleanup-Command im Betrieb." %}
    + +
    + +
    +
    +{% endblock %} diff --git a/backend/workflows/templates/workflows/user_management.html b/backend/workflows/templates/workflows/user_management.html index f74769f..622bdc5 100644 --- a/backend/workflows/templates/workflows/user_management.html +++ b/backend/workflows/templates/workflows/user_management.html @@ -3,24 +3,33 @@ {% block title %}{% trans "Benutzer & Rollen" %}{% endblock %} +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_show_home=1 header_inside_shell=1 %} +{% endblock %} + {% block extra_css %} {% endblock %} {% block shell_body %} - {% include 'workflows/includes/app_header.html' with header_show_lang=1 header_show_home=1 header_inside_shell=1 %} -
    -
    +
    +
    + {% include 'workflows/includes/messages.html' %}
    -

    {% trans "Benutzer anlegen" %}

    -

    {% trans "Nach dem Anlegen wird automatisch eine Zugangseinladung mit Passwort-Link per E-Mail versendet." %}

    +
    +
    +

    {% trans "Benutzer anlegen" %}

    +

    {% trans "Nach dem Anlegen wird automatisch eine Zugangseinladung mit Passwort-Link per E-Mail versendet." %}

    +
    +
    {% csrf_token %}
    @@ -51,21 +60,21 @@
    {% for error in create_form.non_field_errors %}
    {{ error }}
    {% endfor %} -
    +
    -
    +

    {% trans "Benutzerübersicht" %}

    -

    {% trans "Rollen ändern, Zugriffe sperren oder ein neues Passwort setzen." %}

    +

    {% trans "Rollen ändern, Zugriffe sperren oder ein neues Passwort setzen." %}

    -
    - +
    +
    @@ -137,19 +146,19 @@
    {% trans "Name" %}
    -

    {% trans "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst deaktivieren oder auf eine niedrigere Rolle setzen." %}

    +

    {% trans "Hinweis: Der letzte aktive Platform Owner oder Super Admin kann sich hier nicht selbst entfernen oder auf eine niedrigere Rolle setzen." %}

    -
    +

    {% trans "Letzte Benutzeraktionen" %}

    -

    {% trans "Die letzten Änderungen an Benutzerkonten und Rollen." %}

    +

    {% trans "Die letzten Änderungen an Benutzerkonten und Rollen." %}

    {% trans "Zum Audit Log" %}
    -
    - +
    +
    @@ -184,4 +193,5 @@
    {% trans "Zeit" %}
    +
    {% endblock %} diff --git a/backend/workflows/templates/workflows/welcome_emails.html b/backend/workflows/templates/workflows/welcome_emails.html index d2175cb..4718259 100644 --- a/backend/workflows/templates/workflows/welcome_emails.html +++ b/backend/workflows/templates/workflows/welcome_emails.html @@ -11,8 +11,14 @@ {% block shell_body %} {% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} -

    {% trans "Geplante Welcome E-Mails" %}

    -

    {% trans "Welcome-Mails konfigurieren und geplante Mails steuern (sofort senden, pausieren, fortsetzen, abbrechen)." %}

    +
    + {% include 'workflows/includes/messages.html' %} @@ -60,7 +66,7 @@
    {% csrf_token %} -
    +
    {% endblock %} {% block extra_scripts %} diff --git a/backend/workflows/tests/test_account_ui.py b/backend/workflows/tests/test_account_ui.py new file mode 100644 index 0000000..90faa78 --- /dev/null +++ b/backend/workflows/tests/test_account_ui.py @@ -0,0 +1,181 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.utils import timezone + +from workflows.models import UserProfile +from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role +from workflows.totp import generate_totp_token + + +class AccountUISmokeTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username='profile-user', + email='profile@example.com', + password='secret-12345', + first_name='Profile', + last_name='User', + ) + self.client = Client() + self.client.force_login(self.user) + + def test_account_profile_page_renders(self): + response = self.client.get('/account/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'profile@example.com') + self.assertContains(response, 'Passwort ändern') + + def test_password_change_page_renders(self): + response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Aktuelles Passwort') + + def test_user_profile_is_created_automatically(self): + self.assertTrue(UserProfile.objects.filter(user=self.user).exists()) + + def test_notification_preferences_can_be_updated(self): + response = self.client.post( + '/account/', + { + 'account_form': 'notification_preferences', + 'onboarding_success': 'on', + 'onboarding_failure': '', + 'offboarding_success': '', + 'offboarding_failure': 'on', + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + profile = UserProfile.objects.get(user=self.user) + self.assertEqual( + profile.notification_preferences, + { + 'onboarding_success': True, + 'onboarding_failure': False, + 'offboarding_success': False, + 'offboarding_failure': True, + 'backup_success': True, + 'backup_failure': True, + 'welcome_email_success': False, + 'welcome_email_failure': False, + 'trial_alerts': True, + 'system_alerts': True, + }, + ) + + def test_staff_account_notifications_hide_admin_only_categories(self): + response = self.client.get('/account/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'Backup erfolgreich') + self.assertNotContains(response, 'Trial-Hinweise') + self.assertNotContains(response, 'System-Hinweise') + self.assertContains(response, 'Welcome E-Mail erfolgreich') + + def test_platform_owner_sees_all_notification_categories(self): + assign_user_role(self.user, ROLE_PLATFORM_OWNER) + response = self.client.get('/account/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Backup erfolgreich') + self.assertContains(response, 'Trial-Hinweise') + self.assertContains(response, 'System-Hinweise') + + def test_account_profile_details_can_be_updated(self): + response = self.client.post( + '/account/', + { + 'account_form': 'details', + 'first_name': 'Updated', + 'last_name': 'User', + 'email': 'updated@example.com', + 'phone_number': '030 123456', + 'mobile_number': '0176 123456', + 'job_title': 'IT Manager', + 'department': 'IT', + 'location': 'Berlin', + 'contact_notes': 'Available in the mornings', + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + profile = self.user.profile + self.assertEqual(self.user.first_name, 'Updated') + self.assertEqual(self.user.email, 'updated@example.com') + self.assertEqual(profile.phone_number, '030 123456') + self.assertEqual(profile.job_title, 'IT Manager') + + def test_totp_can_be_enabled_from_account(self): + response = self.client.post( + '/account/', + { + 'account_form': 'totp_enable', + 'current_password': 'secret-12345', + 'verification_code': '000000', + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + profile = self.user.profile + pending_secret = self.client.session.get('account_totp_pending_secret') + self.assertTrue(pending_secret) + valid_code = generate_totp_token(pending_secret, int(timezone.now().timestamp())) + + response = self.client.post( + '/account/', + { + 'account_form': 'totp_enable', + 'current_password': 'secret-12345', + 'verification_code': valid_code, + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + profile.refresh_from_db() + self.assertTrue(profile.totp_enabled) + self.assertTrue(profile.totp_secret) + self.assertEqual(len(profile.totp_recovery_codes), 8) + self.assertContains(response, 'Recovery-Codes') + + def test_login_requires_totp_when_enabled(self): + profile = self.user.profile + profile.totp_secret = 'JBSWY3DPEHPK3PXP' + profile.totp_enabled = True + profile.set_recovery_codes(['ABCDE-12345']) + profile.save(update_fields=['totp_secret', 'totp_enabled', 'totp_recovery_codes', 'updated_at']) + + client = Client() + response = client.post( + '/accounts/login/', + {'username': 'profile-user', 'password': 'secret-12345'}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/accounts/login/totp/') + + response = client.get('/accounts/login/totp/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'TOTP-Code') + self.assertContains(response, 'Recovery-Code verwenden') + + token = generate_totp_token(profile.totp_secret, int(timezone.now().timestamp())) + response = client.post( + '/accounts/login/totp/', + {'otp_code': token}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 302) + + client = Client() + first_step = client.post('/accounts/login/', {'username': 'profile-user', 'password': 'secret-12345'}, HTTP_HOST='localhost') + self.assertEqual(first_step.status_code, 302) + response = client.post('/accounts/login/totp/', {'recovery_code': 'ABCDE-12345'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 302) + profile.refresh_from_db() + self.assertEqual(profile.totp_recovery_codes, []) diff --git a/backend/workflows/tests/test_app_registry_permissions.py b/backend/workflows/tests/test_app_registry_permissions.py new file mode 100644 index 0000000..20dc9b9 --- /dev/null +++ b/backend/workflows/tests/test_app_registry_permissions.py @@ -0,0 +1,50 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.app_registry import build_portal_app_sections, ensure_portal_app_configs +from workflows.models import PortalAppConfig +from workflows.roles import ROLE_ADMIN, ROLE_IT_STAFF, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role + + +class AppRegistryPermissionTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.platform_owner = user_model.objects.create_user(username='platform_owner_case', password='secret123') + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) + + self.super_admin = user_model.objects.create_user(username='super_admin_case', password='secret123') + assign_user_role(self.super_admin, ROLE_SUPER_ADMIN) + + self.admin = user_model.objects.create_user(username='admin_case', password='secret123') + assign_user_role(self.admin, ROLE_ADMIN) + + self.it_staff = user_model.objects.create_user(username='it_staff_case', password='secret123') + assign_user_role(self.it_staff, ROLE_IT_STAFF) + + self.staff = user_model.objects.create_user(username='staff_case', password='secret123') + assign_user_role(self.staff, ROLE_STAFF) + + ensure_portal_app_configs() + + def _visible_keys(self, user): + sections = build_portal_app_sections(user) + return {app['key'] for section in sections for app in section['apps']} + + def test_onboarding_and_offboarding_visible_to_staff_by_default(self): + keys = self._visible_keys(self.staff) + self.assertIn('onboarding', keys) + self.assertIn('offboarding', keys) + + def test_trial_management_is_platform_only(self): + self.assertIn('trial_management', self._visible_keys(self.platform_owner)) + self.assertNotIn('trial_management', self._visible_keys(self.super_admin)) + self.assertNotIn('trial_management', self._visible_keys(self.admin)) + + def test_requests_dashboard_can_be_hidden_from_staff_via_registry(self): + config = PortalAppConfig.objects.get(key='requests_dashboard') + config.visible_to_staff = False + config.save(update_fields=['visible_to_staff', 'updated_at']) + + self.assertNotIn('requests_dashboard', self._visible_keys(self.staff)) + self.assertIn('requests_dashboard', self._visible_keys(self.it_staff)) + diff --git a/backend/workflows/tests/test_async_task_logging.py b/backend/workflows/tests/test_async_task_logging.py new file mode 100644 index 0000000..760e75c --- /dev/null +++ b/backend/workflows/tests/test_async_task_logging.py @@ -0,0 +1,52 @@ +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from django.test import TestCase, override_settings +from django.utils import timezone + +from workflows.models import AsyncTaskLog, OnboardingRequest, ScheduledWelcomeEmail +from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class AsyncTaskLoggingTests(TestCase): + def setUp(self): + self.onboarding = OnboardingRequest.objects.create( + full_name='Task Failure', + gender='herr', + job_title='Engineer', + department='IT-Service', + work_email='task.failure@tub.co', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email='requester@tub.co', + agreement='accepted', + ) + + @patch('workflows.tasks._generate_onboarding_pdf', side_effect=RuntimeError('pdf failed')) + def test_failed_onboarding_task_creates_failed_async_log(self, _mock_generate_pdf): + with self.assertRaises(RuntimeError): + process_onboarding_request(self.onboarding.id) + + log = AsyncTaskLog.objects.filter(task_name='process_onboarding_request').latest('id') + self.assertEqual(log.status, 'failed') + self.assertEqual(log.target_id, self.onboarding.id) + self.assertIn('pdf failed', log.error_message) + + @patch('workflows.tasks._send_templated_email', side_effect=RuntimeError('smtp failed')) + def test_failed_welcome_email_creates_failed_async_log(self, _mock_send): + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='task.failure@tub.co', + send_at=timezone.now(), + status='scheduled', + ) + + with self.assertRaises(RuntimeError): + send_scheduled_welcome_email(scheduled.id, True) + + log = AsyncTaskLog.objects.filter(task_name='send_scheduled_welcome_email').latest('id') + self.assertEqual(log.status, 'failed') + self.assertEqual(log.target_id, scheduled.id) + self.assertIn('smtp failed', log.error_message) diff --git a/backend/workflows/tests/test_backup_reliability.py b/backend/workflows/tests/test_backup_reliability.py new file mode 100644 index 0000000..3addfd7 --- /dev/null +++ b/backend/workflows/tests/test_backup_reliability.py @@ -0,0 +1,67 @@ +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase, override_settings +from django.utils import timezone + +from workflows.backup_ops import latest_backup_health_snapshot + + +class BackupReliabilityTests(TestCase): + @override_settings(BACKUP_OUTPUT_DIR=tempfile.gettempdir()) + def test_latest_backup_health_reports_missing_when_no_bundle_exists(self): + with tempfile.TemporaryDirectory() as tmpdir: + with override_settings(BACKUP_OUTPUT_DIR=tmpdir): + snapshot = latest_backup_health_snapshot() + + self.assertEqual(snapshot['status'], 'missing') + self.assertTrue(snapshot['is_stale']) + + def test_latest_backup_health_reports_stale_when_verification_is_old(self): + with tempfile.TemporaryDirectory() as tmpdir: + backup_dir = Path(tmpdir) / 'backup_20260326_010101' + backup_dir.mkdir(parents=True) + (backup_dir / 'db.dump').write_text('db', encoding='utf-8') + (backup_dir / 'media.tar.gz').write_text('media', encoding='utf-8') + (backup_dir / 'backup_meta.json').write_text( + json.dumps( + { + 'created_at': timezone.now().isoformat(), + 'verify_status': 'verified', + 'verified_at': (timezone.now() - timezone.timedelta(hours=72)).isoformat(), + } + ), + encoding='utf-8', + ) + + with override_settings(BACKUP_OUTPUT_DIR=tmpdir): + snapshot = latest_backup_health_snapshot(stale_after_hours=48) + + self.assertEqual(snapshot['status'], 'stale') + self.assertEqual(snapshot['bundle_name'], 'backup_20260326_010101') + + @patch('workflows.management.commands.verify_latest_backup.verify_backup_bundle') + @patch('workflows.management.commands.verify_latest_backup.list_backup_bundles') + def test_verify_latest_backup_command_uses_existing_latest_bundle(self, list_bundles, verify_bundle): + list_bundles.return_value = [{'name': 'backup_20260326_020202'}] + verify_bundle.return_value = {'name': 'backup_20260326_020202', 'summary': 'ok'} + + call_command('verify_latest_backup') + + verify_bundle.assert_called_once_with('backup_20260326_020202') + + @patch('workflows.management.commands.verify_latest_backup.verify_backup_bundle') + @patch('workflows.management.commands.verify_latest_backup.create_backup_bundle') + @patch('workflows.management.commands.verify_latest_backup.list_backup_bundles') + def test_verify_latest_backup_command_can_create_when_missing(self, list_bundles, create_bundle, verify_bundle): + list_bundles.return_value = [] + create_bundle.return_value = {'name': 'backup_20260326_030303'} + verify_bundle.return_value = {'name': 'backup_20260326_030303', 'summary': 'ok'} + + call_command('verify_latest_backup', create_if_missing=True) + + create_bundle.assert_called_once() + verify_bundle.assert_called_once_with('backup_20260326_030303') diff --git a/backend/workflows/tests/test_bilingual_smoke.py b/backend/workflows/tests/test_bilingual_smoke.py index a7a3f4a..e81c026 100644 --- a/backend/workflows/tests/test_bilingual_smoke.py +++ b/backend/workflows/tests/test_bilingual_smoke.py @@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from django.utils import timezone +from workflows.branding import get_company_email_domain from workflows.models import EmployeeProfile, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email @@ -13,11 +14,12 @@ from workflows.tasks import process_onboarding_request, send_scheduled_welcome_e @override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) class BilingualSmokeTests(TestCase): def setUp(self): + self.company_domain = get_company_email_domain() user_model = get_user_model() self.user = user_model.objects.create_user( username='bilingual_user', password='secret123', - email='requester@tub.co', + email=f'requester@{self.company_domain}', first_name='Mia', last_name='Beispiel', ) @@ -28,7 +30,7 @@ class BilingualSmokeTests(TestCase): last_name='Beispiel', department='IT-Service', job_title='Engineer', - work_email='lara.beispiel@tub.co', + work_email=f'lara.beispiel@{self.company_domain}', ) @patch('workflows.views.process_onboarding_request.delay') @@ -39,7 +41,7 @@ class BilingualSmokeTests(TestCase): 'gender': 'herr', 'job_title': 'Consultant', 'department': 'IT-Service', - 'work_email': 'max.mustermann@tub.co', + 'work_email': f'max.mustermann@{self.company_domain}', 'contract_start': '2026-11-01', 'employment_type': 'unbefristet', 'group_mailboxes_required_choice': 'nein', @@ -54,7 +56,7 @@ class BilingualSmokeTests(TestCase): response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost', HTTP_ACCEPT_LANGUAGE='en') self.assertEqual(response.status_code, 302) - obj = OnboardingRequest.objects.get(work_email='max.mustermann@tub.co') + obj = OnboardingRequest.objects.get(work_email=f'max.mustermann@{self.company_domain}') self.assertEqual(obj.preferred_language, 'en') mock_delay.assert_called_once_with(obj.id) @@ -66,7 +68,7 @@ class BilingualSmokeTests(TestCase): 'gender': 'frau', 'job_title': 'Consultant', 'department': 'IT-Service', - 'work_email': 'erika.muster@tub.co', + 'work_email': f'erika.muster@{self.company_domain}', 'contract_start': '2026-11-02', 'employment_type': 'unbefristet', 'group_mailboxes_required_choice': 'nein', @@ -81,7 +83,7 @@ class BilingualSmokeTests(TestCase): response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost', HTTP_ACCEPT_LANGUAGE='de') self.assertEqual(response.status_code, 302) - obj = OnboardingRequest.objects.get(work_email='erika.muster@tub.co') + obj = OnboardingRequest.objects.get(work_email=f'erika.muster@{self.company_domain}') self.assertEqual(obj.preferred_language, 'de') mock_delay.assert_called_once_with(obj.id) @@ -140,10 +142,10 @@ class BilingualSmokeTests(TestCase): gender='herr', job_title='Engineer', department='IT-Service', - work_email='english.person@tub.co', + work_email=f'english.person@{self.company_domain}', contract_start=date(2026, 11, 1), employment_type='unbefristet', - onboarded_by_email='requester@tub.co', + onboarded_by_email=f'requester@{self.company_domain}', onboarded_by_name='Mia Beispiel', agreement='accepted', preferred_language='en', @@ -172,16 +174,16 @@ class BilingualSmokeTests(TestCase): gender='frau', job_title='Manager', department='IT-Service', - work_email='welcome.person@tub.co', + work_email=f'welcome.person@{self.company_domain}', contract_start=date(2026, 11, 1), employment_type='unbefristet', - onboarded_by_email='requester@tub.co', + onboarded_by_email=f'requester@{self.company_domain}', agreement='accepted', preferred_language='en', ) scheduled = ScheduledWelcomeEmail.objects.create( onboarding_request=onboarding, - recipient_email='welcome.person@tub.co', + recipient_email=f'welcome.person@{self.company_domain}', send_at=timezone.now(), status='scheduled', ) diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index b37a472..a1c6d22 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -3,7 +3,8 @@ import json from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import FormFieldConfig, FormOption +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig +from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role class FormBuilderAdminTests(TestCase): @@ -20,6 +21,14 @@ class FormBuilderAdminTests(TestCase): password='secret123', email='builder_user@tub.co', ) + self.platform_owner = user_model.objects.create_user( + username='builder_owner', + password='secret123', + email='builder_owner@tub.co', + is_staff=True, + is_superuser=True, + ) + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) def test_staff_can_open_form_builder(self): self.client.force_login(self.staff) @@ -94,3 +103,325 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(response.status_code, 302) self.assertTrue(FormOption.objects.filter(category='device', label='Tablet').exists()) + + def test_staff_can_save_field_rules(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + department = FormFieldConfig.objects.get(form_type='onboarding', field_name='department') + contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_field_rules', + 'field_rule_ids': [str(department.id), str(contract_start.id)], + f'is_required_{department.id}': 'required', + f'is_visible_{contract_start.id}': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + department.refresh_from_db() + contract_start.refresh_from_db() + self.assertEqual(department.is_required, True) + self.assertEqual(contract_start.is_required, None) + + def test_platform_owner_can_modify_locked_field_rules(self): + self.client.force_login(self.platform_owner) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + full_name = FormFieldConfig.objects.get(form_type='onboarding', field_name='full_name') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_field_rules', + 'field_rule_ids': [str(full_name.id)], + f'is_required_{full_name.id}': 'optional', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + full_name.refresh_from_db() + self.assertEqual(full_name.is_required, False) + + def test_staff_can_save_section_rules_with_locked_sections_preserved(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_section_rules', + 'section_order': ['itsetup', 'stammdaten', 'vertrag', 'abschluss'], + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + itsetup = FormSectionConfig.objects.get(form_type='onboarding', section_key='itsetup') + stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') + self.assertEqual(itsetup.is_visible, False) + self.assertEqual(stammdaten.is_visible, True) + self.assertEqual(itsetup.sort_order, 0) + self.assertEqual(stammdaten.sort_order, 1) + + def test_platform_owner_can_modify_locked_section_rules(self): + self.client.force_login(self.platform_owner) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_section_rules', + 'section_order': ['vertrag', 'stammdaten', 'itsetup', 'abschluss'], + 'section_visible_vertrag': 'on', + 'section_visible_itsetup': 'on', + 'section_visible_abschluss': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') + vertrag = FormSectionConfig.objects.get(form_type='onboarding', section_key='vertrag') + self.assertEqual(vertrag.sort_order, 0) + self.assertEqual(stammdaten.sort_order, 1) + self.assertEqual(stammdaten.is_visible, False) + + def test_apply_onboarding_lean_preset_updates_section_and_field_rules(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'apply_preset', + 'preset_key': 'lean', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + itsetup = FormSectionConfig.objects.get(form_type='onboarding', section_key='itsetup') + gender = FormFieldConfig.objects.get(form_type='onboarding', field_name='gender') + contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start') + self.assertEqual(itsetup.is_visible, False) + self.assertEqual(gender.is_visible, False) + self.assertEqual(contract_start.is_required, None) + + def test_apply_offboarding_hr_heavy_preset_updates_fields(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=offboarding&option_category=device', + data={ + 'builder_action': 'apply_preset', + 'preset_key': 'hr_heavy', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + notes = FormFieldConfig.objects.get(form_type='offboarding', field_name='notes') + department = FormFieldConfig.objects.get(form_type='offboarding', field_name='department') + self.assertEqual(notes.is_visible, True) + self.assertEqual(notes.is_required, True) + self.assertEqual(department.is_required, True) + + def test_staff_can_save_onboarding_conditional_rules(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_conditional_rules', + 'conditional_active_employment-end-box': 'on', + 'conditional_field_employment-end-box_0': 'employment_type', + 'conditional_operator_employment-end-box_0': 'equals', + 'conditional_value_employment-end-box_0': 'befristet', + 'conditional_active_successor-box': 'on', + 'conditional_field_successor-box_0': 'successor_required_choice', + 'conditional_operator_successor-box_0': 'equals', + 'conditional_value_successor-box_0': 'ja', + 'conditional_field_successor-box_1': 'inherit_phone_number_choice', + 'conditional_operator_successor-box_1': 'not_equals', + 'conditional_value_successor-box_1': 'ja', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='successor-box') + self.assertEqual(rule.is_active, True) + self.assertEqual(len(rule.clauses), 2) + self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice') + self.assertEqual(rule.clauses[1]['operator'], 'not_equals') + + def test_staff_can_add_custom_field(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_custom_field', + 'custom_label': 'Laptop-Tag', + 'custom_label_en': 'Laptop tag', + 'custom_section_key': 'itsetup', + 'custom_field_type': 'text', + 'custom_sort_order': '3', + 'custom_is_required': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + field = FormCustomFieldConfig.objects.get(form_type='onboarding', field_key='laptop_tag') + self.assertEqual(field.section_key, 'itsetup') + self.assertEqual(field.field_type, 'text') + self.assertEqual(field.is_required, True) + + def test_save_order_updates_custom_field_section_and_sort_order(self): + self.client.force_login(self.staff) + custom_field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='laptop_tag', + section_key='itsetup', + sort_order=99, + field_type='text', + label='Laptop-Tag', + ) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + payload = { + 'form_type': 'onboarding', + 'columns': { + 'stammdaten': ['department'], + 'vertrag': ['contract_start'], + 'itsetup': ['custom__laptop_tag'], + '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) + custom_field.refresh_from_db() + self.assertEqual(custom_field.section_key, 'itsetup') + self.assertEqual(custom_field.sort_order, 2) + + def test_staff_can_add_custom_section(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_custom_section', + 'custom_section_title': 'Benefits', + 'custom_section_title_en': 'Benefits', + 'custom_section_sort_order': '5', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + section = FormCustomSectionConfig.objects.get(form_type='onboarding', section_key='benefits') + self.assertEqual(section.title, 'Benefits') + self.assertEqual(section.sort_order, 5) + + def test_staff_can_delete_custom_field(self): + self.client.force_login(self.staff) + field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='laptop_tag', + section_key='itsetup', + sort_order=0, + field_type='text', + label='Laptop-Tag', + ) + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={'delete_custom_field_id': str(field.id)}, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists()) + + def test_staff_can_delete_custom_section_and_its_fields(self): + self.client.force_login(self.staff) + section = FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=0, + title='Benefits', + ) + field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='checkbox', + label='Essenszuschuss', + ) + FormConditionalRuleConfig.objects.create( + form_type='onboarding', + target_key='custom__meal_allowance', + clauses=[{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}], + is_active=True, + ) + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={'delete_custom_section_id': str(section.id)}, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(FormCustomSectionConfig.objects.filter(id=section.id).exists()) + self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists()) + self.assertFalse(FormConditionalRuleConfig.objects.filter(target_key='custom__meal_allowance').exists()) + + def test_save_order_accepts_custom_section_column(self): + self.client.force_login(self.staff) + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + custom_field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='stammdaten', + sort_order=99, + field_type='text', + label='Essenszuschuss', + ) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + payload = { + 'form_type': 'onboarding', + 'columns': { + 'stammdaten': ['department'], + 'vertrag': ['contract_start'], + 'itsetup': [], + 'abschluss': [], + 'benefits': ['custom__meal_allowance'], + }, + } + + 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) + custom_field.refresh_from_db() + self.assertEqual(custom_field.section_key, 'benefits') diff --git a/backend/workflows/tests/test_nextcloud_service.py b/backend/workflows/tests/test_nextcloud_service.py index d468893..7044d1c 100644 --- a/backend/workflows/tests/test_nextcloud_service.py +++ b/backend/workflows/tests/test_nextcloud_service.py @@ -22,10 +22,12 @@ class NextcloudServiceTests(TestCase): NEXTCLOUD_USERNAME='u', NEXTCLOUD_PASSWORD='p', ) + @patch('workflows.services.requests.request') @patch('workflows.services.requests.put') - def test_upload_calls_webdav_and_accepts_201(self, mock_put): + def test_upload_calls_webdav_and_accepts_201(self, mock_put, mock_request): temp_file = Path('/tmp/nextcloud_mock_upload.txt') temp_file.write_text('hello', encoding='utf-8') + mock_request.return_value.status_code = 201 mock_put.return_value.status_code = 201 try: @@ -45,8 +47,9 @@ class NextcloudServiceTests(TestCase): NEXTCLOUD_USERNAME='env-user', NEXTCLOUD_PASSWORD='env-pass', ) + @patch('workflows.services.requests.request') @patch('workflows.services.requests.put') - def test_upload_prefers_workflowconfig_overrides(self, mock_put): + def test_upload_prefers_workflowconfig_overrides(self, mock_put, mock_request): WorkflowConfig.objects.update_or_create( name='Default', defaults={ @@ -59,6 +62,7 @@ class NextcloudServiceTests(TestCase): ) temp_file = Path('/tmp/nextcloud_override_upload.txt') temp_file.write_text('hello', encoding='utf-8') + mock_request.return_value.status_code = 201 mock_put.return_value.status_code = 201 try: diff --git a/backend/workflows/tests/test_notifications.py b/backend/workflows/tests/test_notifications.py new file mode 100644 index 0000000..2a043b5 --- /dev/null +++ b/backend/workflows/tests/test_notifications.py @@ -0,0 +1,352 @@ +from pathlib import Path +from datetime import date +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from workflows.models import OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, UserNotification, UserProfile +from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role +from workflows.tasks import process_offboarding_request, process_onboarding_request, send_scheduled_welcome_email + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class NotificationFlowTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.requester = user_model.objects.create_user( + username='notify_user', + password='secret123', + email='requester@workdock.de', + first_name='Nina', + last_name='Requester', + ) + + @patch('workflows.tasks._apply_notification_rules') + @patch('workflows.tasks._schedule_welcome_email') + @patch('workflows.tasks.upload_to_nextcloud') + @patch('workflows.tasks._send_templated_email') + @patch('workflows.tasks._generate_onboarding_pdf') + def test_onboarding_success_creates_success_notification( + self, + mock_generate_pdf, + mock_send_templated_email, + mock_upload, + mock_schedule, + mock_rules, + ): + pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Nina_Notify.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 + request_obj = OnboardingRequest.objects.create( + full_name='Nina Notify', + gender='frau', + job_title='Engineer', + department='IT', + work_email='nina.notify@workdock.de', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email=self.requester.email, + onboarded_by_name='Nina Requester', + agreement='accepted', + ) + + process_onboarding_request(request_obj.id) + + notification = UserNotification.objects.get(user=self.requester) + self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS) + self.assertIn('Onboarding abgeschlossen', notification.title) + self.assertEqual(notification.link_url, '/requests/') + mock_upload.assert_called_once_with(pdf_path, pdf_path.name) + + @patch('workflows.tasks._generate_onboarding_pdf', side_effect=RuntimeError('PDF kaputt')) + def test_onboarding_failure_creates_error_notification(self, mock_generate_pdf): + request_obj = OnboardingRequest.objects.create( + full_name='Lara Broken', + gender='frau', + job_title='Engineer', + department='IT', + work_email='lara.broken@workdock.de', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email=self.requester.email, + onboarded_by_name='Nina Requester', + agreement='accepted', + ) + + with self.assertRaises(RuntimeError): + process_onboarding_request(request_obj.id) + + notification = UserNotification.objects.get(user=self.requester) + self.assertEqual(notification.level, UserNotification.LEVEL_ERROR) + self.assertIn('Onboarding fehlgeschlagen', notification.title) + self.assertIn('PDF kaputt', notification.body) + + @patch('workflows.tasks._apply_notification_rules') + @patch('workflows.tasks.upload_to_nextcloud') + @patch('workflows.tasks._send_templated_email') + @patch('workflows.tasks._generate_offboarding_pdf') + def test_offboarding_success_creates_success_notification( + self, + mock_generate_pdf, + mock_send_templated_email, + mock_upload, + mock_rules, + ): + pdf_path = Path('/tmp/onoff_test_pdfs/offboarding_letter_Nina_Notify.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 + request_obj = OffboardingRequest.objects.create( + full_name='Nina Notify', + work_email='nina.notify@workdock.de', + department='IT', + job_title='Engineer', + last_working_day=date(2026, 12, 31), + requested_by_email=self.requester.email, + requested_by_name='Nina Requester', + ) + + process_offboarding_request(request_obj.id) + + notification = UserNotification.objects.get(user=self.requester) + self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS) + self.assertIn('Offboarding abgeschlossen', notification.title) + self.assertEqual(notification.link_url, '/requests/') + mock_upload.assert_called_once_with(pdf_path, pdf_path.name) + + @patch('workflows.tasks._apply_notification_rules') + @patch('workflows.tasks._schedule_welcome_email') + @patch('workflows.tasks.upload_to_nextcloud') + @patch('workflows.tasks._send_templated_email') + @patch('workflows.tasks._generate_onboarding_pdf') + def test_onboarding_success_notification_respects_user_preferences( + self, + mock_generate_pdf, + mock_send_templated_email, + mock_upload, + mock_schedule, + mock_rules, + ): + profile, _ = UserProfile.objects.get_or_create(user=self.requester) + profile.notification_preferences = { + 'onboarding_success': False, + 'onboarding_failure': True, + 'offboarding_success': True, + 'offboarding_failure': True, + } + profile.save(update_fields=['notification_preferences', 'updated_at']) + pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Pref_Off.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 + request_obj = OnboardingRequest.objects.create( + full_name='Pref Off', + gender='frau', + job_title='Engineer', + department='IT', + work_email='pref.off@workdock.de', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email=self.requester.email, + onboarded_by_name='Nina Requester', + agreement='accepted', + ) + + process_onboarding_request(request_obj.id) + + self.assertFalse(UserNotification.objects.filter(user=self.requester).exists()) + + +class NotificationHeaderTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='notify_header', + password='secret123', + email='notify.header@workdock.de', + ) + self.client.force_login(self.user) + + def test_mark_notification_read_marks_single_entry(self): + notification = UserNotification.objects.create( + user=self.user, + title='Backup fehlgeschlagen', + body='Bitte prüfen.', + level=UserNotification.LEVEL_ERROR, + link_url='/requests/', + ) + + response = self.client.post( + reverse('mark_notification_read', args=[notification.id]), + {'next': '/'}, + HTTP_HOST='localhost', + ) + + notification.refresh_from_db() + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/') + self.assertIsNotNone(notification.read_at) + + def test_mark_all_notifications_read_marks_unread_items(self): + first = UserNotification.objects.create(user=self.user, title='Erfolg', level=UserNotification.LEVEL_SUCCESS) + second = UserNotification.objects.create(user=self.user, title='Fehler', level=UserNotification.LEVEL_ERROR) + + response = self.client.post( + reverse('mark_all_notifications_read'), + {'next': '/requests/'}, + HTTP_HOST='localhost', + ) + + first.refresh_from_db() + second.refresh_from_db() + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/requests/') + self.assertIsNotNone(first.read_at) + self.assertIsNotNone(second.read_at) + + +class OperationalNotificationTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='ops_notify', + password='secret123', + email='ops.notify@workdock.de', + ) + assign_user_role(self.user, ROLE_PLATFORM_OWNER) + self.client = Client(HTTP_HOST='localhost') + self.client.force_login(self.user) + + @patch('workflows.views.create_backup_bundle') + def test_backup_success_creates_notification(self, mock_create_backup_bundle): + mock_create_backup_bundle.return_value = {'name': 'backup_20260327_101010', 'path': '/tmp/backup'} + + response = self.client.post(reverse('create_backup_from_admin')) + + self.assertEqual(response.status_code, 302) + notification = UserNotification.objects.get(user=self.user) + self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS) + self.assertIn('Backup erstellt', notification.title) + + def test_backup_success_respects_preferences(self): + profile = UserProfile.objects.get(user=self.user) + profile.notification_preferences = { + 'onboarding_success': True, + 'onboarding_failure': True, + 'offboarding_success': True, + 'offboarding_failure': True, + 'backup_success': False, + 'backup_failure': True, + 'trial_alerts': True, + 'system_alerts': True, + } + profile.save(update_fields=['notification_preferences', 'updated_at']) + with patch('workflows.views.create_backup_bundle', return_value={'name': 'backup_20260327_111111', 'path': '/tmp/backup'}): + response = self.client.post(reverse('create_backup_from_admin')) + + self.assertEqual(response.status_code, 302) + self.assertFalse(UserNotification.objects.filter(user=self.user).exists()) + + def test_trial_warning_creates_notification(self): + response = self.client.post( + reverse('save_portal_trial_config'), + { + 'is_trial_mode': 'on', + 'trial_started_at': '2026-03-27T10:00', + 'trial_expires_at': '2026-03-30T10:00', + 'restrict_production_integrations': 'on', + 'auto_cleanup_enabled': 'on', + 'trial_banner_text': 'Trial läuft', + 'trial_banner_text_en': 'Trial running', + }, + ) + + self.assertEqual(response.status_code, 200) + notification = UserNotification.objects.get(user=self.user) + self.assertEqual(notification.level, UserNotification.LEVEL_WARNING) + self.assertIn('Trial läuft bald ab', notification.title) + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class WelcomeEmailNotificationTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.requester = user_model.objects.create_user( + username='welcome_notify_user', + password='secret123', + email='welcome.requester@workdock.de', + ) + self.onboarding = OnboardingRequest.objects.create( + full_name='Welcome Notify', + gender='frau', + job_title='Engineer', + department='IT', + work_email='welcome.notify@workdock.de', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email=self.requester.email, + onboarded_by_name='Welcome Requester', + agreement='accepted', + ) + + @patch('workflows.tasks._send_templated_email') + def test_welcome_email_success_creates_notification(self, mock_send_templated_email): + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='welcome.notify@workdock.de', + send_at=timezone.now() - timezone.timedelta(minutes=1), + status='scheduled', + ) + + send_scheduled_welcome_email(scheduled.id, True) + + notification = UserNotification.objects.get(user=self.requester) + self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS) + self.assertIn('Welcome E-Mail gesendet', notification.title) + + @patch('workflows.tasks._send_templated_email', side_effect=RuntimeError('SMTP broken')) + def test_welcome_email_failure_creates_notification(self, mock_send_templated_email): + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='welcome.notify@workdock.de', + send_at=timezone.now() - timezone.timedelta(minutes=1), + status='scheduled', + ) + + with self.assertRaises(RuntimeError): + send_scheduled_welcome_email(scheduled.id, True) + + notification = UserNotification.objects.get(user=self.requester) + self.assertEqual(notification.level, UserNotification.LEVEL_ERROR) + self.assertIn('Welcome E-Mail fehlgeschlagen', notification.title) + + @patch('workflows.tasks._send_templated_email') + def test_welcome_email_success_respects_preferences(self, mock_send_templated_email): + profile, _ = UserProfile.objects.get_or_create(user=self.requester) + profile.notification_preferences = { + 'onboarding_success': True, + 'onboarding_failure': True, + 'offboarding_success': True, + 'offboarding_failure': True, + 'backup_success': True, + 'backup_failure': True, + 'welcome_email_success': False, + 'welcome_email_failure': True, + 'trial_alerts': True, + 'system_alerts': True, + } + profile.save(update_fields=['notification_preferences', 'updated_at']) + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='welcome.notify@workdock.de', + send_at=timezone.now() - timezone.timedelta(minutes=1), + status='scheduled', + ) + + send_scheduled_welcome_email(scheduled.id, True) + + self.assertFalse(UserNotification.objects.filter(user=self.requester).exists()) diff --git a/backend/workflows/tests/test_observability_ui.py b/backend/workflows/tests/test_observability_ui.py new file mode 100644 index 0000000..a02cece --- /dev/null +++ b/backend/workflows/tests/test_observability_ui.py @@ -0,0 +1,121 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.utils import timezone + +from workflows.models import AsyncTaskLog +from workflows.roles import ROLE_ADMIN, ROLE_STAFF, assign_user_role + + +class ObservabilityUITests(TestCase): + def setUp(self): + user_model = get_user_model() + self.admin = user_model.objects.create_user( + username='ops_admin', + email='ops-admin@example.com', + password='secret123', + ) + assign_user_role(self.admin, ROLE_ADMIN) + + self.staff = user_model.objects.create_user( + username='ops_staff', + email='ops-staff@example.com', + password='secret123', + ) + assign_user_role(self.staff, ROLE_STAFF) + + def _create_log(self, *, status: str, task_name: str, target_label: str, error_message: str = '') -> AsyncTaskLog: + log = AsyncTaskLog.objects.create( + task_name=task_name, + status=status, + target_type='request', + target_id=1, + target_label=target_label, + error_message=error_message, + ) + AsyncTaskLog.objects.filter(id=log.id).update( + started_at=timezone.now() - timedelta(hours=2), + finished_at=timezone.now() - timedelta(hours=1, minutes=45), + ) + return AsyncTaskLog.objects.get(id=log.id) + + def test_home_hides_operations_overview_for_admin(self): + self._create_log( + status='failed', + task_name='send_scheduled_welcome_email', + target_label='Request A', + error_message='smtp failed hard', + ) + self._create_log( + status='succeeded', + task_name='process_onboarding_request', + target_label='Request B', + ) + self._create_log( + status='started', + task_name='process_offboarding_request', + target_label='Request C', + ) + + client = Client() + client.force_login(self.admin) + response = client.get('/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'Operations Overview') + self.assertNotContains(response, 'Job Monitor öffnen') + + def test_home_hides_operations_overview_for_staff(self): + self._create_log( + status='failed', + task_name='process_onboarding_request', + target_label='Request A', + error_message='pdf failed', + ) + + client = Client() + client.force_login(self.staff) + response = client.get('/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'Operations Overview') + self.assertNotContains(response, 'Job Monitor öffnen') + + def test_job_monitor_summary_shows_recent_counts(self): + self._create_log( + status='failed', + task_name='process_onboarding_request', + target_label='Request A', + error_message='pdf failed', + ) + self._create_log( + status='succeeded', + task_name='process_offboarding_request', + target_label='Request B', + ) + self._create_log( + status='started', + task_name='send_scheduled_welcome_email', + target_label='Request C', + ) + + client = Client() + client.force_login(self.admin) + response = client.get('/admin-tools/jobs/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Fehlgeschlagene Jobs (24h)') + self.assertContains(response, 'Erfolgreiche Jobs (24h)') + self.assertContains(response, 'Offene Starts (24h)') + self.assertContains(response, 'Zuletzt fehlgeschlagen') + self.assertContains(response, 'pdf failed') + self.assertContains(response, 'Backup-Status') + + def test_job_monitor_requires_capability(self): + client = Client() + client.force_login(self.staff) + + response = client.get('/admin-tools/jobs/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 302) diff --git a/backend/workflows/tests/test_offboarding_flow.py b/backend/workflows/tests/test_offboarding_flow.py index 41244f6..df83b5f 100644 --- a/backend/workflows/tests/test_offboarding_flow.py +++ b/backend/workflows/tests/test_offboarding_flow.py @@ -3,16 +3,18 @@ from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import EmployeeProfile, OffboardingRequest +from workflows.branding import get_company_email_domain +from workflows.models import EmployeeProfile, FormCustomFieldConfig, OffboardingRequest class OffboardingFlowTests(TestCase): def setUp(self): + self.company_domain = get_company_email_domain() user_model = get_user_model() self.user = user_model.objects.create_user( username='offboard_user', password='secret123', - email='operator@tub.co', + email=f'operator@{self.company_domain}', first_name='Nina', last_name='Admin', ) @@ -23,7 +25,7 @@ class OffboardingFlowTests(TestCase): last_name='Beispiel', department='IT-Service', job_title='Engineer', - work_email='lara.beispiel@tub.co', + work_email=f'lara.beispiel@{self.company_domain}', ) def test_offboarding_prefill_from_profile(self): @@ -32,7 +34,7 @@ class OffboardingFlowTests(TestCase): self.assertEqual(response.status_code, 200) self.assertIn('value="Lara Beispiel"', html) - self.assertIn('value="lara.beispiel@tub.co"', html) + self.assertIn(f'value="lara.beispiel@{self.company_domain}"', html) self.assertIn('value="Engineer"', html) @patch('workflows.views.process_offboarding_request.delay') @@ -54,6 +56,40 @@ class OffboardingFlowTests(TestCase): 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_email, f'operator@{self.company_domain}') self.assertEqual(obj.requested_by_name, 'Nina Admin') mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_offboarding_request.delay') + def test_offboarding_custom_field_is_saved(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='offboarding', + field_key='return_comment', + section_key='abschluss', + sort_order=0, + field_type='textarea', + is_active=True, + is_required=False, + label='Rückgabehinweis', + ) + + 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.', + 'custom__return_comment': 'Abholung durch IT am Freitag.', + } + + 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.custom_field_values, {'return_comment': 'Abholung durch IT am Freitag.'}) + mock_delay.assert_called_once_with(obj.id) diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index dd2a34d..84d0efc 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -3,16 +3,18 @@ from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import OnboardingRequest +from workflows.branding import get_company_email_domain +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest class OnboardingFlowTests(TestCase): def setUp(self): + self.company_domain = get_company_email_domain() user_model = get_user_model() self.user = user_model.objects.create_user( username='onboard_user', password='secret123', - email='requester@tub.co', + email=f'requester@{self.company_domain}', first_name='Mia', last_name='Beispiel', ) @@ -26,7 +28,7 @@ class OnboardingFlowTests(TestCase): 'gender': 'herr', 'job_title': 'Consultant', 'department': 'IT-Service', - 'work_email': 'max.mustermann@tub.co', + 'work_email': f'max.mustermann@{self.company_domain}', 'contract_start': '2026-11-01', 'employment_type': 'unbefristet', 'group_mailboxes_required_choice': 'nein', @@ -43,8 +45,362 @@ class OnboardingFlowTests(TestCase): 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') + obj = OnboardingRequest.objects.get(work_email=f'max.mustermann@{self.company_domain}') self.assertEqual(obj.full_name, 'Max Mustermann') - self.assertEqual(obj.onboarded_by_email, 'requester@tub.co') + self.assertEqual(obj.onboarded_by_email, f'requester@{self.company_domain}') self.assertEqual(obj.onboarded_by_name, 'Mia Beispiel') mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_onboarding_request.delay') + def test_hidden_non_locked_field_does_not_block_submission(self, mock_delay): + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='department', + defaults={'is_visible': False}, + ) + payload = { + 'first_name': 'Nora', + 'last_name': 'Neutral', + 'gender': 'frau', + 'job_title': 'Consultant', + 'work_email': f'nora.neutral@{self.company_domain}', + '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) + obj = OnboardingRequest.objects.get(work_email=f'nora.neutral@{self.company_domain}') + self.assertEqual(obj.department, '') + mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_onboarding_request.delay') + def test_required_override_blocks_submission_when_field_is_missing(self, mock_delay): + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='job_title', + defaults={'is_required': True}, + ) + payload = { + 'first_name': 'Lina', + 'last_name': 'Leer', + 'gender': 'frau', + 'department': 'IT-Service', + 'work_email': f'lina.leer@{self.company_domain}', + '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, 200) + self.assertContains(response, 'Dieses Feld ist zwingend erforderlich.') + self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lina.leer@{self.company_domain}').exists()) + mock_delay.assert_not_called() + + @patch('workflows.views.process_onboarding_request.delay') + def test_hidden_itsetup_section_is_removed_from_form_and_submission(self, mock_delay): + FormSectionConfig.objects.update_or_create( + form_type='onboarding', + section_key='itsetup', + defaults={'is_visible': False}, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, '3. IT-Setup') + + payload = { + 'first_name': 'Nora', + 'last_name': 'Neutral', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'nora.section@{self.company_domain}', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'agreement_confirm': 'on', + } + + submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(submit_response.status_code, 302) + self.assertTrue(OnboardingRequest.objects.filter(work_email=f'nora.section@{self.company_domain}').exists()) + mock_delay.assert_called_once() + + def test_onboarding_page_renders_conditional_rules_payload(self): + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('id="onboarding-conditional-rules"', html) + self.assertIn('business-card-box', html) + self.assertIn('employment-end-box', html) + self.assertIn('data-conditional-target="business-card-box"', html) + self.assertNotIn('data-conditional-target="phone-box"', html) + + def test_onboarding_page_uses_stored_conditional_rule_config(self): + FormConditionalRuleConfig.objects.update_or_create( + form_type='onboarding', + target_key='employment-end-box', + defaults={ + 'is_active': True, + 'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}], + }, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('employment-end-box', html) + self.assertIn('"value": "unbefristet"', html) + + def test_onboarding_custom_field_uses_combined_order(self): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=1, + field_type='text', + is_active=True, + label='Bürostandort', + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='gender', + defaults={'sort_order': 2, 'page_key': 'stammdaten'}, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertLess(html.index('Bürostandort'), html.index('Anrede')) + + def test_phone_direct_dial_field_is_visible_without_successor(self): + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('Telefon-Direktwahl', html) + self.assertNotIn('data-conditional-target="phone-box"', html) + + def test_onboarding_custom_section_is_rendered_in_navigation(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='text', + is_active=True, + label='Essenszuschuss', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('Benefits', html) + self.assertIn('Essenszuschuss', html) + + def test_onboarding_custom_section_with_checkbox_fields_shows_section_select_all(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='checkbox', + is_active=True, + label='Essenszuschuss', + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='parking_spot', + section_key='benefits', + sort_order=1, + field_type='checkbox', + is_active=True, + label='Parkplatz', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('data-section-checkbox-toggle', html) + self.assertIn('Essenszuschuss', html) + self.assertIn('Parkplatz', html) + + @patch('workflows.views.process_onboarding_request.delay') + def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + is_required=True, + label='Bürostandort', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + self.assertContains(response, 'Bürostandort') + + payload = { + 'first_name': 'Mara', + 'last_name': 'Muster', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'mara.muster@{self.company_domain}', + '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', + 'custom__office_location': 'Berlin Mitte', + 'agreement_confirm': 'on', + } + + submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(submit_response.status_code, 302) + obj = OnboardingRequest.objects.get(work_email=f'mara.muster@{self.company_domain}') + self.assertEqual(obj.custom_field_values, {'office_location': 'Berlin Mitte'}) + mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_onboarding_request.delay') + def test_hidden_required_custom_field_does_not_block_submission(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='visitor_badge_name', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + is_required=True, + label='Besucherausweis', + ) + FormConditionalRuleConfig.objects.update_or_create( + form_type='onboarding', + target_key='custom__visitor_badge_name', + defaults={ + 'is_active': True, + 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], + }, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + self.assertIn('custom__visitor_badge_name', html) + self.assertIn('"custom__visitor_badge_name"', html) + + payload = { + 'first_name': 'Lea', + 'last_name': 'Leicht', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'lea.leicht@{self.company_domain}', + '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', + } + + submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(submit_response.status_code, 302) + obj = OnboardingRequest.objects.get(work_email=f'lea.leicht@{self.company_domain}') + self.assertEqual(obj.custom_field_values, {'visitor_badge_name': ''}) + mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_onboarding_request.delay') + def test_visible_required_custom_field_blocks_submission(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='visitor_badge_name', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + is_required=True, + label='Besucherausweis', + ) + FormConditionalRuleConfig.objects.update_or_create( + form_type='onboarding', + target_key='custom__visitor_badge_name', + defaults={ + 'is_active': True, + 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], + }, + ) + + payload = { + 'first_name': 'Lia', + 'last_name': 'Laut', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'lia.laut@{self.company_domain}', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'order_business_cards': 'on', + 'business_card_name': 'Lia Laut', + 'business_card_title': 'Consultant', + 'business_card_email': f'lia.laut@{self.company_domain}', + 'business_card_phone': '030 123456', + '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, 200) + self.assertContains(response, 'Besucherausweis') + self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lia.laut@{self.company_domain}').exists()) + mock_delay.assert_not_called() diff --git a/backend/workflows/tests/test_pdf_generation.py b/backend/workflows/tests/test_pdf_generation.py new file mode 100644 index 0000000..83d0ef3 --- /dev/null +++ b/backend/workflows/tests/test_pdf_generation.py @@ -0,0 +1,75 @@ +from datetime import date +from pathlib import Path + +from django.test import TestCase, override_settings +from pypdf import PdfReader + +from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.tasks import _generate_offboarding_pdf, _generate_onboarding_pdf + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class PDFGenerationTests(TestCase): + def _extract_pdf_text(self, pdf_path: Path) -> str: + reader = PdfReader(str(pdf_path)) + return "\n".join((page.extract_text() or "") for page in reader.pages) + + def test_onboarding_pdf_respects_hidden_section_and_field(self): + FormSectionConfig.objects.update_or_create( + form_type='onboarding', + section_key='itsetup', + defaults={'is_visible': False}, + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='job_title', + defaults={'is_visible': False}, + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + needed_devices='Laptop\nMonitor', + onboarded_by_email='requester@workdock.de', + onboarded_by_name='Mia Beispiel', + agreement='accepted', + ) + + pdf_path = _generate_onboarding_pdf(request_obj) + text = self._extract_pdf_text(pdf_path) + + self.assertIn('Max Mustermann', text) + self.assertIn('IT-Service', text) + self.assertIn('Stammdaten', text) + self.assertNotIn('Consultant', text) + self.assertNotIn('Laptop', text) + self.assertNotIn('IT-Setup', text) + self.assertNotIn('1. Stammdaten', text) + self.assertNotIn('Vorname', text) + self.assertNotIn('Nachname', text) + self.assertNotIn('onboarded_by_email', text) + + def test_offboarding_pdf_uses_dynamic_sections(self): + request_obj = OffboardingRequest.objects.create( + full_name='Lara Beispiel', + work_email='lara.beispiel@workdock.de', + department='IT-Service', + job_title='Engineer', + last_working_day=date(2026, 12, 31), + notes='Bitte Accounts sperren.', + requested_by_email='admin@workdock.de', + requested_by_name='Nina Admin', + ) + + pdf_path = _generate_offboarding_pdf(request_obj) + text = self._extract_pdf_text(pdf_path) + + self.assertIn('Lara Beispiel', text) + self.assertIn('Engineer', text) + self.assertIn('31. Dezember 2026', text) + self.assertIn('Bitte Accounts sperren.', text) + self.assertNotIn('1. Mitarbeitende', text) diff --git a/backend/workflows/tests/test_pdf_sections.py b/backend/workflows/tests/test_pdf_sections.py new file mode 100644 index 0000000..8ae826b --- /dev/null +++ b/backend/workflows/tests/test_pdf_sections.py @@ -0,0 +1,160 @@ +from django.test import TestCase + +from workflows.models import FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.pdf_sections import build_pdf_sections + + +class PDFSectionBuilderTests(TestCase): + def test_onboarding_builder_respects_hidden_section_and_hidden_field(self): + FormSectionConfig.objects.update_or_create( + form_type='onboarding', + section_key='itsetup', + defaults={'is_visible': False}, + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='job_title', + defaults={'is_visible': False}, + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + + self.assertEqual([section['key'] for section in sections], ['stammdaten', 'vertrag', 'abschluss']) + stammdaten = next(section for section in sections if section['key'] == 'stammdaten') + self.assertNotIn('job_title', [field['name'] for field in stammdaten['fields']]) + + def test_onboarding_builder_uses_field_order_and_overrides(self): + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='department', + defaults={ + 'sort_order': 1, + 'label_override': 'Team', + 'help_text_override': 'Interne Organisationseinheit', + }, + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='gender', + defaults={'sort_order': 5}, + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + stammdaten = next(section for section in sections if section['key'] == 'stammdaten') + visible_names = [field['name'] for field in stammdaten['fields']] + department_field = next(field for field in stammdaten['fields'] if field['name'] == 'department') + + self.assertLess(visible_names.index('department'), visible_names.index('gender')) + self.assertEqual(department_field['label'], 'Team') + self.assertEqual(department_field['help_text'], 'Interne Organisationseinheit') + self.assertEqual(department_field['display_value'], 'IT-Service') + + def test_offboarding_builder_has_section_parity_and_formats_values(self): + FormSectionConfig.objects.update_or_create( + form_type='offboarding', + section_key='abschluss', + defaults={'is_visible': False}, + ) + request_obj = OffboardingRequest.objects.create( + full_name='Lara Beispiel', + work_email='lara.beispiel@workdock.de', + department='IT-Service', + job_title='Engineer', + last_working_day='2026-12-31', + notes='Bitte Accounts sperren.', + requested_by_email='admin@workdock.de', + ) + + sections = build_pdf_sections('offboarding', request_obj, 'de') + + self.assertEqual([section['key'] for section in sections], ['mitarbeitende', 'austritt']) + mitarbeitende = next(section for section in sections if section['key'] == 'mitarbeitende') + austritt = next(section for section in sections if section['key'] == 'austritt') + self.assertIn('full_name', [field['name'] for field in mitarbeitende['fields']]) + self.assertIn('last_working_day', [field['name'] for field in austritt['fields']]) + date_field = next(field for field in austritt['fields'] if field['name'] == 'last_working_day') + self.assertTrue(date_field['display_value']) + + def test_custom_fields_are_included_in_pdf_sections(self): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + label='Bürostandort', + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'office_location': 'Berlin Mitte'}, + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + stammdaten = next(section for section in sections if section['key'] == 'stammdaten') + custom_field = next(field for field in stammdaten['fields'] if field['name'] == 'custom__office_location') + + self.assertEqual(custom_field['label'], 'Bürostandort') + self.assertEqual(custom_field['display_value'], 'Berlin Mitte') + + def test_custom_section_title_is_used_in_pdf_sections(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='text', + is_active=True, + label='Essenszuschuss', + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'meal_allowance': 'Ja'}, + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + + custom_section = next(section for section in sections if section['key'] == 'benefits') + self.assertEqual(custom_section['title'], 'Benefits') + self.assertIn('custom__meal_allowance', [field['name'] for field in custom_section['fields']]) diff --git a/backend/workflows/tests/test_request_id_logging.py b/backend/workflows/tests/test_request_id_logging.py new file mode 100644 index 0000000..3cf6fe8 --- /dev/null +++ b/backend/workflows/tests/test_request_id_logging.py @@ -0,0 +1,31 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + + +class RequestIDMiddlewareTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='request_id_user', + password='secret123', + email='requestid@tub.co', + ) + + def test_response_contains_request_id_header(self): + client = Client(HTTP_HOST='127.0.0.1') + client.force_login(self.user) + + response = client.get('/') + + self.assertEqual(response.status_code, 200) + self.assertIn('X-Request-ID', response.headers) + self.assertTrue(response.headers['X-Request-ID']) + + def test_incoming_request_id_is_preserved(self): + client = Client(HTTP_HOST='127.0.0.1', HTTP_X_REQUEST_ID='external-request-123') + client.force_login(self.user) + + response = client.get('/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers['X-Request-ID'], 'external-request-123') diff --git a/backend/workflows/tests/test_request_timeline.py b/backend/workflows/tests/test_request_timeline.py new file mode 100644 index 0000000..389e3be --- /dev/null +++ b/backend/workflows/tests/test_request_timeline.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.models import FormCustomFieldConfig, OffboardingRequest, OnboardingRequest +from workflows.roles import ROLE_ADMIN, assign_user_role + + +class RequestTimelineCustomFieldTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='timeline_admin', + email='timeline-admin@example.com', + password='secret123', + ) + assign_user_role(self.user, ROLE_ADMIN) + self.client.force_login(self.user) + + def test_onboarding_timeline_renders_custom_field_values(self): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + label='Bürostandort', + ) + obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'office_location': 'Berlin Mitte'}, + ) + + response = self.client.get(f'/requests/timeline/onboarding/{obj.id}/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Benutzerdefinierte Felder') + self.assertContains(response, 'Bürostandort') + self.assertContains(response, 'Berlin Mitte') + + def test_offboarding_timeline_renders_custom_field_values(self): + FormCustomFieldConfig.objects.create( + form_type='offboarding', + field_key='return_comment', + section_key='abschluss', + sort_order=0, + field_type='textarea', + is_active=True, + label='Rückgabehinweis', + ) + obj = OffboardingRequest.objects.create( + full_name='Lara Beispiel', + work_email='lara.beispiel@workdock.de', + department='IT-Service', + job_title='Engineer', + last_working_day='2026-12-31', + requested_by_email='admin@workdock.de', + custom_field_values={'return_comment': 'Abholung durch IT am Freitag.'}, + ) + + response = self.client.get(f'/requests/timeline/offboarding/{obj.id}/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Benutzerdefinierte Felder') + self.assertContains(response, 'Rückgabehinweis') + self.assertContains(response, 'Abholung durch IT am Freitag.') diff --git a/backend/workflows/tests/test_security_hardening.py b/backend/workflows/tests/test_security_hardening.py new file mode 100644 index 0000000..7d114e4 --- /dev/null +++ b/backend/workflows/tests/test_security_hardening.py @@ -0,0 +1,96 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase, override_settings + +from workflows.checks import security_settings_check + + +@override_settings(DEBUG=True) +class RateLimitMiddlewareTests(TestCase): + @override_settings(RATE_LIMIT_LOGIN_LIMIT=2, RATE_LIMIT_LOGIN_WINDOW=60) + def test_login_is_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.10') + for _ in range(2): + response = client.post('/accounts/login/', {'username': 'x', 'password': 'y'}, HTTP_HOST='localhost') + self.assertNotEqual(response.status_code, 429) + + response = client.post('/accounts/login/', {'username': 'x', 'password': 'y'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 429) + self.assertIn('Retry-After', response) + + @override_settings(RATE_LIMIT_PASSWORD_RESET_LIMIT=1, RATE_LIMIT_PASSWORD_RESET_WINDOW=60) + def test_password_reset_is_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.20') + response = client.post('/accounts/password_reset/', {'email': 'nobody@example.com'}, HTTP_HOST='localhost') + self.assertNotEqual(response.status_code, 429) + + response = client.post('/accounts/password_reset/', {'email': 'nobody@example.com'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 429) + + @override_settings(RATE_LIMIT_ADMIN_ACTION_LIMIT=1, RATE_LIMIT_ADMIN_ACTION_WINDOW=60) + def test_sensitive_admin_posts_are_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.30') + response = client.post('/admin-tools/branding/save/', {'portal_title': 'A'}, HTTP_HOST='localhost') + self.assertNotEqual(response.status_code, 429) + + response = client.post('/admin-tools/branding/save/', {'portal_title': 'B'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 429) + + @override_settings(RATE_LIMIT_LOGIN_LIMIT=1, RATE_LIMIT_LOGIN_WINDOW=60) + def test_get_requests_are_not_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.40') + first = client.get('/accounts/login/', HTTP_HOST='localhost') + second = client.get('/accounts/login/', HTTP_HOST='localhost') + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + + +class SecurityChecksTests(TestCase): + @override_settings( + DEBUG=False, + SECRET_KEY='unsafe-dev-key', + ALLOWED_HOSTS=[], + SESSION_COOKIE_SECURE=False, + CSRF_COOKIE_SECURE=False, + SECURE_SSL_REDIRECT=False, + RUN_SECURITY_CHECKS_DURING_TESTS=True, + ) + def test_security_checks_report_production_issues(self): + issues = security_settings_check(None) + ids = {issue.id for issue in issues} + self.assertIn('workdock.E001', ids) + self.assertIn('workdock.E002', ids) + self.assertIn('workdock.E003', ids) + self.assertIn('workdock.E004', ids) + self.assertIn('workdock.W001', ids) + + +@override_settings(DEBUG=True) +class AuthSessionHardeningTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(username='security-user', password='secret-12345') + + @override_settings(SESSION_IDLE_TIMEOUT_SECONDS=60) + def test_idle_session_forces_relogin(self): + client = Client(REMOTE_ADDR='10.10.10.50') + client.force_login(self.user) + session = client.session + session['last_activity_ts'] = 1 + session['auth_fresh_ts'] = 1 + session.save() + + response = client.get('/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 302) + self.assertIn('/accounts/login/', response['Location']) + + @override_settings(SENSITIVE_ACTION_REAUTH_SECONDS=60) + def test_stale_sensitive_post_forces_relogin(self): + client = Client(REMOTE_ADDR='10.10.10.60') + client.force_login(self.user) + session = client.session + session['last_activity_ts'] = 9999999999 + session['auth_fresh_ts'] = 1 + session.save() + + response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 302) + self.assertIn('/accounts/login/', response['Location']) diff --git a/backend/workflows/tests/test_trial_lifecycle.py b/backend/workflows/tests/test_trial_lifecycle.py new file mode 100644 index 0000000..c0d4c58 --- /dev/null +++ b/backend/workflows/tests/test_trial_lifecycle.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.utils import timezone + +from workflows.branding import get_portal_trial_config +from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role +from workflows.services import is_email_test_mode, is_nextcloud_enabled + + +class TrialLifecycleTests(TestCase): + def setUp(self): + user_model = get_user_model() + + self.platform_owner = user_model.objects.create_user(username='trial_platform_owner', password='secret123') + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) + + self.staff = user_model.objects.create_user(username='trial_staff', password='secret123') + assign_user_role(self.staff, ROLE_STAFF) + + self.trial = get_portal_trial_config() + self.original_values = ( + self.trial.is_trial_mode, + self.trial.trial_started_at, + self.trial.trial_expires_at, + self.trial.restrict_production_integrations, + self.trial.auto_cleanup_enabled, + ) + + def tearDown(self): + ( + self.trial.is_trial_mode, + self.trial.trial_started_at, + self.trial.trial_expires_at, + self.trial.restrict_production_integrations, + self.trial.auto_cleanup_enabled, + ) = self.original_values + self.trial.save() + + def test_staff_is_blocked_after_trial_expiry(self): + self.trial.is_trial_mode = True + self.trial.trial_started_at = timezone.now() - timezone.timedelta(days=10) + self.trial.trial_expires_at = timezone.now() - timezone.timedelta(days=1) + self.trial.save() + + client = Client(HTTP_HOST='127.0.0.1') + client.force_login(self.staff) + response = client.get('/requests/') + + self.assertEqual(response.status_code, 403) + self.assertIn('Trial abgelaufen', response.content.decode('utf-8')) + + def test_platform_owner_keeps_access_after_trial_expiry(self): + self.trial.is_trial_mode = True + self.trial.trial_started_at = timezone.now() - timezone.timedelta(days=10) + self.trial.trial_expires_at = timezone.now() - timezone.timedelta(days=1) + self.trial.save() + + client = Client(HTTP_HOST='127.0.0.1') + client.force_login(self.platform_owner) + response = client.get('/requests/') + + self.assertEqual(response.status_code, 200) + + def test_trial_restriction_forces_safe_integration_modes(self): + self.trial.is_trial_mode = True + self.trial.trial_started_at = timezone.now() - timezone.timedelta(days=1) + self.trial.trial_expires_at = timezone.now() + timezone.timedelta(days=2) + self.trial.restrict_production_integrations = True + self.trial.save() + + self.assertFalse(is_nextcloud_enabled()) + self.assertTrue(is_email_test_mode()) + diff --git a/backend/workflows/tests/test_upload_validation.py b/backend/workflows/tests/test_upload_validation.py new file mode 100644 index 0000000..7d41117 --- /dev/null +++ b/backend/workflows/tests/test_upload_validation.py @@ -0,0 +1,143 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from io import BytesIO +from PIL import Image + +from workflows.forms import AccountAvatarForm, OnboardingRequestForm, PortalBrandingForm + + +class UploadValidationTests(TestCase): + def test_avatar_rejects_mismatched_extension_and_signature(self): + form = AccountAvatarForm( + data={}, + files={ + 'avatar_image': SimpleUploadedFile( + 'avatar.png', + b'not-a-real-png', + content_type='image/png', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('avatar_image', form.errors) + + def test_logo_accepts_valid_svg(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'logo_image': SimpleUploadedFile( + 'logo.svg', + b'', + content_type='image/svg+xml', + ) + }, + ) + + self.assertTrue(form.is_valid(), form.errors) + + def test_favicon_rejects_wrong_signature(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'favicon_image': SimpleUploadedFile( + 'favicon.ico', + b'not-an-ico', + content_type='image/x-icon', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('favicon_image', form.errors) + + def test_pdf_letterhead_rejects_non_pdf_content(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'pdf_letterhead': SimpleUploadedFile( + 'letterhead.pdf', + b'not-a-pdf', + content_type='application/pdf', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('pdf_letterhead', form.errors) + + def test_signature_accepts_valid_png(self): + buffer = BytesIO() + Image.new('RGBA', (2, 2), (0, 0, 0, 255)).save(buffer, format='PNG') + png_bytes = buffer.getvalue() + form = OnboardingRequestForm( + data={ + 'first_name': 'Max', + 'last_name': 'Mustermann', + 'gender': 'herr', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': 'max.mustermann@workdock.de', + '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', + }, + files={ + 'signature_image': SimpleUploadedFile( + 'signature.png', + png_bytes, + content_type='image/png', + ) + }, + requester_email='requester@workdock.de', + ) + + self.assertTrue(form.is_valid(), form.errors) diff --git a/backend/workflows/totp.py b/backend/workflows/totp.py new file mode 100644 index 0000000..7283a31 --- /dev/null +++ b/backend/workflows/totp.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import struct +import string +from urllib.parse import quote + + +def generate_totp_secret(length: int = 20) -> str: + return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=') + + +def normalize_totp_token(value: str | None) -> str: + return ''.join(ch for ch in (value or '').strip() if ch.isdigit()) + + +def _secret_bytes(secret: str) -> bytes: + padded = secret.strip().replace(' ', '').upper() + padding = '=' * ((8 - len(padded) % 8) % 8) + return base64.b32decode(padded + padding, casefold=True) + + +def generate_totp_token(secret: str, for_time: int, *, digits: int = 6, period: int = 30) -> str: + counter = int(for_time // period) + key = _secret_bytes(secret) + msg = struct.pack('>Q', counter) + digest = hmac.new(key, msg, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code_int = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF + return str(code_int % (10**digits)).zfill(digits) + + +def verify_totp_token(secret: str, token: str, *, for_time: int, digits: int = 6, period: int = 30, window: int = 1) -> bool: + normalized = normalize_totp_token(token) + if len(normalized) != digits: + return False + for offset in range(-window, window + 1): + candidate_time = for_time + (offset * period) + if generate_totp_token(secret, candidate_time, digits=digits, period=period) == normalized: + return True + return False + + +def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str: + label = quote(f'{issuer}:{account_name}') + issuer_q = quote(issuer) + return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30' + + +def normalize_recovery_code(value: str | None) -> str: + raw = (value or '').strip().upper().replace(' ', '') + return raw + + +def generate_recovery_codes(count: int = 8) -> list[str]: + alphabet = string.ascii_uppercase + string.digits + codes = [] + for _ in range(count): + parts = [] + for _part in range(2): + parts.append(''.join(secrets.choice(alphabet) for _ in range(5))) + codes.append('-'.join(parts)) + return codes diff --git a/backend/workflows/trial.py b/backend/workflows/trial.py new file mode 100644 index 0000000..dd438bf --- /dev/null +++ b/backend/workflows/trial.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.sessions.models import Session +from django.utils import timezone + +from .branding import get_portal_trial_config, is_trial_expired +from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail +from .roles import ROLE_PLATFORM_OWNER, get_user_role_key + +# Trial cleanup is intentionally destructive but platform-scoped. +# It preserves the platform-owner account so expired demo environments remain +# recoverable and inspectable by the product operator. + + +def cleanup_trial_workspace_data() -> dict[str, int]: + user_model = get_user_model() + deleted_counts = { + 'onboarding_requests': OnboardingRequest.objects.count(), + 'offboarding_requests': OffboardingRequest.objects.count(), + 'intro_sessions': OnboardingIntroductionSession.objects.count(), + 'scheduled_welcome_emails': ScheduledWelcomeEmail.objects.count(), + 'employee_profiles': EmployeeProfile.objects.count(), + 'audit_logs': AdminAuditLog.objects.count(), + 'sessions': Session.objects.count(), + 'users_removed': 0, + 'media_paths_removed': 0, + } + + OnboardingIntroductionSession.objects.all().delete() + ScheduledWelcomeEmail.objects.all().delete() + OnboardingRequest.objects.all().delete() + OffboardingRequest.objects.all().delete() + EmployeeProfile.objects.all().delete() + AdminAuditLog.objects.all().delete() + Session.objects.all().delete() + + for user in user_model.objects.all(): + if get_user_role_key(user) == ROLE_PLATFORM_OWNER: + continue + user.delete() + deleted_counts['users_removed'] += 1 + + for path in (settings.MEDIA_ROOT / 'pdfs', settings.MEDIA_ROOT / 'signatures'): + candidate = Path(path) + if candidate.exists(): + shutil.rmtree(candidate, ignore_errors=True) + candidate.mkdir(parents=True, exist_ok=True) + deleted_counts['media_paths_removed'] += 1 + + trial_config = get_portal_trial_config() + trial_config.last_cleanup_at = timezone.now() + trial_config.save(update_fields=['last_cleanup_at', 'updated_at']) + return deleted_counts + + +def trial_cleanup_is_due() -> bool: + trial_config = get_portal_trial_config() + return bool(trial_config.is_trial_mode and trial_config.auto_cleanup_enabled and is_trial_expired()) diff --git a/backend/workflows/upload_validation.py b/backend/workflows/upload_validation.py new file mode 100644 index 0000000..9e17a86 --- /dev/null +++ b/backend/workflows/upload_validation.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from pathlib import Path + +from django import forms +from django.utils.translation import gettext as _ + + +def _header_matches(extension: str, header: bytes) -> bool: + extension = extension.lower().lstrip(".") + if extension == "png": + return header.startswith(b"\x89PNG\r\n\x1a\n") + if extension in {"jpg", "jpeg"}: + return header.startswith(b"\xff\xd8\xff") + if extension == "webp": + return header.startswith(b"RIFF") and header[8:12] == b"WEBP" + if extension == "pdf": + return header.startswith(b"%PDF") + if extension == "ico": + return header.startswith(b"\x00\x00\x01\x00") + if extension == "svg": + text = header.decode("utf-8", errors="ignore").lower() + return " None: + if not uploaded_file: + return + + if getattr(uploaded_file, "size", 0) > max_size_bytes: + raise forms.ValidationError(size_message) + + extension = Path(getattr(uploaded_file, "name", "")).suffix.lower().lstrip(".") + if extension not in allowed_extensions: + raise forms.ValidationError(invalid_type_message) + + content_type = (getattr(uploaded_file, "content_type", "") or "").lower().strip() + if allowed_content_types and content_type and content_type not in allowed_content_types: + raise forms.ValidationError(invalid_type_message) + + try: + header = uploaded_file.read(512) + uploaded_file.seek(0) + except Exception as exc: + raise forms.ValidationError(unreadable_message) from exc + + if not _header_matches(extension, header): + raise forms.ValidationError(invalid_type_message) + + +def validate_avatar_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"png", "jpg", "jpeg", "webp", "svg"}, + max_size_bytes=5 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte ein PNG-, JPG-, WEBP- oder SVG-Bild hochladen."), + size_message=_("Das Profilbild darf maximal 5 MB groß sein."), + unreadable_message=_("Die Bilddatei konnte nicht gelesen werden."), + ) + + +def validate_logo_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"svg", "png", "jpg", "jpeg", "webp"}, + max_size_bytes=5 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte ein SVG-, PNG-, JPG- oder WEBP-Bild hochladen."), + size_message=_("Das Logo darf maximal 5 MB groß sein."), + unreadable_message=_("Die Logo-Datei konnte nicht gelesen werden."), + ) + + +def validate_favicon_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"ico", "png", "svg", "webp"}, + max_size_bytes=2 * 1024 * 1024, + allowed_content_types={ + "image/x-icon", + "image/vnd.microsoft.icon", + "image/png", + "image/x-png", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte eine ICO-, PNG-, SVG- oder WEBP-Datei hochladen."), + size_message=_("Das Favicon darf maximal 2 MB groß sein."), + unreadable_message=_("Die Favicon-Datei konnte nicht gelesen werden."), + ) + + +def validate_pdf_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"pdf"}, + max_size_bytes=10 * 1024 * 1024, + allowed_content_types={"application/pdf"}, + invalid_type_message=_("Bitte eine gültige PDF-Datei hochladen."), + size_message=_("Der PDF-Briefkopf darf maximal 10 MB groß sein."), + unreadable_message=_("Die PDF-Datei konnte nicht gelesen werden."), + ) + + +def validate_signature_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"png", "jpg", "jpeg"}, + max_size_bytes=4 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + }, + invalid_type_message=_("Bitte eine PNG- oder JPG-Datei hochladen."), + size_message=_("Die Signatur-Datei ist zu groß (max. 4 MB)."), + unreadable_message=_("Die Signatur-Datei konnte nicht gelesen werden."), + ) diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index 88062a2..41421fe 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -5,6 +5,9 @@ from . import views urlpatterns = [ path('healthz/', views.healthz, name='healthz'), path('', views.home, name='home'), + path('account/', views.account_profile_page, name='account_profile_page'), + path('notifications//read/', views.mark_notification_read, name='mark_notification_read'), + path('notifications/read-all/', views.mark_all_notifications_read, name='mark_all_notifications_read'), path('requests/', views.requests_dashboard, name='requests_dashboard'), path('onboarding/new/', views.onboarding_create, name='onboarding_create'), path('onboarding/success//', views.onboarding_success, name='onboarding_success'), @@ -30,6 +33,15 @@ urlpatterns = [ path('admin-tools/welcome-emails//resume/', views.resume_welcome_email, name='resume_welcome_email'), path('admin-tools/welcome-emails//cancel/', views.cancel_welcome_email, name='cancel_welcome_email'), path('admin-tools/handbook/', views.handbook_page, name='handbook_page'), + path('admin-tools/branding/', views.portal_branding_page, name='portal_branding_page'), + path('admin-tools/branding/save/', views.save_portal_branding, name='save_portal_branding'), + path('admin-tools/company/', views.portal_company_config_page, name='portal_company_config_page'), + path('admin-tools/company/save/', views.save_portal_company_config, name='save_portal_company_config'), + path('admin-tools/trial/', views.portal_trial_config_page, name='portal_trial_config_page'), + path('admin-tools/trial/save/', views.save_portal_trial_config, name='save_portal_trial_config'), + path('admin-tools/apps/', views.portal_app_registry_page, name='portal_app_registry_page'), + path('admin-tools/apps/save/', views.save_portal_app_registry, name='save_portal_app_registry'), + path('admin-tools/jobs/', views.job_monitor_page, name='job_monitor_page'), path('admin-tools/users/', views.user_management_page, name='user_management_page'), path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'), path('admin-tools/users//update/', views.update_user_from_admin, name='update_user_from_admin'), diff --git a/backend/workflows/view_audit.py b/backend/workflows/view_audit.py new file mode 100644 index 0000000..3480907 --- /dev/null +++ b/backend/workflows/view_audit.py @@ -0,0 +1,82 @@ +from django.utils.translation import gettext as _ + +from .models import AdminAuditLog + + +def display_user_name(user) -> str: + first_name = (getattr(user, 'first_name', '') or '').strip() + last_name = (getattr(user, 'last_name', '') or '').strip() + full_name = f'{first_name} {last_name}'.strip() + if full_name: + return full_name + username = (getattr(user, 'username', '') or '').strip() + if username: + return username + return (getattr(user, 'email', '') or '').strip() + + +def audit( + request, + action: str, + *, + target_type: str = '', + target_id: int | None = None, + target_label: str = '', + details: dict | None = None, +) -> None: + if not getattr(request, 'user', None) or not request.user.is_authenticated: + return + AdminAuditLog.objects.create( + actor=request.user, + actor_display=display_user_name(request.user), + action=action, + target_type=target_type, + target_id=target_id, + target_label=target_label, + details=details or {}, + ) + + +def audit_action_label(action: str) -> str: + labels = { + 'requests_deleted': _('Vorgänge gelöscht'), + 'request_deleted': _('Vorgang gelöscht'), + 'request_retried': _('Vorgang erneut angestoßen'), + 'intro_pdf_generated': _('Einweisungs-PDF erzeugt'), + 'intro_live_pdf_generated': _('Live-Protokoll erzeugt'), + 'intro_session_reset': _('Einweisung zurückgesetzt'), + 'intro_session_saved': _('Einweisung als Entwurf gespeichert'), + 'intro_session_completed': _('Einweisung abgeschlossen'), + 'form_option_deleted': _('Formularoption gelöscht'), + 'form_options_saved': _('Formularoptionen gespeichert'), + 'form_field_texts_saved': _('Feldtexte gespeichert'), + 'form_layout_saved': _('Formularlayout gespeichert'), + 'intro_checklist_item_deleted': _('Einweisungs-Checkpunkt gelöscht'), + 'intro_checklist_item_added': _('Einweisungs-Checkpunkt hinzugefügt'), + 'intro_checklist_saved': _('Einweisungs-Checkliste gespeichert'), + 'welcome_email_triggered_now': _('Welcome E-Mail sofort ausgelöst'), + 'welcome_email_settings_saved': _('Welcome E-Mail Einstellungen gespeichert'), + 'welcome_email_bulk_action': _('Welcome E-Mail Sammelaktion ausgeführt'), + 'welcome_email_paused': _('Welcome E-Mail pausiert'), + 'welcome_email_resumed': _('Welcome E-Mail fortgesetzt'), + 'welcome_email_cancelled': _('Welcome E-Mail abgebrochen'), + 'smtp_test_sent': _('SMTP-Test gesendet'), + 'nextcloud_test_upload': _('Nextcloud-Testupload ausgeführt'), + 'nextcloud_mode_toggled': _('Nextcloud-Modus umgeschaltet'), + 'email_mode_toggled': _('E-Mail-Modus umgeschaltet'), + 'integrations_saved': _('Integrationen gespeichert'), + 'nextcloud_settings_saved': _('Nextcloud-Einstellungen gespeichert'), + 'mail_settings_saved': _('Mail-Einstellungen gespeichert'), + 'email_routing_saved': _('E-Mail-Routing gespeichert'), + 'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'), + 'user_created': _('Benutzer erstellt'), + 'user_updated': _('Benutzer aktualisiert'), + 'user_password_reset_sent': _('Passwort-Reset-Link versendet'), + 'user_deleted': _('Benutzer gelöscht'), + 'backup_created': _('Backup erstellt'), + 'backup_verified': _('Backup verifiziert'), + 'backup_deleted': _('Backup gelöscht'), + 'backup_settings_saved': _('Backup-Einstellungen gespeichert'), + 'portal_app_registry_saved': _('App-Registry gespeichert'), + } + return labels.get(action, action.replace('_', ' ').strip().capitalize()) diff --git a/backend/workflows/view_context.py b/backend/workflows/view_context.py new file mode 100644 index 0000000..732e005 --- /dev/null +++ b/backend/workflows/view_context.py @@ -0,0 +1,105 @@ +from datetime import timedelta + +from django.db.models import Count +from django.utils import timezone +from django.utils.translation import get_language, gettext as _, override + +from .backup_ops import latest_backup_health_snapshot +from .form_builder import get_custom_field_configs +from .forms import OffboardingRequestForm, OnboardingRequestForm +from .models import AsyncTaskLog, OffboardingRequest, OnboardingRequest +from .roles import user_has_capability + + +def form_field_labels(form_type: str) -> dict[str, str]: + if form_type == 'onboarding': + return {name: str(field.label or name) for name, field in OnboardingRequestForm.base_fields.items()} + if form_type == 'offboarding': + return {name: str(field.label or name) for name, field in OffboardingRequestForm.base_fields.items()} + return {} + + +def request_target_label(obj, kind: str | None = None) -> str: + request_kind = (kind or '').strip() + if not request_kind: + request_kind = 'onboarding' if isinstance(obj, OnboardingRequest) else 'offboarding' + name = (getattr(obj, 'full_name', '') or '').strip() or f'#{getattr(obj, "id", "?")}' + email = (getattr(obj, 'work_email', '') or '').strip() + created_at = getattr(obj, 'created_at', None) + date_label = created_at.strftime('%Y-%m-%d') if created_at else '' + parts = [request_kind.capitalize(), name] + if email: + parts.append(f'<{email}>') + if date_label: + parts.append(date_label) + return ' | '.join(parts) + + +def request_status_label(status_key: str, language_code: str | None = None) -> str: + lang = ((language_code or 'de').split('-')[0] or 'de').lower() + with override(lang): + labels = { + 'submitted': _('Eingereicht'), + 'processing': _('In Bearbeitung'), + 'completed': _('Abgeschlossen'), + 'failed': _('Fehlgeschlagen'), + } + return labels.get(status_key, status_key) + + +def request_custom_field_details(obj, kind: str, language_code: str | None = None) -> list[dict[str, str]]: + form_type = 'onboarding' if kind == 'onboarding' else 'offboarding' + language_code = ((language_code or getattr(obj, 'preferred_language', '') or get_language() or 'de').split('-')[0]).lower() + values = getattr(obj, 'custom_field_values', {}) or {} + rows = [] + yes_label = 'Ja' if language_code == 'de' else 'Yes' + for cfg in get_custom_field_configs(form_type, include_inactive=True): + raw_value = values.get(cfg.field_key) + if raw_value in (None, '', False, []): + continue + if isinstance(raw_value, bool): + display_value = str(yes_label) if raw_value else '' + elif isinstance(raw_value, list): + display_value = ', '.join(str(item).strip() for item in raw_value if str(item).strip()) + else: + display_value = str(raw_value).strip() + if not display_value: + continue + rows.append( + { + 'label': cfg.translated_label(language_code), + 'value': display_value, + 'section': cfg.section_key, + 'sort_order': cfg.sort_order, + } + ) + rows.sort(key=lambda item: (item['section'], item['sort_order'], item['label'])) + return rows + + +def ops_summary_for_user(user) -> dict[str, object]: + can_view_jobs = user_has_capability(user, 'view_job_monitor') + can_manage_backups = user_has_capability(user, 'manage_backups') + summary: dict[str, object] = { + 'show': can_view_jobs or can_manage_backups, + 'can_view_jobs': can_view_jobs, + 'can_manage_backups': can_manage_backups, + 'failed_count_24h': 0, + 'started_count_24h': 0, + 'success_count_24h': 0, + 'recent_failed_logs': [], + 'backup_health': latest_backup_health_snapshot() if can_manage_backups else None, + } + if not can_view_jobs: + return summary + + since = timezone.now() - timedelta(hours=24) + logs = AsyncTaskLog.objects.filter(started_at__gte=since) + counts = {row['status']: row['count'] for row in logs.values('status').annotate(count=Count('id'))} + summary['failed_count_24h'] = counts.get('failed', 0) + summary['started_count_24h'] = counts.get('started', 0) + summary['success_count_24h'] = counts.get('succeeded', 0) + summary['recent_failed_logs'] = list( + AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5] + ) + return summary diff --git a/backend/workflows/view_form_runtime.py b/backend/workflows/view_form_runtime.py new file mode 100644 index 0000000..f3182bb --- /dev/null +++ b/backend/workflows/view_form_runtime.py @@ -0,0 +1,231 @@ +from django.utils.translation import gettext as _, gettext_lazy + +from .form_builder import ( + LOCKED_SECTION_RULES, + OFFBOARDING_PAGE_ORDER, + ensure_form_conditional_rule_configs, + get_section_definitions, +) + +ONBOARDING_GROUPS = { + 'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'], + 'employment-end-box': ['employment_end_date'], + 'group-mailboxes-box': ['group_mailboxes'], + 'extra-hardware-box': ['additional_hardware_multi', 'additional_hardware_other'], + 'extra-software-box': ['additional_software_multi', 'additional_software'], + 'extra-access-box': ['additional_access_text'], + 'successor-box': ['successor_name', 'inherit_phone_number_choice'], +} + +ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} +ONBOARDING_CHECKBOX_LISTS = { + 'needed_devices_multi', + 'additional_hardware_multi', + 'needed_software_multi', + 'additional_software_multi', + 'needed_accesses_multi', + 'needed_workspace_groups_multi', + 'needed_resources_multi', +} + +CONDITIONAL_RULE_OPERATOR_CHOICES = [ + ('checked', _('ist aktiviert')), + ('equals', _('ist gleich')), + ('not_equals', _('ist nicht gleich')), +] + +ONBOARDING_SECTION_META = { + 'stammdaten': {'title': gettext_lazy('Stammdaten'), 'subtitle': gettext_lazy('Person, Rolle, Abteilung')}, + 'vertrag': {'title': gettext_lazy('Vertrag'), 'subtitle': gettext_lazy('Beschäftigung und Termine')}, + 'itsetup': {'title': gettext_lazy('IT-Setup'), 'subtitle': gettext_lazy('Geräte, Software und Zugänge')}, + 'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Notizen und Freigabe')}, +} + +OFFBOARDING_SECTION_META = { + 'mitarbeitende': {'title': gettext_lazy('Mitarbeitende'), 'subtitle': gettext_lazy('Person, Rolle und Bereich')}, + 'austritt': {'title': gettext_lazy('Austritt'), 'subtitle': gettext_lazy('Letzter Arbeitstag')}, + 'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Hinweise und Abschlussnotizen')}, +} + + +def field_rule_summary(*, is_visible: bool, is_required, locked: bool) -> str: + if locked: + return str(_('Fixes Kernfeld, immer sichtbar.')) + if not is_visible: + return str(_('Ausgeblendet, erscheint nicht im Formular.')) + if is_required is True: + return str(_('Sichtbar und als Pflichtfeld markiert.')) + if is_required is False: + return str(_('Sichtbar und optional.')) + return str(_('Sichtbar mit Standardverhalten.')) + + +def conditional_clause_sentence(clause: dict, field_label_map: dict[str, str]) -> str: + field_name = (clause.get('field') or '').strip() + operator = (clause.get('operator') or '').strip() + value = clause.get('value') + if not field_name or not operator: + return '' + field_label = field_label_map.get(field_name, field_name) + if operator == 'checked': + return _('%(field)s ist aktiviert') % {'field': field_label} + if operator == 'equals': + if value not in (None, ''): + return _('%(field)s ist gleich %(value)s') % {'field': field_label, 'value': value} + return _('%(field)s ist gleich') % {'field': field_label} + if operator == 'not_equals': + if value not in (None, ''): + return _('%(field)s ist nicht gleich %(value)s') % {'field': field_label, 'value': value} + return _('%(field)s ist nicht gleich') % {'field': field_label} + return _('%(field)s erfüllt die Bedingung') % {'field': field_label} + + +def conditional_rule_summary(clauses: list[dict], field_label_map: dict[str, str]) -> str: + active_clauses = [clause for clause in clauses if clause.get('field') and clause.get('operator')] + if not active_clauses: + return str(_('Immer sichtbar.')) + parts = [str(conditional_clause_sentence(clause, field_label_map)) for clause in active_clauses] + return str(_('Sichtbar, wenn %(conditions)s.') % {'conditions': ' und '.join(parts)}) + + +def normalized_conditional_rule_payload(form_type: str) -> dict[str, dict]: + configs = ensure_form_conditional_rule_configs(form_type) + payload = {} + for target_key, cfg in configs.items(): + if not cfg.is_active: + continue + clauses = [clause for clause in (cfg.clauses or []) if clause.get('field') and clause.get('operator')] + if clauses: + payload[target_key] = {'all': clauses} + return payload + + +def active_conditional_target_keys(form_type: str) -> set[str]: + return set(normalized_conditional_rule_payload(form_type).keys()) + + +def translate_choice_list(choices): + return [(value, str(label)) for value, label in choices] + + +def build_onboarding_layout(form) -> list[dict]: + ordered_names = list(form.fields.keys()) + group_by_field = {} + for group_id, group_fields in ONBOARDING_GROUPS.items(): + for name in group_fields: + group_by_field[name] = group_id + conditional_target_keys = active_conditional_target_keys('onboarding') + + rendered_groups = set() + consumed = set() + blocks = [] + + for field_name in ordered_names: + if field_name in consumed: + continue + + group_id = group_by_field.get(field_name) + if group_id: + if group_id in rendered_groups: + continue + group_fields = [form[name] for name in ONBOARDING_GROUPS[group_id] if name in form.fields] + if not group_fields: + continue + blocks.append( + { + 'kind': 'group', + 'id': group_id, + 'hidden_default': group_id in conditional_target_keys, + 'fields': group_fields, + } + ) + rendered_groups.add(group_id) + consumed.update([f.name for f in group_fields]) + continue + + if field_name.startswith('custom__') and field_name in conditional_target_keys: + blocks.append( + { + 'kind': 'group', + 'id': field_name, + 'hidden_default': True, + 'fields': [form[field_name]], + } + ) + consumed.add(field_name) + continue + + blocks.append({'kind': 'field', 'field': form[field_name]}) + consumed.add(field_name) + + return blocks + + +def section_for_block(block: dict, field_pages: dict[str, str]) -> str: + if block['kind'] == 'field': + return field_pages.get(block['field'].name, 'abschluss') + fields = block.get('fields') or [] + if not fields: + return 'abschluss' + return field_pages.get(fields[0].name, 'abschluss') + + +def build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], visible_section_keys: set[str] | None = None) -> list[dict]: + section_defs = get_section_definitions('onboarding') + section_order = [item['key'] for item in section_defs] + section_titles = {item['key']: item['title'] for item in section_defs} + grouped = {key: [] for key in section_order} + for block in blocks: + section_key = section_for_block(block, field_pages) + if section_key not in grouped: + section_key = 'abschluss' + grouped[section_key].append(block) + visible_keys = visible_section_keys or set(section_order) + sections = [] + custom_section_keys = {item['key'] for item in section_defs if item.get('is_custom')} + for key in section_order: + if key not in visible_keys: + continue + blocks_for_section = grouped[key] + has_custom_checkbox_fields = False + for block in blocks_for_section: + candidate_fields = [block['field']] if block['kind'] == 'field' else (block.get('fields') or []) + for bound_field in candidate_fields: + widget_type = getattr(getattr(bound_field.field, 'widget', None), 'input_type', '') + if bound_field.name.startswith('custom__') and widget_type == 'checkbox': + has_custom_checkbox_fields = True + break + if has_custom_checkbox_fields: + break + sections.append( + { + 'key': key, + 'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)), + 'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''), + 'blocks': blocks_for_section, + 'is_custom': key in custom_section_keys, + 'has_custom_checkbox_fields': has_custom_checkbox_fields, + } + ) + return sections + + +def build_offboarding_sections(form, visible_section_keys: set[str] | None = None) -> list[dict]: + field_pages = getattr(form, '_field_page_keys', {}) + grouped = {key: [] for key in OFFBOARDING_PAGE_ORDER} + for field_name in form.fields.keys(): + section_key = field_pages.get(field_name, 'abschluss') + if section_key not in grouped: + section_key = 'abschluss' + grouped[section_key].append(form[field_name]) + visible_keys = visible_section_keys or set(OFFBOARDING_PAGE_ORDER) + return [ + { + 'key': key, + 'title': OFFBOARDING_SECTION_META[key]['title'], + 'subtitle': OFFBOARDING_SECTION_META[key]['subtitle'], + 'fields': grouped[key], + } + for key in OFFBOARDING_PAGE_ORDER + if key in visible_keys and grouped[key] + ] diff --git a/backend/workflows/view_permissions.py b/backend/workflows/view_permissions.py new file mode 100644 index 0000000..81fffbe --- /dev/null +++ b/backend/workflows/view_permissions.py @@ -0,0 +1,23 @@ +from functools import wraps + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect +from django.utils.translation import gettext as _ + +from .roles import user_has_capability + + +def require_capability(capability: str): + def decorator(view_func): + @wraps(view_func) + @login_required + def wrapped(request, *args, **kwargs): + if not user_has_capability(request.user, capability): + messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) + return redirect('home') + return view_func(request, *args, **kwargs) + + return wrapped + + return decorator diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 7e97f97..ef340b6 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -1,17 +1,18 @@ from pathlib import Path +import re from datetime import timedelta from tempfile import NamedTemporaryFile import json -from functools import wraps - +from io import BytesIO from celery import current_app from django.conf import settings from django.db import connection from django.db import IntegrityError from django.db.models import Q +from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.contrib import messages -from django.contrib.auth import get_user_model +from django.contrib.auth import get_user_model, login as auth_login from django.contrib.auth.decorators import login_required from django.contrib.auth.tokens import default_token_generator from django.http import JsonResponse @@ -20,26 +21,90 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.utils import timezone from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode -from django.utils.translation import gettext as _, gettext_lazy -from django.utils.translation import get_language, override +from django.utils.translation import gettext as _ from django.urls import reverse -from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle -from .forms import OffboardingRequestForm, OnboardingRequestForm, UserManagementCreateForm +from .app_registry import build_portal_app_sections, get_portal_app_registry_rows, normalize_portal_app_sort_orders +from .backup_ops import ( + create_backup_bundle, + delete_backup_bundle, + latest_backup_health_snapshot, + list_backup_bundles, + verify_backup_bundle, +) +from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired +from . import account_views, admin_config_views, integrations_views, request_views +from .admin_section_builders import ( + build_branding_sections as _build_branding_sections, + build_company_config_sections as _build_company_config_sections, +) +from .admin_user_helpers import ( + render_user_management as _render_user_management, + send_user_access_email as _send_user_access_email, + would_remove_last_platform_owner as _would_remove_last_platform_owner, + would_remove_last_super_admin as _would_remove_last_super_admin, +) +from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, + DEFAULT_CONDITIONAL_RULES, + FORM_PRESETS, LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + OFFBOARDING_PAGE_LABELS, + OFFBOARDING_PAGE_ORDER, ONBOARDING_DEFAULT_PAGE, - ONBOARDING_PAGE_LABELS, - ONBOARDING_PAGE_ORDER, + build_custom_field_key, + custom_field_target_key, ensure_form_field_configs, + ensure_form_conditional_rule_configs, + ensure_form_section_configs, + get_custom_field_configs, + get_custom_section_configs, + get_default_page_map, + get_section_definitions, + get_section_labels, + get_section_order, + apply_form_preset, ) -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .form_builder_views import form_builder_page_impl, form_builder_save_order_impl +from .intro_builder_views import intro_builder_page_impl +from .observability_views import ( + audit_log_page_impl, + backup_recovery_page_impl, + create_backup_from_admin_impl, + job_monitor_page_impl, + verify_backup_from_admin_impl, +) +from .view_audit import audit as _audit, audit_action_label as _audit_action_label, display_user_name as _display_user_name +from .view_context import ( + form_field_labels as _form_field_labels, + request_custom_field_details as _request_custom_field_details, + request_status_label as _request_status_label, + request_target_label as _request_target_label, +) +from .view_form_runtime import ( + CONDITIONAL_RULE_OPERATOR_CHOICES, + ONBOARDING_CHECKBOX_LISTS, + ONBOARDING_GROUPS, + ONBOARDING_INLINE_CHECKS, + active_conditional_target_keys as _active_conditional_target_keys, + build_offboarding_sections as _build_offboarding_sections, + build_onboarding_layout as _build_onboarding_layout, + build_onboarding_sections as _build_onboarding_sections, + conditional_rule_summary as _conditional_rule_summary, + field_rule_summary as _field_rule_summary, + normalized_conditional_rule_payload as _normalized_conditional_rule_payload, + translate_choice_list as _translate_choice_list, +) +from .view_permissions import require_capability as _require_capability +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .emailing import send_system_email -from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability +from .notifications import notify_user +from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud +from .totp import build_otpauth_uri, generate_recovery_codes, generate_totp_secret from .tasks import ( - DEFAULT_NOTIFICATION_TEMPLATES, _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, build_intro_sections_for_request, @@ -58,45 +123,20 @@ def _redirect_back(request, fallback: str): return redirect(referer) return redirect(fallback) -ONBOARDING_GROUPS = { - 'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'], - 'employment-end-box': ['employment_end_date'], - 'group-mailboxes-box': ['group_mailboxes'], - 'extra-hardware-box': ['additional_hardware_multi', 'additional_hardware_other'], - 'extra-software-box': ['additional_software_multi', 'additional_software'], - 'extra-access-box': ['additional_access_text'], - 'successor-box': ['successor_name', 'inherit_phone_number_choice'], - 'phone-box': ['phone_number_choice'], -} -ONBOARDING_HIDDEN_BY_DEFAULT = { - 'business-card-box', - 'employment-end-box', - 'group-mailboxes-box', - 'extra-hardware-box', - 'extra-software-box', - 'extra-access-box', - 'successor-box', -} +@login_required +@require_POST +def mark_notification_read(request, notification_id: int): + notification = get_object_or_404(UserNotification, id=notification_id, user=request.user) + notification.mark_read() + return _redirect_back(request, 'home') -ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} -ONBOARDING_CHECKBOX_LISTS = { - 'needed_devices_multi', - 'additional_hardware_multi', - 'needed_software_multi', - 'additional_software_multi', - 'needed_accesses_multi', - 'needed_workspace_groups_multi', - 'needed_resources_multi', -} -ONBOARDING_SECTION_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] -ONBOARDING_SECTION_META = { - 'stammdaten': {'title': gettext_lazy('Stammdaten'), 'subtitle': gettext_lazy('Person, Rolle, Abteilung')}, - 'vertrag': {'title': gettext_lazy('Vertrag'), 'subtitle': gettext_lazy('Beschäftigung und Termine')}, - 'itsetup': {'title': gettext_lazy('IT-Setup'), 'subtitle': gettext_lazy('Geräte, Software und Zugänge')}, - 'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Notizen und Freigabe')}, -} +@login_required +@require_POST +def mark_all_notifications_read(request): + UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now()) + return _redirect_back(request, 'home') def healthz(request): db_ok = True @@ -111,7 +151,7 @@ def healthz(request): return JsonResponse( { 'status': 'ok' if db_ok else 'degraded', - 'service': 'onoff_v2', + 'service': 'workdock', 'db': 'ok' if db_ok else 'error', 'time': timezone.now().isoformat(), }, @@ -119,213 +159,23 @@ def healthz(request): ) -def _require_capability(capability: str): - def decorator(view_func): - @wraps(view_func) - @login_required - def wrapped(request, *args, **kwargs): - if not user_has_capability(request.user, capability): - messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) - return redirect('home') - return view_func(request, *args, **kwargs) - - return wrapped - - return decorator +def login_page(request): + return account_views.login_page_impl(request) -def _display_user_name(user) -> str: - first_name = (getattr(user, 'first_name', '') or '').strip() - last_name = (getattr(user, 'last_name', '') or '').strip() - full_name = f'{first_name} {last_name}'.strip() - if full_name: - return full_name - username = (getattr(user, 'username', '') or '').strip() - if username: - return username - return (getattr(user, 'email', '') or '').strip() +def login_totp_page(request): + return account_views.login_totp_page_impl(request) -def _audit( - request, - action: str, - *, - target_type: str = '', - target_id: int | None = None, - target_label: str = '', - details: dict | None = None, -) -> None: - if not getattr(request, 'user', None) or not request.user.is_authenticated: - return - AdminAuditLog.objects.create( - actor=request.user, - actor_display=_display_user_name(request.user), - action=action, - target_type=target_type, - target_id=target_id, - target_label=target_label, - details=details or {}, - ) - - -def _form_field_labels(form_type: str) -> dict[str, str]: - if form_type == 'onboarding': - return {name: str(field.label or name) for name, field in OnboardingRequestForm.base_fields.items()} - if form_type == 'offboarding': - return {name: str(field.label or name) for name, field in OffboardingRequestForm.base_fields.items()} - return {} - - -def _request_target_label(obj, kind: str | None = None) -> str: - request_kind = (kind or '').strip() - if not request_kind: - request_kind = 'onboarding' if isinstance(obj, OnboardingRequest) else 'offboarding' - name = (getattr(obj, 'full_name', '') or '').strip() or f'#{getattr(obj, "id", "?")}' - email = (getattr(obj, 'work_email', '') or '').strip() - created_at = getattr(obj, 'created_at', None) - date_label = created_at.strftime('%Y-%m-%d') if created_at else '' - parts = [request_kind.capitalize(), name] - if email: - parts.append(f'<{email}>') - if date_label: - parts.append(date_label) - return ' | '.join(parts) - - -def _request_status_label(status_key: str, language_code: str | None = None) -> str: - lang = ((language_code or 'de').split('-')[0] or 'de').lower() - with override(lang): - labels = { - 'submitted': _('Eingereicht'), - 'processing': _('In Bearbeitung'), - 'completed': _('Abgeschlossen'), - 'failed': _('Fehlgeschlagen'), - } - return labels.get(status_key, status_key) - - -def _audit_action_label(action: str) -> str: - labels = { - 'requests_deleted': _('Vorgänge gelöscht'), - 'request_deleted': _('Vorgang gelöscht'), - 'request_retried': _('Vorgang erneut angestoßen'), - 'intro_pdf_generated': _('Einweisungs-PDF erzeugt'), - 'intro_live_pdf_generated': _('Live-Protokoll erzeugt'), - 'intro_session_reset': _('Einweisung zurückgesetzt'), - 'intro_session_saved': _('Einweisung als Entwurf gespeichert'), - 'intro_session_completed': _('Einweisung abgeschlossen'), - 'form_option_deleted': _('Formularoption gelöscht'), - 'form_options_saved': _('Formularoptionen gespeichert'), - 'form_field_texts_saved': _('Feldtexte gespeichert'), - 'form_layout_saved': _('Formularlayout gespeichert'), - 'intro_checklist_item_deleted': _('Einweisungs-Checkpunkt gelöscht'), - 'intro_checklist_item_added': _('Einweisungs-Checkpunkt hinzugefügt'), - 'intro_checklist_saved': _('Einweisungs-Checkliste gespeichert'), - 'welcome_email_triggered_now': _('Welcome E-Mail sofort ausgelöst'), - 'welcome_email_settings_saved': _('Welcome E-Mail Einstellungen gespeichert'), - 'welcome_email_bulk_action': _('Welcome E-Mail Sammelaktion ausgeführt'), - 'welcome_email_paused': _('Welcome E-Mail pausiert'), - 'welcome_email_resumed': _('Welcome E-Mail fortgesetzt'), - 'welcome_email_cancelled': _('Welcome E-Mail abgebrochen'), - 'smtp_test_sent': _('SMTP-Test gesendet'), - 'nextcloud_test_upload': _('Nextcloud-Testupload ausgeführt'), - 'nextcloud_mode_toggled': _('Nextcloud-Modus umgeschaltet'), - 'email_mode_toggled': _('E-Mail-Modus umgeschaltet'), - 'integrations_saved': _('Integrationen gespeichert'), - 'nextcloud_settings_saved': _('Nextcloud-Einstellungen gespeichert'), - 'mail_settings_saved': _('Mail-Einstellungen gespeichert'), - 'email_routing_saved': _('E-Mail-Routing gespeichert'), - 'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'), - 'user_created': _('Benutzer erstellt'), - 'user_updated': _('Benutzer aktualisiert'), - 'user_password_reset_sent': _('Passwort-Reset-Link versendet'), - 'user_deleted': _('Benutzer gelöscht'), - 'backup_created': _('Backup erstellt'), - 'backup_verified': _('Backup verifiziert'), - 'backup_deleted': _('Backup gelöscht'), - 'backup_settings_saved': _('Backup-Einstellungen gespeichert'), - } - return labels.get(action, action.replace('_', ' ').strip().capitalize()) - - -def _translate_choice_list(choices): - return [(value, str(label)) for value, label in choices] - - -def _build_onboarding_layout(form) -> list[dict]: - ordered_names = list(form.fields.keys()) - group_by_field = {} - for group_id, group_fields in ONBOARDING_GROUPS.items(): - for name in group_fields: - group_by_field[name] = group_id - - rendered_groups = set() - consumed = set() - blocks = [] - - for field_name in ordered_names: - if field_name in consumed: - continue - - group_id = group_by_field.get(field_name) - if group_id: - if group_id in rendered_groups: - continue - group_fields = [ - form[name] - for name in ONBOARDING_GROUPS[group_id] - if name in form.fields - ] - if not group_fields: - continue - blocks.append( - { - 'kind': 'group', - 'id': group_id, - 'hidden_default': group_id in ONBOARDING_HIDDEN_BY_DEFAULT, - 'fields': group_fields, - } - ) - rendered_groups.add(group_id) - consumed.update([f.name for f in group_fields]) - continue - - blocks.append({'kind': 'field', 'field': form[field_name]}) - consumed.add(field_name) - - return blocks - - -def _section_for_block(block: dict, field_pages: dict[str, str]) -> str: - if block['kind'] == 'field': - return field_pages.get(block['field'].name, 'abschluss') - fields = block.get('fields') or [] - if not fields: - return 'abschluss' - return field_pages.get(fields[0].name, 'abschluss') - - -def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str]) -> list[dict]: - grouped = {key: [] for key in ONBOARDING_SECTION_ORDER} - for block in blocks: - section_key = _section_for_block(block, field_pages) - if section_key not in grouped: - section_key = 'abschluss' - grouped[section_key].append(block) - return [ - { - 'key': key, - 'title': ONBOARDING_SECTION_META[key]['title'], - 'subtitle': ONBOARDING_SECTION_META[key]['subtitle'], - 'blocks': grouped[key], - } - for key in ONBOARDING_SECTION_ORDER - ] +@login_required +def account_profile_page(request): + return account_views.account_profile_page_impl(request) @login_required def home(request): config, _ = WorkflowConfig.objects.get_or_create(name='Default') + role_key = get_user_role_key(request.user) return render( request, 'workflows/home.html', @@ -334,333 +184,192 @@ def home(request): 'email_test_mode': is_email_test_mode(), 'workflow_config': config, 'role_label': get_user_role_label(request.user), + 'role_key': role_key, + 'portal_app_sections': build_portal_app_sections(request.user), }, ) -def _user_management_rows(): - user_model = get_user_model() - role_order = { - ROLE_SUPER_ADMIN: 0, - 'admin': 1, - 'it_staff': 2, - 'staff': 3, - } - rows = [] - for user in user_model.objects.all().order_by('-is_active', 'username'): - role_key = get_user_role_key(user) - rows.append( - { - 'user': user, - 'role_key': role_key, - 'role_label': str(ROLE_LABELS[role_key]), - 'role_sort': role_order.get(role_key, 99), - 'display_name': _display_user_name(user), - } - ) - rows.sort(key=lambda item: (not item['user'].is_active, item['role_sort'], item['user'].username.lower())) - return rows +@_require_capability('manage_app_registry') +def portal_app_registry_page(request): + return admin_config_views.portal_app_registry_page_impl(request, translate_choice_list=_translate_choice_list) -def _render_user_management(request, create_form=None, status_code: int = 200): - recent_user_events = list( - AdminAuditLog.objects.select_related('actor') - .filter(action__in=['user_created', 'user_updated', 'user_password_reset_sent', 'user_deleted']) - .order_by('-created_at', '-id')[:12] - ) - for row in recent_user_events: - row.action_label = _audit_action_label(row.action) - role_key = (row.details or {}).get('role') - row.role_label = str(ROLE_LABELS[role_key]) if role_key in ROLE_LABELS else role_key - return render( - request, - 'workflows/user_management.html', - { - 'create_form': create_form or UserManagementCreateForm(), - 'rows': _user_management_rows(), - 'role_choices': [(key, str(ROLE_LABELS[key])) for key in ROLE_GROUP_NAMES], - 'recent_user_events': recent_user_events, - }, - status=status_code, - ) +@_require_capability('view_job_monitor') +def job_monitor_page(request): + return job_monitor_page_impl(request) -def _super_admin_user_count() -> int: - user_model = get_user_model() - return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active) - - -def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: - if get_user_role_key(user) != ROLE_SUPER_ADMIN or not user.is_active: - return False - if _super_admin_user_count() > 1: - return False - if deleting: - return True - if new_role_key is not None and new_role_key != ROLE_SUPER_ADMIN: - return True - if new_is_active is not None and not new_is_active: - return True - return False - - -def _send_user_access_email(request, target_user, *, invitation: bool) -> None: - email = (target_user.email or '').strip() - if not email: - raise ValueError(_('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.')) - - uid = urlsafe_base64_encode(force_bytes(target_user.pk)) - token = default_token_generator.make_token(target_user) - reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) - reset_url = request.build_absolute_uri(reset_path) - - if invitation: - subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username} - body = _( - 'Hallo %(name)s,\n\n' - 'für Sie wurde ein Benutzerkonto im TUBCO Onboarding- und Offboarding-Portal angelegt.\n' - 'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n' - '%(url)s\n\n' - 'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.' - ) % { - 'name': _display_user_name(target_user), - 'url': reset_url, - } - else: - subject = _('Passwort zurücksetzen für %(username)s') % {'username': target_user.username} - body = _( - 'Hallo %(name)s,\n\n' - 'für Ihr Konto wurde ein Link zum Zurücksetzen des Passworts erstellt.\n' - 'Bitte öffnen Sie den folgenden Link:\n' - '%(url)s\n\n' - 'Wenn Sie diese Anfrage nicht erwartet haben, können Sie diese E-Mail ignorieren.' - ) % { - 'name': _display_user_name(target_user), - 'url': reset_url, - } - - send_system_email(subject=subject, body=body, to=[email]) +@_require_capability('manage_app_registry') +@require_POST +def save_portal_app_registry(request): + return admin_config_views.save_portal_app_registry_impl(request, audit_fn=_audit) @_require_capability('manage_users') def user_management_page(request): - return _render_user_management(request) + return admin_config_views.user_management_page_impl( + request, + render_user_management_fn=lambda req, create_form=None, status_code=200: _render_user_management( + req, + create_form=create_form, + status_code=status_code, + audit_action_label_fn=_audit_action_label, + display_user_name_fn=_display_user_name, + ), + ) + + +@_require_capability('manage_product_branding') +def portal_branding_page(request): + return admin_config_views.portal_branding_page_impl(request, build_branding_sections_fn=_build_branding_sections) + + +@_require_capability('manage_product_branding') +@require_POST +def save_portal_branding(request): + return admin_config_views.save_portal_branding_impl(request, audit_fn=_audit, build_branding_sections_fn=_build_branding_sections) + + +@_require_capability('manage_company_config') +def portal_company_config_page(request): + return admin_config_views.portal_company_config_page_impl(request, build_company_config_sections_fn=_build_company_config_sections) + + +@_require_capability('manage_company_config') +@require_POST +def save_portal_company_config(request): + return admin_config_views.save_portal_company_config_impl(request, audit_fn=_audit, build_company_config_sections_fn=_build_company_config_sections) + + +@_require_capability('manage_trial_lifecycle') +def portal_trial_config_page(request): + return admin_config_views.portal_trial_config_page_impl(request) + + +@_require_capability('manage_trial_lifecycle') +@require_POST +def save_portal_trial_config(request): + return admin_config_views.save_portal_trial_config_impl(request, audit_fn=_audit) @_require_capability('manage_users') @require_POST def create_user_from_admin(request): - form = UserManagementCreateForm(request.POST) - if not form.is_valid(): - messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.')) - return _render_user_management(request, create_form=form, status_code=400) - - user = form.save() - _send_user_access_email(request, user, invitation=True) - _audit( + return admin_config_views.create_user_from_admin_impl( request, - 'user_created', - target_type='user', - target_id=user.id, - target_label=_display_user_name(user), - details={'username': user.username, 'role': get_user_role_key(user), 'invitation_sent': True}, + render_user_management_fn=lambda req, create_form=None, status_code=200: _render_user_management( + req, + create_form=create_form, + status_code=status_code, + audit_action_label_fn=_audit_action_label, + display_user_name_fn=_display_user_name, + ), + send_user_access_email_fn=lambda req, target_user, invitation: _send_user_access_email( + req, + target_user, + invitation=invitation, + display_user_name_fn=_display_user_name, + ), + audit_fn=_audit, + display_user_name_fn=_display_user_name, ) - messages.success(request, _('Benutzer wurde erstellt und eingeladen: %(username)s') % {'username': user.username}) - return redirect('user_management_page') @_require_capability('manage_users') @require_POST def update_user_from_admin(request, user_id: int): - user_model = get_user_model() - target_user = get_object_or_404(user_model, id=user_id) - role_key = (request.POST.get('role_key') or '').strip() - is_active = request.POST.get('is_active') == 'on' - new_password = (request.POST.get('new_password') or '').strip() - - if role_key not in ROLE_GROUP_NAMES: - messages.error(request, _('Ungültige Rolle.')) - return redirect('user_management_page') - - if target_user == request.user and (role_key != ROLE_SUPER_ADMIN or not is_active): - messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.')) - return redirect('user_management_page') - if _would_remove_last_super_admin(target_user, new_role_key=role_key, new_is_active=is_active): - messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.')) - return redirect('user_management_page') - - assign_user_role(target_user, role_key) - target_user.is_active = is_active - if new_password: - target_user.set_password(new_password) - target_user.save() - - _audit( + return admin_config_views.update_user_from_admin_impl( request, - 'user_updated', - target_type='user', - target_id=target_user.id, - target_label=_display_user_name(target_user), - details={'username': target_user.username, 'role': role_key, 'is_active': is_active, 'password_changed': bool(new_password)}, + user_id, + would_remove_last_platform_owner_fn=_would_remove_last_platform_owner, + would_remove_last_super_admin_fn=_would_remove_last_super_admin, + audit_fn=_audit, + display_user_name_fn=_display_user_name, ) - messages.success(request, _('Benutzer wurde aktualisiert: %(username)s') % {'username': target_user.username}) - return redirect('user_management_page') @_require_capability('manage_users') @require_POST def send_password_reset_from_admin(request, user_id: int): - user_model = get_user_model() - target_user = get_object_or_404(user_model, id=user_id) - try: - _send_user_access_email(request, target_user, invitation=False) - except ValueError as exc: - messages.error(request, str(exc)) - return redirect('user_management_page') - _audit( + return admin_config_views.send_password_reset_from_admin_impl( request, - 'user_password_reset_sent', - target_type='user', - target_id=target_user.id, - target_label=_display_user_name(target_user), - details={'username': target_user.username, 'email': target_user.email}, + user_id, + send_user_access_email_fn=lambda req, target_user, invitation: _send_user_access_email( + req, + target_user, + invitation=invitation, + display_user_name_fn=_display_user_name, + ), + audit_fn=_audit, + display_user_name_fn=_display_user_name, ) - messages.success(request, _('Passwort-Reset-Link wurde versendet: %(username)s') % {'username': target_user.username}) - return redirect('user_management_page') @_require_capability('manage_users') @require_POST def delete_user_from_admin(request, user_id: int): - user_model = get_user_model() - target_user = get_object_or_404(user_model, id=user_id) - - if target_user == request.user: - messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.')) - return redirect('user_management_page') - if _would_remove_last_super_admin(target_user, deleting=True): - messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.')) - return redirect('user_management_page') - - target_label = _display_user_name(target_user) - username = target_user.username - target_user.delete() - _audit( + return admin_config_views.delete_user_from_admin_impl( request, - 'user_deleted', - target_type='user', - target_label=target_label, - details={'username': username}, + user_id, + would_remove_last_platform_owner_fn=_would_remove_last_platform_owner, + would_remove_last_super_admin_fn=_would_remove_last_super_admin, + audit_fn=_audit, + display_user_name_fn=_display_user_name, ) - messages.success(request, _('Benutzer wurde gelöscht: %(username)s') % {'username': username}) - return redirect('user_management_page') @_require_capability('view_docs') def handbook_page(request): - return render(request, 'workflows/handbook.html') + return admin_config_views.handbook_page_impl(request) @_require_capability('view_docs') def project_wiki_page(request): - return render(request, 'workflows/project_wiki.html') + return admin_config_views.project_wiki_page_impl(request) @_require_capability('view_docs') def developer_handbook_page(request): - return render(request, 'workflows/developer_handbook.html') + return admin_config_views.developer_handbook_page_impl(request) @_require_capability('view_docs') def release_checklist_page(request): - return render(request, 'workflows/release_checklist.html') + return admin_config_views.release_checklist_page_impl(request) @_require_capability('view_audit_log') def audit_log_page(request): - action = (request.GET.get('action') or '').strip() - user_query = (request.GET.get('user') or '').strip() - date_from = (request.GET.get('date_from') or '').strip() - date_to = (request.GET.get('date_to') or '').strip() - - rows_qs = AdminAuditLog.objects.select_related('actor').all() - - if action: - rows_qs = rows_qs.filter(action=action) - if user_query: - rows_qs = rows_qs.filter( - Q(actor_display__icontains=user_query) - | Q(actor__username__icontains=user_query) - | Q(actor__email__icontains=user_query) - ) - if date_from: - rows_qs = rows_qs.filter(created_at__date__gte=date_from) - if date_to: - rows_qs = rows_qs.filter(created_at__date__lte=date_to) - - rows = list(rows_qs[:300]) - action_choices = ( - AdminAuditLog.objects.order_by('action').values_list('action', flat=True).distinct() - ) - return render( - request, - 'workflows/audit_log.html', - { - 'rows': rows, - 'action_choices': action_choices, - 'selected_action': action, - 'user_query': user_query, - 'date_from': date_from, - 'date_to': date_to, - }, - ) + return audit_log_page_impl(request) @_require_capability('manage_backups') def backup_recovery_page(request): - return render( - request, - 'workflows/backup_recovery.html', - { - 'rows': list_backup_bundles(), - }, - ) + return backup_recovery_page_impl(request) @_require_capability('manage_backups') @require_POST def create_backup_from_admin(request): - try: - result = create_backup_bundle() - _audit( - request, - 'backup_created', - target_type='backup_bundle', - target_label=result['name'], - details={'path': result['path']}, - ) - messages.success(request, _('Backup wurde erstellt: %(name)s') % {'name': result['name']}) - except Exception as exc: - messages.error(request, _('Backup konnte nicht erstellt werden: %(error)s') % {'error': exc}) - return redirect('backup_recovery_page') + return create_backup_from_admin_impl( + request, + audit_fn=_audit, + notify_user_fn=notify_user, + create_backup_bundle_fn=create_backup_bundle, + ) @_require_capability('manage_backups') @require_POST def verify_backup_from_admin(request, backup_name: str): - try: - result = verify_backup_bundle(backup_name) - _audit( - request, - 'backup_verified', - target_type='backup_bundle', - target_label=backup_name, - details={'summary': result['summary']}, - ) - messages.success(request, _('Backup wurde verifiziert: %(name)s') % {'name': result['name']}) - except Exception as exc: - messages.error(request, _('Backup-Verifikation fehlgeschlagen: %(error)s') % {'error': exc}) - return redirect('backup_recovery_page') + return verify_backup_from_admin_impl( + request, + backup_name, + audit_fn=_audit, + notify_user_fn=notify_user, + verify_backup_bundle_fn=verify_backup_bundle, + ) @_require_capability('manage_backups') @@ -681,1684 +390,241 @@ def delete_backup_from_admin(request, backup_name: str): return redirect('backup_recovery_page') -@_require_capability('access_requests_dashboard') +@_require_capability('view_request_timeline') def request_timeline_page(request, kind: str, request_id: int): - if kind == 'onboarding': - obj = get_object_or_404(OnboardingRequest, id=request_id) - elif kind == 'offboarding': - obj = get_object_or_404(OffboardingRequest, id=request_id) - else: - messages.error(request, f'Unbekannter Typ: {kind}') - return redirect('requests_dashboard') - - request_label = _request_target_label(obj, kind) - audit_rows = list( - AdminAuditLog.objects.select_related('actor') - .filter(target_type__in=[kind, 'request']) - .filter(Q(target_id=request_id) | Q(target_label__icontains=(obj.full_name or '').strip())) - .order_by('-created_at', '-id')[:200] - ) - - timeline_rows = [ - { - 'created_at': obj.created_at, - 'kind': 'system', - 'title': _('Anfrage erstellt'), - 'summary': request_label, - 'meta': _('Status: %(status)s') % {'status': obj.get_processing_status_display()}, - } - ] - - contract_start = getattr(obj, 'contract_start', None) - if contract_start: - timeline_rows.append( - { - 'created_at': timezone.make_aware(timezone.datetime.combine(contract_start, timezone.datetime.min.time())), - 'kind': 'milestone', - 'title': _('Vertragsbeginn'), - 'summary': str(contract_start), - 'meta': _('Geplanter Start'), - } - ) - - handover_date = getattr(obj, 'handover_date', None) - if handover_date: - timeline_rows.append( - { - 'created_at': timezone.make_aware(timezone.datetime.combine(handover_date, timezone.datetime.min.time())), - 'kind': 'milestone', - 'title': _('Geräteübergabe / Hardware-Abholung'), - 'summary': str(handover_date), - 'meta': _('Geplanter Hardware-Termin'), - } - ) - - if getattr(obj, 'generated_pdf_path', ''): - timeline_rows.append( - { - 'created_at': obj.created_at, - 'kind': 'document', - 'title': _('PDF verfügbar'), - 'summary': Path(obj.generated_pdf_path).name, - 'meta': '', - 'url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}", - } - ) - - for row in audit_rows: - timeline_rows.append( - { - 'created_at': row.created_at, - 'kind': 'audit', - 'title': _audit_action_label(row.action), - 'summary': row.target_label or row.target_type or '-', - 'meta': row.actor_display or '-', - 'details': row.details, - } - ) - - if kind == 'onboarding': - intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first() - if intro_session: - timeline_rows.append( - { - 'created_at': intro_session.updated_at, - 'kind': 'session', - 'title': _('Einweisungssitzung'), - 'summary': intro_session.get_status_display(), - 'meta': intro_session.completed_by_name or '-', - 'url': (f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" if intro_session.exported_pdf_path else ''), - } - ) - welcome_email = ScheduledWelcomeEmail.objects.filter(onboarding_request=obj).first() - if welcome_email: - timeline_rows.append( - { - 'created_at': welcome_email.updated_at, - 'kind': 'email', - 'title': _('Welcome E-Mail'), - 'summary': welcome_email.get_status_display(), - 'meta': welcome_email.recipient_email, - } - ) - - timeline_rows.sort(key=lambda item: item['created_at']) - - return render( + return request_views.request_timeline_page_impl( request, - 'workflows/request_timeline.html', - { - 'request_kind': kind, - 'request_obj': obj, - 'request_label': request_label, - 'timeline_rows': timeline_rows, - 'contract_start': getattr(obj, 'contract_start', None), - 'handover_date': getattr(obj, 'handover_date', None), - }, + kind, + request_id, + request_target_label_fn=_request_target_label, + request_custom_field_details_fn=_request_custom_field_details, + audit_action_label_fn=_audit_action_label, ) @login_required def requests_dashboard(request): - if not user_has_capability(request.user, 'access_requests_dashboard'): - messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) - return redirect('home') - - if request.method == 'POST': - if not user_has_capability(request.user, 'delete_requests'): - messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) - return redirect('requests_dashboard') - - selected = request.POST.getlist('selected_requests') - single_delete = (request.POST.get('single_delete') or '').strip() - if single_delete: - selected = [single_delete] - - if not selected: - messages.warning(request, _('Keine Einträge ausgewählt.')) - return redirect('requests_dashboard') - - deleted_count = 0 - invalid_count = 0 - deleted_labels = [] - for token in selected: - try: - kind, raw_id = token.split(':', 1) - request_id = int(raw_id) - except (ValueError, TypeError): - invalid_count += 1 - continue - - model = None - if kind == 'onboarding': - model = OnboardingRequest - elif kind == 'offboarding': - model = OffboardingRequest - else: - invalid_count += 1 - continue - - obj = model.objects.filter(id=request_id).first() - if not obj: - continue - deleted_labels.append(_request_target_label(obj, kind)) - obj.delete() - deleted_count += 1 - - if deleted_count: - _audit( - request, - 'requests_deleted', - target_type='request', - target_label='Dashboard bulk/single delete', - details={ - 'deleted_count': deleted_count, - 'invalid_count': invalid_count, - 'selected': selected, - 'request_labels': deleted_labels, - }, - ) - messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count}) - if invalid_count: - messages.warning(request, _('%(count)s Auswahl(en) konnten nicht verarbeitet werden.') % {'count': invalid_count}) - if not deleted_count and not invalid_count: - messages.info(request, _('Keine passenden Einträge gefunden.')) - return redirect('requests_dashboard') - - search_query = request.GET.get('q', '').strip() - type_filter = (request.GET.get('type') or '').strip().lower() - status_filter = (request.GET.get('status') or '').strip().lower() - department_filter = (request.GET.get('department') or '').strip() - date_from = (request.GET.get('date_from') or '').strip() - date_to = (request.GET.get('date_to') or '').strip() - - onboarding_qs = OnboardingRequest.objects.order_by('-created_at') - offboarding_qs = OffboardingRequest.objects.order_by('-created_at') - all_onboarding = OnboardingRequest.objects.all() - all_offboarding = OffboardingRequest.objects.all() - - if search_query: - onboarding_qs = onboarding_qs.filter(Q(full_name__icontains=search_query) | Q(work_email__icontains=search_query)) - offboarding_qs = offboarding_qs.filter(Q(full_name__icontains=search_query) | Q(work_email__icontains=search_query)) - if status_filter in {'submitted', 'processing', 'completed', 'failed'}: - onboarding_qs = onboarding_qs.filter(processing_status=status_filter) - offboarding_qs = offboarding_qs.filter(processing_status=status_filter) - if department_filter: - onboarding_qs = onboarding_qs.filter(department=department_filter) - offboarding_qs = offboarding_qs.filter(department=department_filter) - if date_from: - onboarding_qs = onboarding_qs.filter(created_at__date__gte=date_from) - offboarding_qs = offboarding_qs.filter(created_at__date__gte=date_from) - if date_to: - onboarding_qs = onboarding_qs.filter(created_at__date__lte=date_to) - offboarding_qs = offboarding_qs.filter(created_at__date__lte=date_to) - - if type_filter == 'onboarding': - offboarding_qs = offboarding_qs.none() - elif type_filter == 'offboarding': - onboarding_qs = onboarding_qs.none() - - onboarding_items = onboarding_qs[:50] - offboarding_items = offboarding_qs[:50] - language_code = ( - request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME) - or getattr(request, 'LANGUAGE_CODE', '') - or get_language() - or 'de' - ).split('-')[0].lower() - - rows = [] - for obj in onboarding_items: - intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first() - if intro_session and intro_session.exported_pdf_path: - intro_session.exported_pdf_url = f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" - rows.append( - { - 'id': obj.id, - 'kind': 'Onboarding', - 'kind_slug': 'onboarding', - 'name': obj.full_name, - 'work_email': obj.work_email, - 'created_at': obj.created_at, - 'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None, - 'intro_pdf_url': f"/media/pdfs/{Path(obj.intro_pdf_path).name}" if obj.intro_pdf_path else None, - 'intro_session': intro_session, - 'status': _request_status_label(obj.processing_status, language_code), - 'status_key': obj.processing_status, - 'last_error': obj.last_error, - } - ) - for obj in offboarding_items: - rows.append( - { - 'id': obj.id, - 'kind': 'Offboarding', - 'kind_slug': 'offboarding', - 'name': obj.full_name, - 'work_email': obj.work_email, - 'created_at': obj.created_at, - 'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None, - 'intro_pdf_url': None, - 'intro_session': None, - 'status': _request_status_label(obj.processing_status, language_code), - 'status_key': obj.processing_status, - 'last_error': obj.last_error, - } - ) - - rows.sort(key=lambda x: x['created_at'], reverse=True) - - today = timezone.localdate() - start_date = today - timedelta(days=13) - onboarding_daily = {} - offboarding_daily = {} - for i in range(14): - day = start_date + timedelta(days=i) - onboarding_daily[day] = 0 - offboarding_daily[day] = 0 - - for dt in onboarding_qs.filter(created_at__date__gte=start_date).values_list('created_at', flat=True): - onboarding_daily[timezone.localtime(dt).date()] += 1 - for dt in offboarding_qs.filter(created_at__date__gte=start_date).values_list('created_at', flat=True): - offboarding_daily[timezone.localtime(dt).date()] += 1 - - chart_points = [] - max_total = 1 - for i in range(14): - day = start_date + timedelta(days=i) - on_count = onboarding_daily[day] - off_count = offboarding_daily[day] - total = on_count + off_count - max_total = max(max_total, total) - chart_points.append( - { - 'label': day.strftime('%d.%m'), - 'onboarding': on_count, - 'offboarding': off_count, - 'total': total, - } - ) - - for point in chart_points: - point['height'] = max(8, int((point['total'] / max_total) * 84)) - - onboarding_total = onboarding_qs.count() - offboarding_total = offboarding_qs.count() - departments = sorted( - { - value.strip() - for value in list(all_onboarding.exclude(department='').values_list('department', flat=True)) - + list(all_offboarding.exclude(department='').values_list('department', flat=True)) - if value and value.strip() - }, - key=str.lower, - ) - status_choices = [ - {'value': 'submitted', 'label': _request_status_label('submitted', language_code)}, - {'value': 'processing', 'label': _request_status_label('processing', language_code)}, - {'value': 'completed', 'label': _request_status_label('completed', language_code)}, - {'value': 'failed', 'label': _request_status_label('failed', language_code)}, - ] - has_filters = any([search_query, type_filter, status_filter, department_filter, date_from, date_to]) - column_count = 4 - if user_has_capability(request.user, 'delete_requests'): - column_count += 1 - if user_has_capability(request.user, 'run_intro_session') or user_has_capability(request.user, 'generate_intro_pdfs'): - column_count += 1 - if user_has_capability(request.user, 'access_requests_dashboard'): - column_count += 1 - return render( + return request_views.requests_dashboard_impl( request, - 'workflows/requests_dashboard.html', - { - 'rows': rows[:60], - 'search_query': search_query, - 'selected_type': type_filter, - 'selected_status': status_filter, - 'selected_department': department_filter, - 'date_from': date_from, - 'date_to': date_to, - 'departments': departments, - 'status_choices': status_choices, - 'has_filters': has_filters, - 'column_count': column_count, - 'onboarding_total': onboarding_total, - 'offboarding_total': offboarding_total, - 'combined_total': onboarding_total + offboarding_total, - 'chart_points': chart_points, - }, + audit_fn=_audit, + request_target_label_fn=_request_target_label, + request_status_label_fn=_request_status_label, ) @login_required @ensure_csrf_cookie def onboarding_create(request): - config = WorkflowConfig.objects.order_by('id').first() - legal_text = ( - config.legal_text - if config and config.legal_text - else 'Eine Ausrüstungsvereinbarung erlaubt es einem Mitarbeitenden, die Ausrüstung des Unternehmens im Außendienst oder zu Hause zu nutzen und mitzunehmen.' - ) - - if request.method == 'POST': - form = OnboardingRequestForm(request.POST, request.FILES, requester_email=request.user.email) - if form.is_valid(): - obj = form.save() - obj.onboarded_by_name = _display_user_name(request.user) - obj.preferred_language = ((getattr(request, 'LANGUAGE_CODE', '') or get_language() or 'de').split('-')[0]) - obj.save(update_fields=['onboarded_by_name', 'preferred_language']) - process_onboarding_request.delay(obj.id) - return redirect(f"/onboarding/new/?saved=1&id={obj.id}") - else: - form = OnboardingRequestForm(requester_email=request.user.email) - - onboarding_blocks = _build_onboarding_layout(form) - field_pages = getattr(form, '_field_page_keys', {}) - onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages) - - return render( + return request_views.onboarding_create_impl( request, - 'workflows/onboarding_form.html', - { - 'form': form, - 'onboarding_blocks': onboarding_blocks, - 'onboarding_sections': onboarding_sections, - 'onboarding_inline_checks': ONBOARDING_INLINE_CHECKS, - 'onboarding_checkbox_lists': ONBOARDING_CHECKBOX_LISTS, - 'legal_text': legal_text, - 'saved': request.GET.get('saved') == '1', - 'saved_request_id': request.GET.get('id', ''), - }, + build_onboarding_layout_fn=_build_onboarding_layout, + build_onboarding_sections_fn=_build_onboarding_sections, + normalized_conditional_rule_payload_fn=_normalized_conditional_rule_payload, + display_user_name_fn=_display_user_name, + onboarding_inline_checks=ONBOARDING_INLINE_CHECKS, + onboarding_checkbox_lists=ONBOARDING_CHECKBOX_LISTS, ) @login_required def onboarding_success(request, request_id: int): - obj = get_object_or_404(OnboardingRequest, id=request_id) - pdf_url = None - if obj.generated_pdf_path: - pdf_url = f"/media/pdfs/{Path(obj.generated_pdf_path).name}" - return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) + return request_views.onboarding_success_impl(request, request_id) @_require_capability('generate_intro_pdfs') @require_POST def generate_onboarding_intro_pdf(request, request_id: int): - obj = get_object_or_404(OnboardingRequest, id=request_id) - pdf_path = _generate_onboarding_intro_pdf(obj, language_code=get_language()) - obj.intro_pdf_path = str(pdf_path) - obj.save(update_fields=['intro_pdf_path']) - _audit(request, 'intro_pdf_generated', target_type='onboarding', target_id=obj.id, target_label=obj.full_name) - messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.')) - return redirect('requests_dashboard') + return request_views.generate_onboarding_intro_pdf_impl(request, request_id, audit_fn=_audit) @_require_capability('generate_intro_pdfs') @require_POST def generate_onboarding_intro_session_pdf(request, request_id: int): - onboarding = get_object_or_404(OnboardingRequest, id=request_id) - session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) - pdf_path = _generate_onboarding_intro_session_pdf( - session, - admin_signature_name=_display_user_name(request.user), - language_code=get_language(), - ) - session.exported_pdf_path = str(pdf_path) - session.save(update_fields=['exported_pdf_path']) - _audit(request, 'intro_live_pdf_generated', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name) - messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.')) - return redirect('onboarding_intro_session_page', request_id=request_id) + return request_views.generate_onboarding_intro_session_pdf_impl(request, request_id, audit_fn=_audit, display_user_name_fn=_display_user_name) @_require_capability('run_intro_session') def onboarding_intro_session_page(request, request_id: int): - onboarding = get_object_or_404(OnboardingRequest, id=request_id) - session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) - sections = build_intro_sections_for_request(onboarding, language_code=get_language()) - - if request.method == 'POST': - checked_ids = set(request.POST.getlist('checked_items')) - checklist_state = {} - for section in sections: - for item in section['items']: - checklist_state[item['id']] = item['id'] in checked_ids - - action = (request.POST.get('session_action') or 'save').strip() - session.checklist_state = checklist_state - session.notes = (request.POST.get('notes') or '').strip() - if action == 'reset': - session.checklist_state = {} - session.notes = '' - session.status = 'draft' - session.completed_at = None - session.completed_by_name = '' - session.exported_pdf_path = '' - session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path']) - _audit(request, 'intro_session_reset', target_type='onboarding', target_id=onboarding.id, target_label=onboarding.full_name) - messages.success(request, _('Einweisung wurde zurückgesetzt.')) - return redirect('onboarding_intro_session_page', request_id=request_id) - if action == 'complete': - session.status = 'completed' - session.completed_at = timezone.now() - session.completed_by_name = _display_user_name(request.user) - _audit( - request, - 'intro_session_completed', - target_type='onboarding', - target_id=onboarding.id, - target_label=onboarding.full_name, - details={'checked_count': len([value for value in checklist_state.values() if value])}, - ) - messages.success(request, _('Einweisung wurde als abgeschlossen gespeichert.')) - else: - session.status = 'draft' - session.completed_at = None - session.completed_by_name = '' - _audit( - request, - 'intro_session_saved', - target_type='onboarding', - target_id=onboarding.id, - target_label=onboarding.full_name, - details={'checked_count': len([value for value in checklist_state.values() if value])}, - ) - messages.success(request, _('Einweisung wurde als Entwurf gespeichert.')) - session.save() - return redirect('onboarding_intro_session_page', request_id=request_id) - - checked_map = session.checklist_state or {} - checked_count = 0 - total_count = 0 - for section in sections: - for item in section['items']: - item['checked'] = bool(checked_map.get(item['id'])) - total_count += 1 - if item['checked']: - checked_count += 1 - - salutation = (onboarding.get_gender_display() or '').strip() - display_name = f"{salutation} {onboarding.full_name}".strip() if salutation else onboarding.full_name - progress_percent = int((checked_count / total_count) * 100) if total_count else 0 - - return render( - request, - 'workflows/onboarding_intro_session.html', - { - 'onboarding': onboarding, - 'session': session, - 'display_name': display_name, - 'sections': sections, - 'checked_count': checked_count, - 'total_count': total_count, - 'progress_percent': progress_percent, - 'session_pdf_url': f"/media/pdfs/{Path(session.exported_pdf_path).name}" if session.exported_pdf_path else None, - }, - ) + return request_views.onboarding_intro_session_page_impl(request, request_id, audit_fn=_audit, display_user_name_fn=_display_user_name) @login_required @ensure_csrf_cookie def offboarding_create(request): - profile_id = request.GET.get('profile') - search_query = request.GET.get('q', '').strip() - selected_profile = None - - if profile_id: - selected_profile = EmployeeProfile.objects.filter(id=profile_id).first() - - search_results = [] - if search_query: - search_results = list( - EmployeeProfile.objects.filter(full_name__icontains=search_query)[:10] - ) + list( - EmployeeProfile.objects.filter(work_email__icontains=search_query)[:10] - ) - # preserve order while removing duplicates - seen = set() - unique = [] - for r in search_results: - if r.id not in seen: - unique.append(r) - seen.add(r.id) - search_results = unique[:10] - - if request.method == 'POST': - form = OffboardingRequestForm(request.POST, prefill_profile=selected_profile) - if form.is_valid(): - obj = form.save(commit=False) - if selected_profile: - obj.employee_profile = selected_profile - requester_email = (request.user.email or '').strip().lower() - if requester_email and requester_email.endswith('@tub.co'): - obj.requested_by_email = requester_email - else: - obj.requested_by_email = settings.DEFAULT_FROM_EMAIL - obj.requested_by_name = _display_user_name(request.user) - obj.preferred_language = ((getattr(request, 'LANGUAGE_CODE', '') or get_language() or 'de').split('-')[0]) - obj.save() - process_offboarding_request.delay(obj.id) - return redirect(f"/offboarding/new/?saved=1&id={obj.id}") - else: - form = OffboardingRequestForm(prefill_profile=selected_profile, initial={'search_query': search_query}) - - return render( + return request_views.offboarding_create_impl( request, - 'workflows/offboarding_form.html', - { - 'form': form, - 'search_results': search_results, - 'selected_profile': selected_profile, - 'search_query': search_query, - 'saved': request.GET.get('saved') == '1', - 'saved_request_id': request.GET.get('id', ''), - }, + build_offboarding_sections_fn=_build_offboarding_sections, + display_user_name_fn=_display_user_name, ) @login_required def offboarding_success(request, request_id: int): - obj = get_object_or_404(OffboardingRequest, id=request_id) - pdf_url = None - if obj.generated_pdf_path: - pdf_url = f"/media/pdfs/{Path(obj.generated_pdf_path).name}" - return render(request, 'workflows/offboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) + return request_views.offboarding_success_impl(request, request_id) @_require_capability('manage_builders') def form_builder_page(request): - language_code = get_language() - form_type = request.GET.get('form_type', 'onboarding') - if form_type not in DEFAULT_FIELD_ORDER: - form_type = 'onboarding' - option_category = request.GET.get('option_category', 'department') - option_categories = [c[0] for c in FormOption.CATEGORY_CHOICES] - if option_category not in option_categories: - option_category = option_categories[0] - - if request.method == 'POST': - delete_option_id = request.POST.get('delete_option_id', '').strip() - if delete_option_id: - option = FormOption.objects.filter(id=delete_option_id).first() - if not option: - messages.error(request, 'Option nicht gefunden.') - else: - option_category = option.category - deleted_label = option.label - deleted_id = option.id - option.delete() - _audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label) - messages.success(request, 'Option wurde gelöscht.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") - - action = request.POST.get('builder_action', '') - if action == 'add_option': - category = request.POST.get('category', '').strip() - label = request.POST.get('label', '').strip() - label_en = request.POST.get('label_en', '').strip() - value = request.POST.get('value', '').strip() - if category not in option_categories: - messages.error(request, 'Ungültige Kategorie.') - elif not label: - messages.error(request, 'Bitte einen Namen für die Option angeben.') - else: - next_sort = ( - FormOption.objects.filter(category=category).order_by('-sort_order').values_list('sort_order', flat=True).first() - ) - FormOption.objects.create( - # Global form option catalog entry - category=category, - label=label, - label_en=label_en, - value=value or label, - sort_order=(next_sort + 1) if next_sort is not None else 0, - is_active=True, - ) - _audit( - request, - 'form_option_added', - target_type='form_option', - target_label=label, - details={'category': category, 'label_en': label_en, 'value': value or label}, - ) - messages.success(request, 'Option wurde hinzugefügt.') - option_category = category - - elif action == 'save_options': - option_ids = request.POST.getlist('option_ids') - for pos, raw_id in enumerate(option_ids): - option = FormOption.objects.filter(id=raw_id).first() - if not option: - continue - next_label = request.POST.get(f'label_{option.id}', '').strip() or option.label - option.label = next_label - option.label_en = request.POST.get(f'label_en_{option.id}', '').strip() - option.value = request.POST.get(f'value_{option.id}', '').strip() or next_label - option.is_active = request.POST.get(f'active_{option.id}') == 'on' - option.sort_order = pos - try: - option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order']) - except IntegrityError: - messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}") - option_category = option.category - _audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)}) - messages.success(request, 'Optionen wurden gespeichert.') - - elif action == 'save_field_texts': - field_ids = request.POST.getlist('field_ids') - for raw_id in field_ids: - cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() - if not cfg: - continue - cfg.label_override = (request.POST.get(f'label_override_{cfg.id}') or '').strip() - cfg.label_override_en = (request.POST.get(f'label_override_en_{cfg.id}') or '').strip() - cfg.help_text_override = (request.POST.get(f'help_text_override_{cfg.id}') or '').strip() - cfg.help_text_override_en = (request.POST.get(f'help_text_override_en_{cfg.id}') or '').strip() - cfg.save(update_fields=['label_override', 'label_override_en', 'help_text_override', 'help_text_override_en']) - _audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) - messages.success(request, 'Feldtexte wurden gespeichert.') - - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") - - default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) - existing_names = list( - OnboardingRequestForm.base_fields.keys() - if form_type == 'onboarding' - else OffboardingRequestForm.base_fields.keys() - ) - for name in existing_names: - if name not in default_names: - default_names.append(name) - - ensure_form_field_configs(form_type, default_names) - - configs = list( - FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name') - ) - labels = _form_field_labels(form_type) - locked = LOCKED_FIELD_RULES.get(form_type, set()) - - if form_type == 'onboarding': - columns = [ - { - 'key': key, - 'title': ONBOARDING_PAGE_LABELS.get(key, key), - 'items': [], - } - for key in ONBOARDING_PAGE_ORDER - ] - column_by_key = {c['key']: c for c in columns} - fallback = 'abschluss' - for cfg in configs: - page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(cfg.field_name, fallback) - if page_key not in column_by_key: - page_key = fallback - column_by_key[page_key]['items'].append( - { - 'field_name': cfg.field_name, - 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), - 'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name), - 'label_en': cfg.label_override_en, - 'is_visible': cfg.is_visible, - 'is_required': cfg.is_required, - 'locked': cfg.field_name in locked, - } - ) - else: - columns = [ - { - 'key': 'all', - 'title': 'Offboarding Felder', - 'items': [ - { - 'field_name': cfg.field_name, - 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), - 'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name), - 'label_en': cfg.label_override_en, - 'is_visible': cfg.is_visible, - 'is_required': cfg.is_required, - 'locked': cfg.field_name in locked, - } - for cfg in configs - ], - } - ] - - return render( + return form_builder_page_impl( request, - 'workflows/form_builder.html', - { - 'form_type': form_type, - 'columns': columns, - 'form_types': [('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], - 'option_categories': _translate_choice_list(FormOption.CATEGORY_CHOICES), - 'selected_option_category': option_category, - 'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'), - 'field_text_items': configs, - }, + audit_fn=_audit, + translate_choice_list=_translate_choice_list, + form_field_labels_fn=_form_field_labels, + field_rule_summary_fn=_field_rule_summary, + conditional_rule_summary_fn=_conditional_rule_summary, + onboarding_groups=ONBOARDING_GROUPS, + conditional_rule_operator_choices=CONDITIONAL_RULE_OPERATOR_CHOICES, ) @_require_capability('manage_builders') def intro_builder_page(request): - if request.method == 'POST': - delete_id = (request.POST.get('delete_item_id') or '').strip() - if delete_id: - item = IntroChecklistItem.objects.filter(id=delete_id).first() - if item: - deleted_label = item.label - deleted_id_int = item.id - item.delete() - _audit(request, 'intro_checklist_item_deleted', target_type='intro_checklist_item', target_id=deleted_id_int, target_label=deleted_label) - messages.success(request, 'Checklistenpunkt wurde gelöscht.') - else: - messages.error(request, 'Checklistenpunkt nicht gefunden.') - return redirect('intro_builder_page') - - action = (request.POST.get('builder_action') or '').strip() - if action == 'add_item': - section = (request.POST.get('section') or '').strip() - label = (request.POST.get('label') or '').strip() - label_en = (request.POST.get('label_en') or '').strip() - if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}: - messages.error(request, 'Ungültiger Abschnitt.') - return redirect('intro_builder_page') - if not label: - messages.error(request, 'Bitte eine Bezeichnung für den Checklistenpunkt angeben.') - return redirect('intro_builder_page') - next_sort = ( - IntroChecklistItem.objects.filter(section=section).order_by('-sort_order').values_list('sort_order', flat=True).first() - ) - IntroChecklistItem.objects.create( - section=section, - label=label, - label_en=label_en, - sort_order=(next_sort + 1) if next_sort is not None else 0, - is_active=True, - condition_operator='always', - ) - _audit(request, 'intro_checklist_item_added', target_type='intro_checklist_item', target_label=label, details={'section': section, 'label_en': label_en}) - messages.success(request, 'Checklistenpunkt wurde hinzugefügt.') - return redirect('intro_builder_page') - - if action == 'save_items': - item_ids = request.POST.getlist('item_ids') - valid_sections = {k for k, _ in IntroChecklistItem.SECTION_CHOICES} - valid_ops = {k for k, _ in IntroChecklistItem.OPERATOR_CHOICES} - for pos, raw_id in enumerate(item_ids): - item = IntroChecklistItem.objects.filter(id=raw_id).first() - if not item: - continue - section = (request.POST.get(f'section_{item.id}') or item.section).strip() - if section not in valid_sections: - section = item.section - operator = (request.POST.get(f'operator_{item.id}') or item.condition_operator).strip() - if operator not in valid_ops: - operator = 'always' - item.section = section - item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label - item.label_en = (request.POST.get(f'label_en_{item.id}') or '').strip() - item.is_active = request.POST.get(f'active_{item.id}') == 'on' - item.condition_field = (request.POST.get(f'field_{item.id}') or '').strip() - item.condition_operator = operator - item.condition_value = (request.POST.get(f'value_{item.id}') or '').strip() - item.sort_order = pos - item.save( - update_fields=[ - 'section', - 'label', - 'label_en', - 'is_active', - 'condition_field', - 'condition_operator', - 'condition_value', - 'sort_order', - ] - ) - _audit(request, 'intro_checklist_saved', target_type='intro_checklist_item', details={'count': len(item_ids)}) - messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.') - return redirect('intro_builder_page') - - condition_field_choices = [ - ('', 'Keine Bedingung'), - ('needed_devices', 'Benötigte Geräte und Gegenstände'), - ('needed_software', 'Benötigte Software'), - ('needed_accesses', 'Benötigte Zugänge'), - ('needed_workspace_groups', 'Benötigte Gruppen im Workspace'), - ('needed_resources', 'Benötigte Ressourcen'), - ('additional_hardware', 'Zusätzliche Hardware'), - ('additional_software', 'Zusätzliche Software'), - ('additional_access_text', 'Weitere Zugänge (Freitext)'), - ('group_mailboxes_required', 'Gruppenpostfächer erforderlich'), - ('order_business_cards', 'Visitenkarten bestellt'), - ('phone_number', 'Direktwahl vorhanden'), - ('successor_name', 'Nachfolge vorhanden'), - ('department', 'Abteilung'), - ] - - items = list(IntroChecklistItem.objects.all().order_by('section', 'sort_order', 'label')) - return render( + return intro_builder_page_impl( request, - 'workflows/intro_builder.html', - { - 'items': items, - 'section_choices': _translate_choice_list(IntroChecklistItem.SECTION_CHOICES), - 'operator_choices': _translate_choice_list(IntroChecklistItem.OPERATOR_CHOICES), - 'condition_field_choices': condition_field_choices, - }, + audit_fn=_audit, + translate_choice_list=_translate_choice_list, ) @_require_capability('manage_integrations') def integrations_setup_page(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - kind = (request.GET.get('kind') or 'nextcloud').strip().lower() - if kind not in {'nextcloud', 'mail', 'emails', 'rules', 'backup'}: - kind = 'nextcloud' - templates = list(NotificationTemplate.objects.all().order_by('key')) - system_email_config = ( - SystemEmailConfig.objects.filter(is_active=True).order_by('-updated_at').first() - or SystemEmailConfig.objects.filter(name='Default SMTP').first() - ) - return render( - request, - 'workflows/integrations_setup.html', - { - 'workflow_config': config, - 'system_email_config': system_email_config, - 'nextcloud_enabled': is_nextcloud_enabled(), - 'email_test_mode': is_email_test_mode(), - 'kind': kind, - 'templates': templates, - 'notification_rules': NotificationRule.objects.all().order_by('event_type', 'sort_order', 'id'), - 'rule_event_choices': NotificationRule.EVENT_CHOICES, - 'rule_operator_choices': NotificationRule.OPERATOR_CHOICES, - 'template_choices': NotificationTemplate.TEMPLATE_CHOICES, - 'remote_backup_target_choices': WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES, - }, - ) + return integrations_views.integrations_setup_page_impl(request) @_require_capability('manage_welcome_emails') def welcome_emails_page(request): - rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200] - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - welcome_template = NotificationTemplate.objects.filter(key='onboarding_welcome').first() - default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {}) - default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip() - default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip() - default_subject_en = (default_welcome.get('subject_en') or 'Welcome to TUB/CO, {{ FULL_NAME }}').strip() - default_body_en = (default_welcome.get('body_en') or 'Hello {{ FULL_NAME }}, welcome to TUB/CO.').strip() - subject_value = (welcome_template.subject_template if welcome_template else '').strip() or default_subject - body_value = (welcome_template.body_template if welcome_template else '').strip() or default_body - subject_value_en = (welcome_template.subject_template_en if welcome_template else '').strip() or default_subject_en - body_value_en = (welcome_template.body_template_en if welcome_template else '').strip() or default_body_en - return render( - request, - 'workflows/welcome_emails.html', - { - 'rows': rows, - 'workflow_config': config, - 'welcome_template': welcome_template, - 'welcome_subject_value': subject_value, - 'welcome_body_value': body_value, - 'welcome_subject_value_en': subject_value_en, - 'welcome_body_value_en': body_value_en, - 'welcome_keywords': ['{{ FULL_NAME }}', '{{ VORNAME }}', '{{ NACHNAME }}', '{{ DEPARTMENT }}', '{{ CONTRACT_START }}', '{{ EMAIL }}', '{{ REQUESTED_BY }}'], - }, - ) + return integrations_views.welcome_emails_page_impl(request) @_require_capability('manage_welcome_emails') @require_POST def trigger_welcome_email_now(request, schedule_id: int): - scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() - if not scheduled: - messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') - return redirect('welcome_emails_page') - if scheduled.status == 'cancelled': - messages.error(request, f'Welcome E-Mail #{schedule_id} ist abgebrochen und kann nicht gesendet werden.') - return redirect('welcome_emails_page') - - async_result = send_scheduled_welcome_email.delay(scheduled.id, True) - scheduled.celery_task_id = async_result.id or scheduled.celery_task_id - scheduled.status = 'scheduled' - scheduled.last_error = '' - scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) - _audit(request, 'welcome_email_triggered_now', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) - messages.success(request, f'Welcome E-Mail #{schedule_id} wurde sofort angestoßen.') - return redirect('welcome_emails_page') + return integrations_views.trigger_welcome_email_now_impl( + request, + schedule_id, + audit_fn=_audit, + send_task_fn=send_scheduled_welcome_email, + ) @_require_capability('manage_welcome_emails') @require_POST def save_welcome_email_settings(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - try: - delay_days = int(request.POST.get('welcome_email_delay_days', config.welcome_email_delay_days or 5)) - except ValueError: - messages.error(request, 'Ungültige Zahl bei der Welcome-Verzögerung.') - return redirect('welcome_emails_page') - - config.welcome_email_delay_days = max(0, delay_days) - config.welcome_sender_email = request.POST.get('welcome_sender_email', '').strip() - config.welcome_include_pdf = request.POST.get('welcome_include_pdf') == 'on' - config.save(update_fields=['welcome_email_delay_days', 'welcome_sender_email', 'welcome_include_pdf']) - - subject = request.POST.get('welcome_subject') - body = request.POST.get('welcome_body') - subject_en = request.POST.get('welcome_subject_en') - body_en = request.POST.get('welcome_body_en') - if subject is not None or body is not None or subject_en is not None or body_en is not None: - default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {}) - default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip() - default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip() - default_subject_en = (default_welcome.get('subject_en') or 'Welcome to TUB/CO, {{ FULL_NAME }}').strip() - default_body_en = (default_welcome.get('body_en') or 'Hello {{ FULL_NAME }}, welcome to TUB/CO.').strip() - subject_clean = (subject or '').strip() or default_subject - body_clean = (body or '').strip() or default_body - subject_clean_en = (subject_en or '').strip() or default_subject_en - body_clean_en = (body_en or '').strip() or default_body_en - template, _ = NotificationTemplate.objects.get_or_create( - key='onboarding_welcome', - defaults={ - 'subject_template': subject_clean, - 'body_template': body_clean, - 'subject_template_en': subject_clean_en, - 'body_template_en': body_clean_en, - 'is_active': True, - }, - ) - changes = [] - if template.subject_template != subject_clean: - template.subject_template = subject_clean - changes.append('subject_template') - if template.body_template != body_clean: - template.body_template = body_clean - changes.append('body_template') - if template.subject_template_en != subject_clean_en: - template.subject_template_en = subject_clean_en - changes.append('subject_template_en') - if template.body_template_en != body_clean_en: - template.body_template_en = body_clean_en - changes.append('body_template_en') - if not template.is_active: - template.is_active = True - changes.append('is_active') - if changes: - template.save(update_fields=changes) - - _audit( - request, - 'welcome_email_settings_saved', - target_type='welcome_email_settings', - target_label='onboarding_welcome', - details={ - 'delay_days': config.welcome_email_delay_days, - 'sender_email': config.welcome_sender_email, - 'include_pdf': config.welcome_include_pdf, - }, - ) - messages.success(request, 'Welcome-E-Mail Einstellungen wurden gespeichert.') - return redirect('welcome_emails_page') - - -def _revoke_celery_task(task_id: str) -> None: - if not task_id: - return - try: - current_app.control.revoke(task_id, terminate=False) - except Exception: - return - - -def _parse_selected_schedule_ids(raw: str) -> list[int]: - if not raw: - return [] - parsed: list[int] = [] - seen: set[int] = set() - for token in raw.split(','): - token = token.strip() - if not token: - continue - try: - schedule_id = int(token) - except ValueError: - continue - if schedule_id in seen: - continue - seen.add(schedule_id) - parsed.append(schedule_id) - return parsed + return integrations_views.save_welcome_email_settings_impl(request, audit_fn=_audit) @_require_capability('manage_welcome_emails') @require_POST def bulk_welcome_email_action(request): - action = (request.POST.get('bulk_action') or '').strip().lower() - selected_ids = _parse_selected_schedule_ids(request.POST.get('selected_ids', '')) - - if action not in {'pause', 'send_now', 'delete'}: - messages.error(request, 'Ungültige Bulk-Aktion.') - return redirect('welcome_emails_page') - - if not selected_ids: - messages.warning(request, 'Keine Welcome-Einträge ausgewählt.') - return redirect('welcome_emails_page') - - rows = list(ScheduledWelcomeEmail.objects.filter(id__in=selected_ids).order_by('id')) - if not rows: - messages.warning(request, 'Keine passenden Welcome-Einträge gefunden.') - return redirect('welcome_emails_page') - - success_count = 0 - skipped_count = 0 - - for scheduled in rows: - if action == 'pause': - if scheduled.status in {'sent', 'cancelled'}: - skipped_count += 1 - continue - _revoke_celery_task(scheduled.celery_task_id) - scheduled.status = 'paused' - scheduled.save(update_fields=['status', 'updated_at']) - success_count += 1 - continue - - if action == 'send_now': - if scheduled.status == 'cancelled': - skipped_count += 1 - continue - async_result = send_scheduled_welcome_email.delay(scheduled.id, True) - scheduled.celery_task_id = async_result.id or scheduled.celery_task_id - scheduled.status = 'scheduled' - scheduled.last_error = '' - scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) - success_count += 1 - continue - - if action == 'delete': - if scheduled.status == 'scheduled': - _revoke_celery_task(scheduled.celery_task_id) - scheduled.delete() - success_count += 1 - - action_label = { - 'pause': 'pausiert', - 'send_now': 'sofort angestoßen', - 'delete': 'gelöscht', - }[action] - if success_count: - _audit( - request, - 'welcome_email_bulk_action', - target_type='welcome_email', - target_label=action, - details={'selected_ids': selected_ids, 'success_count': success_count, 'skipped_count': skipped_count}, - ) - messages.success(request, f'{success_count} Welcome-Eintrag/Einträge {action_label}.') - if skipped_count: - messages.warning(request, f'{skipped_count} Eintrag/Einträge wurden übersprungen (Status nicht geeignet).') - return redirect('welcome_emails_page') + return integrations_views.bulk_welcome_email_action_impl( + request, + audit_fn=_audit, + send_task_fn=send_scheduled_welcome_email, + ) @_require_capability('manage_welcome_emails') @require_POST def pause_welcome_email(request, schedule_id: int): - scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() - if not scheduled: - messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') - return redirect('welcome_emails_page') - if scheduled.status in {'sent', 'cancelled'}: - messages.error(request, f'Welcome E-Mail #{schedule_id} kann nicht pausiert werden.') - return redirect('welcome_emails_page') - - _revoke_celery_task(scheduled.celery_task_id) - scheduled.status = 'paused' - scheduled.save(update_fields=['status', 'updated_at']) - _audit(request, 'welcome_email_paused', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) - messages.success(request, f'Welcome E-Mail #{schedule_id} wurde pausiert.') - return redirect('welcome_emails_page') + return integrations_views.pause_welcome_email_impl(request, schedule_id, audit_fn=_audit) @_require_capability('manage_welcome_emails') @require_POST def resume_welcome_email(request, schedule_id: int): - scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() - if not scheduled: - messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') - return redirect('welcome_emails_page') - if scheduled.status != 'paused': - messages.error(request, f'Welcome E-Mail #{schedule_id} ist nicht pausiert.') - return redirect('welcome_emails_page') - - eta = scheduled.send_at if timezone.now() < scheduled.send_at else None - async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=eta) - scheduled.celery_task_id = async_result.id or scheduled.celery_task_id - scheduled.status = 'scheduled' - scheduled.last_error = '' - scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) - _audit(request, 'welcome_email_resumed', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) - messages.success(request, f'Welcome E-Mail #{schedule_id} wurde fortgesetzt.') - return redirect('welcome_emails_page') + return integrations_views.resume_welcome_email_impl( + request, + schedule_id, + audit_fn=_audit, + send_task_fn=send_scheduled_welcome_email, + ) @_require_capability('manage_welcome_emails') @require_POST def cancel_welcome_email(request, schedule_id: int): - scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() - if not scheduled: - messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') - return redirect('welcome_emails_page') - if scheduled.status == 'sent': - messages.error(request, f'Welcome E-Mail #{schedule_id} wurde bereits gesendet.') - return redirect('welcome_emails_page') - - _revoke_celery_task(scheduled.celery_task_id) - scheduled.status = 'cancelled' - scheduled.last_error = '' - scheduled.save(update_fields=['status', 'last_error', 'updated_at']) - _audit(request, 'welcome_email_cancelled', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) - messages.success(request, f'Welcome E-Mail #{schedule_id} wurde abgebrochen.') - return redirect('welcome_emails_page') + return integrations_views.cancel_welcome_email_impl(request, schedule_id, audit_fn=_audit) @_require_capability('manage_builders') @require_POST def form_builder_save_order(request): - try: - payload = json.loads(request.body.decode('utf-8')) - except (json.JSONDecodeError, UnicodeDecodeError): - return JsonResponse({'ok': False, 'error': 'Ungültige JSON-Daten.'}, status=400) - - form_type = payload.get('form_type') - if form_type not in DEFAULT_FIELD_ORDER: - return JsonResponse({'ok': False, 'error': 'Ungültiger Formulartyp.'}, status=400) - - columns = payload.get('columns') - if not isinstance(columns, dict): - return JsonResponse({'ok': False, 'error': 'Spalten-Daten fehlen.'}, status=400) - - configs = list(FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name')) - allowed_names = {cfg.field_name for cfg in configs} - seen = set() - ordered_names = [] - - if form_type == 'onboarding': - allowed_columns = ONBOARDING_PAGE_ORDER - else: - allowed_columns = ['all'] - - name_to_cfg = {cfg.field_name: cfg for cfg in configs} - sort_order = 0 - - for column_key in allowed_columns: - names = columns.get(column_key, []) - if not isinstance(names, list): - return JsonResponse({'ok': False, 'error': f'Ungültige Spalte: {column_key}'}, status=400) - - for name in names: - if not isinstance(name, str): - continue - if name not in allowed_names or name in seen: - continue - seen.add(name) - ordered_names.append(name) - cfg = name_to_cfg[name] - cfg.sort_order = sort_order - sort_order += 1 - if form_type == 'onboarding': - cfg.page_key = column_key - else: - cfg.page_key = '' - - missing = [cfg.field_name for cfg in configs if cfg.field_name not in seen] - for name in missing: - cfg = name_to_cfg[name] - cfg.sort_order = sort_order - sort_order += 1 - if form_type == 'onboarding': - cfg.page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss') - else: - cfg.page_key = '' - - FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) - _audit(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': len(configs)}) - return JsonResponse({'ok': True, 'saved_count': len(configs)}) + return form_builder_save_order_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def send_test_email(request): - mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF' - redirect_email = get_email_test_redirect() - send_system_email( - subject=f'SMTP test from onboarding/offboarding v2 ({mode})', - body=( - 'This is a test email. If you see this, SMTP is configured correctly.\n' - f'EMAIL_TEST_MODE={is_email_test_mode()}\n' - f'EMAIL_TEST_REDIRECT={redirect_email}\n' - ), - to=[settings.TEST_NOTIFICATION_EMAIL], - ) - _audit(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()}) - messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).') - return _redirect_back(request, 'home') + return integrations_views.send_test_email_impl(request, audit_fn=_audit, redirect_back_fn=_redirect_back) @_require_capability('manage_integrations') @require_POST def nextcloud_test_upload(request): - filename = f"nextcloud_test_{timezone.now().strftime('%Y%m%d_%H%M%S')}.txt" - content = ( - "Nextcloud test upload from onboarding/offboarding system.\n" - f"Time: {timezone.now().isoformat()}\n" - f"User: {request.user.username}\n" - ) - - temp_path = None - try: - with NamedTemporaryFile('w', suffix='.txt', delete=False, encoding='utf-8') as tf: - tf.write(content) - temp_path = Path(tf.name) - - ok = upload_to_nextcloud(temp_path, filename) - if ok: - _audit(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'success'}) - messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}') - else: - _audit(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'error'}) - messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.') - except Exception as exc: - messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}') - finally: - if temp_path and temp_path.exists(): - temp_path.unlink(missing_ok=True) - - return _redirect_back(request, 'home') + return integrations_views.nextcloud_test_upload_impl(request, audit_fn=_audit, redirect_back_fn=_redirect_back) @_require_capability('manage_integrations') @require_POST def toggle_nextcloud_enabled(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - currently_enabled = is_nextcloud_enabled() - config.nextcloud_enabled_override = not currently_enabled - config.save(update_fields=['nextcloud_enabled_override']) - _audit(request, 'nextcloud_mode_toggled', target_type='workflow_config', target_label='nextcloud', details={'enabled': config.nextcloud_enabled_override}) - - state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert' - messages.success(request, f'Nextcloud Upload wurde {state}.') - return _redirect_back(request, 'home') + return integrations_views.toggle_nextcloud_enabled_impl(request, audit_fn=_audit, redirect_back_fn=_redirect_back) @_require_capability('manage_integrations') @require_POST def toggle_email_mode(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - currently_test_mode = is_email_test_mode() - config.email_test_mode_override = not currently_test_mode - config.save(update_fields=['email_test_mode_override']) - _audit(request, 'email_mode_toggled', target_type='workflow_config', target_label='email_mode', details={'test_mode': config.email_test_mode_override}) - - state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus' - messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.') - return _redirect_back(request, 'home') + return integrations_views.toggle_email_mode_impl(request, audit_fn=_audit, redirect_back_fn=_redirect_back) @_require_capability('manage_integrations') @require_POST def save_integrations_settings(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - try: - sync_interval = int(request.POST.get('sync_interval_seconds', config.sync_interval_seconds or 60)) - smtp_port = int(request.POST.get('smtp_port', config.smtp_port or 465)) - except ValueError: - messages.error(request, 'Ungültige Zahl bei Sync-Intervall oder SMTP Port.') - return redirect('home') - - config.nextcloud_base_url_override = request.POST.get('nextcloud_base_url_override', '').strip() - config.nextcloud_username_override = request.POST.get('nextcloud_username_override', '').strip() - config.nextcloud_directory_override = request.POST.get('nextcloud_directory_override', '').strip() - config.sync_interval_seconds = max(10, sync_interval) - - config.imap_server = request.POST.get('imap_server', '').strip() - config.mailbox = request.POST.get('mailbox', '').strip() or 'INBOX' - config.smtp_server = request.POST.get('smtp_server', '').strip() - config.smtp_port = max(1, smtp_port) - config.email_account = request.POST.get('email_account', '').strip() - config.smtp_use_ssl = request.POST.get('smtp_use_ssl') == 'on' - config.smtp_use_tls = request.POST.get('smtp_use_tls') == 'on' - - nextcloud_password = request.POST.get('nextcloud_password_override', '').strip() - if nextcloud_password: - config.nextcloud_password_override = nextcloud_password - - email_password = request.POST.get('email_password', '').strip() - if email_password: - config.email_password = email_password - - config.save() - _audit(request, 'integrations_saved', target_type='workflow_config', target_label='all_integrations') - messages.success(request, 'Integrations-Einstellungen wurden gespeichert.') - return redirect('home') + return integrations_views.save_integrations_settings_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def save_nextcloud_settings(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - try: - sync_interval = int(request.POST.get('sync_interval_seconds', config.sync_interval_seconds or 60)) - except ValueError: - messages.error(request, 'Ungültige Zahl beim Sync-Intervall.') - return redirect('home') - - config.nextcloud_base_url_override = request.POST.get('nextcloud_base_url_override', '').strip() - config.nextcloud_username_override = request.POST.get('nextcloud_username_override', '').strip() - config.nextcloud_directory_override = request.POST.get('nextcloud_directory_override', '').strip() - config.sync_interval_seconds = max(10, sync_interval) - - nextcloud_password = request.POST.get('nextcloud_password_override', '').strip() - if nextcloud_password: - config.nextcloud_password_override = nextcloud_password - - config.save() - _audit(request, 'nextcloud_settings_saved', target_type='workflow_config', target_label='nextcloud') - messages.success(request, 'Nextcloud-Einstellungen wurden gespeichert.') - return redirect('/admin-tools/integrations/?kind=nextcloud') + return integrations_views.save_nextcloud_settings_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def save_workflow_rules(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - try: - handover_lead_days = int( - request.POST.get( - 'device_handover_lead_days', - config.device_handover_lead_days or 5, - ) - ) - except ValueError: - messages.error(request, 'Ungültige Zahl beim Hardware-Vorlauf.') - return redirect('/admin-tools/integrations/?kind=rules') - - config.device_handover_lead_days = max(0, handover_lead_days) - config.save(update_fields=['device_handover_lead_days']) - _audit( - request, - 'workflow_rules_saved', - target_type='workflow_config', - target_label='workflow_rules', - details={ - 'device_handover_lead_days': config.device_handover_lead_days, - }, - ) - messages.success(request, 'Workflow-Regeln wurden gespeichert.') - return redirect('/admin-tools/integrations/?kind=rules') + return integrations_views.save_workflow_rules_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def save_backup_settings(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - target_type = (request.POST.get('remote_backup_target_type') or config.remote_backup_target_type or 'nextcloud').strip().lower() - if target_type not in {choice for choice, _ in WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES}: - target_type = 'nextcloud' - remote_backup_enabled = request.POST.get('remote_backup_enabled') == 'on' - remote_backup_nextcloud_directory = request.POST.get('remote_backup_nextcloud_directory', '').strip() - primary_nextcloud_directory = ( - (config.nextcloud_directory_override or '').strip() - or settings.NEXTCLOUD_DIRECTORY.strip() - ).strip('/') - - if remote_backup_enabled and target_type == 'nextcloud': - if not remote_backup_nextcloud_directory: - messages.error(request, 'Bitte ein separates Nextcloud Backup-Verzeichnis angeben.') - return redirect('/admin-tools/integrations/?kind=backup') - if remote_backup_nextcloud_directory.strip('/') == primary_nextcloud_directory: - messages.error(request, 'Das Backup-Verzeichnis muss vom normalen Nextcloud Dokumentenordner getrennt sein.') - return redirect('/admin-tools/integrations/?kind=backup') - - config.remote_backup_enabled = remote_backup_enabled - config.remote_backup_target_type = target_type - config.remote_backup_nextcloud_directory = remote_backup_nextcloud_directory - config.remote_backup_s3_bucket = request.POST.get('remote_backup_s3_bucket', '').strip() - config.remote_backup_nfs_path = request.POST.get('remote_backup_nfs_path', '').strip() - config.save( - update_fields=[ - 'device_handover_lead_days', - 'remote_backup_enabled', - 'remote_backup_target_type', - 'remote_backup_nextcloud_directory', - 'remote_backup_s3_bucket', - 'remote_backup_nfs_path', - ] - ) - _audit( - request, - 'backup_settings_saved', - target_type='workflow_config', - target_label='backup_settings', - details={ - 'remote_backup_enabled': config.remote_backup_enabled, - 'remote_backup_target_type': config.remote_backup_target_type, - 'remote_backup_nextcloud_directory': config.remote_backup_nextcloud_directory, - }, - ) - messages.success(request, 'Backup-Einstellungen wurden gespeichert.') - return redirect('/admin-tools/integrations/?kind=backup') + return integrations_views.save_backup_settings_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def save_mail_settings(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - try: - smtp_port = int(request.POST.get('smtp_port', config.smtp_port or 465)) - except ValueError: - messages.error(request, 'Ungültige Zahl beim SMTP Port.') - return redirect('home') - - config.imap_server = request.POST.get('imap_server', '').strip() - config.mailbox = request.POST.get('mailbox', '').strip() or 'INBOX' - config.smtp_server = request.POST.get('smtp_server', '').strip() - config.smtp_port = max(1, smtp_port) - config.email_account = request.POST.get('email_account', '').strip() - config.smtp_use_ssl = request.POST.get('smtp_use_ssl') == 'on' - config.smtp_use_tls = request.POST.get('smtp_use_tls') == 'on' - - email_password = request.POST.get('email_password', '').strip() - if email_password: - config.email_password = email_password - - config.save() - smtp_cfg, _ = SystemEmailConfig.objects.get_or_create(name='Default SMTP') - SystemEmailConfig.objects.exclude(id=smtp_cfg.id).update(is_active=False) - smtp_cfg.is_active = True - smtp_cfg.host = config.smtp_server - smtp_cfg.port = config.smtp_port - smtp_cfg.username = config.email_account - if email_password: - smtp_cfg.password = email_password - smtp_cfg.use_ssl = config.smtp_use_ssl - smtp_cfg.use_tls = config.smtp_use_tls - smtp_cfg.from_email = request.POST.get('from_email', '').strip() - smtp_cfg.save() - _audit(request, 'mail_settings_saved', target_type='workflow_config', target_label='mail') - messages.success(request, 'Mail-Einstellungen wurden gespeichert.') - return redirect('/admin-tools/integrations/?kind=mail') + return integrations_views.save_mail_settings_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def save_email_routing_settings(request): - config, _ = WorkflowConfig.objects.get_or_create(name='Default') - config.it_onboarding_email = request.POST.get('it_onboarding_email', '').strip() - config.general_info_email = request.POST.get('general_info_email', '').strip() - config.business_card_email = request.POST.get('business_card_email', '').strip() - config.hr_works_email = request.POST.get('hr_works_email', '').strip() - config.key_notification_email = request.POST.get('key_notification_email', '').strip() - config.save( - update_fields=[ - 'it_onboarding_email', - 'general_info_email', - 'business_card_email', - 'hr_works_email', - 'key_notification_email', - ] - ) - - known_keys = {k for k, _ in NotificationTemplate.TEMPLATE_CHOICES} - for key in known_keys: - subject = request.POST.get(f'subject_{key}') - body = request.POST.get(f'body_{key}') - subject_en = request.POST.get(f'subject_en_{key}') - body_en = request.POST.get(f'body_en_{key}') - if subject is None and body is None and subject_en is None and body_en is None: - continue - subject = (subject or '').strip() - body = (body or '').strip() - subject_en = (subject_en or '').strip() - body_en = (body_en or '').strip() - if not subject and not body and not subject_en and not body_en: - continue - obj, _ = NotificationTemplate.objects.get_or_create( - key=key, - defaults={ - 'subject_template': subject or f'[{key}]', - 'body_template': body or '-', - 'subject_template_en': subject_en, - 'body_template_en': body_en, - 'is_active': True, - }, - ) - changed = [] - if subject and obj.subject_template != subject: - obj.subject_template = subject - changed.append('subject_template') - if body and obj.body_template != body: - obj.body_template = body - changed.append('body_template') - if obj.subject_template_en != subject_en: - obj.subject_template_en = subject_en - changed.append('subject_template_en') - if obj.body_template_en != body_en: - obj.body_template_en = body_en - changed.append('body_template_en') - if not obj.is_active: - obj.is_active = True - changed.append('is_active') - if changed: - obj.save(update_fields=changed) - - _audit(request, 'email_routing_saved', target_type='workflow_config', target_label='email_routing') - messages.success(request, 'E-Mail Routing und Vorlagen wurden gespeichert.') - return redirect('/admin-tools/integrations/?kind=emails') + return integrations_views.save_email_routing_settings_impl(request, audit_fn=_audit) @_require_capability('manage_integrations') @require_POST def save_notification_rules(request): - rule_ids = request.POST.getlist('rule_ids') - for position, raw_id in enumerate(rule_ids): - rule = NotificationRule.objects.filter(id=raw_id).first() - if not rule: - continue - if request.POST.get(f'delete_{rule.id}') == 'on': - rule.delete() - continue - - rule.name = request.POST.get(f'name_{rule.id}', '').strip() or rule.name - event_type = request.POST.get(f'event_type_{rule.id}', '').strip() - if event_type in {'onboarding', 'offboarding'}: - rule.event_type = event_type - operator = request.POST.get(f'operator_{rule.id}', '').strip() - if operator in {x[0] for x in NotificationRule.OPERATOR_CHOICES}: - rule.operator = operator - rule.field_name = request.POST.get(f'field_name_{rule.id}', '').strip() - rule.expected_value = request.POST.get(f'expected_value_{rule.id}', '').strip() - rule.recipients = request.POST.get(f'recipients_{rule.id}', '').strip() - rule.template_key = request.POST.get(f'template_key_{rule.id}', '').strip() - rule.custom_subject = request.POST.get(f'custom_subject_{rule.id}', '').strip() - rule.custom_body = request.POST.get(f'custom_body_{rule.id}', '').strip() - rule.custom_subject_en = request.POST.get(f'custom_subject_en_{rule.id}', '').strip() - rule.custom_body_en = request.POST.get(f'custom_body_en_{rule.id}', '').strip() - rule.include_pdf_attachment = request.POST.get(f'include_pdf_{rule.id}') == 'on' - rule.is_active = request.POST.get(f'active_{rule.id}') == 'on' - rule.sort_order = position - rule.save() - - new_name = request.POST.get('new_name', '').strip() - new_recipients = request.POST.get('new_recipients', '').strip() - if new_name and new_recipients: - new_event = request.POST.get('new_event_type', 'onboarding').strip() - if new_event not in {'onboarding', 'offboarding'}: - new_event = 'onboarding' - new_operator = request.POST.get('new_operator', 'always').strip() - if new_operator not in {x[0] for x in NotificationRule.OPERATOR_CHOICES}: - new_operator = 'always' - NotificationRule.objects.create( - name=new_name, - event_type=new_event, - field_name=request.POST.get('new_field_name', '').strip(), - operator=new_operator, - expected_value=request.POST.get('new_expected_value', '').strip(), - recipients=new_recipients, - template_key=request.POST.get('new_template_key', '').strip(), - custom_subject=request.POST.get('new_custom_subject', '').strip(), - custom_body=request.POST.get('new_custom_body', '').strip(), - custom_subject_en=request.POST.get('new_custom_subject_en', '').strip(), - custom_body_en=request.POST.get('new_custom_body_en', '').strip(), - include_pdf_attachment=request.POST.get('new_include_pdf') == 'on', - is_active=True, - sort_order=NotificationRule.objects.filter(event_type=new_event).count() + 1, - ) - - _audit(request, 'notification_rules_saved', target_type='notification_rule') - messages.success(request, 'Benachrichtigungsregeln wurden gespeichert.') - return redirect('/admin-tools/integrations/?kind=emails') + return integrations_views.save_notification_rules_impl(request, audit_fn=_audit) @_require_capability('delete_requests') @require_POST def delete_request_from_dashboard(request, kind: str, request_id: int): - if kind == 'onboarding': - obj = get_object_or_404(OnboardingRequest, id=request_id) - elif kind == 'offboarding': - obj = get_object_or_404(OffboardingRequest, id=request_id) - else: - messages.error(request, f'Unbekannter Typ: {kind}') - return redirect('requests_dashboard') - - target_label = _request_target_label(obj, kind) - obj.delete() - _audit(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=target_label) - messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.') - return redirect('requests_dashboard') + return request_views.delete_request_from_dashboard_impl(request, kind, request_id, audit_fn=_audit, request_target_label_fn=_request_target_label) @_require_capability('retry_requests') @require_POST def retry_request_from_dashboard(request, kind: str, request_id: int): - if kind == 'onboarding': - obj = get_object_or_404(OnboardingRequest, id=request_id) - obj.processing_status = 'submitted' - obj.last_error = '' - obj.save(update_fields=['processing_status', 'last_error']) - process_onboarding_request.delay(obj.id) - _audit(request, 'request_retried', target_type='onboarding', target_id=obj.id, target_label=_request_target_label(obj, 'onboarding')) - elif kind == 'offboarding': - obj = get_object_or_404(OffboardingRequest, id=request_id) - obj.processing_status = 'submitted' - obj.last_error = '' - obj.save(update_fields=['processing_status', 'last_error']) - process_offboarding_request.delay(obj.id) - _audit(request, 'request_retried', target_type='offboarding', target_id=obj.id, target_label=_request_target_label(obj, 'offboarding')) - else: - messages.error(request, f'Unbekannter Typ: {kind}') - return redirect('requests_dashboard') - - messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde erneut angestoßen.') - return redirect('requests_dashboard') + return request_views.retry_request_from_dashboard_impl(request, kind, request_id, audit_fn=_audit, request_target_label_fn=_request_target_label) diff --git a/backend/workflows/welcome_email_views.py b/backend/workflows/welcome_email_views.py new file mode 100644 index 0000000..bb76e7a --- /dev/null +++ b/backend/workflows/welcome_email_views.py @@ -0,0 +1,279 @@ +from celery import current_app +from django.contrib import messages +from django.shortcuts import redirect, render +from django.utils import timezone + +from .branding import get_default_notification_templates +from .models import NotificationTemplate, ScheduledWelcomeEmail, WorkflowConfig +from .tasks import send_scheduled_welcome_email + + +def welcome_emails_page_impl(request): + rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200] + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + welcome_template = NotificationTemplate.objects.filter(key='onboarding_welcome').first() + default_welcome = get_default_notification_templates().get('onboarding_welcome', {}) + default_subject = (default_welcome.get('subject') or '').strip() + default_body = (default_welcome.get('body') or '').strip() + default_subject_en = (default_welcome.get('subject_en') or '').strip() + default_body_en = (default_welcome.get('body_en') or '').strip() + subject_value = (welcome_template.subject_template if welcome_template else '').strip() or default_subject + body_value = (welcome_template.body_template if welcome_template else '').strip() or default_body + subject_value_en = (welcome_template.subject_template_en if welcome_template else '').strip() or default_subject_en + body_value_en = (welcome_template.body_template_en if welcome_template else '').strip() or default_body_en + return render( + request, + 'workflows/welcome_emails.html', + { + 'rows': rows, + 'workflow_config': config, + 'welcome_template': welcome_template, + 'welcome_subject_value': subject_value, + 'welcome_body_value': body_value, + 'welcome_subject_value_en': subject_value_en, + 'welcome_body_value_en': body_value_en, + 'welcome_keywords': ['{{ FULL_NAME }}', '{{ VORNAME }}', '{{ NACHNAME }}', '{{ DEPARTMENT }}', '{{ CONTRACT_START }}', '{{ EMAIL }}', '{{ REQUESTED_BY }}'], + }, + ) + + +def trigger_welcome_email_now_impl(request, schedule_id: int, *, audit_fn, send_task_fn=send_scheduled_welcome_email): + scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() + if not scheduled: + messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') + return redirect('welcome_emails_page') + if scheduled.status == 'cancelled': + messages.error(request, f'Welcome E-Mail #{schedule_id} ist abgebrochen und kann nicht gesendet werden.') + return redirect('welcome_emails_page') + + async_result = send_task_fn.delay(scheduled.id, True) + scheduled.celery_task_id = async_result.id or scheduled.celery_task_id + scheduled.status = 'scheduled' + scheduled.last_error = '' + scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) + audit_fn(request, 'welcome_email_triggered_now', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde sofort angestoßen.') + return redirect('welcome_emails_page') + + +def save_welcome_email_settings_impl(request, *, audit_fn): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + try: + delay_days = int(request.POST.get('welcome_email_delay_days', config.welcome_email_delay_days or 5)) + except ValueError: + messages.error(request, 'Ungültige Zahl bei der Welcome-Verzögerung.') + return redirect('welcome_emails_page') + + config.welcome_email_delay_days = max(0, delay_days) + config.welcome_sender_email = request.POST.get('welcome_sender_email', '').strip() + config.welcome_include_pdf = request.POST.get('welcome_include_pdf') == 'on' + config.save(update_fields=['welcome_email_delay_days', 'welcome_sender_email', 'welcome_include_pdf']) + + subject = request.POST.get('welcome_subject') + body = request.POST.get('welcome_body') + subject_en = request.POST.get('welcome_subject_en') + body_en = request.POST.get('welcome_body_en') + if subject is not None or body is not None or subject_en is not None or body_en is not None: + default_welcome = get_default_notification_templates().get('onboarding_welcome', {}) + default_subject = (default_welcome.get('subject') or '').strip() + default_body = (default_welcome.get('body') or '').strip() + default_subject_en = (default_welcome.get('subject_en') or '').strip() + default_body_en = (default_welcome.get('body_en') or '').strip() + subject_clean = (subject or '').strip() or default_subject + body_clean = (body or '').strip() or default_body + subject_clean_en = (subject_en or '').strip() or default_subject_en + body_clean_en = (body_en or '').strip() or default_body_en + template, _ = NotificationTemplate.objects.get_or_create( + key='onboarding_welcome', + defaults={ + 'subject_template': subject_clean, + 'body_template': body_clean, + 'subject_template_en': subject_clean_en, + 'body_template_en': body_clean_en, + 'is_active': True, + }, + ) + changes = [] + if template.subject_template != subject_clean: + template.subject_template = subject_clean + changes.append('subject_template') + if template.body_template != body_clean: + template.body_template = body_clean + changes.append('body_template') + if template.subject_template_en != subject_clean_en: + template.subject_template_en = subject_clean_en + changes.append('subject_template_en') + if template.body_template_en != body_clean_en: + template.body_template_en = body_clean_en + changes.append('body_template_en') + if not template.is_active: + template.is_active = True + changes.append('is_active') + if changes: + template.save(update_fields=changes) + + audit_fn( + request, + 'welcome_email_settings_saved', + target_type='welcome_email_settings', + target_label='onboarding_welcome', + details={ + 'delay_days': config.welcome_email_delay_days, + 'sender_email': config.welcome_sender_email, + 'include_pdf': config.welcome_include_pdf, + }, + ) + messages.success(request, 'Welcome-E-Mail Einstellungen wurden gespeichert.') + return redirect('welcome_emails_page') + + +def _revoke_celery_task(task_id: str) -> None: + if not task_id: + return + try: + current_app.control.revoke(task_id, terminate=False) + except Exception: + return + + +def _parse_selected_schedule_ids(raw: str) -> list[int]: + if not raw: + return [] + parsed: list[int] = [] + seen: set[int] = set() + for token in raw.split(','): + token = token.strip() + if not token: + continue + try: + schedule_id = int(token) + except ValueError: + continue + if schedule_id in seen: + continue + seen.add(schedule_id) + parsed.append(schedule_id) + return parsed + + +def bulk_welcome_email_action_impl(request, *, audit_fn, send_task_fn=send_scheduled_welcome_email): + action = (request.POST.get('bulk_action') or '').strip().lower() + selected_ids = _parse_selected_schedule_ids(request.POST.get('selected_ids', '')) + + if action not in {'pause', 'send_now', 'delete'}: + messages.error(request, 'Ungültige Bulk-Aktion.') + return redirect('welcome_emails_page') + + if not selected_ids: + messages.warning(request, 'Keine Welcome-Einträge ausgewählt.') + return redirect('welcome_emails_page') + + rows = list(ScheduledWelcomeEmail.objects.filter(id__in=selected_ids).order_by('id')) + if not rows: + messages.warning(request, 'Keine passenden Welcome-Einträge gefunden.') + return redirect('welcome_emails_page') + + success_count = 0 + skipped_count = 0 + + for scheduled in rows: + if action == 'pause': + if scheduled.status in {'sent', 'cancelled'}: + skipped_count += 1 + continue + _revoke_celery_task(scheduled.celery_task_id) + scheduled.status = 'paused' + scheduled.save(update_fields=['status', 'updated_at']) + success_count += 1 + continue + + if action == 'send_now': + if scheduled.status == 'cancelled': + skipped_count += 1 + continue + async_result = send_task_fn.delay(scheduled.id, True) + scheduled.celery_task_id = async_result.id or scheduled.celery_task_id + scheduled.status = 'scheduled' + scheduled.last_error = '' + scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) + success_count += 1 + continue + + if action == 'delete': + if scheduled.status == 'scheduled': + _revoke_celery_task(scheduled.celery_task_id) + scheduled.delete() + success_count += 1 + + action_label = { + 'pause': 'pausiert', + 'send_now': 'sofort angestoßen', + 'delete': 'gelöscht', + }[action] + if success_count: + audit_fn( + request, + 'welcome_email_bulk_action', + target_type='welcome_email', + target_label=action, + details={'selected_ids': selected_ids, 'success_count': success_count, 'skipped_count': skipped_count}, + ) + messages.success(request, f'{success_count} Welcome-Eintrag/Einträge {action_label}.') + if skipped_count: + messages.warning(request, f'{skipped_count} Eintrag/Einträge wurden übersprungen (Status nicht geeignet).') + return redirect('welcome_emails_page') + + +def pause_welcome_email_impl(request, schedule_id: int, *, audit_fn): + scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() + if not scheduled: + messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') + return redirect('welcome_emails_page') + if scheduled.status in {'sent', 'cancelled'}: + messages.error(request, f'Welcome E-Mail #{schedule_id} kann nicht pausiert werden.') + return redirect('welcome_emails_page') + + _revoke_celery_task(scheduled.celery_task_id) + scheduled.status = 'paused' + scheduled.save(update_fields=['status', 'updated_at']) + audit_fn(request, 'welcome_email_paused', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde pausiert.') + return redirect('welcome_emails_page') + + +def resume_welcome_email_impl(request, schedule_id: int, *, audit_fn, send_task_fn=send_scheduled_welcome_email): + scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() + if not scheduled: + messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') + return redirect('welcome_emails_page') + if scheduled.status != 'paused': + messages.error(request, f'Welcome E-Mail #{schedule_id} ist nicht pausiert.') + return redirect('welcome_emails_page') + + eta = scheduled.send_at if timezone.now() < scheduled.send_at else None + async_result = send_task_fn.apply_async(args=[scheduled.id], eta=eta) + scheduled.celery_task_id = async_result.id or scheduled.celery_task_id + scheduled.status = 'scheduled' + scheduled.last_error = '' + scheduled.save(update_fields=['celery_task_id', 'status', 'last_error', 'updated_at']) + audit_fn(request, 'welcome_email_resumed', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde fortgesetzt.') + return redirect('welcome_emails_page') + + +def cancel_welcome_email_impl(request, schedule_id: int, *, audit_fn): + scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() + if not scheduled: + messages.error(request, f'Geplanter Welcome-Eintrag #{schedule_id} nicht gefunden.') + return redirect('welcome_emails_page') + if scheduled.status == 'sent': + messages.error(request, f'Welcome E-Mail #{schedule_id} wurde bereits gesendet.') + return redirect('welcome_emails_page') + + _revoke_celery_task(scheduled.celery_task_id) + scheduled.status = 'cancelled' + scheduled.last_error = '' + scheduled.save(update_fields=['status', 'last_error', 'updated_at']) + audit_fn(request, 'welcome_email_cancelled', target_type='welcome_email', target_id=scheduled.id, target_label=scheduled.recipient_email) + messages.success(request, f'Welcome E-Mail #{schedule_id} wurde abgebrochen.') + return redirect('welcome_emails_page') diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..5eb8061 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,15 @@ +{$SITE_ADDRESS} { + encode gzip zstd + + handle_path /static/* { + root * /srv/static + file_server + } + + handle_path /media/* { + root * /srv/media + file_server + } + + reverse_proxy web:8000 +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2a8f915 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,72 @@ +services: + caddy: + image: caddy:2.10-alpine + restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env.prod} + ports: + - "${APP_PORT:-8088}:80" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - media_data:/srv/media:ro + - static_data:/srv/static:ro + depends_on: + - web + + web: + build: + context: ./backend + command: sh -c "./entrypoint-web-prod.sh" + restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env.prod} + volumes: + - media_data:/app/media + - static_data:/app/staticfiles + - backup_data:/app/backups + user: "app" + depends_on: + - db + - redis + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz/', timeout=5)\""] + interval: 20s + timeout: 8s + retries: 5 + start_period: 20s + + worker: + build: + context: ./backend + command: sh -c "./entrypoint-worker-prod.sh" + restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env.prod} + volumes: + - media_data:/app/media + - static_data:/app/staticfiles + - backup_data:/app/backups + user: "app" + depends_on: + - db + - redis + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-workdock} + POSTGRES_USER: ${POSTGRES_USER:-workdock} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-workdock} + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + +volumes: + postgres_data: + media_data: + static_data: + backup_data: diff --git a/scripts/deploy_stack.sh b/scripts/deploy_stack.sh new file mode 100755 index 0000000..fa9eb0f --- /dev/null +++ b/scripts/deploy_stack.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env.prod}" +COMPOSE_FILE="${2:-docker-compose.prod.yml}" +HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${APP_PORT:-8088}/healthz/}" +RUN_DJANGO_CHECK="${RUN_DJANGO_CHECK:-1}" +export APP_ENV_FILE="$ENV_FILE" +COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE") + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing env file: $ENV_FILE" >&2 + exit 1 +fi + +"${COMPOSE[@]}" build web worker caddy +"${COMPOSE[@]}" up -d db redis +"${COMPOSE[@]}" run --rm --user root web sh -c "mkdir -p /app/media/pdfs /app/staticfiles /app/backups && chown -R app:app /app/media /app/staticfiles /app/backups" +"${COMPOSE[@]}" run --rm web python manage.py migrate --noinput +"${COMPOSE[@]}" run --rm web python manage.py bootstrap_initial_users +"${COMPOSE[@]}" run --rm web python manage.py collectstatic --noinput + +if [[ "$RUN_DJANGO_CHECK" == "1" ]]; then + "${COMPOSE[@]}" run --rm web python manage.py check +fi + +"${COMPOSE[@]}" up -d web worker caddy + +for i in $(seq 1 30); do + if curl --fail --silent --show-error --max-time 5 "$HEALTH_URL" >/dev/null; then + echo "Deployment healthy: $HEALTH_URL" + exit 0 + fi + sleep 2 +done + +echo "Health check did not become ready in time: $HEALTH_URL" >&2 +exit 1