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' %}
+
+
+
+
+
+
+
+
+ {% trans "Start" %}
+ {% trans "Task" %}
+ {% trans "Status" %}
+ {% trans "Ziel" %}
+ {% trans "Task ID" %}
+ {% trans "Fehler" %}
+
+
+
+ {% for log in logs %}
+
+ {{ 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 %}
+
+ {% empty %}
+ {% trans "Noch keine Task-Läufe vorhanden." %}
+ {% endfor %}
+
+
+
+
+{% 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 → Integrationen → Backup-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/