snapshot: preserve reliability hardening and Workdock identity pass
This commit is contained in:
@@ -40,6 +40,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'workflows.middleware.RequestIDMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
@@ -116,11 +117,11 @@ EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', '0') == '1'
|
||||
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', '0') == '1'
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'onboarding@example.local')
|
||||
TEST_NOTIFICATION_EMAIL = os.getenv('TEST_NOTIFICATION_EMAIL', 'hr@example.local')
|
||||
IT_ONBOARDING_NOTIFICATION_EMAIL = os.getenv('IT_ONBOARDING_NOTIFICATION_EMAIL', 'it@tub.co')
|
||||
GENERAL_INFO_NOTIFICATION_EMAIL = os.getenv('GENERAL_INFO_NOTIFICATION_EMAIL', 'ingo.einacker@tub.co')
|
||||
BUSINESS_CARD_NOTIFICATION_EMAIL = os.getenv('BUSINESS_CARD_NOTIFICATION_EMAIL', 'kommunikation@tub.co')
|
||||
HR_WORKS_NOTIFICATION_EMAIL = os.getenv('HR_WORKS_NOTIFICATION_EMAIL', 'dittrich@tub.co')
|
||||
KEY_NOTIFICATION_EMAIL = os.getenv('KEY_NOTIFICATION_EMAIL', 'minuth@tub.co')
|
||||
IT_ONBOARDING_NOTIFICATION_EMAIL = os.getenv('IT_ONBOARDING_NOTIFICATION_EMAIL', 'it@workdock.de')
|
||||
GENERAL_INFO_NOTIFICATION_EMAIL = os.getenv('GENERAL_INFO_NOTIFICATION_EMAIL', 'info@workdock.de')
|
||||
BUSINESS_CARD_NOTIFICATION_EMAIL = os.getenv('BUSINESS_CARD_NOTIFICATION_EMAIL', 'cards@workdock.de')
|
||||
HR_WORKS_NOTIFICATION_EMAIL = os.getenv('HR_WORKS_NOTIFICATION_EMAIL', 'hr@workdock.de')
|
||||
KEY_NOTIFICATION_EMAIL = os.getenv('KEY_NOTIFICATION_EMAIL', 'keys@workdock.de')
|
||||
EMAIL_TEST_MODE = os.getenv('EMAIL_TEST_MODE', '0') == '1'
|
||||
EMAIL_TEST_REDIRECT = os.getenv('EMAIL_TEST_REDIRECT', TEST_NOTIFICATION_EMAIL)
|
||||
|
||||
@@ -149,3 +150,52 @@ SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20'))
|
||||
|
||||
NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS = int(os.getenv('NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', '30'))
|
||||
NEXTCLOUD_UPLOAD_RETRIES = int(os.getenv('NEXTCLOUD_UPLOAD_RETRIES', '2'))
|
||||
|
||||
LOG_LEVEL = os.getenv('DJANGO_LOG_LEVEL', 'INFO')
|
||||
LOG_JSON = os.getenv('DJANGO_LOG_JSON', '1') == '1'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'filters': {
|
||||
'request_context': {
|
||||
'()': 'workflows.logging_utils.RequestContextFilter',
|
||||
},
|
||||
},
|
||||
'formatters': {
|
||||
'structured': {
|
||||
'()': 'workflows.logging_utils.JsonFormatter',
|
||||
},
|
||||
'verbose': {
|
||||
'format': '[%(asctime)s] %(levelname)s %(name)s request_id=%(request_id)s task_id=%(task_id)s %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'filters': ['request_context'],
|
||||
'formatter': 'structured' if LOG_JSON else 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': LOG_LEVEL,
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': LOG_LEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
'workflows': {
|
||||
'handlers': ['console'],
|
||||
'level': LOG_LEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
'celery': {
|
||||
'handlers': ['console'],
|
||||
'level': LOG_LEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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 .emailing import send_system_email
|
||||
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
||||
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
||||
|
||||
|
||||
@admin.register(EmployeeProfile)
|
||||
@@ -20,6 +20,14 @@ class AdminAuditLogAdmin(admin.ModelAdmin):
|
||||
ordering = ('-created_at', '-id')
|
||||
|
||||
|
||||
@admin.register(AsyncTaskLog)
|
||||
class AsyncTaskLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('started_at', 'task_name', 'status', 'target_type', 'target_id', 'target_label', 'task_id')
|
||||
list_filter = ('status', 'task_name', 'target_type', 'started_at')
|
||||
search_fields = ('task_name', 'task_id', 'target_label', 'error_message')
|
||||
ordering = ('-started_at', '-id')
|
||||
|
||||
|
||||
@admin.register(PortalBranding)
|
||||
class PortalBrandingAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at')
|
||||
|
||||
@@ -103,6 +103,15 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
|
||||
action_label=_('Öffnen'),
|
||||
capability='manage_integrations',
|
||||
),
|
||||
AppDefinition(
|
||||
key='job_monitor',
|
||||
section=PortalAppConfig.SECTION_ADMIN,
|
||||
route_name='job_monitor_page',
|
||||
title=_('Job Monitor'),
|
||||
description=_('Asynchrone Aufgaben, Fehler und letzte Worker-Läufe prüfen.'),
|
||||
action_label=_('Öffnen'),
|
||||
capability='view_job_monitor',
|
||||
),
|
||||
AppDefinition(
|
||||
key='users',
|
||||
section=PortalAppConfig.SECTION_ADMIN,
|
||||
@@ -227,6 +236,12 @@ DEFAULT_ROLE_VISIBILITY = {
|
||||
ROLE_IT_STAFF: False,
|
||||
ROLE_STAFF: False,
|
||||
},
|
||||
'job_monitor': {
|
||||
ROLE_SUPER_ADMIN: True,
|
||||
ROLE_ADMIN: True,
|
||||
ROLE_IT_STAFF: False,
|
||||
ROLE_STAFF: False,
|
||||
},
|
||||
'users': {
|
||||
ROLE_SUPER_ADMIN: True,
|
||||
ROLE_ADMIN: False,
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from .models import WorkflowConfig
|
||||
@@ -113,6 +114,55 @@ def list_backup_bundles() -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def latest_backup_health_snapshot(stale_after_hours: int = 48) -> dict:
|
||||
rows = list_backup_bundles()
|
||||
if not rows:
|
||||
return {
|
||||
'status': 'missing',
|
||||
'label': str(_('Kein Backup vorhanden')),
|
||||
'summary': str(_('Es wurde noch kein Backup-Bundle erstellt.')),
|
||||
'bundle_name': '',
|
||||
'is_stale': True,
|
||||
}
|
||||
|
||||
latest = rows[0]
|
||||
verified_at_raw = latest.get('verified_at') or ''
|
||||
verified_at = parse_datetime(verified_at_raw) if verified_at_raw else None
|
||||
if verified_at and timezone.is_naive(verified_at):
|
||||
verified_at = timezone.make_aware(verified_at, timezone.get_current_timezone())
|
||||
|
||||
if latest.get('verify_status') != 'verified' or not verified_at:
|
||||
return {
|
||||
'status': 'unverified',
|
||||
'label': str(_('Nicht verifiziert')),
|
||||
'summary': str(_('Das neueste Backup-Bundle wurde noch nicht erfolgreich verifiziert.')),
|
||||
'bundle_name': latest['name'],
|
||||
'verified_at': verified_at_raw,
|
||||
'is_stale': True,
|
||||
}
|
||||
|
||||
age = timezone.now() - verified_at
|
||||
is_stale = age.total_seconds() > stale_after_hours * 3600
|
||||
if is_stale:
|
||||
return {
|
||||
'status': 'stale',
|
||||
'label': str(_('Verifikation veraltet')),
|
||||
'summary': _('Die letzte erfolgreiche Backup-Verifikation ist älter als %(hours)s Stunden.') % {'hours': stale_after_hours},
|
||||
'bundle_name': latest['name'],
|
||||
'verified_at': verified_at_raw,
|
||||
'is_stale': True,
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'label': str(_('Verifikation aktuell')),
|
||||
'summary': str(_('Das neueste Backup-Bundle wurde erfolgreich und rechtzeitig verifiziert.')),
|
||||
'bundle_name': latest['name'],
|
||||
'verified_at': verified_at_raw,
|
||||
'is_stale': False,
|
||||
}
|
||||
|
||||
|
||||
def _remote_backup_config() -> dict:
|
||||
config = WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first()
|
||||
if not config:
|
||||
@@ -264,8 +314,6 @@ def verify_backup_bundle(backup_name: str) -> dict:
|
||||
output=restore.stdout,
|
||||
stderr=restore.stderr,
|
||||
)
|
||||
with connection.cursor() as cursor:
|
||||
pass
|
||||
table_count = subprocess.check_output(
|
||||
['psql', *args, '-d', verify_db, '-t', '-A', '-c', "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';"],
|
||||
env=env,
|
||||
|
||||
@@ -15,14 +15,14 @@ def get_portal_branding() -> PortalBranding:
|
||||
branding, _ = PortalBranding.objects.get_or_create(
|
||||
name='Default',
|
||||
defaults={
|
||||
'portal_title': 'TUBCO Onboarding & Offboarding Portal',
|
||||
'company_name': 'TUBCO',
|
||||
'company_domain': 'tub.co',
|
||||
'support_email': 'info@tub.co',
|
||||
'sender_display_name': 'TUBCO',
|
||||
'portal_title': 'Workdock',
|
||||
'company_name': 'Workdock',
|
||||
'company_domain': 'workdock.de',
|
||||
'support_email': 'info@workdock.de',
|
||||
'sender_display_name': 'Workdock',
|
||||
'login_subtitle': 'Bitte melden Sie sich mit Ihrem Benutzerkonto an.',
|
||||
'footer_text': 'TUBCO Onboarding & Offboarding Portal',
|
||||
'footer_text_en': 'TUBCO Onboarding & Offboarding Portal',
|
||||
'footer_text': 'Workdock',
|
||||
'footer_text_en': 'Workdock',
|
||||
'legal_notice': '',
|
||||
'legal_notice_en': '',
|
||||
'default_language': 'de',
|
||||
@@ -37,7 +37,7 @@ def get_portal_company_config() -> PortalCompanyConfig:
|
||||
company_config, _ = PortalCompanyConfig.objects.get_or_create(
|
||||
name='Default',
|
||||
defaults={
|
||||
'legal_company_name': 'TUBCO GmbH',
|
||||
'legal_company_name': 'Workdock',
|
||||
'country': 'Deutschland',
|
||||
'website_url': '',
|
||||
'imprint_url': '',
|
||||
@@ -108,7 +108,7 @@ def get_trial_context() -> dict[str, object]:
|
||||
def get_company_email_domain() -> str:
|
||||
branding = get_portal_branding()
|
||||
domain = (branding.company_domain or '').strip().lower().lstrip('@')
|
||||
return domain or 'tub.co'
|
||||
return domain or 'workdock.de'
|
||||
|
||||
|
||||
def get_portal_logo_url() -> str:
|
||||
@@ -191,7 +191,7 @@ def get_branding_context() -> dict[str, object]:
|
||||
|
||||
def get_branding_email_copy() -> dict[str, str]:
|
||||
branding = get_portal_branding()
|
||||
company_name = (branding.company_name or 'TUBCO').strip()
|
||||
company_name = (branding.company_name or 'Workdock').strip()
|
||||
portal_title = (branding.portal_title or f'{company_name} Portal').strip()
|
||||
return {
|
||||
'company_name': company_name,
|
||||
@@ -205,7 +205,7 @@ def get_branding_email_copy() -> dict[str, str]:
|
||||
def get_company_contact_copy() -> dict[str, str]:
|
||||
branding = get_portal_branding()
|
||||
company_config = get_portal_company_config()
|
||||
company_name = (branding.company_name or 'TUBCO').strip()
|
||||
company_name = (branding.company_name or 'Workdock').strip()
|
||||
legal_name = (company_config.legal_company_name or company_name).strip()
|
||||
domain = get_company_email_domain()
|
||||
support_email = (branding.support_email or '').strip()
|
||||
|
||||
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 = [
|
||||
{
|
||||
'username': 'admin_test',
|
||||
'email': 'admin_test@tub.co',
|
||||
'email': 'admin_test@workdock.de',
|
||||
'password': 'admin12345',
|
||||
'first_name': 'Admin',
|
||||
'last_name': 'Test',
|
||||
@@ -15,7 +15,7 @@ DEFAULT_USERS = [
|
||||
},
|
||||
{
|
||||
'username': 'user_test',
|
||||
'email': 'user_test@tub.co',
|
||||
'email': 'user_test@workdock.de',
|
||||
'password': 'user12345',
|
||||
'first_name': 'Normal',
|
||||
'last_name': 'User',
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from workflows.branding import get_company_email_domain
|
||||
from workflows.models import EmployeeProfile, OffboardingRequest, OnboardingRequest
|
||||
from workflows.tasks import process_offboarding_request, process_onboarding_request
|
||||
|
||||
@@ -100,8 +101,9 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
run_id = timezone.now().strftime('%Y%m%d%H%M%S')
|
||||
employee_name = f'E2E Check {run_id}'
|
||||
work_email = f'e2e.{run_id}@tub.co'
|
||||
requester_email = 'e2e.requester@tub.co'
|
||||
domain = get_company_email_domain()
|
||||
work_email = f'e2e.{run_id}@{domain}'
|
||||
requester_email = f'e2e.requester@{domain}'
|
||||
|
||||
created_onboarding: OnboardingRequest | None = None
|
||||
created_offboarding: OffboardingRequest | None = None
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from workflows.backup_ops import create_backup_bundle, list_backup_bundles, verify_backup_bundle
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Verifies the latest backup bundle. Can create one first if no bundle exists yet.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--create-if-missing',
|
||||
action='store_true',
|
||||
help='Create a new backup bundle first if none exists yet.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
rows = list_backup_bundles()
|
||||
if not rows:
|
||||
if not options['create_if_missing']:
|
||||
raise CommandError(_('Kein Backup-Bundle vorhanden.'))
|
||||
created = create_backup_bundle()
|
||||
backup_name = created['name']
|
||||
self.stdout.write(self.style.WARNING(_('Kein Backup gefunden. Neues Bundle erstellt: %(name)s') % {'name': backup_name}))
|
||||
else:
|
||||
backup_name = rows[0]['name']
|
||||
|
||||
result = verify_backup_bundle(backup_name)
|
||||
self.stdout.write(self.style.SUCCESS(_('Backup erfolgreich verifiziert: %(name)s') % {'name': backup_name}))
|
||||
self.stdout.write(result['summary'])
|
||||
@@ -1,9 +1,34 @@
|
||||
import uuid
|
||||
|
||||
from django.shortcuts import render
|
||||
|
||||
from .branding import is_trial_expired, is_trial_mode_enabled
|
||||
from .logging_utils import clear_request_id, set_request_id
|
||||
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
||||
|
||||
|
||||
class RequestIDMiddleware:
|
||||
HEADER_NAME = 'X-Request-ID'
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request_id = (
|
||||
request.META.get('HTTP_X_REQUEST_ID')
|
||||
or request.META.get('HTTP_X_CORRELATION_ID')
|
||||
or uuid.uuid4().hex
|
||||
)
|
||||
request.request_id = request_id
|
||||
set_request_id(request_id)
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
finally:
|
||||
clear_request_id()
|
||||
response[self.HEADER_NAME] = request_id
|
||||
return response
|
||||
|
||||
|
||||
class TrialModeMiddleware:
|
||||
EXEMPT_PREFIXES = (
|
||||
'/healthz/',
|
||||
|
||||
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):
|
||||
name = models.CharField(max_length=80, default='Default', unique=True)
|
||||
portal_title = models.CharField(max_length=255, default='TUBCO Onboarding & Offboarding Portal')
|
||||
company_name = models.CharField(max_length=255, default='TUBCO')
|
||||
company_domain = models.CharField(max_length=120, blank=True, default='tub.co')
|
||||
support_email = models.EmailField(blank=True, default='info@tub.co')
|
||||
sender_display_name = models.CharField(max_length=255, blank=True, default='TUBCO')
|
||||
portal_title = models.CharField(max_length=255, default='Workdock')
|
||||
company_name = models.CharField(max_length=255, default='Workdock')
|
||||
company_domain = models.CharField(max_length=120, blank=True, default='workdock.de')
|
||||
support_email = models.EmailField(blank=True, default='info@workdock.de')
|
||||
sender_display_name = models.CharField(max_length=255, blank=True, default='Workdock')
|
||||
login_subtitle = models.CharField(max_length=255, blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.')
|
||||
footer_text = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal')
|
||||
footer_text_en = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal')
|
||||
footer_text = models.CharField(max_length=255, blank=True, default='Workdock')
|
||||
footer_text_en = models.CharField(max_length=255, blank=True, default='Workdock')
|
||||
legal_notice = models.TextField(blank=True, default='')
|
||||
legal_notice_en = models.TextField(blank=True, default='')
|
||||
default_language = models.CharField(
|
||||
@@ -170,6 +170,32 @@ class PortalAppConfig(models.Model):
|
||||
return self._translated_value('action_label_override', language_code)
|
||||
|
||||
|
||||
class AsyncTaskLog(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('started', _('Gestartet')),
|
||||
('succeeded', _('Erfolgreich')),
|
||||
('failed', _('Fehlgeschlagen')),
|
||||
]
|
||||
|
||||
task_name = models.CharField(max_length=255)
|
||||
task_id = models.CharField(max_length=255, blank=True)
|
||||
target_type = models.CharField(max_length=80, blank=True)
|
||||
target_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
target_label = models.CharField(max_length=255, blank=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='started')
|
||||
error_message = models.TextField(blank=True)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-started_at', '-id']
|
||||
verbose_name = 'Async Task Log'
|
||||
verbose_name_plural = 'Async Task Logs'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.task_name} | {self.status} | {self.target_label or self.target_type}'
|
||||
|
||||
|
||||
class AdminAuditLog(models.Model):
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -41,6 +41,7 @@ CAPABILITIES = {
|
||||
'manage_integrations': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
'manage_welcome_emails': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
'manage_builders': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
'view_job_monitor': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
'view_audit_log': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
'manage_backups': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
'view_docs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
|
||||
@@ -140,6 +141,7 @@ def template_role_context(user) -> dict[str, object]:
|
||||
'can_manage_integrations': user_has_capability(user, 'manage_integrations'),
|
||||
'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'),
|
||||
'can_manage_builders': user_has_capability(user, 'manage_builders'),
|
||||
'can_view_job_monitor': user_has_capability(user, 'view_job_monitor'),
|
||||
'can_view_audit_log': user_has_capability(user, 'view_audit_log'),
|
||||
'can_manage_backups': user_has_capability(user, 'manage_backups'),
|
||||
'can_view_docs': user_has_capability(user, 'view_docs'),
|
||||
|
||||
@@ -78,12 +78,16 @@ def _ensure_nextcloud_directory(base_url: str, directory: str, auth: tuple[str,
|
||||
current_parts: list[str] = []
|
||||
for part in [p for p in directory.split('/') if p]:
|
||||
current_parts.append(part)
|
||||
response = requests.request(
|
||||
'MKCOL',
|
||||
f"{base_url}/{'/'.join(current_parts)}",
|
||||
auth=auth,
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
response = requests.request(
|
||||
'MKCOL',
|
||||
f"{base_url}/{'/'.join(current_parts)}",
|
||||
auth=auth,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning('Nextcloud directory ensure error for %s: %s', '/'.join(current_parts), exc)
|
||||
return False
|
||||
if response.status_code in (201, 301, 405):
|
||||
continue
|
||||
logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts))
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
const fullName = byName('full_name');
|
||||
const workEmail = byName('work_email');
|
||||
const form = fullName ? fullName.closest('form') : null;
|
||||
const emailDomain = (((form && form.dataset.emailDomain) || 'tub.co') + '').replace(/^@+/, '').trim();
|
||||
const emailDomain = (((form && form.dataset.emailDomain) || 'workdock.de') + '').replace(/^@+/, '').trim();
|
||||
if (!fullName || !workEmail) return;
|
||||
|
||||
let lastSuggested = '';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const btnNext = document.getElementById('btn-next');
|
||||
const btnSubmit = document.getElementById('btn-submit');
|
||||
const form = document.getElementById('onboarding-form');
|
||||
const emailDomain = ((form && form.dataset.emailDomain) || 'tub.co').replace(/^@+/, '').trim();
|
||||
const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim();
|
||||
let current = 0;
|
||||
form.setAttribute('novalidate', 'novalidate');
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
import mimetypes
|
||||
import re
|
||||
|
||||
from celery import shared_task
|
||||
from celery import current_task, shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -13,8 +13,8 @@ from jinja2 import Template
|
||||
from pypdf import PageObject, PdfReader, PdfWriter
|
||||
from xhtml2pdf import pisa
|
||||
|
||||
from .branding import get_company_contact_copy, get_default_notification_templates, get_portal_letterhead_path
|
||||
from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
|
||||
from .branding import get_branding_email_copy, get_company_contact_copy, get_default_notification_templates, get_portal_letterhead_path
|
||||
from .models import AsyncTaskLog, EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
|
||||
from .emailing import send_system_email
|
||||
from .services import upload_to_nextcloud
|
||||
from .services import get_email_test_redirect, is_email_test_mode
|
||||
@@ -247,6 +247,27 @@ DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _start_task_log(task_name: str, *, target_type: str = '', target_id: int | None = None, target_label: str = '') -> AsyncTaskLog:
|
||||
task_request = getattr(current_task, 'request', None)
|
||||
return AsyncTaskLog.objects.create(
|
||||
task_name=task_name,
|
||||
task_id=getattr(task_request, 'id', '') or '',
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
target_label=target_label,
|
||||
status='started',
|
||||
)
|
||||
|
||||
|
||||
def _finish_task_log(task_log: AsyncTaskLog | None, *, status: str, error_message: str = '') -> None:
|
||||
if not task_log:
|
||||
return
|
||||
task_log.status = status
|
||||
task_log.error_message = error_message
|
||||
task_log.finished_at = timezone.now()
|
||||
task_log.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||
|
||||
def _split_name(full_name: str) -> tuple[str, str]:
|
||||
parts = full_name.split()
|
||||
if not parts:
|
||||
@@ -1196,6 +1217,12 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
|
||||
@shared_task
|
||||
def process_onboarding_request(onboarding_request_id: int) -> None:
|
||||
request_obj = OnboardingRequest.objects.get(id=onboarding_request_id)
|
||||
task_log = _start_task_log(
|
||||
'process_onboarding_request',
|
||||
target_type='onboarding_request',
|
||||
target_id=request_obj.id,
|
||||
target_label=request_obj.full_name,
|
||||
)
|
||||
request_obj.processing_status = 'processing'
|
||||
request_obj.last_error = ''
|
||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||
@@ -1301,16 +1328,24 @@ def process_onboarding_request(onboarding_request_id: int) -> None:
|
||||
request_obj.processing_status = 'completed'
|
||||
request_obj.last_error = ''
|
||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||
_finish_task_log(task_log, status='succeeded')
|
||||
except Exception as exc:
|
||||
request_obj.processing_status = 'failed'
|
||||
request_obj.last_error = str(exc)
|
||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||
_finish_task_log(task_log, status='failed', error_message=str(exc))
|
||||
raise
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_offboarding_request(offboarding_request_id: int) -> None:
|
||||
request_obj = OffboardingRequest.objects.get(id=offboarding_request_id)
|
||||
task_log = _start_task_log(
|
||||
'process_offboarding_request',
|
||||
target_type='offboarding_request',
|
||||
target_id=request_obj.id,
|
||||
target_label=request_obj.full_name,
|
||||
)
|
||||
request_obj.processing_status = 'processing'
|
||||
request_obj.last_error = ''
|
||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||
@@ -1380,10 +1415,12 @@ def process_offboarding_request(offboarding_request_id: int) -> None:
|
||||
request_obj.processing_status = 'completed'
|
||||
request_obj.last_error = ''
|
||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||
_finish_task_log(task_log, status='succeeded')
|
||||
except Exception as exc:
|
||||
request_obj.processing_status = 'failed'
|
||||
request_obj.last_error = str(exc)
|
||||
request_obj.save(update_fields=['processing_status', 'last_error'])
|
||||
_finish_task_log(task_log, status='failed', error_message=str(exc))
|
||||
raise
|
||||
|
||||
|
||||
@@ -1392,15 +1429,24 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals
|
||||
scheduled = ScheduledWelcomeEmail.objects.select_related('onboarding_request').filter(id=scheduled_email_id).first()
|
||||
if not scheduled:
|
||||
return
|
||||
task_log = _start_task_log(
|
||||
'send_scheduled_welcome_email',
|
||||
target_type='scheduled_welcome_email',
|
||||
target_id=scheduled.id,
|
||||
target_label=scheduled.recipient_email,
|
||||
)
|
||||
if scheduled.status in {'sent', 'cancelled'} and not force_now:
|
||||
_finish_task_log(task_log, status='succeeded')
|
||||
return
|
||||
if scheduled.status == 'paused' and not force_now:
|
||||
_finish_task_log(task_log, status='succeeded')
|
||||
return
|
||||
|
||||
if not force_now and timezone.now() < scheduled.send_at:
|
||||
async_result = send_scheduled_welcome_email.apply_async(args=[scheduled.id], eta=scheduled.send_at)
|
||||
scheduled.celery_task_id = async_result.id or scheduled.celery_task_id
|
||||
scheduled.save(update_fields=['celery_task_id', 'updated_at'])
|
||||
_finish_task_log(task_log, status='succeeded')
|
||||
return
|
||||
|
||||
request_obj = scheduled.onboarding_request
|
||||
@@ -1441,9 +1487,11 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals
|
||||
scheduled.status = 'sent'
|
||||
scheduled.sent_at = timezone.now()
|
||||
scheduled.last_error = ''
|
||||
_finish_task_log(task_log, status='succeeded')
|
||||
except Exception as exc:
|
||||
scheduled.status = 'failed'
|
||||
scheduled.last_error = str(exc)
|
||||
_finish_task_log(task_log, status='failed', error_message=str(exc))
|
||||
raise
|
||||
finally:
|
||||
scheduled.save(update_fields=['status', 'sent_at', 'last_error', 'updated_at'])
|
||||
|
||||
@@ -14,6 +14,32 @@
|
||||
|
||||
{% 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">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
@@ -27,6 +53,12 @@
|
||||
</div>
|
||||
</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">
|
||||
<h2>{% trans "Verfügbare Backup-Bundles" %}</h2>
|
||||
{% if rows %}
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
</section>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</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>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>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:
|
||||
<pre><code>./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
||||
</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>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>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>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>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>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:
|
||||
|
||||
@@ -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 "Compile translations after UI/content changes." %}</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>
|
||||
<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 verify_latest_backup --create-if-missing
|
||||
make i18n-compile
|
||||
docker compose exec -T web python -c "import requests"</code></pre>
|
||||
</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_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:
|
||||
|
||||
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/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/<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 .app_registry import build_portal_app_sections, get_portal_app_registry_rows
|
||||
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
|
||||
from .backup_ops import (
|
||||
create_backup_bundle,
|
||||
delete_backup_bundle,
|
||||
latest_backup_health_snapshot,
|
||||
list_backup_bundles,
|
||||
verify_backup_bundle,
|
||||
)
|
||||
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired
|
||||
from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
||||
from .form_builder import (
|
||||
@@ -36,7 +42,7 @@ from .form_builder import (
|
||||
ONBOARDING_PAGE_ORDER,
|
||||
ensure_form_field_configs,
|
||||
)
|
||||
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
||||
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
||||
from .emailing import send_system_email
|
||||
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
|
||||
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
|
||||
@@ -355,6 +361,30 @@ def portal_app_registry_page(request):
|
||||
)
|
||||
|
||||
|
||||
@_require_capability('view_job_monitor')
|
||||
def job_monitor_page(request):
|
||||
status_filter = (request.GET.get('status') or '').strip()
|
||||
task_filter = (request.GET.get('task') or '').strip()
|
||||
logs = AsyncTaskLog.objects.all()
|
||||
if status_filter:
|
||||
logs = logs.filter(status=status_filter)
|
||||
if task_filter:
|
||||
logs = logs.filter(task_name=task_filter)
|
||||
logs = logs.order_by('-started_at', '-id')[:200]
|
||||
task_names = list(AsyncTaskLog.objects.order_by('task_name').values_list('task_name', flat=True).distinct())
|
||||
return render(
|
||||
request,
|
||||
'workflows/job_monitor.html',
|
||||
{
|
||||
'logs': logs,
|
||||
'status_filter': status_filter,
|
||||
'task_filter': task_filter,
|
||||
'task_names': task_names,
|
||||
'status_choices': [('started', _('Gestartet')), ('succeeded', _('Erfolgreich')), ('failed', _('Fehlgeschlagen'))],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@_require_capability('manage_app_registry')
|
||||
@require_POST
|
||||
def save_portal_app_registry(request):
|
||||
@@ -893,11 +923,13 @@ def audit_log_page(request):
|
||||
|
||||
@_require_capability('manage_backups')
|
||||
def backup_recovery_page(request):
|
||||
rows = list_backup_bundles()
|
||||
return render(
|
||||
request,
|
||||
'workflows/backup_recovery.html',
|
||||
{
|
||||
'rows': list_backup_bundles(),
|
||||
'rows': rows,
|
||||
'backup_health': latest_backup_health_snapshot(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user