From 8553482dddca9f291dd03258444327c9ad018b2f Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 00:28:34 +0100 Subject: [PATCH] snapshot: preserve reliability hardening and Workdock identity pass --- .env.example | 10 +- .github/workflows/ci.yml | 75 +- Makefile | 10 +- PRODUCTIZATION_ROADMAP.md | 2 +- README.md | 21 +- backend/config/settings.py | 60 +- backend/locale/en/LC_MESSAGES/django.mo | Bin 35206 -> 35017 bytes backend/locale/en/LC_MESSAGES/django.po | 677 +++++++++++------- backend/workflows/admin.py | 10 +- backend/workflows/app_registry.py | 15 + backend/workflows/backup_ops.py | 52 +- backend/workflows/branding.py | 22 +- backend/workflows/logging_utils.py | 54 ++ .../commands/bootstrap_initial_users.py | 4 +- .../commands/run_staging_e2e_check.py | 6 +- .../commands/verify_latest_backup.py | 30 + backend/workflows/middleware.py | 25 + .../workflows/migrations/0044_asynctasklog.py | 33 + ..._portalbranding_company_domain_and_more.py | 48 ++ backend/workflows/models.py | 40 +- backend/workflows/roles.py | 2 + backend/workflows/services.py | 16 +- .../static/workflows/js/offboarding_form.js | 2 +- .../static/workflows/js/onboarding_form.js | 2 +- backend/workflows/tasks.py | 54 +- .../templates/workflows/backup_recovery.html | 32 + .../workflows/branding_settings.html | 2 +- .../workflows/developer_handbook.html | 4 + .../templates/workflows/job_monitor.html | 73 ++ .../templates/workflows/project_wiki.html | 4 + .../workflows/release_checklist.html | 8 +- .../tests/test_app_registry_permissions.py | 50 ++ .../tests/test_async_task_logging.py | 52 ++ .../tests/test_backup_reliability.py | 67 ++ .../workflows/tests/test_nextcloud_service.py | 8 +- .../tests/test_request_id_logging.py | 31 + .../workflows/tests/test_trial_lifecycle.py | 73 ++ backend/workflows/urls.py | 1 + backend/workflows/views.py | 38 +- 39 files changed, 1393 insertions(+), 320 deletions(-) create mode 100644 backend/workflows/logging_utils.py create mode 100644 backend/workflows/management/commands/verify_latest_backup.py create mode 100644 backend/workflows/migrations/0044_asynctasklog.py create mode 100644 backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py create mode 100644 backend/workflows/templates/workflows/job_monitor.html create mode 100644 backend/workflows/tests/test_app_registry_permissions.py create mode 100644 backend/workflows/tests/test_async_task_logging.py create mode 100644 backend/workflows/tests/test_backup_reliability.py create mode 100644 backend/workflows/tests/test_request_id_logging.py create mode 100644 backend/workflows/tests/test_trial_lifecycle.py diff --git a/.env.example b/.env.example index 2b7df3c..5566960 100644 --- a/.env.example +++ b/.env.example @@ -19,11 +19,11 @@ EMAIL_USE_TLS=0 EMAIL_USE_SSL=0 DEFAULT_FROM_EMAIL=onboarding@example.local TEST_NOTIFICATION_EMAIL=hr@example.local -IT_ONBOARDING_NOTIFICATION_EMAIL=it@tub.co -GENERAL_INFO_NOTIFICATION_EMAIL=ingo.einacker@tub.co -BUSINESS_CARD_NOTIFICATION_EMAIL=kommunikation@tub.co -HR_WORKS_NOTIFICATION_EMAIL=dittrich@tub.co -KEY_NOTIFICATION_EMAIL=minuth@tub.co +IT_ONBOARDING_NOTIFICATION_EMAIL=it@workdock.de +GENERAL_INFO_NOTIFICATION_EMAIL=info@workdock.de +BUSINESS_CARD_NOTIFICATION_EMAIL=cards@workdock.de +HR_WORKS_NOTIFICATION_EMAIL=hr@workdock.de +KEY_NOTIFICATION_EMAIL=keys@workdock.de NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding NEXTCLOUD_USERNAME=onboarding@example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8d6a92..cb08aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,12 @@ on: push: pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - django-tests: + python-validation: runs-on: ubuntu-latest services: @@ -59,11 +63,80 @@ jobs: - name: Install dependencies run: pip install -r requirements.txt + - name: Install gettext + run: | + sudo apt-get update + sudo apt-get install -y gettext + - name: Django system check run: python manage.py check - name: Migration drift check run: python manage.py makemigrations --check --dry-run + - name: Compile translations + run: django-admin compilemessages + + - name: Collect static assets + run: python manage.py collectstatic --noinput + - name: Run tests run: python manage.py test workflows.tests -v 2 + + docker-release-gate: + runs-on: ubuntu-latest + needs: python-validation + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare environment file + run: cp .env.example .env + + - name: Build and start stack + run: docker compose up -d --build db redis mailhog web worker + + - name: Wait for web health + run: | + for i in $(seq 1 30); do + if curl --fail --silent --show-error --max-time 5 http://127.0.0.1:8088/healthz/ >/dev/null; then + exit 0 + fi + sleep 2 + done + echo "web health check did not become ready in time" >&2 + exit 1 + + - name: Django system check in container + run: docker compose exec -T web python manage.py check + + - name: Backup verification gate + run: docker compose exec -T web python manage.py verify_latest_backup --create-if-missing + + - name: Staging smoke gate + run: docker compose exec -T web python manage.py run_staging_e2e_check --cleanup --email-check none --skip-nextcloud + + - name: Upload generated PDFs + if: always() + uses: actions/upload-artifact@v4 + with: + name: staging-pdfs + path: backend/media/pdfs/ + if-no-files-found: ignore + + - name: Upload docker logs on failure + if: failure() + run: docker compose logs --no-color web worker db redis mailhog > docker-compose-ci.log + + - name: Publish docker logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: docker-compose-ci-logs + path: docker-compose-ci.log + if-no-files-found: ignore + + - name: Stop stack + if: always() + run: docker compose down -v diff --git a/Makefile b/Makefile index 878034a..8a88937 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ COMPOSE ?= docker compose -.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify +.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify release-validate i18n-extract-en: $(COMPOSE) exec -T web django-admin makemessages -l en @@ -21,3 +21,11 @@ backup-create: backup-verify: @if [ -z "$(BACKUP_DIR)" ]; then echo "Usage: make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS"; exit 1; fi ./scripts/backup_verify.sh "$(BACKUP_DIR)" + +release-validate: + $(COMPOSE) exec -T web python manage.py check + $(COMPOSE) exec -T web python manage.py test workflows.tests -v 2 + $(COMPOSE) exec -T web django-admin compilemessages + $(COMPOSE) exec -T web python manage.py collectstatic --noinput + $(COMPOSE) exec -T web python manage.py verify_latest_backup --create-if-missing + $(COMPOSE) exec -T web python manage.py run_staging_e2e_check --cleanup --email-check none --skip-nextcloud diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md index ec9f3c2..2a1e29b 100644 --- a/PRODUCTIZATION_ROADMAP.md +++ b/PRODUCTIZATION_ROADMAP.md @@ -2,7 +2,7 @@ ## Goal -Turn the current TUBCO-specific onboarding/offboarding portal into a reusable company portal product while preserving the current TUBCO deployment as a stable customer-specific baseline. +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: diff --git a/README.md b/README.md index 8730ed8..c2377f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# TUBCO Onboarding & Offboarding Portal +# Workdock -This is the standalone dockerized web application for the TUBCO onboarding and offboarding workflow. +Workdock is the dockerized business operations platform that powers internal company apps such as onboarding, offboarding, requests, integrations, backups, and future modular workplace tools. ## Services - `web`: Django app (`http://localhost:8000`) @@ -99,3 +99,20 @@ Verification behavior: - restores the dump into a temporary verification database - extracts media into a temporary directory - checks that the restored DB and media structure are readable + +## Release validation +Use one local gate before shipping larger changes: + +- `make release-validate` + +What it runs: +- Django system checks +- full workflow test suite +- translation compile +- collectstatic +- latest-backup verification +- production-like onboarding/offboarding smoke check + +CI mirrors this split in two layers: +- fast Python validation +- Docker-based release gate with backup verification and smoke workflow checks diff --git a/backend/config/settings.py b/backend/config/settings.py index 0b714f5..cfc5d11 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -40,6 +40,7 @@ MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', + 'workflows.middleware.RequestIDMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', @@ -116,11 +117,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) @@ -149,3 +150,52 @@ SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20')) NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS = int(os.getenv('NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', '30')) NEXTCLOUD_UPLOAD_RETRIES = int(os.getenv('NEXTCLOUD_UPLOAD_RETRIES', '2')) + +LOG_LEVEL = os.getenv('DJANGO_LOG_LEVEL', 'INFO') +LOG_JSON = os.getenv('DJANGO_LOG_JSON', '1') == '1' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'request_context': { + '()': 'workflows.logging_utils.RequestContextFilter', + }, + }, + 'formatters': { + 'structured': { + '()': 'workflows.logging_utils.JsonFormatter', + }, + 'verbose': { + 'format': '[%(asctime)s] %(levelname)s %(name)s request_id=%(request_id)s task_id=%(task_id)s %(message)s', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'filters': ['request_context'], + 'formatter': 'structured' if LOG_JSON else 'verbose', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'workflows': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'celery': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + }, +} diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index 699e7d3367a371f567cb197cafec442cdc0aab11..0c041f30cf54646f6d475f46d65e9982ffa38a3f 100644 GIT binary patch delta 7737 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 delta 7910 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 Oe~mw*^@}x^JN+NSgbwup diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 62fc5c0..3068da9 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-26 13:38+0000\n" +"POT-Creation-Date: 2026-03-26 23:25+0000\n" "PO-Revision-Date: 2026-03-24 00:00+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: workflows/app_registry.py:32 workflows/models.py:323 workflows/models.py:404 +#: workflows/app_registry.py:32 workflows/models.py:349 workflows/models.py:430 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:43 workflows/models.py:324 workflows/models.py:405 +#: workflows/app_registry.py:43 workflows/models.py:350 workflows/models.py:431 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -91,7 +91,9 @@ msgstr "Search" #: workflows/app_registry.py:59 #: workflows/templates/workflows/app_registry.html:31 -#: workflows/templates/workflows/backup_recovery.html:40 +#: workflows/templates/workflows/backup_recovery.html:72 +#: workflows/templates/workflows/job_monitor.html:29 +#: workflows/templates/workflows/job_monitor.html:50 #: workflows/templates/workflows/onboarding_intro_session.html:37 #: workflows/templates/workflows/request_timeline.html:70 #: workflows/templates/workflows/requests_dashboard.html:136 @@ -123,7 +125,7 @@ msgstr "" #: workflows/app_registry.py:121 workflows/app_registry.py:130 #: workflows/app_registry.py:139 workflows/app_registry.py:148 #: workflows/app_registry.py:157 workflows/app_registry.py:166 -#: workflows/app_registry.py:175 +#: workflows/app_registry.py:175 workflows/app_registry.py:184 msgid "Öffnen" msgstr "Open" @@ -167,164 +169,217 @@ msgid "Nextcloud- und E-Mail-Setup." msgstr "Nextcloud and email setup." #: workflows/app_registry.py:110 +#: workflows/templates/workflows/job_monitor.html:4 +#: workflows/templates/workflows/job_monitor.html:12 +msgid "Job Monitor" +msgstr "" + +#: workflows/app_registry.py:111 +msgid "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe prüfen." +msgstr "" + +#: workflows/app_registry.py:119 #: workflows/templates/workflows/user_management.html:4 #: workflows/templates/workflows/user_management.html:14 msgid "Benutzer & Rollen" msgstr "Users & roles" -#: workflows/app_registry.py:111 +#: workflows/app_registry.py:120 msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." msgstr "Create users, assign roles, and control access." -#: workflows/app_registry.py:119 workflows/templates/workflows/audit_log.html:4 +#: workflows/app_registry.py:128 workflows/templates/workflows/audit_log.html:4 #: workflows/templates/workflows/audit_log.html:15 msgid "Audit Log" msgstr "" -#: workflows/app_registry.py:120 +#: workflows/app_registry.py:129 msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." msgstr "" -#: workflows/app_registry.py:128 +#: workflows/app_registry.py:137 #: workflows/templates/workflows/backup_recovery.html:4 #: workflows/templates/workflows/backup_recovery.html:12 msgid "Backup & Recovery" msgstr "Backup & Recovery" -#: workflows/app_registry.py:129 +#: workflows/app_registry.py:138 msgid "Backups erstellen und sicher verifizieren." msgstr "" -#: workflows/app_registry.py:137 +#: workflows/app_registry.py:146 #: workflows/templates/workflows/welcome_emails.html:4 msgid "Welcome E-Mails" msgstr "Welcome Emails" -#: workflows/app_registry.py:138 +#: workflows/app_registry.py:147 msgid "Geplante Welcome Mails verwalten." msgstr "Manage scheduled welcome emails." -#: workflows/app_registry.py:146 +#: workflows/app_registry.py:155 #: workflows/templates/workflows/form_builder.html:4 #: workflows/templates/workflows/form_builder.html:14 msgid "Form Builder" msgstr "Form Builder" -#: workflows/app_registry.py:147 +#: workflows/app_registry.py:156 msgid "Felder, Schritte und Optionen verwalten." msgstr "Manage fields, steps, and options." -#: workflows/app_registry.py:155 +#: workflows/app_registry.py:164 #: workflows/templates/workflows/intro_builder.html:4 #: workflows/templates/workflows/intro_builder.html:17 msgid "Einweisungs-Builder" msgstr "Introduction Builder" -#: workflows/app_registry.py:156 +#: workflows/app_registry.py:165 msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." msgstr "Configure checklist items for the introduction protocol." -#: workflows/app_registry.py:164 workflows/templates/workflows/handbook.html:4 +#: workflows/app_registry.py:173 workflows/templates/workflows/handbook.html:4 #: workflows/templates/workflows/handbook.html:15 msgid "Handbook" msgstr "Handbook" -#: workflows/app_registry.py:165 +#: workflows/app_registry.py:174 msgid "Project wiki and developer documentation in one place." msgstr "Project wiki and developer documentation in one place." -#: workflows/app_registry.py:173 +#: workflows/app_registry.py:182 msgid "Django Admin" msgstr "Django Admin" -#: workflows/app_registry.py:174 +#: workflows/app_registry.py:183 msgid "Vollständige Datenverwaltung." msgstr "Full data management." -#: workflows/app_registry.py:289 +#: workflows/app_registry.py:304 msgid "Nur Platform" msgstr "" -#: workflows/app_registry.py:291 +#: workflows/app_registry.py:306 #: workflows/templates/workflows/app_registry.html:85 msgid "Alle Firmenrollen" msgstr "" -#: workflows/app_registry.py:297 workflows/models.py:126 +#: workflows/app_registry.py:312 workflows/models.py:126 #: workflows/templates/workflows/app_registry.html:43 #: workflows/templates/workflows/app_registry.html:78 msgid "Apps" msgstr "Apps" -#: workflows/app_registry.py:298 +#: workflows/app_registry.py:313 msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:303 workflows/models.py:127 +#: workflows/app_registry.py:318 workflows/models.py:127 #: workflows/templates/workflows/app_registry.html:44 #: workflows/templates/workflows/app_registry.html:74 msgid "Platform Apps" msgstr "" -#: workflows/app_registry.py:304 +#: workflows/app_registry.py:319 #, fuzzy #| msgid "Konfiguration, Tests und Steuerung." msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:309 workflows/models.py:128 +#: workflows/app_registry.py:324 workflows/models.py:128 #: workflows/templates/workflows/app_registry.html:45 #: workflows/templates/workflows/app_registry.html:76 msgid "Admin Apps" msgstr "Admin Apps" -#: workflows/app_registry.py:310 +#: workflows/app_registry.py:325 msgid "Konfiguration, Tests und Steuerung." msgstr "Configuration, tests, and controls." -#: workflows/backup_ops.py:141 +#: workflows/backup_ops.py:122 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Kein Backup vorhanden" +msgstr "No backup bundles available yet." + +#: workflows/backup_ops.py:123 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Es wurde noch kein Backup-Bundle erstellt." +msgstr "No backup bundles available yet." + +#: workflows/backup_ops.py:137 +#, fuzzy +#| msgid "Verifiziert" +msgid "Nicht verifiziert" +msgstr "Verified" + +#: workflows/backup_ops.py:138 +msgid "Das neueste Backup-Bundle wurde noch nicht erfolgreich verifiziert." +msgstr "" + +#: workflows/backup_ops.py:149 +#, fuzzy +#| msgid "Verifiziert" +msgid "Verifikation veraltet" +msgstr "Verified" + +#: workflows/backup_ops.py:150 +#, python-format +msgid "" +"Die letzte erfolgreiche Backup-Verifikation ist älter als %(hours)s Stunden." +msgstr "" + +#: workflows/backup_ops.py:158 +msgid "Verifikation aktuell" +msgstr "" + +#: workflows/backup_ops.py:159 +msgid "" +"Das neueste Backup-Bundle wurde erfolgreich und rechtzeitig verifiziert." +msgstr "" + +#: workflows/backup_ops.py:191 msgid "Remote Backup ist deaktiviert." msgstr "" -#: workflows/backup_ops.py:146 +#: workflows/backup_ops.py:196 #, python-format msgid "Zieltyp %(target)s ist vorbereitet, aber noch nicht implementiert." msgstr "" -#: workflows/backup_ops.py:152 +#: workflows/backup_ops.py:202 msgid "Nextcloud Backup-Verzeichnis fehlt." msgstr "" -#: workflows/backup_ops.py:170 +#: workflows/backup_ops.py:220 #, python-format msgid "Upload nach Nextcloud fehlgeschlagen bei %(file)s." msgstr "" -#: workflows/backup_ops.py:176 +#: workflows/backup_ops.py:226 #, python-format msgid "Nach Nextcloud hochgeladen: %(count)s Datei(en)." msgstr "" -#: workflows/backup_ops.py:239 workflows/backup_ops.py:318 +#: workflows/backup_ops.py:289 workflows/backup_ops.py:366 msgid "Backup-Dateien nicht gefunden." msgstr "" -#: workflows/backup_ops.py:289 +#: workflows/backup_ops.py:337 msgid "Media-Archiv enthält kein media/-Verzeichnis." msgstr "" -#: workflows/backup_ops.py:291 +#: workflows/backup_ops.py:339 #, python-format msgid "" "%(tables)s Tabellen, %(onboarding)s Onboarding, %(offboarding)s Offboarding, " "%(media)s Mediendateien geprüft." msgstr "" -#: workflows/backup_ops.py:316 +#: workflows/backup_ops.py:364 msgid "Ungültiger Backup-Pfad." msgstr "" -#: workflows/backup_ops.py:323 +#: workflows/backup_ops.py:371 msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" @@ -373,11 +428,11 @@ msgstr "Role:" msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:154 workflows/views.py:740 +#: workflows/forms.py:154 workflows/views.py:770 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:156 workflows/views.py:743 +#: workflows/forms.py:156 workflows/views.py:773 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" @@ -586,244 +641,274 @@ msgstr "" msgid "Trial-Workspace bereinigt." msgstr "" -#: workflows/models.py:201 workflows/views.py:200 -msgid "Eingereicht" +#: workflows/management/commands/verify_latest_backup.py:21 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Kein Backup-Bundle vorhanden." +msgstr "No backup bundles available yet." + +#: workflows/management/commands/verify_latest_backup.py:24 +#, python-format +msgid "Kein Backup gefunden. Neues Bundle erstellt: %(name)s" +msgstr "" + +#: workflows/management/commands/verify_latest_backup.py:29 +#, fuzzy, python-format +#| msgid "Backup wird verifiziert" +msgid "Backup erfolgreich verifiziert: %(name)s" +msgstr "Backup is being verified" + +#: workflows/models.py:175 workflows/views.py:383 +#, fuzzy +#| msgid "Gesamtbestand" +msgid "Gestartet" +msgstr "Total records" + +#: workflows/models.py:176 workflows/views.py:383 +#, fuzzy +#| msgid "Eingereicht" +msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:202 workflows/views.py:201 -msgid "In Bearbeitung" -msgstr "Processing" - -#: workflows/models.py:203 workflows/models.py:518 workflows/views.py:202 -msgid "Abgeschlossen" -msgstr "Completed" - -#: workflows/models.py:204 workflows/models.py:458 -#: workflows/templates/workflows/backup_recovery.html:70 +#: workflows/models.py:177 workflows/models.py:230 workflows/models.py:484 +#: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:203 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:209 +#: workflows/views.py:383 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:211 +#: workflows/models.py:227 workflows/views.py:206 +msgid "Eingereicht" +msgstr "Submitted" + +#: workflows/models.py:228 workflows/views.py:207 +msgid "In Bearbeitung" +msgstr "Processing" + +#: workflows/models.py:229 workflows/models.py:544 workflows/views.py:208 +msgid "Abgeschlossen" +msgstr "Completed" + +#: workflows/models.py:237 msgid "Herr" msgstr "" -#: workflows/models.py:211 +#: workflows/models.py:237 msgid "Frau" msgstr "" -#: workflows/models.py:211 +#: workflows/models.py:237 msgid "Divers" msgstr "" -#: workflows/models.py:221 +#: workflows/models.py:247 msgid "befristet" msgstr "" -#: workflows/models.py:221 +#: workflows/models.py:247 msgid "unbefristet" msgstr "" -#: workflows/models.py:284 +#: workflows/models.py:310 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/models.py:285 +#: workflows/models.py:311 msgid "Geräte" msgstr "" -#: workflows/models.py:286 +#: workflows/models.py:312 msgid "Software" msgstr "" -#: workflows/models.py:287 +#: workflows/models.py:313 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:288 +#: workflows/models.py:314 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:289 +#: workflows/models.py:315 msgid "Ressourcen" msgstr "" -#: workflows/models.py:290 +#: workflows/models.py:316 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:316 +#: workflows/models.py:342 msgid "Automatisch" msgstr "" -#: workflows/models.py:317 workflows/views.py:95 +#: workflows/models.py:343 workflows/views.py:101 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:318 workflows/views.py:96 +#: workflows/models.py:344 workflows/views.py:102 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:319 workflows/views.py:97 +#: workflows/models.py:345 workflows/views.py:103 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:320 workflows/views.py:98 +#: workflows/models.py:346 workflows/views.py:104 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:362 +#: workflows/models.py:388 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:363 +#: workflows/models.py:389 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:364 +#: workflows/models.py:390 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:365 +#: workflows/models.py:391 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:366 +#: workflows/models.py:392 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:367 +#: workflows/models.py:393 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:368 +#: workflows/models.py:394 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:369 +#: workflows/models.py:395 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:370 +#: workflows/models.py:396 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:371 +#: workflows/models.py:397 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:372 +#: workflows/models.py:398 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:408 +#: workflows/models.py:434 msgid "Immer" msgstr "" -#: workflows/models.py:409 workflows/models.py:487 +#: workflows/models.py:435 workflows/models.py:513 msgid "Enthält" msgstr "" -#: workflows/models.py:410 workflows/models.py:488 +#: workflows/models.py:436 workflows/models.py:514 msgid "Ist gleich" msgstr "" -#: workflows/models.py:411 +#: workflows/models.py:437 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:412 +#: workflows/models.py:438 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:454 +#: workflows/models.py:480 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:455 +#: workflows/models.py:481 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:456 +#: workflows/models.py:482 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:457 +#: workflows/models.py:483 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:480 workflows/tasks.py:576 +#: workflows/models.py:506 workflows/tasks.py:597 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:481 workflows/tasks.py:577 +#: workflows/models.py:507 workflows/tasks.py:598 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:482 workflows/tasks.py:578 +#: workflows/models.py:508 workflows/tasks.py:599 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:483 workflows/tasks.py:579 +#: workflows/models.py:509 workflows/tasks.py:600 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:486 +#: workflows/models.py:512 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:489 +#: workflows/models.py:515 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:490 +#: workflows/models.py:516 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:517 +#: workflows/models.py:543 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:537 +#: workflows/models.py:563 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:538 +#: workflows/models.py:564 msgid "S3" msgstr "" -#: workflows/models.py:539 +#: workflows/models.py:565 msgid "NFS" msgstr "" @@ -850,86 +935,86 @@ msgstr "IT Staff" msgid "Mitarbeiter" msgstr "Staff" -#: workflows/tasks.py:592 +#: workflows/tasks.py:613 #, python-format msgid "%(item)s übergeben und Grundfunktionen erklärt" msgstr "%(item)s handed over and basic functions explained" -#: workflows/tasks.py:594 +#: workflows/tasks.py:615 #, python-format msgid "%(item)s gezeigt bzw. Nutzung erklärt" msgstr "%(item)s shown or usage explained" -#: workflows/tasks.py:596 +#: workflows/tasks.py:617 #, python-format msgid "Telefonnummer / Direktwahl erklärt: %(value)s" msgstr "Phone number / direct extension explained: %(value)s" -#: workflows/tasks.py:598 +#: workflows/tasks.py:619 msgid "Arbeitsplatz, Geräte und allgemeine Nutzung besprochen" msgstr "Workplace, devices, and general usage reviewed" -#: workflows/tasks.py:600 +#: workflows/tasks.py:621 #, python-format msgid "%(item)s Zugang erklärt" msgstr "%(item)s access explained" -#: workflows/tasks.py:601 +#: workflows/tasks.py:622 #, python-format msgid "%(item)s Gruppe / Berechtigung erläutert" msgstr "%(item)s group / permission explained" -#: workflows/tasks.py:603 +#: workflows/tasks.py:624 #, python-format msgid "Dienstliche E-Mail-Adresse erläutert: %(value)s" msgstr "Work email address explained: %(value)s" -#: workflows/tasks.py:605 +#: workflows/tasks.py:626 #, python-format msgid "Gruppenpostfach erklärt: %(item)s" msgstr "Group mailbox explained: %(item)s" -#: workflows/tasks.py:607 +#: workflows/tasks.py:628 msgid "Zugänge, Konten und Anmeldelogik besprochen" msgstr "Accesses, accounts, and login logic reviewed" -#: workflows/tasks.py:609 +#: workflows/tasks.py:630 #, python-format msgid "%(item)s Einführung durchgeführt" msgstr "%(item)s introduction completed" -#: workflows/tasks.py:610 +#: workflows/tasks.py:631 #, python-format msgid "%(item)s zusätzlich besprochen" msgstr "%(item)s discussed additionally" -#: workflows/tasks.py:612 +#: workflows/tasks.py:633 msgid "Benötigte Standardsoftware und tägliche Nutzung erklärt" msgstr "Required standard software and daily usage explained" -#: workflows/tasks.py:615 +#: workflows/tasks.py:636 msgid "Passwortregeln und sicherer Umgang besprochen" msgstr "Password rules and secure handling reviewed" -#: workflows/tasks.py:616 +#: workflows/tasks.py:637 msgid "Dateiablage, Nextcloud und Freigaben erklärt" msgstr "File storage, Nextcloud, and sharing explained" -#: workflows/tasks.py:617 +#: workflows/tasks.py:638 msgid "Kommunikationswege und Support-Prozess erklärt" msgstr "Communication channels and support process explained" -#: workflows/tasks.py:620 +#: workflows/tasks.py:641 #, python-format msgid "%(item)s als zusätzliche Ausstattung besprochen" msgstr "%(item)s discussed as additional equipment" -#: workflows/tasks.py:622 +#: workflows/tasks.py:643 #, python-format msgid "Zusätzlicher Zugang besprochen: %(item)s" msgstr "Additional access discussed: %(item)s" -#: workflows/tasks.py:624 +#: workflows/tasks.py:645 #, python-format msgid "Übergabe-/Nachfolgekontext besprochen: %(value)s" msgstr "Handover / successor context reviewed: %(value)s" @@ -1098,6 +1183,8 @@ msgstr "Search by name or email" #: workflows/templates/workflows/app_registry.html:33 #: workflows/templates/workflows/app_registry.html:42 #: workflows/templates/workflows/audit_log.html:25 +#: workflows/templates/workflows/job_monitor.html:22 +#: workflows/templates/workflows/job_monitor.html:31 #: workflows/templates/workflows/requests_dashboard.html:130 #: workflows/templates/workflows/requests_dashboard.html:138 #: workflows/templates/workflows/requests_dashboard.html:147 @@ -1116,7 +1203,7 @@ msgstr "Active" #: workflows/templates/workflows/app_registry.html:35 #: workflows/templates/workflows/app_registry.html:64 -#: workflows/templates/workflows/backup_recovery.html:74 +#: workflows/templates/workflows/backup_recovery.html:106 #: workflows/templates/workflows/trial_management.html:30 #: workflows/templates/workflows/trial_management.html:43 #, fuzzy @@ -1261,7 +1348,7 @@ msgstr "" #: workflows/templates/workflows/audit_log.html:23 #: workflows/templates/workflows/audit_log.html:54 -#: workflows/templates/workflows/backup_recovery.html:43 +#: workflows/templates/workflows/backup_recovery.html:75 #: workflows/templates/workflows/requests_dashboard.html:193 #: workflows/templates/workflows/user_management.html:156 #: workflows/templates/workflows/welcome_emails.html:87 @@ -1288,6 +1375,7 @@ msgid "Bis Datum" msgstr "" #: workflows/templates/workflows/audit_log.html:44 +#: workflows/templates/workflows/job_monitor.html:38 msgid "Filtern" msgstr "" @@ -1309,6 +1397,7 @@ msgid "Typ" msgstr "Type" #: workflows/templates/workflows/audit_log.html:56 +#: workflows/templates/workflows/job_monitor.html:51 msgid "Ziel" msgstr "" @@ -1348,11 +1437,23 @@ msgid "" msgstr "Create database and media backups and verify existing bundles safely." #: workflows/templates/workflows/backup_recovery.html:20 +#, fuzzy +#| msgid "Trial-Status" +msgid "Backup-Status" +msgstr "Trial status" + +#: workflows/templates/workflows/backup_recovery.html:37 +#, fuzzy +#| msgid "Backup jetzt verifizieren?" +msgid "Zuletzt verifiziert:" +msgstr "Verify backup now?" + +#: workflows/templates/workflows/backup_recovery.html:46 #: workflows/templates/workflows/user_management.html:78 msgid "Aktionen" msgstr "Actions" -#: workflows/templates/workflows/backup_recovery.html:21 +#: workflows/templates/workflows/backup_recovery.html:47 msgid "" "Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst " "CLI-only." @@ -1360,93 +1461,103 @@ msgstr "" "Creation and verification run inside the app context. Restore intentionally " "remains CLI-only." -#: workflows/templates/workflows/backup_recovery.html:23 +#: workflows/templates/workflows/backup_recovery.html:49 msgid "Neues Backup jetzt erstellen?" msgstr "Create a new backup now?" -#: workflows/templates/workflows/backup_recovery.html:23 +#: workflows/templates/workflows/backup_recovery.html:49 msgid "Backup wird erstellt" msgstr "Backup is being created" -#: workflows/templates/workflows/backup_recovery.html:23 +#: workflows/templates/workflows/backup_recovery.html:49 msgid "Bitte warten. Datenbank- und Media-Bundle werden gerade vorbereitet." msgstr "Please wait. The database and media bundle are being prepared." -#: workflows/templates/workflows/backup_recovery.html:25 +#: workflows/templates/workflows/backup_recovery.html:51 msgid "Backup erstellen" msgstr "Create backup" -#: workflows/templates/workflows/backup_recovery.html:31 +#: workflows/templates/workflows/backup_recovery.html:57 +#, fuzzy +#| msgid "Aktion" +msgid "Automation" +msgstr "Action" + +#: workflows/templates/workflows/backup_recovery.html:58 +msgid "Für einen geplanten Verify-Run außerhalb der UI:" +msgstr "" + +#: workflows/templates/workflows/backup_recovery.html:63 msgid "Verfügbare Backup-Bundles" msgstr "Available backup bundles" -#: workflows/templates/workflows/backup_recovery.html:37 +#: workflows/templates/workflows/backup_recovery.html:69 msgid "Bundle" msgstr "Bundle" -#: workflows/templates/workflows/backup_recovery.html:38 +#: workflows/templates/workflows/backup_recovery.html:70 msgid "Erstellt" msgstr "Created" -#: workflows/templates/workflows/backup_recovery.html:39 -#: workflows/templates/workflows/backup_recovery.html:54 +#: workflows/templates/workflows/backup_recovery.html:71 +#: workflows/templates/workflows/backup_recovery.html:86 msgid "Verifiziert" msgstr "Verified" -#: workflows/templates/workflows/backup_recovery.html:41 +#: workflows/templates/workflows/backup_recovery.html:73 msgid "Inhalt" msgstr "Contents" -#: workflows/templates/workflows/backup_recovery.html:42 +#: workflows/templates/workflows/backup_recovery.html:74 msgid "Remote" msgstr "Remote" -#: workflows/templates/workflows/backup_recovery.html:56 +#: workflows/templates/workflows/backup_recovery.html:88 msgid "Nicht geprüft" msgstr "Not verified" -#: workflows/templates/workflows/backup_recovery.html:68 +#: workflows/templates/workflows/backup_recovery.html:100 msgid "Hochgeladen" msgstr "Uploaded" -#: workflows/templates/workflows/backup_recovery.html:72 +#: workflows/templates/workflows/backup_recovery.html:104 msgid "Vorbereitet" msgstr "Prepared" -#: workflows/templates/workflows/backup_recovery.html:76 +#: workflows/templates/workflows/backup_recovery.html:108 msgid "Lokal" msgstr "Local" -#: workflows/templates/workflows/backup_recovery.html:79 +#: workflows/templates/workflows/backup_recovery.html:111 msgid "Lokal gespeichert" msgstr "Stored locally" -#: workflows/templates/workflows/backup_recovery.html:81 +#: workflows/templates/workflows/backup_recovery.html:113 msgid "Lokal nicht vorhanden" msgstr "Not stored locally" -#: workflows/templates/workflows/backup_recovery.html:95 +#: workflows/templates/workflows/backup_recovery.html:127 msgid "Backup jetzt verifizieren?" msgstr "Verify backup now?" -#: workflows/templates/workflows/backup_recovery.html:95 +#: workflows/templates/workflows/backup_recovery.html:127 msgid "Backup wird verifiziert" msgstr "Backup is being verified" -#: workflows/templates/workflows/backup_recovery.html:95 +#: workflows/templates/workflows/backup_recovery.html:127 msgid "Bitte warten. Bundle, Datenbank-Dump und Media-Archiv werden geprüft." msgstr "" "Please wait. The bundle, database dump, and media archive are being checked." -#: workflows/templates/workflows/backup_recovery.html:97 +#: workflows/templates/workflows/backup_recovery.html:129 msgid "Verifizieren" msgstr "Verify" -#: workflows/templates/workflows/backup_recovery.html:99 +#: workflows/templates/workflows/backup_recovery.html:131 msgid "Backup-Bundle wirklich löschen?" msgstr "Delete this backup bundle?" -#: workflows/templates/workflows/backup_recovery.html:101 +#: workflows/templates/workflows/backup_recovery.html:133 #: workflows/templates/workflows/form_builder.html:92 #: workflows/templates/workflows/form_builder.html:107 #: workflows/templates/workflows/integrations_setup.html:265 @@ -1458,7 +1569,7 @@ msgstr "Delete this backup bundle?" msgid "Löschen" msgstr "Delete" -#: workflows/templates/workflows/backup_recovery.html:111 +#: workflows/templates/workflows/backup_recovery.html:143 #, fuzzy #| msgid "Noch keine Vorgänge vorhanden." msgid "Noch keine Backup-Bundles vorhanden." @@ -1616,9 +1727,13 @@ msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." msgstr "" #: workflows/templates/workflows/branding_settings.html:166 +#, fuzzy +#| msgid "" +#| "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " +#| "hochgeladen werden." msgid "" -"TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " -"hochgeladen werden." +"Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte " +"geändert oder Dateien hochgeladen werden." msgstr "" "TUBCO remains the default until values are changed or files are uploaded " "here." @@ -2343,6 +2458,33 @@ msgstr "Order currently follows the table order when saving." msgid "Checkliste speichern" msgstr "Save checklist" +#: workflows/templates/workflows/job_monitor.html:13 +msgid "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe zentral prüfen." +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:20 +#: workflows/templates/workflows/job_monitor.html:49 +msgid "Task" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:48 +msgid "Start" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:52 +msgid "Task ID" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:53 +msgid "Fehler" +msgstr "" + +#: workflows/templates/workflows/job_monitor.html:67 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Noch keine Task-Läufe vorhanden." +msgstr "No backup bundles available yet." + #: workflows/templates/workflows/offboarding_form.html:4 #: workflows/templates/workflows/offboarding_form.html:27 msgid "Offboarding-Anfrage" @@ -2534,7 +2676,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:993 +#: workflows/views.py:1025 msgid "Vertragsbeginn" msgstr "Contract start" @@ -2684,103 +2826,114 @@ msgstr "" msgid "If dependencies changed, verify imports do not emit warnings." msgstr "" -#: workflows/templates/workflows/release_checklist.html:51 +#: workflows/templates/workflows/release_checklist.html:43 +msgid "" +"Verify the latest backup bundle before release if operational tooling, " +"storage, or restore behavior changed." +msgstr "" + +#: workflows/templates/workflows/release_checklist.html:44 +msgid "" +"Prefer the single local release gate command so local validation matches CI." +msgstr "" + +#: workflows/templates/workflows/release_checklist.html:57 msgid "3. Data and asset steps" msgstr "" -#: workflows/templates/workflows/release_checklist.html:53 +#: workflows/templates/workflows/release_checklist.html:59 msgid "Create and apply migrations if models changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:54 +#: workflows/templates/workflows/release_checklist.html:60 msgid "Run collectstatic if UI assets changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:55 +#: workflows/templates/workflows/release_checklist.html:61 msgid "Generate fresh PDFs if PDF templates or document logic changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:56 +#: workflows/templates/workflows/release_checklist.html:62 msgid "Confirm file outputs appear under backend/media/pdfs/." msgstr "" -#: workflows/templates/workflows/release_checklist.html:64 +#: workflows/templates/workflows/release_checklist.html:70 #, fuzzy #| msgid "Integrationen" msgid "4. Integration checks" msgstr "Integrations" -#: workflows/templates/workflows/release_checklist.html:66 +#: workflows/templates/workflows/release_checklist.html:72 msgid "Verify the health endpoint returns status ok." msgstr "" -#: workflows/templates/workflows/release_checklist.html:67 +#: workflows/templates/workflows/release_checklist.html:73 msgid "Verify MailHog in test mode or SMTP in production mode." msgstr "" -#: workflows/templates/workflows/release_checklist.html:68 +#: workflows/templates/workflows/release_checklist.html:74 msgid "Verify Nextcloud upload if synchronization behavior changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:69 +#: workflows/templates/workflows/release_checklist.html:75 msgid "" "Verify welcome-email scheduling or notification rules if email routing " "changed." msgstr "" -#: workflows/templates/workflows/release_checklist.html:76 +#: workflows/templates/workflows/release_checklist.html:82 msgid "5. Release evidence" msgstr "" -#: workflows/templates/workflows/release_checklist.html:78 +#: workflows/templates/workflows/release_checklist.html:84 msgid "Record which checks were run and their result." msgstr "" -#: workflows/templates/workflows/release_checklist.html:79 +#: workflows/templates/workflows/release_checklist.html:85 msgid "Take a snapshot commit before moving to the next change phase." msgstr "" -#: workflows/templates/workflows/release_checklist.html:80 +#: workflows/templates/workflows/release_checklist.html:86 msgid "" "If a release introduces new operations or engineering behavior, update both " "handbooks." msgstr "" -#: workflows/templates/workflows/release_checklist.html:81 +#: workflows/templates/workflows/release_checklist.html:87 msgid "" "Keep at least one successful onboarding and one offboarding smoke example " "during major workflow changes." msgstr "" -#: workflows/templates/workflows/release_checklist.html:86 +#: workflows/templates/workflows/release_checklist.html:92 msgid "6. Rollback basics" msgstr "" -#: workflows/templates/workflows/release_checklist.html:88 +#: workflows/templates/workflows/release_checklist.html:94 msgid "" "If rollout fails after code-only changes, redeploy the previous snapshot " "commit." msgstr "" -#: workflows/templates/workflows/release_checklist.html:89 +#: workflows/templates/workflows/release_checklist.html:95 msgid "" "If rollout includes schema changes, verify backward compatibility before " "rollback." msgstr "" -#: workflows/templates/workflows/release_checklist.html:90 +#: workflows/templates/workflows/release_checklist.html:96 msgid "" "If integrations fail, switch email mode/test settings conservatively before " "wider retry." msgstr "" -#: workflows/templates/workflows/release_checklist.html:91 +#: workflows/templates/workflows/release_checklist.html:97 msgid "" "Use logs from web and worker containers to isolate whether the issue is " "request, task, or integration related." msgstr "" -#: workflows/templates/workflows/release_checklist.html:98 +#: workflows/templates/workflows/release_checklist.html:104 msgid "" "Project rule: German remains the primary/fallback language. English is " "secondary. If a release adds new dynamic text, add the German source first " @@ -3348,256 +3501,256 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:95 +#: workflows/views.py:101 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:96 +#: workflows/views.py:102 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:97 +#: workflows/views.py:103 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:98 +#: workflows/views.py:104 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:129 workflows/views.py:1079 workflows/views.py:1084 +#: workflows/views.py:135 workflows/views.py:1111 workflows/views.py:1116 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:210 +#: workflows/views.py:216 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:211 +#: workflows/views.py:217 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:212 +#: workflows/views.py:218 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:213 +#: workflows/views.py:219 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:214 +#: workflows/views.py:220 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:215 +#: workflows/views.py:221 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:216 +#: workflows/views.py:222 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:217 +#: workflows/views.py:223 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:218 +#: workflows/views.py:224 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:219 +#: workflows/views.py:225 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:220 +#: workflows/views.py:226 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:221 +#: workflows/views.py:227 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:222 +#: workflows/views.py:228 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:223 +#: workflows/views.py:229 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:224 +#: workflows/views.py:230 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:225 +#: workflows/views.py:231 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:226 +#: workflows/views.py:232 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:227 +#: workflows/views.py:233 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:228 +#: workflows/views.py:234 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:229 +#: workflows/views.py:235 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:230 +#: workflows/views.py:236 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:231 +#: workflows/views.py:237 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:232 +#: workflows/views.py:238 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:233 +#: workflows/views.py:239 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:234 +#: workflows/views.py:240 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:235 +#: workflows/views.py:241 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:236 +#: workflows/views.py:242 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:237 +#: workflows/views.py:243 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:238 +#: workflows/views.py:244 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:239 +#: workflows/views.py:245 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:240 +#: workflows/views.py:246 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:241 +#: workflows/views.py:247 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:242 +#: workflows/views.py:248 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:243 +#: workflows/views.py:249 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:244 +#: workflows/views.py:250 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:245 +#: workflows/views.py:251 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:246 +#: workflows/views.py:252 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:247 +#: workflows/views.py:253 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:248 +#: workflows/views.py:254 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:392 +#: workflows/views.py:422 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:491 +#: workflows/views.py:521 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:500 +#: workflows/views.py:530 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:502 +#: workflows/views.py:532 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3610,12 +3763,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:513 +#: workflows/views.py:543 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:515 +#: workflows/views.py:545 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3628,7 +3781,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:553 +#: workflows/views.py:583 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3636,13 +3789,13 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:579 +#: workflows/views.py:609 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:610 +#: workflows/views.py:640 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3651,13 +3804,13 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:637 +#: workflows/views.py:667 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:669 +#: workflows/views.py:699 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3666,21 +3819,21 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:696 +#: workflows/views.py:726 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:713 +#: workflows/views.py:743 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:726 +#: workflows/views.py:756 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:748 +#: workflows/views.py:778 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3691,14 +3844,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:751 +#: workflows/views.py:781 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:754 +#: workflows/views.py:784 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3709,7 +3862,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:757 +#: workflows/views.py:787 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3720,18 +3873,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:774 +#: workflows/views.py:804 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:796 +#: workflows/views.py:826 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:808 +#: workflows/views.py:838 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3741,7 +3894,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:811 +#: workflows/views.py:841 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3751,7 +3904,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:814 +#: workflows/views.py:844 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3760,7 +3913,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:817 +#: workflows/views.py:847 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3769,121 +3922,121 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:830 +#: workflows/views.py:860 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:917 +#: workflows/views.py:949 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:919 +#: workflows/views.py:951 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:935 +#: workflows/views.py:967 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:937 +#: workflows/views.py:969 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:953 +#: workflows/views.py:985 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:955 +#: workflows/views.py:987 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:981 +#: workflows/views.py:1013 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:983 +#: workflows/views.py:1015 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:995 +#: workflows/views.py:1027 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1005 +#: workflows/views.py:1037 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1007 +#: workflows/views.py:1039 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1016 +#: workflows/views.py:1048 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1042 +#: workflows/views.py:1074 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1054 +#: workflows/views.py:1086 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:1093 +#: workflows/views.py:1125 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1136 +#: workflows/views.py:1168 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1138 +#: workflows/views.py:1170 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1140 +#: workflows/views.py:1172 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1368 +#: workflows/views.py:1400 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1385 +#: workflows/views.py:1417 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1414 +#: workflows/views.py:1446 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1428 +#: workflows/views.py:1460 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1441 +#: workflows/views.py:1473 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index b861da5..3f683b5 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -3,7 +3,7 @@ from django.conf import settings from django import forms from .emailing import send_system_email -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, 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,14 @@ 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') diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py index 2d6aee3..9e01c2b 100644 --- a/backend/workflows/app_registry.py +++ b/backend/workflows/app_registry.py @@ -103,6 +103,15 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = ( 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, @@ -227,6 +236,12 @@ DEFAULT_ROLE_VISIBILITY = { 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, diff --git a/backend/workflows/backup_ops.py b/backend/workflows/backup_ops.py index 8b6ec7c..6cf5ce3 100644 --- a/backend/workflows/backup_ops.py +++ b/backend/workflows/backup_ops.py @@ -12,6 +12,7 @@ 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 @@ -113,6 +114,55 @@ def list_backup_bundles() -> list[dict]: return rows +def latest_backup_health_snapshot(stale_after_hours: int = 48) -> dict: + 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 +314,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, diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py index 18607e0..2220c3b 100644 --- a/backend/workflows/branding.py +++ b/backend/workflows/branding.py @@ -15,14 +15,14 @@ def get_portal_branding() -> PortalBranding: branding, _ = PortalBranding.objects.get_or_create( name='Default', defaults={ - 'portal_title': 'TUBCO Onboarding & Offboarding Portal', - 'company_name': 'TUBCO', - 'company_domain': 'tub.co', - 'support_email': 'info@tub.co', - 'sender_display_name': 'TUBCO', + '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': 'TUBCO Onboarding & Offboarding Portal', - 'footer_text_en': 'TUBCO Onboarding & Offboarding Portal', + 'footer_text': 'Workdock', + 'footer_text_en': 'Workdock', 'legal_notice': '', 'legal_notice_en': '', 'default_language': 'de', @@ -37,7 +37,7 @@ def get_portal_company_config() -> PortalCompanyConfig: company_config, _ = PortalCompanyConfig.objects.get_or_create( name='Default', defaults={ - 'legal_company_name': 'TUBCO GmbH', + 'legal_company_name': 'Workdock', 'country': 'Deutschland', 'website_url': '', 'imprint_url': '', @@ -108,7 +108,7 @@ def get_trial_context() -> dict[str, object]: def get_company_email_domain() -> str: branding = get_portal_branding() domain = (branding.company_domain or '').strip().lower().lstrip('@') - return domain or 'tub.co' + return domain or 'workdock.de' def get_portal_logo_url() -> str: @@ -191,7 +191,7 @@ def get_branding_context() -> dict[str, object]: def get_branding_email_copy() -> dict[str, str]: branding = get_portal_branding() - company_name = (branding.company_name or 'TUBCO').strip() + company_name = (branding.company_name or 'Workdock').strip() portal_title = (branding.portal_title or f'{company_name} Portal').strip() return { 'company_name': company_name, @@ -205,7 +205,7 @@ def get_branding_email_copy() -> dict[str, str]: def get_company_contact_copy() -> dict[str, str]: branding = get_portal_branding() company_config = get_portal_company_config() - company_name = (branding.company_name or 'TUBCO').strip() + 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() diff --git a/backend/workflows/logging_utils.py b/backend/workflows/logging_utils.py new file mode 100644 index 0000000..a3dd2c5 --- /dev/null +++ b/backend/workflows/logging_utils.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import contextvars +import json +import logging +from datetime import datetime, timezone + +from celery import current_task + +_request_id: contextvars.ContextVar[str] = contextvars.ContextVar('request_id', default='') + + +def set_request_id(value: str) -> None: + _request_id.set(value or '') + + +def get_request_id() -> str: + return _request_id.get('') + + +def clear_request_id() -> None: + _request_id.set('') + + +class RequestContextFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = get_request_id() + task = current_task + request = getattr(task, 'request', None) if task else None + record.task_id = getattr(request, 'id', '') if request else '' + record.task_name = getattr(task, 'name', '') if task else '' + return True + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + 'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + } + request_id = getattr(record, 'request_id', '') or '' + task_id = getattr(record, 'task_id', '') or '' + task_name = getattr(record, 'task_name', '') or '' + if request_id: + payload['request_id'] = request_id + if task_id: + payload['task_id'] = task_id + if task_name: + payload['task_name'] = task_name + if record.exc_info: + payload['exception'] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False) diff --git a/backend/workflows/management/commands/bootstrap_initial_users.py b/backend/workflows/management/commands/bootstrap_initial_users.py index aa35bce..a34d823 100644 --- a/backend/workflows/management/commands/bootstrap_initial_users.py +++ b/backend/workflows/management/commands/bootstrap_initial_users.py @@ -6,7 +6,7 @@ from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role, e 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', diff --git a/backend/workflows/management/commands/run_staging_e2e_check.py b/backend/workflows/management/commands/run_staging_e2e_check.py index cb415fd..b58ed55 100644 --- a/backend/workflows/management/commands/run_staging_e2e_check.py +++ b/backend/workflows/management/commands/run_staging_e2e_check.py @@ -9,6 +9,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.utils import timezone +from workflows.branding import get_company_email_domain from workflows.models import EmployeeProfile, OffboardingRequest, OnboardingRequest from workflows.tasks import process_offboarding_request, process_onboarding_request @@ -100,8 +101,9 @@ class Command(BaseCommand): def handle(self, *args, **options): run_id = timezone.now().strftime('%Y%m%d%H%M%S') employee_name = f'E2E Check {run_id}' - work_email = f'e2e.{run_id}@tub.co' - requester_email = 'e2e.requester@tub.co' + domain = get_company_email_domain() + work_email = f'e2e.{run_id}@{domain}' + requester_email = f'e2e.requester@{domain}' created_onboarding: OnboardingRequest | None = None created_offboarding: OffboardingRequest | None = None diff --git a/backend/workflows/management/commands/verify_latest_backup.py b/backend/workflows/management/commands/verify_latest_backup.py new file mode 100644 index 0000000..8b82f6a --- /dev/null +++ b/backend/workflows/management/commands/verify_latest_backup.py @@ -0,0 +1,30 @@ +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import gettext as _ + +from workflows.backup_ops import create_backup_bundle, list_backup_bundles, verify_backup_bundle + + +class Command(BaseCommand): + help = 'Verifies the latest backup bundle. Can create one first if no bundle exists yet.' + + def add_arguments(self, parser): + parser.add_argument( + '--create-if-missing', + action='store_true', + help='Create a new backup bundle first if none exists yet.', + ) + + def handle(self, *args, **options): + rows = list_backup_bundles() + if not rows: + if not options['create_if_missing']: + raise CommandError(_('Kein Backup-Bundle vorhanden.')) + created = create_backup_bundle() + backup_name = created['name'] + self.stdout.write(self.style.WARNING(_('Kein Backup gefunden. Neues Bundle erstellt: %(name)s') % {'name': backup_name})) + else: + backup_name = rows[0]['name'] + + result = verify_backup_bundle(backup_name) + self.stdout.write(self.style.SUCCESS(_('Backup erfolgreich verifiziert: %(name)s') % {'name': backup_name})) + self.stdout.write(result['summary']) diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py index 55ec825..904ade5 100644 --- a/backend/workflows/middleware.py +++ b/backend/workflows/middleware.py @@ -1,9 +1,34 @@ +import uuid + from django.shortcuts import render 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 TrialModeMiddleware: EXEMPT_PREFIXES = ( '/healthz/', diff --git a/backend/workflows/migrations/0044_asynctasklog.py b/backend/workflows/migrations/0044_asynctasklog.py new file mode 100644 index 0000000..fc105f7 --- /dev/null +++ b/backend/workflows/migrations/0044_asynctasklog.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-26 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0043_portaltrialconfig'), + ] + + operations = [ + migrations.CreateModel( + name='AsyncTaskLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_name', models.CharField(max_length=255)), + ('task_id', models.CharField(blank=True, max_length=255)), + ('target_type', models.CharField(blank=True, max_length=80)), + ('target_id', models.PositiveIntegerField(blank=True, null=True)), + ('target_label', models.CharField(blank=True, max_length=255)), + ('status', models.CharField(choices=[('started', 'Gestartet'), ('succeeded', 'Erfolgreich'), ('failed', 'Fehlgeschlagen')], default='started', max_length=20)), + ('error_message', models.TextField(blank=True)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Async Task Log', + 'verbose_name_plural': 'Async Task Logs', + 'ordering': ['-started_at', '-id'], + }, + ), + ] diff --git a/backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py b/backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py new file mode 100644 index 0000000..4c9b928 --- /dev/null +++ b/backend/workflows/migrations/0045_alter_portalbranding_company_domain_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.5 on 2026-03-26 23:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0044_asynctasklog'), + ] + + operations = [ + migrations.AlterField( + model_name='portalbranding', + name='company_domain', + field=models.CharField(blank=True, default='workdock.de', max_length=120), + ), + migrations.AlterField( + model_name='portalbranding', + name='company_name', + field=models.CharField(default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='footer_text', + field=models.CharField(blank=True, default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='footer_text_en', + field=models.CharField(blank=True, default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='portal_title', + field=models.CharField(default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='sender_display_name', + field=models.CharField(blank=True, default='Workdock', max_length=255), + ), + migrations.AlterField( + model_name='portalbranding', + name='support_email', + field=models.EmailField(blank=True, default='info@workdock.de', max_length=254), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index b0a0741..0133958 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -27,14 +27,14 @@ class EmployeeProfile(models.Model): class PortalBranding(models.Model): name = models.CharField(max_length=80, default='Default', unique=True) - portal_title = models.CharField(max_length=255, default='TUBCO Onboarding & Offboarding Portal') - company_name = models.CharField(max_length=255, default='TUBCO') - company_domain = models.CharField(max_length=120, blank=True, default='tub.co') - support_email = models.EmailField(blank=True, default='info@tub.co') - sender_display_name = models.CharField(max_length=255, blank=True, default='TUBCO') + 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='TUBCO Onboarding & Offboarding Portal') - footer_text_en = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal') + 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( @@ -170,6 +170,32 @@ class PortalAppConfig(models.Model): return self._translated_value('action_label_override', language_code) +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, diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py index 4b81ce7..579d4cb 100644 --- a/backend/workflows/roles.py +++ b/backend/workflows/roles.py @@ -41,6 +41,7 @@ CAPABILITIES = { '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}, @@ -140,6 +141,7 @@ def template_role_context(user) -> dict[str, object]: 'can_manage_integrations': user_has_capability(user, 'manage_integrations'), 'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'), 'can_manage_builders': user_has_capability(user, 'manage_builders'), + 'can_view_job_monitor': user_has_capability(user, 'view_job_monitor'), 'can_view_audit_log': user_has_capability(user, 'view_audit_log'), 'can_manage_backups': user_has_capability(user, 'manage_backups'), 'can_view_docs': user_has_capability(user, 'view_docs'), diff --git a/backend/workflows/services.py b/backend/workflows/services.py index d4ce1ec..559e8de 100644 --- a/backend/workflows/services.py +++ b/backend/workflows/services.py @@ -78,12 +78,16 @@ def _ensure_nextcloud_directory(base_url: str, directory: str, auth: tuple[str, current_parts: list[str] = [] for part in [p for p in directory.split('/') if p]: current_parts.append(part) - response = requests.request( - 'MKCOL', - f"{base_url}/{'/'.join(current_parts)}", - auth=auth, - timeout=timeout, - ) + try: + response = requests.request( + 'MKCOL', + f"{base_url}/{'/'.join(current_parts)}", + auth=auth, + timeout=timeout, + ) + except requests.RequestException as exc: + logger.warning('Nextcloud directory ensure error for %s: %s', '/'.join(current_parts), exc) + return False if response.status_code in (201, 301, 405): continue logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts)) diff --git a/backend/workflows/static/workflows/js/offboarding_form.js b/backend/workflows/static/workflows/js/offboarding_form.js index bccbae1..d62db6e 100644 --- a/backend/workflows/static/workflows/js/offboarding_form.js +++ b/backend/workflows/static/workflows/js/offboarding_form.js @@ -23,7 +23,7 @@ const fullName = byName('full_name'); const workEmail = byName('work_email'); const form = fullName ? fullName.closest('form') : null; - const emailDomain = (((form && form.dataset.emailDomain) || 'tub.co') + '').replace(/^@+/, '').trim(); + const emailDomain = (((form && form.dataset.emailDomain) || 'workdock.de') + '').replace(/^@+/, '').trim(); if (!fullName || !workEmail) return; let lastSuggested = ''; diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index 8a0ce0b..b33ac38 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -5,7 +5,7 @@ const btnNext = document.getElementById('btn-next'); const btnSubmit = document.getElementById('btn-submit'); const form = document.getElementById('onboarding-form'); - const emailDomain = ((form && form.dataset.emailDomain) || 'tub.co').replace(/^@+/, '').trim(); + const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim(); let current = 0; form.setAttribute('novalidate', 'novalidate'); diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 238efcb..e45e594 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -4,7 +4,7 @@ import base64 import mimetypes import re -from celery import shared_task +from celery import current_task, shared_task from django.contrib.auth import get_user_model from django.conf import settings from django.utils import timezone @@ -13,8 +13,8 @@ from jinja2 import Template from pypdf import PageObject, PdfReader, PdfWriter from xhtml2pdf import pisa -from .branding import get_company_contact_copy, get_default_notification_templates, get_portal_letterhead_path -from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .branding import get_branding_email_copy, get_company_contact_copy, get_default_notification_templates, get_portal_letterhead_path +from .models import AsyncTaskLog, EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .emailing import send_system_email from .services import upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode @@ -247,6 +247,27 @@ DEFAULT_NOTIFICATION_TEMPLATES = { }, } + +def _start_task_log(task_name: str, *, target_type: str = '', target_id: int | None = None, target_label: str = '') -> AsyncTaskLog: + task_request = getattr(current_task, 'request', None) + return AsyncTaskLog.objects.create( + task_name=task_name, + task_id=getattr(task_request, 'id', '') or '', + target_type=target_type, + target_id=target_id, + target_label=target_label, + status='started', + ) + + +def _finish_task_log(task_log: AsyncTaskLog | None, *, status: str, error_message: str = '') -> None: + if not task_log: + return + task_log.status = status + task_log.error_message = error_message + task_log.finished_at = timezone.now() + task_log.save(update_fields=['status', 'error_message', 'finished_at']) + def _split_name(full_name: str) -> tuple[str, str]: parts = full_name.split() if not parts: @@ -1196,6 +1217,12 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path: @shared_task def process_onboarding_request(onboarding_request_id: int) -> None: request_obj = OnboardingRequest.objects.get(id=onboarding_request_id) + task_log = _start_task_log( + 'process_onboarding_request', + target_type='onboarding_request', + target_id=request_obj.id, + target_label=request_obj.full_name, + ) request_obj.processing_status = 'processing' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) @@ -1301,16 +1328,24 @@ def process_onboarding_request(onboarding_request_id: int) -> None: request_obj.processing_status = 'completed' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) + _finish_task_log(task_log, status='succeeded') except Exception as exc: request_obj.processing_status = 'failed' request_obj.last_error = str(exc) request_obj.save(update_fields=['processing_status', 'last_error']) + _finish_task_log(task_log, status='failed', error_message=str(exc)) raise @shared_task def process_offboarding_request(offboarding_request_id: int) -> None: request_obj = OffboardingRequest.objects.get(id=offboarding_request_id) + task_log = _start_task_log( + 'process_offboarding_request', + target_type='offboarding_request', + target_id=request_obj.id, + target_label=request_obj.full_name, + ) request_obj.processing_status = 'processing' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) @@ -1380,10 +1415,12 @@ def process_offboarding_request(offboarding_request_id: int) -> None: request_obj.processing_status = 'completed' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) + _finish_task_log(task_log, status='succeeded') except Exception as exc: request_obj.processing_status = 'failed' request_obj.last_error = str(exc) request_obj.save(update_fields=['processing_status', 'last_error']) + _finish_task_log(task_log, status='failed', error_message=str(exc)) raise @@ -1392,15 +1429,24 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals scheduled = ScheduledWelcomeEmail.objects.select_related('onboarding_request').filter(id=scheduled_email_id).first() if not scheduled: return + task_log = _start_task_log( + 'send_scheduled_welcome_email', + target_type='scheduled_welcome_email', + target_id=scheduled.id, + target_label=scheduled.recipient_email, + ) if scheduled.status in {'sent', 'cancelled'} and not force_now: + _finish_task_log(task_log, status='succeeded') return if scheduled.status == 'paused' and not force_now: + _finish_task_log(task_log, status='succeeded') return if not force_now and timezone.now() < scheduled.send_at: async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=scheduled.send_at) scheduled.celery_task_id = async_result.id or scheduled.celery_task_id scheduled.save(update_fields=['celery_task_id', 'updated_at']) + _finish_task_log(task_log, status='succeeded') return request_obj = scheduled.onboarding_request @@ -1441,9 +1487,11 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals scheduled.status = 'sent' scheduled.sent_at = timezone.now() scheduled.last_error = '' + _finish_task_log(task_log, status='succeeded') except Exception as exc: scheduled.status = 'failed' scheduled.last_error = str(exc) + _finish_task_log(task_log, status='failed', error_message=str(exc)) raise finally: scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at']) diff --git a/backend/workflows/templates/workflows/backup_recovery.html b/backend/workflows/templates/workflows/backup_recovery.html index 9edda46..91b8065 100644 --- a/backend/workflows/templates/workflows/backup_recovery.html +++ b/backend/workflows/templates/workflows/backup_recovery.html @@ -14,6 +14,32 @@ {% include 'workflows/includes/messages.html' %} +
+
+
+

{% trans "Backup-Status" %}

+
{{ backup_health.summary }}
+
+
+ {% if backup_health.status == 'healthy' %} + {{ backup_health.label }} + {% elif backup_health.status == 'stale' %} + {{ backup_health.label }} + {% elif backup_health.status == 'unverified' %} + {{ backup_health.label }} + {% else %} + {{ backup_health.label }} + {% endif %} + {% if backup_health.bundle_name %} + {{ backup_health.bundle_name }} + {% endif %} + {% if backup_health.verified_at %} + {% trans "Zuletzt verifiziert:" %} {{ backup_health.verified_at|slice:":16"|cut:"T" }} + {% endif %} +
+
+
+
@@ -27,6 +53,12 @@
+
+

{% trans "Automation" %}

+
{% trans "Für einen geplanten Verify-Run außerhalb der UI:" %}
+
docker compose exec -T web python manage.py verify_latest_backup --create-if-missing
+
+

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

{% if rows %} diff --git a/backend/workflows/templates/workflows/branding_settings.html b/backend/workflows/templates/workflows/branding_settings.html index 2257c93..f1a989f 100644 --- a/backend/workflows/templates/workflows/branding_settings.html +++ b/backend/workflows/templates/workflows/branding_settings.html @@ -163,7 +163,7 @@
-
{% trans "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}
+
{% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}
diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index 9574168..9765b96 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -254,6 +254,10 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS
  • Remote backup target configuration is managed in Integrationen → Backup-Ziel.
  • Current remote target support: nextcloud implemented, s3 and nfs config-ready but not yet implemented.
  • Verification is non-destructive: it restores into a temporary verification database and extracts media into a temporary directory.
  • +
  • For scheduled operational hygiene, verify the newest bundle directly from the app container: +
    docker compose exec -T web python manage.py verify_latest_backup --create-if-missing
    +
  • +
  • The Backup & Recovery page now shows whether the latest verification is current, stale, missing, or still unverified.
  • Real restore is explicit and destructive by design:
    ./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS
  • diff --git a/backend/workflows/templates/workflows/job_monitor.html b/backend/workflows/templates/workflows/job_monitor.html new file mode 100644 index 0000000..3828049 --- /dev/null +++ b/backend/workflows/templates/workflows/job_monitor.html @@ -0,0 +1,73 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Job Monitor" %}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +

    {% trans "Job Monitor" %}

    +

    {% trans "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe zentral prüfen." %}

    + +{% include 'workflows/includes/messages.html' %} + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% empty %} + + {% endfor %} + +
    {% trans "Start" %}{% trans "Task" %}{% trans "Status" %}{% trans "Ziel" %}{% trans "Task ID" %}{% trans "Fehler" %}
    {{ log.started_at|date:"d.m.Y H:i:s" }}{{ log.task_name }}{{ log.get_status_display }}{{ log.target_label|default:log.target_type }}{{ log.task_id|default:"-" }}{% if log.error_message %}{{ log.error_message|truncatechars:180 }}{% else %}-{% endif %}
    {% trans "Noch keine Task-Läufe vorhanden." %}
    +
    +
    +{% endblock %} diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html index 52fd3b7..437de2d 100644 --- a/backend/workflows/templates/workflows/project_wiki.html +++ b/backend/workflows/templates/workflows/project_wiki.html @@ -207,9 +207,13 @@
  • Backup standard: create DB+media bundles under backups/ and verify them with a temporary restore before using a real restore.
  • Staff UI shortcut: Admin Apps → Backup & Recovery for create, verify, and delete actions.
  • Each backup row shows both local bundle availability and remote backup state.
  • +
  • The backup page also shows whether the latest verification is current, stale, missing, or still pending.
  • Remote backup target configuration lives under Admin Apps → IntegrationenBackup-Ziel.
  • Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.
  • Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.
  • +
  • For scheduled verification outside the browser, run: +
    docker compose exec -T web python manage.py verify_latest_backup --create-if-missing
    +
  • Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → Branding.
  • Trial deployments can force safe integration behavior: Nextcloud is treated as disabled and email remains in test mode while the trial restriction is active.
  • Expired trials should be cleaned with the dedicated command, not from the browser: diff --git a/backend/workflows/templates/workflows/release_checklist.html b/backend/workflows/templates/workflows/release_checklist.html index 55efb5e..8e9f72c 100644 --- a/backend/workflows/templates/workflows/release_checklist.html +++ b/backend/workflows/templates/workflows/release_checklist.html @@ -40,9 +40,15 @@ docker compose up -d --build web worker
  • {% trans "Run tests or a targeted verification command for the changed area." %}
  • {% trans "Compile translations after UI/content changes." %}
  • {% trans "If dependencies changed, verify imports do not emit warnings." %}
  • +
  • {% trans "Verify the latest backup bundle before release if operational tooling, storage, or restore behavior changed." %}
  • +
  • {% trans "Prefer the single local release gate command so local validation matches CI." %}
  • -
    docker compose exec -T web python manage.py check
    +        
    make release-validate
    +
    +# individual commands if needed:
    +docker compose exec -T web python manage.py check
     docker compose exec -T web python manage.py test
    +docker compose exec -T web python manage.py verify_latest_backup --create-if-missing
     make i18n-compile
     docker compose exec -T web python -c "import requests"
    diff --git a/backend/workflows/tests/test_app_registry_permissions.py b/backend/workflows/tests/test_app_registry_permissions.py new file mode 100644 index 0000000..20dc9b9 --- /dev/null +++ b/backend/workflows/tests/test_app_registry_permissions.py @@ -0,0 +1,50 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.app_registry import build_portal_app_sections, ensure_portal_app_configs +from workflows.models import PortalAppConfig +from workflows.roles import ROLE_ADMIN, ROLE_IT_STAFF, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role + + +class AppRegistryPermissionTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.platform_owner = user_model.objects.create_user(username='platform_owner_case', password='secret123') + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) + + self.super_admin = user_model.objects.create_user(username='super_admin_case', password='secret123') + assign_user_role(self.super_admin, ROLE_SUPER_ADMIN) + + self.admin = user_model.objects.create_user(username='admin_case', password='secret123') + assign_user_role(self.admin, ROLE_ADMIN) + + self.it_staff = user_model.objects.create_user(username='it_staff_case', password='secret123') + assign_user_role(self.it_staff, ROLE_IT_STAFF) + + self.staff = user_model.objects.create_user(username='staff_case', password='secret123') + assign_user_role(self.staff, ROLE_STAFF) + + ensure_portal_app_configs() + + def _visible_keys(self, user): + sections = build_portal_app_sections(user) + return {app['key'] for section in sections for app in section['apps']} + + def test_onboarding_and_offboarding_visible_to_staff_by_default(self): + keys = self._visible_keys(self.staff) + self.assertIn('onboarding', keys) + self.assertIn('offboarding', keys) + + def test_trial_management_is_platform_only(self): + self.assertIn('trial_management', self._visible_keys(self.platform_owner)) + self.assertNotIn('trial_management', self._visible_keys(self.super_admin)) + self.assertNotIn('trial_management', self._visible_keys(self.admin)) + + def test_requests_dashboard_can_be_hidden_from_staff_via_registry(self): + config = PortalAppConfig.objects.get(key='requests_dashboard') + config.visible_to_staff = False + config.save(update_fields=['visible_to_staff', 'updated_at']) + + self.assertNotIn('requests_dashboard', self._visible_keys(self.staff)) + self.assertIn('requests_dashboard', self._visible_keys(self.it_staff)) + diff --git a/backend/workflows/tests/test_async_task_logging.py b/backend/workflows/tests/test_async_task_logging.py new file mode 100644 index 0000000..760e75c --- /dev/null +++ b/backend/workflows/tests/test_async_task_logging.py @@ -0,0 +1,52 @@ +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from django.test import TestCase, override_settings +from django.utils import timezone + +from workflows.models import AsyncTaskLog, OnboardingRequest, ScheduledWelcomeEmail +from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class AsyncTaskLoggingTests(TestCase): + def setUp(self): + self.onboarding = OnboardingRequest.objects.create( + full_name='Task Failure', + gender='herr', + job_title='Engineer', + department='IT-Service', + work_email='task.failure@tub.co', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + onboarded_by_email='requester@tub.co', + agreement='accepted', + ) + + @patch('workflows.tasks._generate_onboarding_pdf', side_effect=RuntimeError('pdf failed')) + def test_failed_onboarding_task_creates_failed_async_log(self, _mock_generate_pdf): + with self.assertRaises(RuntimeError): + process_onboarding_request(self.onboarding.id) + + log = AsyncTaskLog.objects.filter(task_name='process_onboarding_request').latest('id') + self.assertEqual(log.status, 'failed') + self.assertEqual(log.target_id, self.onboarding.id) + self.assertIn('pdf failed', log.error_message) + + @patch('workflows.tasks._send_templated_email', side_effect=RuntimeError('smtp failed')) + def test_failed_welcome_email_creates_failed_async_log(self, _mock_send): + scheduled = ScheduledWelcomeEmail.objects.create( + onboarding_request=self.onboarding, + recipient_email='task.failure@tub.co', + send_at=timezone.now(), + status='scheduled', + ) + + with self.assertRaises(RuntimeError): + send_scheduled_welcome_email(scheduled.id, True) + + log = AsyncTaskLog.objects.filter(task_name='send_scheduled_welcome_email').latest('id') + self.assertEqual(log.status, 'failed') + self.assertEqual(log.target_id, scheduled.id) + self.assertIn('smtp failed', log.error_message) diff --git a/backend/workflows/tests/test_backup_reliability.py b/backend/workflows/tests/test_backup_reliability.py new file mode 100644 index 0000000..3addfd7 --- /dev/null +++ b/backend/workflows/tests/test_backup_reliability.py @@ -0,0 +1,67 @@ +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase, override_settings +from django.utils import timezone + +from workflows.backup_ops import latest_backup_health_snapshot + + +class BackupReliabilityTests(TestCase): + @override_settings(BACKUP_OUTPUT_DIR=tempfile.gettempdir()) + def test_latest_backup_health_reports_missing_when_no_bundle_exists(self): + with tempfile.TemporaryDirectory() as tmpdir: + with override_settings(BACKUP_OUTPUT_DIR=tmpdir): + snapshot = latest_backup_health_snapshot() + + self.assertEqual(snapshot['status'], 'missing') + self.assertTrue(snapshot['is_stale']) + + def test_latest_backup_health_reports_stale_when_verification_is_old(self): + with tempfile.TemporaryDirectory() as tmpdir: + backup_dir = Path(tmpdir) / 'backup_20260326_010101' + backup_dir.mkdir(parents=True) + (backup_dir / 'db.dump').write_text('db', encoding='utf-8') + (backup_dir / 'media.tar.gz').write_text('media', encoding='utf-8') + (backup_dir / 'backup_meta.json').write_text( + json.dumps( + { + 'created_at': timezone.now().isoformat(), + 'verify_status': 'verified', + 'verified_at': (timezone.now() - timezone.timedelta(hours=72)).isoformat(), + } + ), + encoding='utf-8', + ) + + with override_settings(BACKUP_OUTPUT_DIR=tmpdir): + snapshot = latest_backup_health_snapshot(stale_after_hours=48) + + self.assertEqual(snapshot['status'], 'stale') + self.assertEqual(snapshot['bundle_name'], 'backup_20260326_010101') + + @patch('workflows.management.commands.verify_latest_backup.verify_backup_bundle') + @patch('workflows.management.commands.verify_latest_backup.list_backup_bundles') + def test_verify_latest_backup_command_uses_existing_latest_bundle(self, list_bundles, verify_bundle): + list_bundles.return_value = [{'name': 'backup_20260326_020202'}] + verify_bundle.return_value = {'name': 'backup_20260326_020202', 'summary': 'ok'} + + call_command('verify_latest_backup') + + verify_bundle.assert_called_once_with('backup_20260326_020202') + + @patch('workflows.management.commands.verify_latest_backup.verify_backup_bundle') + @patch('workflows.management.commands.verify_latest_backup.create_backup_bundle') + @patch('workflows.management.commands.verify_latest_backup.list_backup_bundles') + def test_verify_latest_backup_command_can_create_when_missing(self, list_bundles, create_bundle, verify_bundle): + list_bundles.return_value = [] + create_bundle.return_value = {'name': 'backup_20260326_030303'} + verify_bundle.return_value = {'name': 'backup_20260326_030303', 'summary': 'ok'} + + call_command('verify_latest_backup', create_if_missing=True) + + create_bundle.assert_called_once() + verify_bundle.assert_called_once_with('backup_20260326_030303') diff --git a/backend/workflows/tests/test_nextcloud_service.py b/backend/workflows/tests/test_nextcloud_service.py index d468893..7044d1c 100644 --- a/backend/workflows/tests/test_nextcloud_service.py +++ b/backend/workflows/tests/test_nextcloud_service.py @@ -22,10 +22,12 @@ class NextcloudServiceTests(TestCase): NEXTCLOUD_USERNAME='u', NEXTCLOUD_PASSWORD='p', ) + @patch('workflows.services.requests.request') @patch('workflows.services.requests.put') - def test_upload_calls_webdav_and_accepts_201(self, mock_put): + def test_upload_calls_webdav_and_accepts_201(self, mock_put, mock_request): temp_file = Path('/tmp/nextcloud_mock_upload.txt') temp_file.write_text('hello', encoding='utf-8') + mock_request.return_value.status_code = 201 mock_put.return_value.status_code = 201 try: @@ -45,8 +47,9 @@ class NextcloudServiceTests(TestCase): NEXTCLOUD_USERNAME='env-user', NEXTCLOUD_PASSWORD='env-pass', ) + @patch('workflows.services.requests.request') @patch('workflows.services.requests.put') - def test_upload_prefers_workflowconfig_overrides(self, mock_put): + def test_upload_prefers_workflowconfig_overrides(self, mock_put, mock_request): WorkflowConfig.objects.update_or_create( name='Default', defaults={ @@ -59,6 +62,7 @@ class NextcloudServiceTests(TestCase): ) temp_file = Path('/tmp/nextcloud_override_upload.txt') temp_file.write_text('hello', encoding='utf-8') + mock_request.return_value.status_code = 201 mock_put.return_value.status_code = 201 try: diff --git a/backend/workflows/tests/test_request_id_logging.py b/backend/workflows/tests/test_request_id_logging.py new file mode 100644 index 0000000..3cf6fe8 --- /dev/null +++ b/backend/workflows/tests/test_request_id_logging.py @@ -0,0 +1,31 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + + +class RequestIDMiddlewareTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='request_id_user', + password='secret123', + email='requestid@tub.co', + ) + + def test_response_contains_request_id_header(self): + client = Client(HTTP_HOST='127.0.0.1') + client.force_login(self.user) + + response = client.get('/') + + self.assertEqual(response.status_code, 200) + self.assertIn('X-Request-ID', response.headers) + self.assertTrue(response.headers['X-Request-ID']) + + def test_incoming_request_id_is_preserved(self): + client = Client(HTTP_HOST='127.0.0.1', HTTP_X_REQUEST_ID='external-request-123') + client.force_login(self.user) + + response = client.get('/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers['X-Request-ID'], 'external-request-123') diff --git a/backend/workflows/tests/test_trial_lifecycle.py b/backend/workflows/tests/test_trial_lifecycle.py new file mode 100644 index 0000000..c0d4c58 --- /dev/null +++ b/backend/workflows/tests/test_trial_lifecycle.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.utils import timezone + +from workflows.branding import get_portal_trial_config +from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role +from workflows.services import is_email_test_mode, is_nextcloud_enabled + + +class TrialLifecycleTests(TestCase): + def setUp(self): + user_model = get_user_model() + + self.platform_owner = user_model.objects.create_user(username='trial_platform_owner', password='secret123') + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) + + self.staff = user_model.objects.create_user(username='trial_staff', password='secret123') + assign_user_role(self.staff, ROLE_STAFF) + + self.trial = get_portal_trial_config() + self.original_values = ( + self.trial.is_trial_mode, + self.trial.trial_started_at, + self.trial.trial_expires_at, + self.trial.restrict_production_integrations, + self.trial.auto_cleanup_enabled, + ) + + def tearDown(self): + ( + self.trial.is_trial_mode, + self.trial.trial_started_at, + self.trial.trial_expires_at, + self.trial.restrict_production_integrations, + self.trial.auto_cleanup_enabled, + ) = self.original_values + self.trial.save() + + def test_staff_is_blocked_after_trial_expiry(self): + self.trial.is_trial_mode = True + self.trial.trial_started_at = timezone.now() - timezone.timedelta(days=10) + self.trial.trial_expires_at = timezone.now() - timezone.timedelta(days=1) + self.trial.save() + + client = Client(HTTP_HOST='127.0.0.1') + client.force_login(self.staff) + response = client.get('/requests/') + + self.assertEqual(response.status_code, 403) + self.assertIn('Trial abgelaufen', response.content.decode('utf-8')) + + def test_platform_owner_keeps_access_after_trial_expiry(self): + self.trial.is_trial_mode = True + self.trial.trial_started_at = timezone.now() - timezone.timedelta(days=10) + self.trial.trial_expires_at = timezone.now() - timezone.timedelta(days=1) + self.trial.save() + + client = Client(HTTP_HOST='127.0.0.1') + client.force_login(self.platform_owner) + response = client.get('/requests/') + + self.assertEqual(response.status_code, 200) + + def test_trial_restriction_forces_safe_integration_modes(self): + self.trial.is_trial_mode = True + self.trial.trial_started_at = timezone.now() - timezone.timedelta(days=1) + self.trial.trial_expires_at = timezone.now() + timezone.timedelta(days=2) + self.trial.restrict_production_integrations = True + self.trial.save() + + self.assertFalse(is_nextcloud_enabled()) + self.assertTrue(is_email_test_mode()) + diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index 32e9e3b..13265e0 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -38,6 +38,7 @@ urlpatterns = [ path('admin-tools/trial/save/', views.save_portal_trial_config, name='save_portal_trial_config'), path('admin-tools/apps/', views.portal_app_registry_page, name='portal_app_registry_page'), path('admin-tools/apps/save/', views.save_portal_app_registry, name='save_portal_app_registry'), + path('admin-tools/jobs/', views.job_monitor_page, name='job_monitor_page'), path('admin-tools/users/', views.user_management_page, name='user_management_page'), path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'), path('admin-tools/users//update/', views.update_user_from_admin, name='update_user_from_admin'), diff --git a/backend/workflows/views.py b/backend/workflows/views.py index ba22be9..afbacb0 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -25,7 +25,13 @@ from django.utils.translation import get_language, override from django.urls import reverse from .app_registry import build_portal_app_sections, get_portal_app_registry_rows -from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle +from .backup_ops import ( + create_backup_bundle, + delete_backup_bundle, + latest_backup_health_snapshot, + list_backup_bundles, + verify_backup_bundle, +) from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( @@ -36,7 +42,7 @@ from .form_builder import ( ONBOARDING_PAGE_ORDER, ensure_form_field_configs, ) -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig from .emailing import send_system_email from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud @@ -355,6 +361,30 @@ def portal_app_registry_page(request): ) +@_require_capability('view_job_monitor') +def job_monitor_page(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()) + 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'))], + }, + ) + + @_require_capability('manage_app_registry') @require_POST def save_portal_app_registry(request): @@ -893,11 +923,13 @@ def audit_log_page(request): @_require_capability('manage_backups') def backup_recovery_page(request): + rows = list_backup_bundles() return render( request, 'workflows/backup_recovery.html', { - 'rows': list_backup_bundles(), + 'rows': rows, + 'backup_health': latest_backup_health_snapshot(), }, )