snapshot: preserve reliability hardening and Workdock identity pass

This commit is contained in:
Md Bayazid Bostame
2026-03-27 00:28:34 +01:00
parent 811bcd8745
commit 8553482ddd
39 changed files with 1393 additions and 320 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# TUBCO Onboarding & Offboarding Portal # Workdock
This is the standalone dockerized web application for the TUBCO onboarding and offboarding workflow. Workdock is the dockerized business operations platform that powers internal company apps such as onboarding, offboarding, requests, integrations, backups, and future modular workplace tools.
## Services ## 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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import contextvars
import json
import logging
from datetime import datetime, timezone
from celery import current_task
_request_id: contextvars.ContextVar[str] = contextvars.ContextVar('request_id', default='')
def set_request_id(value: str) -> None:
_request_id.set(value or '')
def get_request_id() -> str:
return _request_id.get('')
def clear_request_id() -> None:
_request_id.set('')
class RequestContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = get_request_id()
task = current_task
request = getattr(task, 'request', None) if task else None
record.task_id = getattr(request, 'id', '') if request else ''
record.task_name = getattr(task, 'name', '') if task else ''
return True
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
}
request_id = getattr(record, 'request_id', '') or ''
task_id = getattr(record, 'task_id', '') or ''
task_name = getattr(record, 'task_name', '') or ''
if request_id:
payload['request_id'] = request_id
if task_id:
payload['task_id'] = task_id
if task_name:
payload['task_name'] = task_name
if record.exc_info:
payload['exception'] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import gettext as _
from workflows.backup_ops import create_backup_bundle, list_backup_bundles, verify_backup_bundle
class Command(BaseCommand):
help = 'Verifies the latest backup bundle. Can create one first if no bundle exists yet.'
def add_arguments(self, parser):
parser.add_argument(
'--create-if-missing',
action='store_true',
help='Create a new backup bundle first if none exists yet.',
)
def handle(self, *args, **options):
rows = list_backup_bundles()
if not rows:
if not options['create_if_missing']:
raise CommandError(_('Kein Backup-Bundle vorhanden.'))
created = create_backup_bundle()
backup_name = created['name']
self.stdout.write(self.style.WARNING(_('Kein Backup gefunden. Neues Bundle erstellt: %(name)s') % {'name': backup_name}))
else:
backup_name = rows[0]['name']
result = verify_backup_bundle(backup_name)
self.stdout.write(self.style.SUCCESS(_('Backup erfolgreich verifiziert: %(name)s') % {'name': backup_name}))
self.stdout.write(result['summary'])

View File

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

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.5 on 2026-03-26 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0043_portaltrialconfig'),
]
operations = [
migrations.CreateModel(
name='AsyncTaskLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('task_name', models.CharField(max_length=255)),
('task_id', models.CharField(blank=True, max_length=255)),
('target_type', models.CharField(blank=True, max_length=80)),
('target_id', models.PositiveIntegerField(blank=True, null=True)),
('target_label', models.CharField(blank=True, max_length=255)),
('status', models.CharField(choices=[('started', 'Gestartet'), ('succeeded', 'Erfolgreich'), ('failed', 'Fehlgeschlagen')], default='started', max_length=20)),
('error_message', models.TextField(blank=True)),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name': 'Async Task Log',
'verbose_name_plural': 'Async Task Logs',
'ordering': ['-started_at', '-id'],
},
),
]

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.1.5 on 2026-03-26 23:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0044_asynctasklog'),
]
operations = [
migrations.AlterField(
model_name='portalbranding',
name='company_domain',
field=models.CharField(blank=True, default='workdock.de', max_length=120),
),
migrations.AlterField(
model_name='portalbranding',
name='company_name',
field=models.CharField(default='Workdock', max_length=255),
),
migrations.AlterField(
model_name='portalbranding',
name='footer_text',
field=models.CharField(blank=True, default='Workdock', max_length=255),
),
migrations.AlterField(
model_name='portalbranding',
name='footer_text_en',
field=models.CharField(blank=True, default='Workdock', max_length=255),
),
migrations.AlterField(
model_name='portalbranding',
name='portal_title',
field=models.CharField(default='Workdock', max_length=255),
),
migrations.AlterField(
model_name='portalbranding',
name='sender_display_name',
field=models.CharField(blank=True, default='Workdock', max_length=255),
),
migrations.AlterField(
model_name='portalbranding',
name='support_email',
field=models.EmailField(blank=True, default='info@workdock.de', max_length=254),
),
]

View File

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

View File

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

View File

@@ -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)
response = requests.request( try:
'MKCOL', response = requests.request(
f"{base_url}/{'/'.join(current_parts)}", 'MKCOL',
auth=auth, f"{base_url}/{'/'.join(current_parts)}",
timeout=timeout, auth=auth,
) timeout=timeout,
)
except requests.RequestException as exc:
logger.warning('Nextcloud directory ensure error for %s: %s', '/'.join(current_parts), exc)
return False
if response.status_code in (201, 301, 405): 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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &amp; 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>

View 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 %}

View File

@@ -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 &amp; Recovery</code> for create, verify, and delete actions.</li> <li>Staff UI shortcut: Admin Apps → <code>Backup &amp; 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:

View File

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

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

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

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

View File

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

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

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

View File

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

View File

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