Merge pull request #1 from Bostame/develop

Merge validated platform and deployment improvements into main
This commit is contained in:
Bostame Md Bayazid
2026-03-28 23:32:59 +01:00
committed by GitHub
170 changed files with 25464 additions and 5345 deletions

46
.env.dev.example Normal file
View File

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

View File

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

48
.env.prod.example Normal file
View File

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

48
.env.test.example Normal file
View File

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

View File

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

43
.github/workflows/deploy-prod.yml vendored Normal file
View File

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

46
.github/workflows/deploy-test.yml vendored Normal file
View File

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

3
.gitignore vendored
View File

@@ -6,6 +6,9 @@ __pycache__/
.env
.env.*
!.env.example
!.env.dev.example
!.env.test.example
!.env.prod.example
.venv/
venv/
db.sqlite3

372
DEPLOYMENT.md Normal file
View File

@@ -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/<CTID>.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 <CTID>
```
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=<private key that can ssh to root@192.168.2.55>`
### 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
<paste the full private SSH key that can log in to root@192.168.2.55>
```
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/`

View File

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

270
PRODUCTIZATION_ROADMAP.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

9
backend/entrypoint-web-prod.sh Executable file
View File

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

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec celery -A config worker -l info

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ T.lang or 'de' }}">
<html lang="{{ PDF_LANG or T.lang or 'de' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -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%;
}
</style>
</head>
<body>
@@ -158,25 +144,26 @@
<h1 class="title">{{ T.offboarding_title }}</h1>
</div>
<div class="section">{{ T.employee_info }}</div>
<table>
<tr>
<th>{{ T.name }}</th>
<td class="mono">{{ FULL_NAME }}</td>
<th>{{ T.email }}</th>
<td>{{ EMAIL }}</td>
</tr>
<tr>
<th>{{ T.department }}</th>
<td>{{ DEPARTMENT }}</td>
<th>{{ T.job_title }}</th>
<td>{{ JOB_TITLE }}</td>
</tr>
<tr>
<th>{{ T.last_working_day }}</th>
<td colspan="3">{{ LAST_WORKING_DAY }}</td>
</tr>
</table>
{% for section in PDF_SECTIONS %}
{% if section.has_content %}
<div class="section">{{ section.title }}</div>
{% if section.scalar_rows %}
<table>
{% for row in section.scalar_rows %}
<tr>
<th>{{ row[0].label }}</th>
<td{% if not row[1] %} colspan="3"{% endif %}{% if row[0].name in ['full_name', 'work_email'] %} class="mono"{% endif %}>{{ row[0].display_value }}</td>
{% if row[1] %}
<th>{{ row[1].label }}</th>
<td>{{ row[1].display_value }}</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% endif %}
{% endif %}
{% endfor %}
<div class="section">{{ T.offboarding_requester }}</div>
<table>
@@ -294,14 +281,6 @@
<div class="sigline">{{ T.return_complete }} <span class="cb">&#9633;</span>&#160;{{ T.yes }} &#160;&#160;&#160; <span class="cb">&#9633;</span>&#160;{{ T.no }}</div>
</div>
<div class="section">{{ T.notes }}</div>
<table>
<tr>
<th>{{ T.notes }}</th>
<td>{{ NOTES }}</td>
</tr>
</table>
<p class="small">{{ T.offboarding_note }}</p>
</body>
</html>

View File

@@ -134,6 +134,7 @@
vertical-align: bottom;
margin: 0 6px;
}
</style>
</head>
<body>

View File

@@ -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 @@
<h1 class="title">{{ T.onboarding_title }}</h1>
</div>
<div class="section">{{ T.onboarding_staff_data }}</div>
<div class="section">{% if PDF_LANG == 'en' %}Master data{% else %}Stammdaten{% endif %}</div>
<div class="opt-card">
<div class="opt-title">{{ T.onboarding_staff_data }}</div>
<table>
<tr>
<th>{{ T.name }}</th>
@@ -161,8 +152,10 @@
<td colspan="3">{{ UEBERGABEDATUM }}</td>
</tr>
</table>
</div>
<div class="section">{{ T.equipment_access }}</div>
{% 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 %}
<div class="section">{% if PDF_LANG == 'en' %}IT setup{% else %}IT-Setup{% endif %}</div>
{% if HAS_DEVICES %}
<div class="opt-card">
@@ -307,6 +300,7 @@
</table>
</div>
{% endif %}
{% endif %}
{% if (VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN) or HAS_ADDITIONAL_HARDWARE_OTHER or HAS_SUCCESSOR_INFO or HAS_ADDITIONAL_NOTES %}
<div class="section">{{ T.additional_details }}</div>

View File

@@ -10,3 +10,4 @@ pypdf==5.1.0
jinja2==3.1.4
xhtml2pdf==0.2.16
gunicorn==23.0.0
qrcode==8.2

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig):
def ready(self):
from . import signals # noqa: F401
from . import checks # noqa: F401

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"&section_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})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = (
'<style>'
'@page { size: A4; margin: 58mm 16mm 16mm 16mm; }'
'body { margin: 0; }'
'</style>'
)
if '<head>' in html_content:
html_content = html_content.replace('<head>', f'<head>{page_style}', 1)
else:
html_content = page_style + html_content
output_pdf.parent.mkdir(parents=True, exist_ok=True)
with output_pdf.open('wb') as fp:
result = pisa.CreatePDF(
src=html_content,
dest=fp,
encoding='utf-8',
)
if result.err:
raise RuntimeError(f'Failed to render PDF content for {output_pdf.name}')
def _overlay_with_letterhead(content_pdf: Path, letterhead_pdf: Path, output_pdf: Path) -> None:
letterhead_reader = PdfReader(str(letterhead_pdf))
content_reader = PdfReader(str(content_pdf))
writer = PdfWriter()
letterhead_page = letterhead_reader.pages[0]
for page in content_reader.pages:
merged = PageObject.create_blank_page(
width=letterhead_page.mediabox.width,
height=letterhead_page.mediabox.height,
)
merged.merge_page(letterhead_page)
merged.merge_page(page)
writer.add_page(merged)
with output_pdf.open('wb') as fp:
writer.write(fp)
def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More