snapshot: modularize workflow helper and task orchestration layers

This commit is contained in:
Md Bayazid Bostame
2026-03-28 09:10:07 +01:00
parent ee323106e9
commit e80a68d6f8
9 changed files with 748 additions and 1233 deletions

View File

@@ -4,8 +4,6 @@ from datetime import timedelta
from tempfile import NamedTemporaryFile
import json
from io import BytesIO
from functools import wraps
from celery import current_app
from django.conf import settings
from django.db import connection
@@ -23,8 +21,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext as _
from django.urls import reverse
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows, normalize_portal_app_sort_orders
@@ -69,6 +66,28 @@ from .observability_views import (
job_monitor_page_impl,
verify_backup_from_admin_impl,
)
from .view_audit import audit as _audit, audit_action_label as _audit_action_label, display_user_name as _display_user_name
from .view_context import (
form_field_labels as _form_field_labels,
request_custom_field_details as _request_custom_field_details,
request_status_label as _request_status_label,
request_target_label as _request_target_label,
)
from .view_form_runtime import (
CONDITIONAL_RULE_OPERATOR_CHOICES,
ONBOARDING_CHECKBOX_LISTS,
ONBOARDING_GROUPS,
ONBOARDING_INLINE_CHECKS,
active_conditional_target_keys as _active_conditional_target_keys,
build_offboarding_sections as _build_offboarding_sections,
build_onboarding_layout as _build_onboarding_layout,
build_onboarding_sections as _build_onboarding_sections,
conditional_rule_summary as _conditional_rule_summary,
field_rule_summary as _field_rule_summary,
normalized_conditional_rule_payload as _normalized_conditional_rule_payload,
translate_choice_list as _translate_choice_list,
)
from .view_permissions import require_capability as _require_capability
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig
from .emailing import send_system_email
from .notifications import notify_user
@@ -109,96 +128,6 @@ def mark_all_notifications_read(request):
UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now())
return _redirect_back(request, 'home')
ONBOARDING_GROUPS = {
'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'],
'employment-end-box': ['employment_end_date'],
'group-mailboxes-box': ['group_mailboxes'],
'extra-hardware-box': ['additional_hardware_multi', 'additional_hardware_other'],
'extra-software-box': ['additional_software_multi', 'additional_software'],
'extra-access-box': ['additional_access_text'],
'successor-box': ['successor_name', 'inherit_phone_number_choice'],
}
ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'}
ONBOARDING_CHECKBOX_LISTS = {
'needed_devices_multi',
'additional_hardware_multi',
'needed_software_multi',
'additional_software_multi',
'needed_accesses_multi',
'needed_workspace_groups_multi',
'needed_resources_multi',
}
ONBOARDING_SECTION_META = {
'stammdaten': {'title': gettext_lazy('Stammdaten'), 'subtitle': gettext_lazy('Person, Rolle, Abteilung')},
'vertrag': {'title': gettext_lazy('Vertrag'), 'subtitle': gettext_lazy('Beschäftigung und Termine')},
'itsetup': {'title': gettext_lazy('IT-Setup'), 'subtitle': gettext_lazy('Geräte, Software und Zugänge')},
'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Notizen und Freigabe')},
}
CONDITIONAL_RULE_OPERATOR_CHOICES = [
('checked', _('ist aktiviert')),
('equals', _('ist gleich')),
('not_equals', _('ist nicht gleich')),
]
def _field_rule_summary(*, is_visible: bool, is_required, locked: bool) -> str:
if locked:
return str(_('Fixes Kernfeld, immer sichtbar.'))
if not is_visible:
return str(_('Ausgeblendet, erscheint nicht im Formular.'))
if is_required is True:
return str(_('Sichtbar und als Pflichtfeld markiert.'))
if is_required is False:
return str(_('Sichtbar und optional.'))
return str(_('Sichtbar mit Standardverhalten.'))
def _conditional_clause_sentence(clause: dict, field_label_map: dict[str, str]) -> str:
field_name = (clause.get('field') or '').strip()
operator = (clause.get('operator') or '').strip()
value = clause.get('value')
if not field_name or not operator:
return ''
field_label = field_label_map.get(field_name, field_name)
if operator == 'checked':
return _('%(field)s ist aktiviert') % {'field': field_label}
if operator == 'equals':
if value not in (None, ''):
return _('%(field)s ist gleich %(value)s') % {'field': field_label, 'value': value}
return _('%(field)s ist gleich') % {'field': field_label}
if operator == 'not_equals':
if value not in (None, ''):
return _('%(field)s ist nicht gleich %(value)s') % {'field': field_label, 'value': value}
return _('%(field)s ist nicht gleich') % {'field': field_label}
return _('%(field)s erfüllt die Bedingung') % {'field': field_label}
def _conditional_rule_summary(clauses: list[dict], field_label_map: dict[str, str]) -> str:
active_clauses = [clause for clause in clauses if clause.get('field') and clause.get('operator')]
if not active_clauses:
return str(_('Immer sichtbar.'))
parts = [str(_conditional_clause_sentence(clause, field_label_map)) for clause in active_clauses]
return str(_('Sichtbar, wenn %(conditions)s.') % {'conditions': ' und '.join(parts)})
def _normalized_conditional_rule_payload(form_type: str) -> dict[str, dict]:
configs = ensure_form_conditional_rule_configs(form_type)
payload = {}
for target_key, cfg in configs.items():
if not cfg.is_active:
continue
clauses = [clause for clause in (cfg.clauses or []) if clause.get('field') and clause.get('operator')]
if clauses:
payload[target_key] = {'all': clauses}
return payload
def _active_conditional_target_keys(form_type: str) -> set[str]:
return set(_normalized_conditional_rule_payload(form_type).keys())
def healthz(request):
db_ok = True
try:
@@ -233,335 +162,6 @@ def account_profile_page(request):
return account_views.account_profile_page_impl(request)
def _require_capability(capability: str):
def decorator(view_func):
@wraps(view_func)
@login_required
def wrapped(request, *args, **kwargs):
if not user_has_capability(request.user, capability):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('home')
return view_func(request, *args, **kwargs)
return wrapped
return decorator
def _display_user_name(user) -> str:
first_name = (getattr(user, 'first_name', '') or '').strip()
last_name = (getattr(user, 'last_name', '') or '').strip()
full_name = f'{first_name} {last_name}'.strip()
if full_name:
return full_name
username = (getattr(user, 'username', '') or '').strip()
if username:
return username
return (getattr(user, 'email', '') or '').strip()
def _audit(
request,
action: str,
*,
target_type: str = '',
target_id: int | None = None,
target_label: str = '',
details: dict | None = None,
) -> None:
if not getattr(request, 'user', None) or not request.user.is_authenticated:
return
AdminAuditLog.objects.create(
actor=request.user,
actor_display=_display_user_name(request.user),
action=action,
target_type=target_type,
target_id=target_id,
target_label=target_label,
details=details or {},
)
def _form_field_labels(form_type: str) -> dict[str, str]:
if form_type == 'onboarding':
return {name: str(field.label or name) for name, field in OnboardingRequestForm.base_fields.items()}
if form_type == 'offboarding':
return {name: str(field.label or name) for name, field in OffboardingRequestForm.base_fields.items()}
return {}
def _request_target_label(obj, kind: str | None = None) -> str:
request_kind = (kind or '').strip()
if not request_kind:
request_kind = 'onboarding' if isinstance(obj, OnboardingRequest) else 'offboarding'
name = (getattr(obj, 'full_name', '') or '').strip() or f'#{getattr(obj, "id", "?")}'
email = (getattr(obj, 'work_email', '') or '').strip()
created_at = getattr(obj, 'created_at', None)
date_label = created_at.strftime('%Y-%m-%d') if created_at else ''
parts = [request_kind.capitalize(), name]
if email:
parts.append(f'<{email}>')
if date_label:
parts.append(date_label)
return ' | '.join(parts)
def _request_status_label(status_key: str, language_code: str | None = None) -> str:
lang = ((language_code or 'de').split('-')[0] or 'de').lower()
with override(lang):
labels = {
'submitted': _('Eingereicht'),
'processing': _('In Bearbeitung'),
'completed': _('Abgeschlossen'),
'failed': _('Fehlgeschlagen'),
}
return labels.get(status_key, status_key)
def _request_custom_field_details(obj, kind: str, language_code: str | None = None) -> list[dict[str, str]]:
form_type = 'onboarding' if kind == 'onboarding' else 'offboarding'
language_code = ((language_code or getattr(obj, 'preferred_language', '') or get_language() or 'de').split('-')[0]).lower()
values = getattr(obj, 'custom_field_values', {}) or {}
rows = []
yes_label = 'Ja' if language_code == 'de' else 'Yes'
for cfg in get_custom_field_configs(form_type, include_inactive=True):
raw_value = values.get(cfg.field_key)
if raw_value in (None, '', False, []):
continue
if isinstance(raw_value, bool):
display_value = str(yes_label) if raw_value else ''
elif isinstance(raw_value, list):
display_value = ', '.join(str(item).strip() for item in raw_value if str(item).strip())
else:
display_value = str(raw_value).strip()
if not display_value:
continue
rows.append(
{
'label': cfg.translated_label(language_code),
'value': display_value,
'section': cfg.section_key,
'sort_order': cfg.sort_order,
}
)
rows.sort(key=lambda item: (item['section'], item['sort_order'], item['label']))
return rows
def _audit_action_label(action: str) -> str:
labels = {
'requests_deleted': _('Vorgänge gelöscht'),
'request_deleted': _('Vorgang gelöscht'),
'request_retried': _('Vorgang erneut angestoßen'),
'intro_pdf_generated': _('Einweisungs-PDF erzeugt'),
'intro_live_pdf_generated': _('Live-Protokoll erzeugt'),
'intro_session_reset': _('Einweisung zurückgesetzt'),
'intro_session_saved': _('Einweisung als Entwurf gespeichert'),
'intro_session_completed': _('Einweisung abgeschlossen'),
'form_option_deleted': _('Formularoption gelöscht'),
'form_options_saved': _('Formularoptionen gespeichert'),
'form_field_texts_saved': _('Feldtexte gespeichert'),
'form_layout_saved': _('Formularlayout gespeichert'),
'intro_checklist_item_deleted': _('Einweisungs-Checkpunkt gelöscht'),
'intro_checklist_item_added': _('Einweisungs-Checkpunkt hinzugefügt'),
'intro_checklist_saved': _('Einweisungs-Checkliste gespeichert'),
'welcome_email_triggered_now': _('Welcome E-Mail sofort ausgelöst'),
'welcome_email_settings_saved': _('Welcome E-Mail Einstellungen gespeichert'),
'welcome_email_bulk_action': _('Welcome E-Mail Sammelaktion ausgeführt'),
'welcome_email_paused': _('Welcome E-Mail pausiert'),
'welcome_email_resumed': _('Welcome E-Mail fortgesetzt'),
'welcome_email_cancelled': _('Welcome E-Mail abgebrochen'),
'smtp_test_sent': _('SMTP-Test gesendet'),
'nextcloud_test_upload': _('Nextcloud-Testupload ausgeführt'),
'nextcloud_mode_toggled': _('Nextcloud-Modus umgeschaltet'),
'email_mode_toggled': _('E-Mail-Modus umgeschaltet'),
'integrations_saved': _('Integrationen gespeichert'),
'nextcloud_settings_saved': _('Nextcloud-Einstellungen gespeichert'),
'mail_settings_saved': _('Mail-Einstellungen gespeichert'),
'email_routing_saved': _('E-Mail-Routing gespeichert'),
'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'),
'user_created': _('Benutzer erstellt'),
'user_updated': _('Benutzer aktualisiert'),
'user_password_reset_sent': _('Passwort-Reset-Link versendet'),
'user_deleted': _('Benutzer gelöscht'),
'backup_created': _('Backup erstellt'),
'backup_verified': _('Backup verifiziert'),
'backup_deleted': _('Backup gelöscht'),
'backup_settings_saved': _('Backup-Einstellungen gespeichert'),
'portal_app_registry_saved': _('App-Registry gespeichert'),
}
return labels.get(action, action.replace('_', ' ').strip().capitalize())
def _translate_choice_list(choices):
return [(value, str(label)) for value, label in choices]
def _build_onboarding_layout(form) -> list[dict]:
ordered_names = list(form.fields.keys())
group_by_field = {}
for group_id, group_fields in ONBOARDING_GROUPS.items():
for name in group_fields:
group_by_field[name] = group_id
conditional_target_keys = _active_conditional_target_keys('onboarding')
rendered_groups = set()
consumed = set()
blocks = []
for field_name in ordered_names:
if field_name in consumed:
continue
group_id = group_by_field.get(field_name)
if group_id:
if group_id in rendered_groups:
continue
group_fields = [
form[name]
for name in ONBOARDING_GROUPS[group_id]
if name in form.fields
]
if not group_fields:
continue
blocks.append(
{
'kind': 'group',
'id': group_id,
'hidden_default': group_id in conditional_target_keys,
'fields': group_fields,
}
)
rendered_groups.add(group_id)
consumed.update([f.name for f in group_fields])
continue
if field_name.startswith('custom__') and field_name in conditional_target_keys:
blocks.append(
{
'kind': 'group',
'id': field_name,
'hidden_default': True,
'fields': [form[field_name]],
}
)
consumed.add(field_name)
continue
blocks.append({'kind': 'field', 'field': form[field_name]})
consumed.add(field_name)
return blocks
def _section_for_block(block: dict, field_pages: dict[str, str]) -> str:
if block['kind'] == 'field':
return field_pages.get(block['field'].name, 'abschluss')
fields = block.get('fields') or []
if not fields:
return 'abschluss'
return field_pages.get(fields[0].name, 'abschluss')
def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], visible_section_keys: set[str] | None = None) -> list[dict]:
section_defs = get_section_definitions('onboarding')
section_order = [item['key'] for item in section_defs]
section_titles = {item['key']: item['title'] for item in section_defs}
grouped = {key: [] for key in section_order}
for block in blocks:
section_key = _section_for_block(block, field_pages)
if section_key not in grouped:
section_key = 'abschluss'
grouped[section_key].append(block)
visible_keys = visible_section_keys or set(section_order)
sections = []
custom_section_keys = {item['key'] for item in section_defs if item.get('is_custom')}
for key in section_order:
if key not in visible_keys:
continue
blocks_for_section = grouped[key]
has_custom_checkbox_fields = False
for block in blocks_for_section:
candidate_fields = [block['field']] if block['kind'] == 'field' else (block.get('fields') or [])
for bound_field in candidate_fields:
widget_type = getattr(getattr(bound_field.field, 'widget', None), 'input_type', '')
if bound_field.name.startswith('custom__') and widget_type == 'checkbox':
has_custom_checkbox_fields = True
break
if has_custom_checkbox_fields:
break
sections.append(
{
'key': key,
'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)),
'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''),
'blocks': blocks_for_section,
'is_custom': key in custom_section_keys,
'has_custom_checkbox_fields': has_custom_checkbox_fields,
}
)
return sections
OFFBOARDING_SECTION_META = {
'mitarbeitende': {'title': gettext_lazy('Mitarbeitende'), 'subtitle': gettext_lazy('Person, Rolle und Bereich')},
'austritt': {'title': gettext_lazy('Austritt'), 'subtitle': gettext_lazy('Letzter Arbeitstag')},
'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Hinweise und Abschlussnotizen')},
}
def _build_offboarding_sections(form, visible_section_keys: set[str] | None = None) -> list[dict]:
field_pages = getattr(form, '_field_page_keys', {})
grouped = {key: [] for key in OFFBOARDING_PAGE_ORDER}
for field_name in form.fields.keys():
section_key = field_pages.get(field_name, 'abschluss')
if section_key not in grouped:
section_key = 'abschluss'
grouped[section_key].append(form[field_name])
visible_keys = visible_section_keys or set(OFFBOARDING_PAGE_ORDER)
return [
{
'key': key,
'title': OFFBOARDING_SECTION_META[key]['title'],
'subtitle': OFFBOARDING_SECTION_META[key]['subtitle'],
'fields': grouped[key],
}
for key in OFFBOARDING_PAGE_ORDER
if key in visible_keys and grouped[key]
]
def _ops_summary_for_user(user) -> dict[str, object]:
can_view_jobs = user_has_capability(user, 'view_job_monitor')
can_manage_backups = user_has_capability(user, 'manage_backups')
summary: dict[str, object] = {
'show': can_view_jobs or can_manage_backups,
'can_view_jobs': can_view_jobs,
'can_manage_backups': can_manage_backups,
'failed_count_24h': 0,
'started_count_24h': 0,
'success_count_24h': 0,
'recent_failed_logs': [],
'backup_health': latest_backup_health_snapshot() if can_manage_backups else None,
}
if not can_view_jobs:
return summary
since = timezone.now() - timedelta(hours=24)
logs = AsyncTaskLog.objects.filter(started_at__gte=since)
counts = {
row['status']: row['count']
for row in logs.values('status').annotate(count=Count('id'))
}
summary['failed_count_24h'] = counts.get('failed', 0)
summary['started_count_24h'] = counts.get('started', 0)
summary['success_count_24h'] = counts.get('succeeded', 0)
summary['recent_failed_logs'] = list(
AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5]
)
return summary
@login_required
def home(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -957,13 +557,24 @@ def backup_recovery_page(request):
@_require_capability('manage_backups')
@require_POST
def create_backup_from_admin(request):
return create_backup_from_admin_impl(request, audit_fn=_audit)
return create_backup_from_admin_impl(
request,
audit_fn=_audit,
notify_user_fn=notify_user,
create_backup_bundle_fn=create_backup_bundle,
)
@_require_capability('manage_backups')
@require_POST
def verify_backup_from_admin(request, backup_name: str):
return verify_backup_from_admin_impl(request, backup_name, audit_fn=_audit)
return verify_backup_from_admin_impl(
request,
backup_name,
audit_fn=_audit,
notify_user_fn=notify_user,
verify_backup_bundle_fn=verify_backup_bundle,
)
@_require_capability('manage_backups')