snapshot: preserve reliability hardening and Workdock identity pass
This commit is contained in:
10
.env.example
10
.env.example
@@ -19,11 +19,11 @@ EMAIL_USE_TLS=0
|
|||||||
EMAIL_USE_SSL=0
|
EMAIL_USE_SSL=0
|
||||||
DEFAULT_FROM_EMAIL=onboarding@example.local
|
DEFAULT_FROM_EMAIL=onboarding@example.local
|
||||||
TEST_NOTIFICATION_EMAIL=hr@example.local
|
TEST_NOTIFICATION_EMAIL=hr@example.local
|
||||||
IT_ONBOARDING_NOTIFICATION_EMAIL=it@tub.co
|
IT_ONBOARDING_NOTIFICATION_EMAIL=it@workdock.de
|
||||||
GENERAL_INFO_NOTIFICATION_EMAIL=ingo.einacker@tub.co
|
GENERAL_INFO_NOTIFICATION_EMAIL=info@workdock.de
|
||||||
BUSINESS_CARD_NOTIFICATION_EMAIL=kommunikation@tub.co
|
BUSINESS_CARD_NOTIFICATION_EMAIL=cards@workdock.de
|
||||||
HR_WORKS_NOTIFICATION_EMAIL=dittrich@tub.co
|
HR_WORKS_NOTIFICATION_EMAIL=hr@workdock.de
|
||||||
KEY_NOTIFICATION_EMAIL=minuth@tub.co
|
KEY_NOTIFICATION_EMAIL=keys@workdock.de
|
||||||
|
|
||||||
NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding
|
NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding
|
||||||
NEXTCLOUD_USERNAME=onboarding@example.com
|
NEXTCLOUD_USERNAME=onboarding@example.com
|
||||||
|
|||||||
75
.github/workflows/ci.yml
vendored
75
.github/workflows/ci.yml
vendored
@@ -4,8 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
django-tests:
|
python-validation:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -59,11 +63,80 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install gettext
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
- name: Django system check
|
- name: Django system check
|
||||||
run: python manage.py check
|
run: python manage.py check
|
||||||
|
|
||||||
- name: Migration drift check
|
- name: Migration drift check
|
||||||
run: python manage.py makemigrations --check --dry-run
|
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
|
- name: Run tests
|
||||||
run: python manage.py test workflows.tests -v 2
|
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
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
|||||||
COMPOSE ?= docker compose
|
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:
|
i18n-extract-en:
|
||||||
$(COMPOSE) exec -T web django-admin makemessages -l en
|
$(COMPOSE) exec -T web django-admin makemessages -l en
|
||||||
@@ -21,3 +21,11 @@ backup-create:
|
|||||||
backup-verify:
|
backup-verify:
|
||||||
@if [ -z "$(BACKUP_DIR)" ]; then echo "Usage: make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS"; exit 1; fi
|
@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)"
|
./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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Goal
|
## 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:
|
Current branch roles:
|
||||||
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -1,6 +1,6 @@
|
|||||||
# TUBCO Onboarding & Offboarding Portal
|
# Workdock
|
||||||
|
|
||||||
This is the standalone dockerized web application for the TUBCO onboarding and offboarding workflow.
|
Workdock is the dockerized business operations platform that powers internal company apps such as onboarding, offboarding, requests, integrations, backups, and future modular workplace tools.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
- `web`: Django app (`http://localhost:8000`)
|
- `web`: Django app (`http://localhost:8000`)
|
||||||
@@ -99,3 +99,20 @@ Verification behavior:
|
|||||||
- restores the dump into a temporary verification database
|
- restores the dump into a temporary verification database
|
||||||
- extracts media into a temporary directory
|
- extracts media into a temporary directory
|
||||||
- checks that the restored DB and media structure are readable
|
- 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
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'workflows.middleware.RequestIDMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'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'
|
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', '0') == '1'
|
||||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'onboarding@example.local')
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'onboarding@example.local')
|
||||||
TEST_NOTIFICATION_EMAIL = os.getenv('TEST_NOTIFICATION_EMAIL', 'hr@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')
|
IT_ONBOARDING_NOTIFICATION_EMAIL = os.getenv('IT_ONBOARDING_NOTIFICATION_EMAIL', 'it@workdock.de')
|
||||||
GENERAL_INFO_NOTIFICATION_EMAIL = os.getenv('GENERAL_INFO_NOTIFICATION_EMAIL', 'ingo.einacker@tub.co')
|
GENERAL_INFO_NOTIFICATION_EMAIL = os.getenv('GENERAL_INFO_NOTIFICATION_EMAIL', 'info@workdock.de')
|
||||||
BUSINESS_CARD_NOTIFICATION_EMAIL = os.getenv('BUSINESS_CARD_NOTIFICATION_EMAIL', 'kommunikation@tub.co')
|
BUSINESS_CARD_NOTIFICATION_EMAIL = os.getenv('BUSINESS_CARD_NOTIFICATION_EMAIL', 'cards@workdock.de')
|
||||||
HR_WORKS_NOTIFICATION_EMAIL = os.getenv('HR_WORKS_NOTIFICATION_EMAIL', 'dittrich@tub.co')
|
HR_WORKS_NOTIFICATION_EMAIL = os.getenv('HR_WORKS_NOTIFICATION_EMAIL', 'hr@workdock.de')
|
||||||
KEY_NOTIFICATION_EMAIL = os.getenv('KEY_NOTIFICATION_EMAIL', 'minuth@tub.co')
|
KEY_NOTIFICATION_EMAIL = os.getenv('KEY_NOTIFICATION_EMAIL', 'keys@workdock.de')
|
||||||
EMAIL_TEST_MODE = os.getenv('EMAIL_TEST_MODE', '0') == '1'
|
EMAIL_TEST_MODE = os.getenv('EMAIL_TEST_MODE', '0') == '1'
|
||||||
EMAIL_TEST_REDIRECT = os.getenv('EMAIL_TEST_REDIRECT', TEST_NOTIFICATION_EMAIL)
|
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_TIMEOUT_SECONDS = int(os.getenv('NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', '30'))
|
||||||
NEXTCLOUD_UPLOAD_RETRIES = int(os.getenv('NEXTCLOUD_UPLOAD_RETRIES', '2'))
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ from django.conf import settings
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .emailing import send_system_email
|
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)
|
@admin.register(EmployeeProfile)
|
||||||
@@ -20,6 +20,14 @@ class AdminAuditLogAdmin(admin.ModelAdmin):
|
|||||||
ordering = ('-created_at', '-id')
|
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)
|
@admin.register(PortalBranding)
|
||||||
class PortalBrandingAdmin(admin.ModelAdmin):
|
class PortalBrandingAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at')
|
list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at')
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
|
|||||||
action_label=_('Öffnen'),
|
action_label=_('Öffnen'),
|
||||||
capability='manage_integrations',
|
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(
|
AppDefinition(
|
||||||
key='users',
|
key='users',
|
||||||
section=PortalAppConfig.SECTION_ADMIN,
|
section=PortalAppConfig.SECTION_ADMIN,
|
||||||
@@ -227,6 +236,12 @@ DEFAULT_ROLE_VISIBILITY = {
|
|||||||
ROLE_IT_STAFF: False,
|
ROLE_IT_STAFF: False,
|
||||||
ROLE_STAFF: False,
|
ROLE_STAFF: False,
|
||||||
},
|
},
|
||||||
|
'job_monitor': {
|
||||||
|
ROLE_SUPER_ADMIN: True,
|
||||||
|
ROLE_ADMIN: True,
|
||||||
|
ROLE_IT_STAFF: False,
|
||||||
|
ROLE_STAFF: False,
|
||||||
|
},
|
||||||
'users': {
|
'users': {
|
||||||
ROLE_SUPER_ADMIN: True,
|
ROLE_SUPER_ADMIN: True,
|
||||||
ROLE_ADMIN: False,
|
ROLE_ADMIN: False,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from .models import WorkflowConfig
|
from .models import WorkflowConfig
|
||||||
@@ -113,6 +114,55 @@ def list_backup_bundles() -> list[dict]:
|
|||||||
return rows
|
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:
|
def _remote_backup_config() -> dict:
|
||||||
config = WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first()
|
config = WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first()
|
||||||
if not config:
|
if not config:
|
||||||
@@ -264,8 +314,6 @@ def verify_backup_bundle(backup_name: str) -> dict:
|
|||||||
output=restore.stdout,
|
output=restore.stdout,
|
||||||
stderr=restore.stderr,
|
stderr=restore.stderr,
|
||||||
)
|
)
|
||||||
with connection.cursor() as cursor:
|
|
||||||
pass
|
|
||||||
table_count = subprocess.check_output(
|
table_count = subprocess.check_output(
|
||||||
['psql', *args, '-d', verify_db, '-t', '-A', '-c', "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';"],
|
['psql', *args, '-d', verify_db, '-t', '-A', '-c', "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';"],
|
||||||
env=env,
|
env=env,
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ def get_portal_branding() -> PortalBranding:
|
|||||||
branding, _ = PortalBranding.objects.get_or_create(
|
branding, _ = PortalBranding.objects.get_or_create(
|
||||||
name='Default',
|
name='Default',
|
||||||
defaults={
|
defaults={
|
||||||
'portal_title': 'TUBCO Onboarding & Offboarding Portal',
|
'portal_title': 'Workdock',
|
||||||
'company_name': 'TUBCO',
|
'company_name': 'Workdock',
|
||||||
'company_domain': 'tub.co',
|
'company_domain': 'workdock.de',
|
||||||
'support_email': 'info@tub.co',
|
'support_email': 'info@workdock.de',
|
||||||
'sender_display_name': 'TUBCO',
|
'sender_display_name': 'Workdock',
|
||||||
'login_subtitle': 'Bitte melden Sie sich mit Ihrem Benutzerkonto an.',
|
'login_subtitle': 'Bitte melden Sie sich mit Ihrem Benutzerkonto an.',
|
||||||
'footer_text': 'TUBCO Onboarding & Offboarding Portal',
|
'footer_text': 'Workdock',
|
||||||
'footer_text_en': 'TUBCO Onboarding & Offboarding Portal',
|
'footer_text_en': 'Workdock',
|
||||||
'legal_notice': '',
|
'legal_notice': '',
|
||||||
'legal_notice_en': '',
|
'legal_notice_en': '',
|
||||||
'default_language': 'de',
|
'default_language': 'de',
|
||||||
@@ -37,7 +37,7 @@ def get_portal_company_config() -> PortalCompanyConfig:
|
|||||||
company_config, _ = PortalCompanyConfig.objects.get_or_create(
|
company_config, _ = PortalCompanyConfig.objects.get_or_create(
|
||||||
name='Default',
|
name='Default',
|
||||||
defaults={
|
defaults={
|
||||||
'legal_company_name': 'TUBCO GmbH',
|
'legal_company_name': 'Workdock',
|
||||||
'country': 'Deutschland',
|
'country': 'Deutschland',
|
||||||
'website_url': '',
|
'website_url': '',
|
||||||
'imprint_url': '',
|
'imprint_url': '',
|
||||||
@@ -108,7 +108,7 @@ def get_trial_context() -> dict[str, object]:
|
|||||||
def get_company_email_domain() -> str:
|
def get_company_email_domain() -> str:
|
||||||
branding = get_portal_branding()
|
branding = get_portal_branding()
|
||||||
domain = (branding.company_domain or '').strip().lower().lstrip('@')
|
domain = (branding.company_domain or '').strip().lower().lstrip('@')
|
||||||
return domain or 'tub.co'
|
return domain or 'workdock.de'
|
||||||
|
|
||||||
|
|
||||||
def get_portal_logo_url() -> str:
|
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]:
|
def get_branding_email_copy() -> dict[str, str]:
|
||||||
branding = get_portal_branding()
|
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()
|
portal_title = (branding.portal_title or f'{company_name} Portal').strip()
|
||||||
return {
|
return {
|
||||||
'company_name': company_name,
|
'company_name': company_name,
|
||||||
@@ -205,7 +205,7 @@ def get_branding_email_copy() -> dict[str, str]:
|
|||||||
def get_company_contact_copy() -> dict[str, str]:
|
def get_company_contact_copy() -> dict[str, str]:
|
||||||
branding = get_portal_branding()
|
branding = get_portal_branding()
|
||||||
company_config = get_portal_company_config()
|
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()
|
legal_name = (company_config.legal_company_name or company_name).strip()
|
||||||
domain = get_company_email_domain()
|
domain = get_company_email_domain()
|
||||||
support_email = (branding.support_email or '').strip()
|
support_email = (branding.support_email or '').strip()
|
||||||
|
|||||||
54
backend/workflows/logging_utils.py
Normal file
54
backend/workflows/logging_utils.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from celery import current_task
|
||||||
|
|
||||||
|
_request_id: contextvars.ContextVar[str] = contextvars.ContextVar('request_id', default='')
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_id(value: str) -> None:
|
||||||
|
_request_id.set(value or '')
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> str:
|
||||||
|
return _request_id.get('')
|
||||||
|
|
||||||
|
|
||||||
|
def clear_request_id() -> None:
|
||||||
|
_request_id.set('')
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.request_id = get_request_id()
|
||||||
|
task = current_task
|
||||||
|
request = getattr(task, 'request', None) if task else None
|
||||||
|
record.task_id = getattr(request, 'id', '') if request else ''
|
||||||
|
record.task_name = getattr(task, 'name', '') if task else ''
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload = {
|
||||||
|
'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
||||||
|
'level': record.levelname,
|
||||||
|
'logger': record.name,
|
||||||
|
'message': record.getMessage(),
|
||||||
|
}
|
||||||
|
request_id = getattr(record, 'request_id', '') or ''
|
||||||
|
task_id = getattr(record, 'task_id', '') or ''
|
||||||
|
task_name = getattr(record, 'task_name', '') or ''
|
||||||
|
if request_id:
|
||||||
|
payload['request_id'] = request_id
|
||||||
|
if task_id:
|
||||||
|
payload['task_id'] = task_id
|
||||||
|
if task_name:
|
||||||
|
payload['task_name'] = task_name
|
||||||
|
if record.exc_info:
|
||||||
|
payload['exception'] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
@@ -6,7 +6,7 @@ from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role, e
|
|||||||
DEFAULT_USERS = [
|
DEFAULT_USERS = [
|
||||||
{
|
{
|
||||||
'username': 'admin_test',
|
'username': 'admin_test',
|
||||||
'email': 'admin_test@tub.co',
|
'email': 'admin_test@workdock.de',
|
||||||
'password': 'admin12345',
|
'password': 'admin12345',
|
||||||
'first_name': 'Admin',
|
'first_name': 'Admin',
|
||||||
'last_name': 'Test',
|
'last_name': 'Test',
|
||||||
@@ -15,7 +15,7 @@ DEFAULT_USERS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'user_test',
|
'username': 'user_test',
|
||||||
'email': 'user_test@tub.co',
|
'email': 'user_test@workdock.de',
|
||||||
'password': 'user12345',
|
'password': 'user12345',
|
||||||
'first_name': 'Normal',
|
'first_name': 'Normal',
|
||||||
'last_name': 'User',
|
'last_name': 'User',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.conf import settings
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from workflows.branding import get_company_email_domain
|
||||||
from workflows.models import EmployeeProfile, OffboardingRequest, OnboardingRequest
|
from workflows.models import EmployeeProfile, OffboardingRequest, OnboardingRequest
|
||||||
from workflows.tasks import process_offboarding_request, process_onboarding_request
|
from workflows.tasks import process_offboarding_request, process_onboarding_request
|
||||||
|
|
||||||
@@ -100,8 +101,9 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
run_id = timezone.now().strftime('%Y%m%d%H%M%S')
|
run_id = timezone.now().strftime('%Y%m%d%H%M%S')
|
||||||
employee_name = f'E2E Check {run_id}'
|
employee_name = f'E2E Check {run_id}'
|
||||||
work_email = f'e2e.{run_id}@tub.co'
|
domain = get_company_email_domain()
|
||||||
requester_email = 'e2e.requester@tub.co'
|
work_email = f'e2e.{run_id}@{domain}'
|
||||||
|
requester_email = f'e2e.requester@{domain}'
|
||||||
|
|
||||||
created_onboarding: OnboardingRequest | None = None
|
created_onboarding: OnboardingRequest | None = None
|
||||||
created_offboarding: OffboardingRequest | None = None
|
created_offboarding: OffboardingRequest | None = None
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from workflows.backup_ops import create_backup_bundle, list_backup_bundles, verify_backup_bundle
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Verifies the latest backup bundle. Can create one first if no bundle exists yet.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--create-if-missing',
|
||||||
|
action='store_true',
|
||||||
|
help='Create a new backup bundle first if none exists yet.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
rows = list_backup_bundles()
|
||||||
|
if not rows:
|
||||||
|
if not options['create_if_missing']:
|
||||||
|
raise CommandError(_('Kein Backup-Bundle vorhanden.'))
|
||||||
|
created = create_backup_bundle()
|
||||||
|
backup_name = created['name']
|
||||||
|
self.stdout.write(self.style.WARNING(_('Kein Backup gefunden. Neues Bundle erstellt: %(name)s') % {'name': backup_name}))
|
||||||
|
else:
|
||||||
|
backup_name = rows[0]['name']
|
||||||
|
|
||||||
|
result = verify_backup_bundle(backup_name)
|
||||||
|
self.stdout.write(self.style.SUCCESS(_('Backup erfolgreich verifiziert: %(name)s') % {'name': backup_name}))
|
||||||
|
self.stdout.write(result['summary'])
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
from .branding import is_trial_expired, is_trial_mode_enabled
|
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
|
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:
|
class TrialModeMiddleware:
|
||||||
EXEMPT_PREFIXES = (
|
EXEMPT_PREFIXES = (
|
||||||
'/healthz/',
|
'/healthz/',
|
||||||
|
|||||||
33
backend/workflows/migrations/0044_asynctasklog.py
Normal file
33
backend/workflows/migrations/0044_asynctasklog.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2026-03-26 22:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('workflows', '0043_portaltrialconfig'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AsyncTaskLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('task_name', models.CharField(max_length=255)),
|
||||||
|
('task_id', models.CharField(blank=True, max_length=255)),
|
||||||
|
('target_type', models.CharField(blank=True, max_length=80)),
|
||||||
|
('target_id', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('target_label', models.CharField(blank=True, max_length=255)),
|
||||||
|
('status', models.CharField(choices=[('started', 'Gestartet'), ('succeeded', 'Erfolgreich'), ('failed', 'Fehlgeschlagen')], default='started', max_length=20)),
|
||||||
|
('error_message', models.TextField(blank=True)),
|
||||||
|
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Async Task Log',
|
||||||
|
'verbose_name_plural': 'Async Task Logs',
|
||||||
|
'ordering': ['-started_at', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2026-03-26 23:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('workflows', '0044_asynctasklog'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='company_domain',
|
||||||
|
field=models.CharField(blank=True, default='workdock.de', max_length=120),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='company_name',
|
||||||
|
field=models.CharField(default='Workdock', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='footer_text',
|
||||||
|
field=models.CharField(blank=True, default='Workdock', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='footer_text_en',
|
||||||
|
field=models.CharField(blank=True, default='Workdock', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='portal_title',
|
||||||
|
field=models.CharField(default='Workdock', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='sender_display_name',
|
||||||
|
field=models.CharField(blank=True, default='Workdock', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='portalbranding',
|
||||||
|
name='support_email',
|
||||||
|
field=models.EmailField(blank=True, default='info@workdock.de', max_length=254),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -27,14 +27,14 @@ class EmployeeProfile(models.Model):
|
|||||||
|
|
||||||
class PortalBranding(models.Model):
|
class PortalBranding(models.Model):
|
||||||
name = models.CharField(max_length=80, default='Default', unique=True)
|
name = models.CharField(max_length=80, default='Default', unique=True)
|
||||||
portal_title = models.CharField(max_length=255, default='TUBCO Onboarding & Offboarding Portal')
|
portal_title = models.CharField(max_length=255, default='Workdock')
|
||||||
company_name = models.CharField(max_length=255, default='TUBCO')
|
company_name = models.CharField(max_length=255, default='Workdock')
|
||||||
company_domain = models.CharField(max_length=120, blank=True, default='tub.co')
|
company_domain = models.CharField(max_length=120, blank=True, default='workdock.de')
|
||||||
support_email = models.EmailField(blank=True, default='info@tub.co')
|
support_email = models.EmailField(blank=True, default='info@workdock.de')
|
||||||
sender_display_name = models.CharField(max_length=255, blank=True, default='TUBCO')
|
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.')
|
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 = models.CharField(max_length=255, blank=True, default='Workdock')
|
||||||
footer_text_en = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal')
|
footer_text_en = models.CharField(max_length=255, blank=True, default='Workdock')
|
||||||
legal_notice = models.TextField(blank=True, default='')
|
legal_notice = models.TextField(blank=True, default='')
|
||||||
legal_notice_en = models.TextField(blank=True, default='')
|
legal_notice_en = models.TextField(blank=True, default='')
|
||||||
default_language = models.CharField(
|
default_language = models.CharField(
|
||||||
@@ -170,6 +170,32 @@ class PortalAppConfig(models.Model):
|
|||||||
return self._translated_value('action_label_override', language_code)
|
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):
|
class AdminAuditLog(models.Model):
|
||||||
actor = models.ForeignKey(
|
actor = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ CAPABILITIES = {
|
|||||||
'manage_integrations': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
'manage_integrations': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||||
'manage_welcome_emails': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
'manage_welcome_emails': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||||
'manage_builders': {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},
|
'view_audit_log': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||||
'manage_backups': {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},
|
'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_integrations': user_has_capability(user, 'manage_integrations'),
|
||||||
'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'),
|
'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'),
|
||||||
'can_manage_builders': user_has_capability(user, 'manage_builders'),
|
'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_view_audit_log': user_has_capability(user, 'view_audit_log'),
|
||||||
'can_manage_backups': user_has_capability(user, 'manage_backups'),
|
'can_manage_backups': user_has_capability(user, 'manage_backups'),
|
||||||
'can_view_docs': user_has_capability(user, 'view_docs'),
|
'can_view_docs': user_has_capability(user, 'view_docs'),
|
||||||
|
|||||||
@@ -78,12 +78,16 @@ def _ensure_nextcloud_directory(base_url: str, directory: str, auth: tuple[str,
|
|||||||
current_parts: list[str] = []
|
current_parts: list[str] = []
|
||||||
for part in [p for p in directory.split('/') if p]:
|
for part in [p for p in directory.split('/') if p]:
|
||||||
current_parts.append(part)
|
current_parts.append(part)
|
||||||
|
try:
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
'MKCOL',
|
'MKCOL',
|
||||||
f"{base_url}/{'/'.join(current_parts)}",
|
f"{base_url}/{'/'.join(current_parts)}",
|
||||||
auth=auth,
|
auth=auth,
|
||||||
timeout=timeout,
|
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):
|
if response.status_code in (201, 301, 405):
|
||||||
continue
|
continue
|
||||||
logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts))
|
logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
const fullName = byName('full_name');
|
const fullName = byName('full_name');
|
||||||
const workEmail = byName('work_email');
|
const workEmail = byName('work_email');
|
||||||
const form = fullName ? fullName.closest('form') : null;
|
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;
|
if (!fullName || !workEmail) return;
|
||||||
|
|
||||||
let lastSuggested = '';
|
let lastSuggested = '';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
const btnNext = document.getElementById('btn-next');
|
const btnNext = document.getElementById('btn-next');
|
||||||
const btnSubmit = document.getElementById('btn-submit');
|
const btnSubmit = document.getElementById('btn-submit');
|
||||||
const form = document.getElementById('onboarding-form');
|
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;
|
let current = 0;
|
||||||
form.setAttribute('novalidate', 'novalidate');
|
form.setAttribute('novalidate', 'novalidate');
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import base64
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import current_task, shared_task
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -13,8 +13,8 @@ from jinja2 import Template
|
|||||||
from pypdf import PageObject, PdfReader, PdfWriter
|
from pypdf import PageObject, PdfReader, PdfWriter
|
||||||
from xhtml2pdf import pisa
|
from xhtml2pdf import pisa
|
||||||
|
|
||||||
from .branding import get_company_contact_copy, get_default_notification_templates, get_portal_letterhead_path
|
from .branding import get_branding_email_copy, 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 .models import AsyncTaskLog, EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
|
||||||
from .emailing import send_system_email
|
from .emailing import send_system_email
|
||||||
from .services import upload_to_nextcloud
|
from .services import upload_to_nextcloud
|
||||||
from .services import get_email_test_redirect, is_email_test_mode
|
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]:
|
def _split_name(full_name: str) -> tuple[str, str]:
|
||||||
parts = full_name.split()
|
parts = full_name.split()
|
||||||
if not parts:
|
if not parts:
|
||||||
@@ -1196,6 +1217,12 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
|
|||||||
@shared_task
|
@shared_task
|
||||||
def process_onboarding_request(onboarding_request_id: int) -> None:
|
def process_onboarding_request(onboarding_request_id: int) -> None:
|
||||||
request_obj = OnboardingRequest.objects.get(id=onboarding_request_id)
|
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.processing_status = 'processing'
|
||||||
request_obj.last_error = ''
|
request_obj.last_error = ''
|
||||||
request_obj.save(update_fields=['processing_status', '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.processing_status = 'completed'
|
||||||
request_obj.last_error = ''
|
request_obj.last_error = ''
|
||||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||||
|
_finish_task_log(task_log, status='succeeded')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
request_obj.processing_status = 'failed'
|
request_obj.processing_status = 'failed'
|
||||||
request_obj.last_error = str(exc)
|
request_obj.last_error = str(exc)
|
||||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||||
|
_finish_task_log(task_log, status='failed', error_message=str(exc))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_offboarding_request(offboarding_request_id: int) -> None:
|
def process_offboarding_request(offboarding_request_id: int) -> None:
|
||||||
request_obj = OffboardingRequest.objects.get(id=offboarding_request_id)
|
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.processing_status = 'processing'
|
||||||
request_obj.last_error = ''
|
request_obj.last_error = ''
|
||||||
request_obj.save(update_fields=['processing_status', '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.processing_status = 'completed'
|
||||||
request_obj.last_error = ''
|
request_obj.last_error = ''
|
||||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||||
|
_finish_task_log(task_log, status='succeeded')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
request_obj.processing_status = 'failed'
|
request_obj.processing_status = 'failed'
|
||||||
request_obj.last_error = str(exc)
|
request_obj.last_error = str(exc)
|
||||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||||
|
_finish_task_log(task_log, status='failed', error_message=str(exc))
|
||||||
raise
|
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()
|
scheduled = ScheduledWelcomeEmail.objects.select_related('onboarding_request').filter(id=scheduled_email_id).first()
|
||||||
if not scheduled:
|
if not scheduled:
|
||||||
return
|
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:
|
if scheduled.status in {'sent', 'cancelled'} and not force_now:
|
||||||
|
_finish_task_log(task_log, status='succeeded')
|
||||||
return
|
return
|
||||||
if scheduled.status == 'paused' and not force_now:
|
if scheduled.status == 'paused' and not force_now:
|
||||||
|
_finish_task_log(task_log, status='succeeded')
|
||||||
return
|
return
|
||||||
|
|
||||||
if not force_now and timezone.now() < scheduled.send_at:
|
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)
|
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.celery_task_id = async_result.id or scheduled.celery_task_id
|
||||||
scheduled.save(update_fields=['celery_task_id', 'updated_at'])
|
scheduled.save(update_fields=['celery_task_id', 'updated_at'])
|
||||||
|
_finish_task_log(task_log, status='succeeded')
|
||||||
return
|
return
|
||||||
|
|
||||||
request_obj = scheduled.onboarding_request
|
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.status = 'sent'
|
||||||
scheduled.sent_at = timezone.now()
|
scheduled.sent_at = timezone.now()
|
||||||
scheduled.last_error = ''
|
scheduled.last_error = ''
|
||||||
|
_finish_task_log(task_log, status='succeeded')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
scheduled.status = 'failed'
|
scheduled.status = 'failed'
|
||||||
scheduled.last_error = str(exc)
|
scheduled.last_error = str(exc)
|
||||||
|
_finish_task_log(task_log, status='failed', error_message=str(exc))
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at'])
|
scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at'])
|
||||||
|
|||||||
@@ -14,6 +14,32 @@
|
|||||||
|
|
||||||
{% include 'workflows/includes/messages.html' %}
|
{% include 'workflows/includes/messages.html' %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0;">{% trans "Backup-Status" %}</h2>
|
||||||
|
<div class="hint">{{ backup_health.summary }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
{% if backup_health.status == 'healthy' %}
|
||||||
|
<span class="badge sent">{{ backup_health.label }}</span>
|
||||||
|
{% elif backup_health.status == 'stale' %}
|
||||||
|
<span class="badge failed">{{ backup_health.label }}</span>
|
||||||
|
{% elif backup_health.status == 'unverified' %}
|
||||||
|
<span class="badge paused">{{ backup_health.label }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge cancelled">{{ backup_health.label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if backup_health.bundle_name %}
|
||||||
|
<span class="badge scheduled"><code>{{ backup_health.bundle_name }}</code></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if backup_health.verified_at %}
|
||||||
|
<span class="hint">{% trans "Zuletzt verifiziert:" %} {{ backup_health.verified_at|slice:":16"|cut:"T" }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
@@ -27,6 +53,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>{% trans "Automation" %}</h2>
|
||||||
|
<div class="hint">{% trans "Für einen geplanten Verify-Run außerhalb der UI:" %}</div>
|
||||||
|
<pre><code>docker compose exec -T web python manage.py verify_latest_backup --create-if-missing</code></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>{% trans "Verfügbare Backup-Bundles" %}</h2>
|
<h2>{% trans "Verfügbare Backup-Bundles" %}</h2>
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar" style="margin-top:1.25rem;">
|
<div class="toolbar" style="margin-top:1.25rem;">
|
||||||
<div class="hint">{% trans "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}</div>
|
<div class="hint">{% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}</div>
|
||||||
<button class="btn btn-primary" type="submit">{% trans "Branding speichern" %}</button>
|
<button class="btn btn-primary" type="submit">{% trans "Branding speichern" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
|||||||
<li>Remote backup target configuration is managed in <code>Integrationen → Backup-Ziel</code>.</li>
|
<li>Remote backup target configuration is managed in <code>Integrationen → Backup-Ziel</code>.</li>
|
||||||
<li>Current remote target support: <code>nextcloud</code> implemented, <code>s3</code> and <code>nfs</code> config-ready but not yet implemented.</li>
|
<li>Current remote target support: <code>nextcloud</code> implemented, <code>s3</code> and <code>nfs</code> config-ready but not yet implemented.</li>
|
||||||
<li>Verification is non-destructive: it restores into a temporary verification database and extracts media into a temporary directory.</li>
|
<li>Verification is non-destructive: it restores into a temporary verification database and extracts media into a temporary directory.</li>
|
||||||
|
<li>For scheduled operational hygiene, verify the newest bundle directly from the app container:
|
||||||
|
<pre><code>docker compose exec -T web python manage.py verify_latest_backup --create-if-missing</code></pre>
|
||||||
|
</li>
|
||||||
|
<li>The Backup & Recovery page now shows whether the latest verification is current, stale, missing, or still unverified.</li>
|
||||||
<li>Real restore is explicit and destructive by design:
|
<li>Real restore is explicit and destructive by design:
|
||||||
<pre><code>./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
<pre><code>./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
73
backend/workflows/templates/workflows/job_monitor.html
Normal file
73
backend/workflows/templates/workflows/job_monitor.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends 'workflows/base_shell.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Job Monitor" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_body %}
|
||||||
|
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}
|
||||||
|
<h1>{% trans "Job Monitor" %}</h1>
|
||||||
|
<p class="sub">{% trans "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe zentral prüfen." %}</p>
|
||||||
|
|
||||||
|
{% include 'workflows/includes/messages.html' %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<form method="get" class="app-registry-filters">
|
||||||
|
<div class="field">
|
||||||
|
<label for="task-filter">{% trans "Task" %}</label>
|
||||||
|
<select id="task-filter" name="task">
|
||||||
|
<option value="">{% trans "Alle" %}</option>
|
||||||
|
{% for task_name in task_names %}
|
||||||
|
<option value="{{ task_name }}" {% if task_filter == task_name %}selected{% endif %}>{{ task_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status-filter">{% trans "Status" %}</label>
|
||||||
|
<select id="status-filter" name="status">
|
||||||
|
<option value="">{% trans "Alle" %}</option>
|
||||||
|
{% for key, label in status_choices %}
|
||||||
|
<option value="{{ key }}" {% if status_filter == key %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field actions" style="align-items:end;">
|
||||||
|
<button class="btn btn-secondary" type="submit">{% trans "Filtern" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Start" %}</th>
|
||||||
|
<th>{% trans "Task" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Ziel" %}</th>
|
||||||
|
<th>{% trans "Task ID" %}</th>
|
||||||
|
<th>{% trans "Fehler" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.started_at|date:"d.m.Y H:i:s" }}</td>
|
||||||
|
<td>{{ log.task_name }}</td>
|
||||||
|
<td><span class="badge {% if log.status == 'failed' %}failed{% elif log.status == 'succeeded' %}sent{% else %}scheduled{% endif %}">{{ log.get_status_display }}</span></td>
|
||||||
|
<td>{{ log.target_label|default:log.target_type }}</td>
|
||||||
|
<td><code>{{ log.task_id|default:"-" }}</code></td>
|
||||||
|
<td>{% if log.error_message %}<code>{{ log.error_message|truncatechars:180 }}</code>{% else %}-{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6">{% trans "Noch keine Task-Läufe vorhanden." %}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -207,9 +207,13 @@
|
|||||||
<li>Backup standard: create DB+media bundles under <code>backups/</code> and verify them with a temporary restore before using a real restore.</li>
|
<li>Backup standard: create DB+media bundles under <code>backups/</code> and verify them with a temporary restore before using a real restore.</li>
|
||||||
<li>Staff UI shortcut: Admin Apps → <code>Backup & Recovery</code> for create, verify, and delete actions.</li>
|
<li>Staff UI shortcut: Admin Apps → <code>Backup & Recovery</code> for create, verify, and delete actions.</li>
|
||||||
<li>Each backup row shows both local bundle availability and remote backup state.</li>
|
<li>Each backup row shows both local bundle availability and remote backup state.</li>
|
||||||
|
<li>The backup page also shows whether the latest verification is current, stale, missing, or still pending.</li>
|
||||||
<li>Remote backup target configuration lives under Admin Apps → <code>Integrationen</code> → <code>Backup-Ziel</code>.</li>
|
<li>Remote backup target configuration lives under Admin Apps → <code>Integrationen</code> → <code>Backup-Ziel</code>.</li>
|
||||||
<li>Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.</li>
|
<li>Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.</li>
|
||||||
<li>Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.</li>
|
<li>Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.</li>
|
||||||
|
<li>For scheduled verification outside the browser, run:
|
||||||
|
<pre><code>docker compose exec -T web python manage.py verify_latest_backup --create-if-missing</code></pre>
|
||||||
|
</li>
|
||||||
<li>Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → <code>Branding</code>.</li>
|
<li>Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → <code>Branding</code>.</li>
|
||||||
<li>Trial deployments can force safe integration behavior: Nextcloud is treated as disabled and email remains in test mode while the trial restriction is active.</li>
|
<li>Trial deployments can force safe integration behavior: Nextcloud is treated as disabled and email remains in test mode while the trial restriction is active.</li>
|
||||||
<li>Expired trials should be cleaned with the dedicated command, not from the browser:
|
<li>Expired trials should be cleaned with the dedicated command, not from the browser:
|
||||||
|
|||||||
@@ -40,9 +40,15 @@ docker compose up -d --build web worker</code></pre>
|
|||||||
<li>{% trans "Run tests or a targeted verification command for the changed area." %}</li>
|
<li>{% trans "Run tests or a targeted verification command for the changed area." %}</li>
|
||||||
<li>{% trans "Compile translations after UI/content changes." %}</li>
|
<li>{% trans "Compile translations after UI/content changes." %}</li>
|
||||||
<li>{% trans "If dependencies changed, verify imports do not emit warnings." %}</li>
|
<li>{% trans "If dependencies changed, verify imports do not emit warnings." %}</li>
|
||||||
|
<li>{% trans "Verify the latest backup bundle before release if operational tooling, storage, or restore behavior changed." %}</li>
|
||||||
|
<li>{% trans "Prefer the single local release gate command so local validation matches CI." %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<pre><code>docker compose exec -T web python manage.py check
|
<pre><code>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 test
|
||||||
|
docker compose exec -T web python manage.py verify_latest_backup --create-if-missing
|
||||||
make i18n-compile
|
make i18n-compile
|
||||||
docker compose exec -T web python -c "import requests"</code></pre>
|
docker compose exec -T web python -c "import requests"</code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
50
backend/workflows/tests/test_app_registry_permissions.py
Normal file
50
backend/workflows/tests/test_app_registry_permissions.py
Normal file
@@ -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))
|
||||||
|
|
||||||
52
backend/workflows/tests/test_async_task_logging.py
Normal file
52
backend/workflows/tests/test_async_task_logging.py
Normal file
@@ -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)
|
||||||
67
backend/workflows/tests/test_backup_reliability.py
Normal file
67
backend/workflows/tests/test_backup_reliability.py
Normal file
@@ -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')
|
||||||
@@ -22,10 +22,12 @@ class NextcloudServiceTests(TestCase):
|
|||||||
NEXTCLOUD_USERNAME='u',
|
NEXTCLOUD_USERNAME='u',
|
||||||
NEXTCLOUD_PASSWORD='p',
|
NEXTCLOUD_PASSWORD='p',
|
||||||
)
|
)
|
||||||
|
@patch('workflows.services.requests.request')
|
||||||
@patch('workflows.services.requests.put')
|
@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 = Path('/tmp/nextcloud_mock_upload.txt')
|
||||||
temp_file.write_text('hello', encoding='utf-8')
|
temp_file.write_text('hello', encoding='utf-8')
|
||||||
|
mock_request.return_value.status_code = 201
|
||||||
mock_put.return_value.status_code = 201
|
mock_put.return_value.status_code = 201
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -45,8 +47,9 @@ class NextcloudServiceTests(TestCase):
|
|||||||
NEXTCLOUD_USERNAME='env-user',
|
NEXTCLOUD_USERNAME='env-user',
|
||||||
NEXTCLOUD_PASSWORD='env-pass',
|
NEXTCLOUD_PASSWORD='env-pass',
|
||||||
)
|
)
|
||||||
|
@patch('workflows.services.requests.request')
|
||||||
@patch('workflows.services.requests.put')
|
@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(
|
WorkflowConfig.objects.update_or_create(
|
||||||
name='Default',
|
name='Default',
|
||||||
defaults={
|
defaults={
|
||||||
@@ -59,6 +62,7 @@ class NextcloudServiceTests(TestCase):
|
|||||||
)
|
)
|
||||||
temp_file = Path('/tmp/nextcloud_override_upload.txt')
|
temp_file = Path('/tmp/nextcloud_override_upload.txt')
|
||||||
temp_file.write_text('hello', encoding='utf-8')
|
temp_file.write_text('hello', encoding='utf-8')
|
||||||
|
mock_request.return_value.status_code = 201
|
||||||
mock_put.return_value.status_code = 201
|
mock_put.return_value.status_code = 201
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
31
backend/workflows/tests/test_request_id_logging.py
Normal file
31
backend/workflows/tests/test_request_id_logging.py
Normal file
@@ -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')
|
||||||
73
backend/workflows/tests/test_trial_lifecycle.py
Normal file
73
backend/workflows/tests/test_trial_lifecycle.py
Normal file
@@ -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())
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ urlpatterns = [
|
|||||||
path('admin-tools/trial/save/', views.save_portal_trial_config, name='save_portal_trial_config'),
|
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/', 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/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/', 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/create/', views.create_user_from_admin, name='create_user_from_admin'),
|
||||||
path('admin-tools/users/<int:user_id>/update/', views.update_user_from_admin, name='update_user_from_admin'),
|
path('admin-tools/users/<int:user_id>/update/', views.update_user_from_admin, name='update_user_from_admin'),
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ from django.utils.translation import get_language, override
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows
|
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 .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 .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
||||||
from .form_builder import (
|
from .form_builder import (
|
||||||
@@ -36,7 +42,7 @@ from .form_builder import (
|
|||||||
ONBOARDING_PAGE_ORDER,
|
ONBOARDING_PAGE_ORDER,
|
||||||
ensure_form_field_configs,
|
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 .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 .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
|
||||||
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
|
from .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_capability('manage_app_registry')
|
||||||
@require_POST
|
@require_POST
|
||||||
def save_portal_app_registry(request):
|
def save_portal_app_registry(request):
|
||||||
@@ -893,11 +923,13 @@ def audit_log_page(request):
|
|||||||
|
|
||||||
@_require_capability('manage_backups')
|
@_require_capability('manage_backups')
|
||||||
def backup_recovery_page(request):
|
def backup_recovery_page(request):
|
||||||
|
rows = list_backup_bundles()
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
'workflows/backup_recovery.html',
|
'workflows/backup_recovery.html',
|
||||||
{
|
{
|
||||||
'rows': list_backup_bundles(),
|
'rows': rows,
|
||||||
|
'backup_health': latest_backup_health_snapshot(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user