Merge pull request #1 from Bostame/develop
Merge validated platform and deployment improvements into main
This commit is contained in:
46
.env.dev.example
Normal file
46
.env.dev.example
Normal 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
|
||||
27
.env.example
27
.env.example
@@ -1,15 +1,26 @@
|
||||
DJANGO_SECRET_KEY=change-me
|
||||
DJANGO_DEBUG=1
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
DJANGO_SECURE_COOKIES=0
|
||||
DJANGO_SECURE_SSL_REDIRECT=0
|
||||
SESSION_IDLE_TIMEOUT_SECONDS=1800
|
||||
SENSITIVE_ACTION_REAUTH_SECONDS=1200
|
||||
|
||||
POSTGRES_DB=onoff
|
||||
POSTGRES_USER=onoff
|
||||
POSTGRES_PASSWORD=onoff
|
||||
POSTGRES_DB=workdock
|
||||
POSTGRES_USER=workdock
|
||||
POSTGRES_PASSWORD=workdock
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
CELERY_TASK_ALWAYS_EAGER=0
|
||||
RATE_LIMIT_ENABLED=1
|
||||
RATE_LIMIT_LOGIN_LIMIT=8
|
||||
RATE_LIMIT_LOGIN_WINDOW=300
|
||||
RATE_LIMIT_PASSWORD_RESET_LIMIT=5
|
||||
RATE_LIMIT_PASSWORD_RESET_WINDOW=600
|
||||
RATE_LIMIT_ADMIN_ACTION_LIMIT=20
|
||||
RATE_LIMIT_ADMIN_ACTION_WINDOW=300
|
||||
|
||||
EMAIL_HOST=mailhog
|
||||
EMAIL_PORT=1025
|
||||
@@ -19,11 +30,11 @@ EMAIL_USE_TLS=0
|
||||
EMAIL_USE_SSL=0
|
||||
DEFAULT_FROM_EMAIL=onboarding@example.local
|
||||
TEST_NOTIFICATION_EMAIL=hr@example.local
|
||||
IT_ONBOARDING_NOTIFICATION_EMAIL=it@tub.co
|
||||
GENERAL_INFO_NOTIFICATION_EMAIL=ingo.einacker@tub.co
|
||||
BUSINESS_CARD_NOTIFICATION_EMAIL=kommunikation@tub.co
|
||||
HR_WORKS_NOTIFICATION_EMAIL=dittrich@tub.co
|
||||
KEY_NOTIFICATION_EMAIL=minuth@tub.co
|
||||
IT_ONBOARDING_NOTIFICATION_EMAIL=it@workdock.de
|
||||
GENERAL_INFO_NOTIFICATION_EMAIL=info@workdock.de
|
||||
BUSINESS_CARD_NOTIFICATION_EMAIL=cards@workdock.de
|
||||
HR_WORKS_NOTIFICATION_EMAIL=hr@workdock.de
|
||||
KEY_NOTIFICATION_EMAIL=keys@workdock.de
|
||||
|
||||
NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding
|
||||
NEXTCLOUD_USERNAME=onboarding@example.com
|
||||
|
||||
48
.env.prod.example
Normal file
48
.env.prod.example
Normal 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
48
.env.test.example
Normal 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
|
||||
75
.github/workflows/ci.yml
vendored
75
.github/workflows/ci.yml
vendored
@@ -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
43
.github/workflows/deploy-prod.yml
vendored
Normal 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
46
.github/workflows/deploy-test.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -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
372
DEPLOYMENT.md
Normal 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/`
|
||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
||||
COMPOSE ?= docker compose
|
||||
|
||||
.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify
|
||||
.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify release-validate
|
||||
|
||||
i18n-extract-en:
|
||||
$(COMPOSE) exec -T web django-admin makemessages -l en
|
||||
@@ -21,3 +21,11 @@ backup-create:
|
||||
backup-verify:
|
||||
@if [ -z "$(BACKUP_DIR)" ]; then echo "Usage: make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS"; exit 1; fi
|
||||
./scripts/backup_verify.sh "$(BACKUP_DIR)"
|
||||
|
||||
release-validate:
|
||||
$(COMPOSE) exec -T web python manage.py check
|
||||
$(COMPOSE) exec -T web python manage.py test workflows.tests -v 2
|
||||
$(COMPOSE) exec -T web django-admin compilemessages
|
||||
$(COMPOSE) exec -T web python manage.py collectstatic --noinput
|
||||
$(COMPOSE) exec -T web python manage.py verify_latest_backup --create-if-missing
|
||||
$(COMPOSE) exec -T web python manage.py run_staging_e2e_check --cleanup --email-check none --skip-nextcloud
|
||||
|
||||
270
PRODUCTIZATION_ROADMAP.md
Normal file
270
PRODUCTIZATION_ROADMAP.md
Normal 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
|
||||
21
README.md
21
README.md
@@ -1,6 +1,6 @@
|
||||
# TUBCO Onboarding & Offboarding Portal
|
||||
# Workdock
|
||||
|
||||
This is the standalone dockerized web application for the TUBCO onboarding and offboarding workflow.
|
||||
Workdock is the dockerized business operations platform that powers internal company apps such as onboarding, offboarding, requests, integrations, backups, and future modular workplace tools.
|
||||
|
||||
## Services
|
||||
- `web`: Django app (`http://localhost:8000`)
|
||||
@@ -99,3 +99,20 @@ Verification behavior:
|
||||
- restores the dump into a temporary verification database
|
||||
- extracts media into a temporary directory
|
||||
- checks that the restored DB and media structure are readable
|
||||
|
||||
## Release validation
|
||||
Use one local gate before shipping larger changes:
|
||||
|
||||
- `make release-validate`
|
||||
|
||||
What it runs:
|
||||
- Django system checks
|
||||
- full workflow test suite
|
||||
- translation compile
|
||||
- collectstatic
|
||||
- latest-backup verification
|
||||
- production-like onboarding/offboarding smoke check
|
||||
|
||||
CI mirrors this split in two layers:
|
||||
- fast Python validation
|
||||
- Docker-based release gate with backup verification and smoke workflow checks
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
9
backend/entrypoint-web-prod.sh
Executable 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 -
|
||||
4
backend/entrypoint-worker-prod.sh
Executable file
4
backend/entrypoint-worker-prod.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
exec celery -A config worker -l info
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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">□</span> {{ T.yes }}     <span class="cb">□</span> {{ 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>
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
vertical-align: bottom;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,3 +10,4 @@ pypdf==5.1.0
|
||||
jinja2==3.1.4
|
||||
xhtml2pdf==0.2.16
|
||||
gunicorn==23.0.0
|
||||
qrcode==8.2
|
||||
|
||||
211
backend/workflows/account_views.py
Normal file
211
backend/workflows/account_views.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@@ -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')
|
||||
|
||||
410
backend/workflows/admin_config_views.py
Normal file
410
backend/workflows/admin_config_views.py
Normal 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')
|
||||
109
backend/workflows/admin_section_builders.py
Normal file
109
backend/workflows/admin_section_builders.py
Normal 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
|
||||
145
backend/workflows/admin_user_helpers.py
Normal file
145
backend/workflows/admin_user_helpers.py
Normal 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])
|
||||
433
backend/workflows/app_registry.py
Normal file
433
backend/workflows/app_registry.py
Normal 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
|
||||
@@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
from . import checks # noqa: F401
|
||||
|
||||
@@ -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'
|
||||
|
||||
293
backend/workflows/branding.py
Normal file
293
backend/workflows/branding.py
Normal 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
|
||||
57
backend/workflows/checks.py
Normal file
57
backend/workflows/checks.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
188
backend/workflows/email_workflows.py
Normal file
188
backend/workflows/email_workflows.py
Normal 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'])
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 *
|
||||
|
||||
117
backend/workflows/form_builder_config.py
Normal file
117
backend/workflows/form_builder_config.py
Normal 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__'
|
||||
382
backend/workflows/form_builder_runtime.py
Normal file
382
backend/workflows/form_builder_runtime.py
Normal 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
|
||||
968
backend/workflows/form_builder_views.py
Normal file
968
backend/workflows/form_builder_views.py
Normal 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"§ion_rules_section={active_section_rules_section}"
|
||||
if active_field_rules_section and active_module == 'field-rules':
|
||||
redirect_target += f"&field_rules_section={active_field_rules_section}"
|
||||
if active_conditional_target and active_module == 'conditional-rules':
|
||||
redirect_target += f"&conditional_target={active_conditional_target}"
|
||||
if active_field_texts_section and active_module == 'field-texts':
|
||||
redirect_target += f"&field_texts_section={active_field_texts_section}"
|
||||
if active_custom_fields_section and active_module == 'custom-fields':
|
||||
redirect_target += f"&custom_fields_section={active_custom_fields_section}"
|
||||
return redirect(redirect_target)
|
||||
|
||||
default_names = list(DEFAULT_FIELD_ORDER.get(form_type, []))
|
||||
existing_names = list(
|
||||
OnboardingRequestForm.base_fields.keys()
|
||||
if form_type == 'onboarding'
|
||||
else OffboardingRequestForm.base_fields.keys()
|
||||
)
|
||||
|
||||
for name in existing_names:
|
||||
if name not in default_names:
|
||||
default_names.append(name)
|
||||
|
||||
ensure_form_field_configs(form_type, default_names)
|
||||
section_configs = ensure_form_section_configs(form_type)
|
||||
conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {}
|
||||
section_definitions = get_section_definitions(form_type, include_inactive_custom=True)
|
||||
section_order = [item['key'] for item in section_definitions]
|
||||
section_labels = {item['key']: item['title'] for item in section_definitions}
|
||||
default_page_map = get_default_page_map(form_type)
|
||||
|
||||
configs = list(
|
||||
FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name')
|
||||
)
|
||||
labels = _form_field_labels(form_type)
|
||||
locked = LOCKED_FIELD_RULES.get(form_type, set())
|
||||
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
|
||||
custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key'))
|
||||
custom_section_configs = get_custom_section_configs(form_type, include_inactive=True)
|
||||
|
||||
if form_type == 'onboarding':
|
||||
columns = [
|
||||
{
|
||||
'key': key,
|
||||
'title': section_labels.get(key, key),
|
||||
'items': [],
|
||||
}
|
||||
for key in section_order
|
||||
]
|
||||
column_by_key = {c['key']: c for c in columns}
|
||||
fallback = 'abschluss'
|
||||
for cfg in configs:
|
||||
page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(cfg.field_name, fallback)
|
||||
if page_key not in column_by_key:
|
||||
page_key = fallback
|
||||
column_by_key[page_key]['items'].append(
|
||||
{
|
||||
'field_name': cfg.field_name,
|
||||
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
|
||||
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
|
||||
'label_en': cfg.label_override_en,
|
||||
'is_visible': cfg.is_visible,
|
||||
'is_required': cfg.is_required,
|
||||
'locked': cfg.field_name in locked,
|
||||
'page_key': page_key,
|
||||
'is_custom': False,
|
||||
'sort_order': cfg.sort_order,
|
||||
}
|
||||
)
|
||||
for cfg in custom_field_configs:
|
||||
page_key = cfg.section_key or fallback
|
||||
if page_key not in column_by_key:
|
||||
page_key = fallback
|
||||
column_by_key[page_key]['items'].append(
|
||||
{
|
||||
'field_name': f'custom__{cfg.field_key}',
|
||||
'label': cfg.translated_label(language_code),
|
||||
'label_de': cfg.label,
|
||||
'label_en': cfg.label_en,
|
||||
'is_visible': cfg.is_active,
|
||||
'is_required': cfg.is_required,
|
||||
'locked': False,
|
||||
'page_key': page_key,
|
||||
'is_custom': True,
|
||||
'sort_order': cfg.sort_order,
|
||||
}
|
||||
)
|
||||
for column in columns:
|
||||
column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name']))
|
||||
else:
|
||||
columns = [
|
||||
{
|
||||
'key': key,
|
||||
'title': section_labels.get(key, key),
|
||||
'items': [],
|
||||
}
|
||||
for key in section_order
|
||||
]
|
||||
column_by_key = {c['key']: c for c in columns}
|
||||
fallback = section_order[-1] if section_order else 'all'
|
||||
for cfg in configs:
|
||||
page_key = cfg.page_key or default_page_map.get(cfg.field_name, fallback)
|
||||
if page_key not in column_by_key:
|
||||
page_key = fallback
|
||||
column_by_key[page_key]['items'].append(
|
||||
{
|
||||
'field_name': cfg.field_name,
|
||||
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
|
||||
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
|
||||
'label_en': cfg.label_override_en,
|
||||
'is_visible': cfg.is_visible,
|
||||
'is_required': cfg.is_required,
|
||||
'locked': cfg.field_name in locked,
|
||||
'page_key': page_key,
|
||||
'is_custom': False,
|
||||
'sort_order': cfg.sort_order,
|
||||
}
|
||||
)
|
||||
for cfg in custom_field_configs:
|
||||
page_key = cfg.section_key or fallback
|
||||
if page_key not in column_by_key:
|
||||
page_key = fallback
|
||||
column_by_key[page_key]['items'].append(
|
||||
{
|
||||
'field_name': f'custom__{cfg.field_key}',
|
||||
'label': cfg.translated_label(language_code),
|
||||
'label_de': cfg.label,
|
||||
'label_en': cfg.label_en,
|
||||
'is_visible': cfg.is_active,
|
||||
'is_required': cfg.is_required,
|
||||
'locked': False,
|
||||
'page_key': page_key,
|
||||
'is_custom': True,
|
||||
'sort_order': cfg.sort_order,
|
||||
}
|
||||
)
|
||||
for column in columns:
|
||||
column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name']))
|
||||
|
||||
section_rule_items = []
|
||||
if section_order:
|
||||
if active_structure_section not in section_order:
|
||||
active_structure_section = section_order[0]
|
||||
fallback_section = section_order[-1] if section_order else ''
|
||||
custom_section_map = {cfg.section_key: cfg for cfg in custom_section_configs}
|
||||
for key in section_order:
|
||||
cfg = section_configs.get(key)
|
||||
custom_cfg = custom_section_map.get(key)
|
||||
is_custom = custom_cfg is not None
|
||||
raw_title = str(section_labels.get(key, key))
|
||||
display_title = re.sub(r'^\d+\.\s*', '', raw_title) if not is_custom else raw_title
|
||||
section_rule_items.append(
|
||||
{
|
||||
'key': key,
|
||||
'title': raw_title,
|
||||
'display_title': display_title,
|
||||
'is_visible': bool(custom_cfg.is_active) if is_custom else (True if not cfg else cfg.is_visible),
|
||||
'locked': False if is_custom else (key in locked_sections and not can_override_locked_builder_rules),
|
||||
'is_custom': is_custom,
|
||||
'sort_order': custom_cfg.sort_order if is_custom else (cfg.sort_order if cfg else 0),
|
||||
'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]) + len([c for c in custom_field_configs if c.section_key == key]),
|
||||
}
|
||||
)
|
||||
section_rule_keys = [item['key'] for item in section_rule_items]
|
||||
if section_rule_keys and active_section_rules_section not in section_rule_keys:
|
||||
active_section_rules_section = section_rule_keys[0]
|
||||
|
||||
field_rule_items = []
|
||||
for cfg in configs:
|
||||
page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '')
|
||||
field_rule_items.append(
|
||||
{
|
||||
'id': cfg.id,
|
||||
'field_name': cfg.field_name,
|
||||
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
|
||||
'page_key': page_key,
|
||||
'page_label': section_labels.get(page_key, page_key) if section_order else '',
|
||||
'is_visible': cfg.is_visible,
|
||||
'is_required': cfg.is_required,
|
||||
'locked': cfg.field_name in locked and not can_override_locked_builder_rules,
|
||||
'summary': _field_rule_summary(
|
||||
is_visible=cfg.is_visible,
|
||||
is_required=cfg.is_required,
|
||||
locked=cfg.field_name in locked and not can_override_locked_builder_rules,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
custom_field_groups = []
|
||||
if section_order:
|
||||
grouped_custom = {key: [] for key in section_order}
|
||||
for cfg in custom_field_configs:
|
||||
grouped_custom.setdefault(cfg.section_key, []).append(cfg)
|
||||
for key in section_order:
|
||||
custom_field_groups.append(
|
||||
{
|
||||
'key': key,
|
||||
'title': section_labels.get(key, key),
|
||||
'items': grouped_custom.get(key, []),
|
||||
}
|
||||
)
|
||||
custom_section_field_counts: dict[str, int] = {}
|
||||
for cfg in custom_field_configs:
|
||||
custom_section_field_counts[cfg.section_key] = custom_section_field_counts.get(cfg.section_key, 0) + 1
|
||||
for cfg in custom_section_configs:
|
||||
cfg.custom_field_count = custom_section_field_counts.get(cfg.section_key, 0)
|
||||
|
||||
field_rule_groups = []
|
||||
if section_order:
|
||||
grouped_rules = {key: [] for key in section_order}
|
||||
for item in field_rule_items:
|
||||
grouped_rules.setdefault(item['page_key'], []).append(item)
|
||||
for key in section_order:
|
||||
field_rule_groups.append(
|
||||
{
|
||||
'key': key,
|
||||
'title': section_labels.get(key, key),
|
||||
'items': grouped_rules.get(key, []),
|
||||
}
|
||||
)
|
||||
field_rule_group_keys = [group['key'] for group in field_rule_groups]
|
||||
if field_rule_group_keys and active_field_rules_section not in field_rule_group_keys:
|
||||
active_field_rules_section = field_rule_group_keys[0]
|
||||
|
||||
field_text_groups = []
|
||||
if section_order:
|
||||
grouped_texts = {key: [] for key in section_order}
|
||||
for cfg in configs:
|
||||
page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '')
|
||||
grouped_texts.setdefault(page_key, []).append(cfg)
|
||||
for key in section_order:
|
||||
field_text_groups.append(
|
||||
{
|
||||
'key': key,
|
||||
'title': section_labels.get(key, key),
|
||||
'items': grouped_texts.get(key, []),
|
||||
}
|
||||
)
|
||||
field_text_group_keys = [group['key'] for group in field_text_groups]
|
||||
if field_text_group_keys and active_field_texts_section not in field_text_group_keys:
|
||||
active_field_texts_section = field_text_group_keys[0]
|
||||
custom_field_group_keys = [group['key'] for group in custom_field_groups]
|
||||
if custom_field_group_keys and active_custom_fields_section not in custom_field_group_keys:
|
||||
active_custom_fields_section = custom_field_group_keys[0]
|
||||
|
||||
conditional_rule_items = []
|
||||
if form_type == 'onboarding':
|
||||
conditional_field_choices = []
|
||||
for field_name in [
|
||||
'order_business_cards',
|
||||
'employment_type',
|
||||
'group_mailboxes_required_choice',
|
||||
'additional_hardware_needed_choice',
|
||||
'additional_software_needed_choice',
|
||||
'additional_access_needed_choice',
|
||||
'successor_required_choice',
|
||||
'inherit_phone_number_choice',
|
||||
]:
|
||||
conditional_field_choices.append((field_name, labels.get(field_name, field_name)))
|
||||
for cfg in custom_field_configs:
|
||||
conditional_field_choices.append((f'custom__{cfg.field_key}', cfg.translated_label(language_code)))
|
||||
conditional_field_label_map = {value: label for value, label in conditional_field_choices}
|
||||
conditional_target_titles = {
|
||||
'business-card-box': _('Visitenkarten-Details'),
|
||||
'employment-end-box': _('Vertragsende'),
|
||||
'group-mailboxes-box': _('Gruppenpostfächer'),
|
||||
'extra-hardware-box': _('Zusätzliche Hardware'),
|
||||
'extra-software-box': _('Zusätzliche Software'),
|
||||
'extra-access-box': _('Zusätzliche Zugänge'),
|
||||
'successor-box': _('Nachfolge'),
|
||||
}
|
||||
conditional_target_descriptions = {
|
||||
'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'),
|
||||
'employment-end-box': _('Steuert das Enddatum bei befristeter Beschäftigung.'),
|
||||
'group-mailboxes-box': _('Steuert das Freitextfeld für Gruppenpostfächer.'),
|
||||
'extra-hardware-box': _('Steuert zusätzliche Hardware-Felder.'),
|
||||
'extra-software-box': _('Steuert zusätzliche Software-Felder.'),
|
||||
'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'),
|
||||
'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'),
|
||||
}
|
||||
for target_key, cfg in conditional_rule_configs.items():
|
||||
clauses = list(cfg.clauses or [])
|
||||
while len(clauses) < 2:
|
||||
clauses.append({'field': '', 'operator': 'equals', 'value': ''})
|
||||
if target_key.startswith('custom__'):
|
||||
custom_field_key = target_key.replace('custom__', '', 1)
|
||||
custom_field = next((item for item in custom_field_configs if item.field_key == custom_field_key), None)
|
||||
target_title = custom_field.translated_label(language_code) if custom_field else target_key
|
||||
target_description = _('Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes.')
|
||||
target_fields = [target_title]
|
||||
else:
|
||||
target_title = conditional_target_titles.get(target_key, target_key)
|
||||
target_description = conditional_target_descriptions.get(target_key, '')
|
||||
target_fields = [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])]
|
||||
conditional_rule_items.append(
|
||||
{
|
||||
'target_key': target_key,
|
||||
'title': target_title,
|
||||
'description': target_description,
|
||||
'is_active': cfg.is_active,
|
||||
'clauses': clauses[:2],
|
||||
'summary': _conditional_rule_summary(clauses[:2], conditional_field_label_map),
|
||||
'field_choices': conditional_field_choices,
|
||||
'operator_choices': CONDITIONAL_RULE_OPERATOR_CHOICES,
|
||||
'target_fields': target_fields,
|
||||
}
|
||||
)
|
||||
conditional_rule_keys = [item['target_key'] for item in conditional_rule_items]
|
||||
if conditional_rule_keys and active_conditional_target not in conditional_rule_keys:
|
||||
active_conditional_target = conditional_rule_keys[0]
|
||||
|
||||
preview_sections = []
|
||||
if section_order:
|
||||
field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups}
|
||||
for key in section_order:
|
||||
section_cfg = section_configs.get(key)
|
||||
section_locked = key in locked_sections
|
||||
custom_section = next((cfg for cfg in custom_section_configs if cfg.section_key == key), None)
|
||||
section_visible = bool(custom_section.is_active) if custom_section else (True if section_locked or not section_cfg else section_cfg.is_visible)
|
||||
visible_items = [
|
||||
item for item in field_rule_group_map.get(key, [])
|
||||
if item['locked'] or item['is_visible']
|
||||
]
|
||||
visible_items.extend(
|
||||
[
|
||||
{
|
||||
'label': cfg.translated_label(language_code),
|
||||
'locked': False,
|
||||
}
|
||||
for cfg in custom_field_configs
|
||||
if cfg.section_key == key and cfg.is_active
|
||||
]
|
||||
)
|
||||
if section_visible:
|
||||
preview_sections.append(
|
||||
{
|
||||
'key': key,
|
||||
'title': section_labels.get(key, key),
|
||||
'items': visible_items,
|
||||
}
|
||||
)
|
||||
|
||||
locked_field_count = len([item for item in field_rule_items if item['locked']])
|
||||
hidden_field_count = len([item for item in field_rule_items if not item['is_visible']])
|
||||
configurable_field_count = len(field_rule_items) - locked_field_count
|
||||
hidden_section_count = len([item for item in section_rule_items if not item['is_visible']]) if section_rule_items else 0
|
||||
builder_summary = {
|
||||
'locked_field_count': locked_field_count,
|
||||
'configurable_field_count': configurable_field_count,
|
||||
'hidden_field_count': hidden_field_count,
|
||||
'hidden_section_count': hidden_section_count,
|
||||
'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]),
|
||||
'custom_section_count': len([cfg for cfg in custom_section_configs if cfg.is_active]),
|
||||
}
|
||||
module_labels = {
|
||||
'structure': _('Struktur & Reihenfolge'),
|
||||
'section-rules': _('Abschnitte'),
|
||||
'field-rules': _('Feldregeln'),
|
||||
'conditional-rules': _('Bedingte Logik'),
|
||||
'options': _('Optionen'),
|
||||
'field-texts': _('Feldtexte'),
|
||||
'custom-sections': _('Eigene Abschnitte'),
|
||||
'custom-fields': _('Eigene Felder'),
|
||||
'preview': _('Vorschau'),
|
||||
}
|
||||
option_category_labels = dict(_translate_choice_list(FormOption.CATEGORY_CHOICES))
|
||||
form_type_labels = {
|
||||
'onboarding': _('Onboarding'),
|
||||
'offboarding': _('Offboarding'),
|
||||
}
|
||||
active_focus_label = ''
|
||||
if active_module == 'structure' and active_structure_section:
|
||||
active_focus_label = section_labels.get(active_structure_section, active_structure_section)
|
||||
elif active_module == 'section-rules' and section_rule_items:
|
||||
active_focus_label = _('Alle Abschnitte')
|
||||
elif active_module == 'field-rules' and active_field_rules_section:
|
||||
active_focus_label = section_labels.get(active_field_rules_section, active_field_rules_section)
|
||||
elif active_module == 'conditional-rules' and active_conditional_target:
|
||||
active_focus_label = next((item['title'] for item in conditional_rule_items if item['target_key'] == active_conditional_target), active_conditional_target)
|
||||
elif active_module == 'options':
|
||||
active_focus_label = option_category_labels.get(option_category, option_category)
|
||||
elif active_module == 'field-texts' and active_field_texts_section:
|
||||
active_focus_label = section_labels.get(active_field_texts_section, active_field_texts_section)
|
||||
elif active_module == 'custom-sections':
|
||||
active_focus_label = _('Onboarding')
|
||||
elif active_module == 'custom-fields' and active_custom_fields_section:
|
||||
active_focus_label = section_labels.get(active_custom_fields_section, active_custom_fields_section)
|
||||
elif active_module == 'preview':
|
||||
active_focus_label = _('Live-Vorschau')
|
||||
|
||||
return render(
|
||||
request,
|
||||
'workflows/form_builder.html',
|
||||
{
|
||||
'form_type': form_type,
|
||||
'columns': columns,
|
||||
'form_types': [('onboarding', _('Onboarding')), ('offboarding', _('Offboarding'))],
|
||||
'option_categories': _translate_choice_list(FormOption.CATEGORY_CHOICES),
|
||||
'selected_option_category': option_category,
|
||||
'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'),
|
||||
'field_text_items': configs,
|
||||
'field_rule_items': field_rule_items,
|
||||
'field_rule_groups': field_rule_groups,
|
||||
'field_text_groups': field_text_groups,
|
||||
'preview_sections': preview_sections,
|
||||
'section_rule_items': section_rule_items,
|
||||
'builder_summary': builder_summary,
|
||||
'conditional_rule_items': conditional_rule_items,
|
||||
'custom_field_groups': custom_field_groups,
|
||||
'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES),
|
||||
'custom_section_items': custom_section_configs,
|
||||
'active_panel': active_panel,
|
||||
'active_subpanel': active_subpanel,
|
||||
'active_rules_panel': active_rules_panel,
|
||||
'active_module': active_module,
|
||||
'active_form_type_label': form_type_labels.get(form_type, form_type),
|
||||
'active_module_label': module_labels.get(active_module, active_module),
|
||||
'active_focus_label': active_focus_label,
|
||||
'active_structure_section': active_structure_section,
|
||||
'active_field_rules_section': active_field_rules_section,
|
||||
'active_field_texts_section': active_field_texts_section,
|
||||
'active_custom_fields_section': active_custom_fields_section,
|
||||
'active_section_rules_section': active_section_rules_section,
|
||||
'active_conditional_target': active_conditional_target,
|
||||
'available_presets': FORM_PRESETS.get(form_type, {}),
|
||||
'can_override_locked_builder_rules': can_override_locked_builder_rules,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def form_builder_save_order_impl(request, *, audit_fn):
|
||||
try:
|
||||
payload = json.loads(request.body.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return JsonResponse({'ok': False, 'error': _('Ungültige JSON-Daten.')}, status=400)
|
||||
|
||||
form_type = payload.get('form_type')
|
||||
if form_type not in DEFAULT_FIELD_ORDER:
|
||||
return JsonResponse({'ok': False, 'error': _('Ungültiger Formulartyp.')}, status=400)
|
||||
default_page_map = get_default_page_map(form_type)
|
||||
|
||||
columns = payload.get('columns')
|
||||
if not isinstance(columns, dict):
|
||||
return JsonResponse({'ok': False, 'error': _('Spalten-Daten fehlen.')}, status=400)
|
||||
|
||||
configs = list(FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name'))
|
||||
custom_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_key'))
|
||||
allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs}
|
||||
seen = set()
|
||||
|
||||
allowed_columns = get_section_order(form_type)
|
||||
fallback_section = allowed_columns[-1] if allowed_columns else ''
|
||||
|
||||
name_to_cfg = {cfg.field_name: cfg for cfg in configs}
|
||||
custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs}
|
||||
sort_order = 0
|
||||
|
||||
for column_key in allowed_columns:
|
||||
names = columns.get(column_key, [])
|
||||
if not isinstance(names, list):
|
||||
return JsonResponse({'ok': False, 'error': _('Ungültige Spalte: %(column)s') % {'column': column_key}}, status=400)
|
||||
|
||||
for name in names:
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
if name not in allowed_names or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
if name in name_to_cfg:
|
||||
cfg = name_to_cfg[name]
|
||||
cfg.sort_order = sort_order
|
||||
cfg.page_key = column_key
|
||||
else:
|
||||
cfg = custom_name_to_cfg[name]
|
||||
cfg.sort_order = sort_order
|
||||
cfg.section_key = column_key
|
||||
sort_order += 1
|
||||
|
||||
missing = [cfg.field_name for cfg in configs if cfg.field_name not in seen]
|
||||
for name in missing:
|
||||
cfg = name_to_cfg[name]
|
||||
cfg.sort_order = sort_order
|
||||
sort_order += 1
|
||||
cfg.page_key = cfg.page_key or default_page_map.get(name, fallback_section)
|
||||
|
||||
missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen]
|
||||
for name in missing_custom:
|
||||
cfg = custom_name_to_cfg[name]
|
||||
cfg.sort_order = sort_order
|
||||
sort_order += 1
|
||||
cfg.section_key = cfg.section_key or fallback_section
|
||||
|
||||
FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key'])
|
||||
if custom_configs:
|
||||
FormCustomFieldConfig.objects.bulk_update(custom_configs, ['sort_order', 'section_key'])
|
||||
saved_count = len(configs) + len(custom_configs)
|
||||
audit_fn(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': saved_count})
|
||||
return JsonResponse({'ok': True, 'saved_count': saved_count})
|
||||
@@ -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
|
||||
|
||||
459
backend/workflows/integration_admin_views.py
Normal file
459
backend/workflows/integration_admin_views.py
Normal 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')
|
||||
45
backend/workflows/integrations_views.py
Normal file
45
backend/workflows/integrations_views.py
Normal 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',
|
||||
]
|
||||
134
backend/workflows/intro_builder_views.py
Normal file
134
backend/workflows/intro_builder_views.py
Normal 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,
|
||||
},
|
||||
)
|
||||
54
backend/workflows/logging_utils.py
Normal file
54
backend/workflows/logging_utils.py
Normal 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)
|
||||
@@ -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'))
|
||||
|
||||
@@ -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}')
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
207
backend/workflows/middleware.py
Normal file
207
backend/workflows/middleware.py
Normal 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)
|
||||
33
backend/workflows/migrations/0036_portalbranding.py
Normal file
33
backend/workflows/migrations/0036_portalbranding.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'])]),
|
||||
),
|
||||
]
|
||||
35
backend/workflows/migrations/0038_portalappconfig.py
Normal file
35
backend/workflows/migrations/0038_portalappconfig.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
39
backend/workflows/migrations/0042_portalcompanyconfig.py
Normal file
39
backend/workflows/migrations/0042_portalcompanyconfig.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
33
backend/workflows/migrations/0043_portaltrialconfig.py
Normal file
33
backend/workflows/migrations/0043_portaltrialconfig.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
33
backend/workflows/migrations/0044_asynctasklog.py
Normal file
33
backend/workflows/migrations/0044_asynctasklog.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
42
backend/workflows/migrations/0048_userprofile.py
Normal file
42
backend/workflows/migrations/0048_userprofile.py
Normal 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),
|
||||
]
|
||||
26
backend/workflows/migrations/0049_userprofile_totp_fields.py
Normal file
26
backend/workflows/migrations/0049_userprofile_totp_fields.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
31
backend/workflows/migrations/0051_usernotification.py
Normal file
31
backend/workflows/migrations/0051_usernotification.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
37
backend/workflows/migrations/0053_formsectionconfig.py
Normal file
37
backend/workflows/migrations/0053_formsectionconfig.py
Normal 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),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
152
backend/workflows/model_account.py
Normal file
152
backend/workflows/model_account.py
Normal 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'])
|
||||
202
backend/workflows/model_forms.py
Normal file
202
backend/workflows/model_forms.py
Normal 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
|
||||
89
backend/workflows/model_notifications.py
Normal file
89
backend/workflows/model_notifications.py
Normal 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()
|
||||
49
backend/workflows/model_ops.py
Normal file
49
backend/workflows/model_ops.py
Normal 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}'
|
||||
129
backend/workflows/model_portal.py
Normal file
129
backend/workflows/model_portal.py
Normal 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)
|
||||
172
backend/workflows/model_requests.py
Normal file
172
backend/workflows/model_requests.py
Normal 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)
|
||||
14
backend/workflows/model_shared.py
Normal file
14
backend/workflows/model_shared.py
Normal 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')),
|
||||
]
|
||||
62
backend/workflows/model_system.py
Normal file
62
backend/workflows/model_system.py
Normal 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})'
|
||||
@@ -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 *
|
||||
|
||||
29
backend/workflows/notification_dispatch.py
Normal file
29
backend/workflows/notification_dispatch.py
Normal 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,
|
||||
)
|
||||
35
backend/workflows/notifications.py
Normal file
35
backend/workflows/notifications.py
Normal 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)
|
||||
159
backend/workflows/observability_views.py
Normal file
159
backend/workflows/observability_views.py
Normal 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')
|
||||
810
backend/workflows/pdf_rendering.py
Normal file
810
backend/workflows/pdf_rendering.py
Normal 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
|
||||
311
backend/workflows/pdf_sections.py
Normal file
311
backend/workflows/pdf_sections.py
Normal 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
|
||||
650
backend/workflows/request_views.py
Normal file
650
backend/workflows/request_views.py
Normal 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')
|
||||
@@ -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'),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
812
backend/workflows/static/workflows/css/account.css
Normal file
812
backend/workflows/static/workflows/css/account.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
602
backend/workflows/static/workflows/css/design_system.css
Normal file
602
backend/workflows/static/workflows/css/design_system.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user