diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index 84c12c1..8dffc01 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -1,626 +1,2 @@ -from collections import OrderedDict -from django import forms -from django.utils.text import slugify -from django.utils.translation import get_language, gettext_lazy as _ - -from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig - - -DEFAULT_FIELD_ORDER = { - 'onboarding': [ - 'first_name', - 'last_name', - 'full_name', - 'gender', - 'job_title', - 'department', - 'work_email', - 'order_business_cards', - 'business_card_name', - 'business_card_title', - 'business_card_email', - 'business_card_phone', - 'contract_start', - 'employment_type', - 'employment_end_date', - 'handover_date', - 'group_mailboxes_required_choice', - 'group_mailboxes', - 'needed_devices_multi', - 'additional_hardware_needed_choice', - 'additional_hardware_multi', - 'additional_hardware_other', - 'needed_software_multi', - 'additional_software_needed_choice', - 'additional_software_multi', - 'additional_software', - 'needed_accesses_multi', - 'additional_access_needed_choice', - 'additional_access_text', - 'needed_workspace_groups_multi', - 'needed_resources_multi', - 'successor_required_choice', - 'successor_name', - 'inherit_phone_number_choice', - 'phone_number_choice', - 'additional_notes', - 'signature_url', - 'signature_image', - 'onboarded_by_email', - 'agreement_confirm', - ], - 'offboarding': [ - 'full_name', - 'work_email', - 'department', - 'job_title', - 'last_working_day', - 'notes', - ], -} - -ONBOARDING_PAGE_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] -OFFBOARDING_PAGE_ORDER = ['mitarbeitende', 'austritt', 'abschluss'] -ONBOARDING_PAGE_LABELS = { - 'stammdaten': _('1. Stammdaten'), - 'vertrag': _('2. Vertrag'), - 'itsetup': _('3. IT-Setup'), - 'abschluss': _('4. Abschluss'), -} -OFFBOARDING_PAGE_LABELS = { - 'mitarbeitende': _('1. Mitarbeitende'), - 'austritt': _('2. Austritt'), - 'abschluss': _('3. Abschluss'), -} -CORE_SECTION_LABELS = { - 'onboarding': ONBOARDING_PAGE_LABELS, - 'offboarding': OFFBOARDING_PAGE_LABELS, -} - -LOCKED_FIELD_RULES = { - 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, - 'offboarding': {'full_name', 'work_email', 'last_working_day'}, -} - -LOCKED_SECTION_RULES = { - 'onboarding': {'stammdaten', 'vertrag', 'abschluss'}, - 'offboarding': {'mitarbeitende', 'austritt'}, -} - -ONBOARDING_DEFAULT_PAGE = { - 'first_name': 'stammdaten', - 'last_name': 'stammdaten', - 'full_name': 'stammdaten', - 'gender': 'stammdaten', - 'job_title': 'stammdaten', - 'department': 'stammdaten', - 'work_email': 'stammdaten', - 'order_business_cards': 'stammdaten', - 'business_card_name': 'stammdaten', - 'business_card_title': 'stammdaten', - 'business_card_email': 'stammdaten', - 'business_card_phone': 'stammdaten', - 'contract_start': 'vertrag', - 'employment_type': 'vertrag', - 'employment_end_date': 'vertrag', - 'handover_date': 'vertrag', - 'group_mailboxes_required_choice': 'vertrag', - 'group_mailboxes': 'vertrag', - 'needed_devices_multi': 'itsetup', - 'additional_hardware_needed_choice': 'itsetup', - 'additional_hardware_multi': 'itsetup', - 'additional_hardware_other': 'itsetup', - 'needed_software_multi': 'itsetup', - 'additional_software_needed_choice': 'itsetup', - 'additional_software_multi': 'itsetup', - 'additional_software': 'itsetup', - 'needed_accesses_multi': 'itsetup', - 'additional_access_needed_choice': 'itsetup', - 'additional_access_text': 'itsetup', - 'needed_workspace_groups_multi': 'itsetup', - 'needed_resources_multi': 'itsetup', - 'successor_required_choice': 'itsetup', - 'successor_name': 'itsetup', - 'inherit_phone_number_choice': 'itsetup', - 'phone_number_choice': 'itsetup', - 'additional_notes': 'abschluss', - 'signature_url': 'abschluss', - 'signature_image': 'abschluss', - 'onboarded_by_email': 'abschluss', - 'agreement_confirm': 'abschluss', -} -OFFBOARDING_DEFAULT_PAGE = { - 'full_name': 'mitarbeitende', - 'work_email': 'mitarbeitende', - 'department': 'mitarbeitende', - 'job_title': 'mitarbeitende', - 'last_working_day': 'austritt', - 'notes': 'abschluss', -} - -DEFAULT_CONDITIONAL_RULES = { - 'onboarding': { - 'business-card-box': { - 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], - }, - 'employment-end-box': { - 'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}], - }, - 'group-mailboxes-box': { - 'clauses': [{'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}], - }, - 'extra-hardware-box': { - 'clauses': [{'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}], - }, - 'extra-software-box': { - 'clauses': [{'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}], - }, - 'extra-access-box': { - 'clauses': [{'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}], - }, - 'successor-box': { - 'clauses': [{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}], - }, - }, -} - -CUSTOM_FIELD_PREFIX = 'custom__' - - -def get_section_order(form_type: str) -> list[str]: - return [item['key'] for item in get_section_definitions(form_type)] - - -def get_section_labels(form_type: str) -> dict[str, str]: - return {item['key']: item['title'] for item in get_section_definitions(form_type)} - - -def get_default_page_map(form_type: str) -> dict[str, str]: - if form_type == 'onboarding': - return ONBOARDING_DEFAULT_PAGE - if form_type == 'offboarding': - return OFFBOARDING_DEFAULT_PAGE - return {} - - -def get_custom_section_configs(form_type: str, include_inactive: bool = False) -> list[FormCustomSectionConfig]: - qs = FormCustomSectionConfig.objects.filter(form_type=form_type) - if not include_inactive: - qs = qs.filter(is_active=True) - return list(qs.order_by('sort_order', 'section_key')) - - -def get_section_definitions(form_type: str, include_inactive_custom: bool = False) -> list[dict[str, object]]: - definitions: list[dict[str, object]] = [] - section_configs = ensure_form_section_configs(form_type) - for cfg in sorted(section_configs.values(), key=lambda item: (item.sort_order, item.section_key)): - label_map = CORE_SECTION_LABELS.get(form_type, {}) - definitions.append( - { - 'key': cfg.section_key, - 'title': label_map.get(cfg.section_key, cfg.section_key), - 'locked': cfg.section_key in LOCKED_SECTION_RULES.get(form_type, set()), - 'is_custom': False, - 'sort_order': cfg.sort_order, - } - ) - for cfg in get_custom_section_configs(form_type, include_inactive=include_inactive_custom): - definitions.append( - { - 'key': cfg.section_key, - 'title': cfg.translated_title(get_language()), - 'locked': False, - 'is_custom': True, - 'is_active': cfg.is_active, - 'sort_order': cfg.sort_order, - } - ) - definitions.sort(key=lambda item: (item.get('sort_order', 9999), item['key'])) - return definitions - - -def get_default_conditional_rules(form_type: str) -> dict[str, dict]: - return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) - - -def custom_field_target_key(field_key: str) -> str: - return f'custom__{field_key}' - - -def is_custom_field_target_key(target_key: str) -> bool: - return target_key.startswith(CUSTOM_FIELD_PREFIX) - - -def custom_field_form_name(field_key: str) -> str: - return f'{CUSTOM_FIELD_PREFIX}{field_key}' - - -def is_custom_field_name(field_name: str) -> bool: - return field_name.startswith(CUSTOM_FIELD_PREFIX) - - -def custom_field_key_from_name(field_name: str) -> str: - return field_name[len(CUSTOM_FIELD_PREFIX):] if is_custom_field_name(field_name) else field_name - - -def build_custom_field_key(label: str) -> str: - return slugify(label).replace('-', '_')[:60] or 'custom_field' - - -def get_custom_field_configs(form_type: str, include_inactive: bool = False): - qs = FormCustomFieldConfig.objects.filter(form_type=form_type) - if not include_inactive: - qs = qs.filter(is_active=True) - return list(qs.order_by('sort_order', 'field_key')) - - -def add_custom_form_fields(form_type: str, form, initial_values: dict | None = None) -> None: - language_code = get_language() - initial_values = initial_values or {} - field_page_keys = getattr(form, '_field_page_keys', {}) - sort_map = {} - for cfg in get_custom_field_configs(form_type): - field_name = custom_field_form_name(cfg.field_key) - initial = initial_values.get(cfg.field_key) - if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_TEXTAREA: - field = forms.CharField( - label=cfg.translated_label(language_code), - help_text=cfg.translated_help_text(language_code), - required=cfg.is_required, - initial=initial, - widget=forms.Textarea(attrs={'rows': 3}), - ) - elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT: - field = forms.ChoiceField( - label=cfg.translated_label(language_code), - help_text=cfg.translated_help_text(language_code), - required=cfg.is_required, - initial=initial or '', - choices=[('', '--')] + cfg.translated_select_options(language_code), - ) - elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX: - field = forms.BooleanField( - label=cfg.translated_label(language_code), - help_text=cfg.translated_help_text(language_code), - required=False, - initial=bool(initial), - ) - else: - field = forms.CharField( - label=cfg.translated_label(language_code), - help_text=cfg.translated_help_text(language_code), - required=cfg.is_required, - initial=initial, - ) - form.fields[field_name] = field - field_page_keys[field_name] = cfg.section_key - sort_map[field_name] = cfg.sort_order - - if form.fields: - core_configs = ensure_form_field_configs(form_type, [name for name in form.fields.keys() if not is_custom_field_name(name)]) - for name in form.fields.keys(): - if is_custom_field_name(name): - continue - cfg = core_configs.get(name) - if cfg: - sort_map[name] = cfg.sort_order - form.fields = OrderedDict( - (name, form.fields[name]) - for name in sorted(form.fields.keys(), key=lambda name: (sort_map.get(name, 9999), name)) - ) - form._field_page_keys = field_page_keys - - -def _default_sort(form_type: str, field_name: str) -> int: - ordered = DEFAULT_FIELD_ORDER.get(form_type, []) - if field_name in ordered: - return ordered.index(field_name) - return len(ordered) + 500 - - -def _ensure_configs(form_type: str, field_names: list[str]) -> dict[str, FormFieldConfig]: - existing = { - cfg.field_name: cfg - for cfg in FormFieldConfig.objects.filter(form_type=form_type, field_name__in=field_names) - } - missing_names = [name for name in field_names if name not in existing] - if missing_names: - FormFieldConfig.objects.bulk_create( - [ - FormFieldConfig( - form_type=form_type, - field_name=name, - sort_order=_default_sort(form_type, name), - page_key=get_default_page_map(form_type).get(name, ''), - ) - for name in missing_names - ], - ignore_conflicts=True, - ) - existing = { - cfg.field_name: cfg - for cfg in FormFieldConfig.objects.filter(form_type=form_type, field_name__in=field_names) - } - return existing - - -def ensure_form_field_configs(form_type: str, field_names: list[str]) -> dict[str, FormFieldConfig]: - return _ensure_configs(form_type, field_names) - - -def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: - section_order = list(CORE_SECTION_LABELS.get(form_type, {}).keys()) - if not section_order: - return {} - existing = { - cfg.section_key: cfg - for cfg in FormSectionConfig.objects.filter(form_type=form_type) - } - missing = [key for key in section_order if key not in existing] - if missing: - FormSectionConfig.objects.bulk_create( - [ - FormSectionConfig( - form_type=form_type, - section_key=key, - sort_order=section_order.index(key), - is_visible=True, - ) - for key in missing - ], - ignore_conflicts=True, - ) - existing = { - cfg.section_key: cfg - for cfg in FormSectionConfig.objects.filter(form_type=form_type) - } - return existing - - -def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]: - defaults = get_default_conditional_rules(form_type) - if form_type != 'onboarding' and not defaults: - return {} - custom_targets = { - custom_field_target_key(cfg.field_key): {'clauses': []} - for cfg in get_custom_field_configs(form_type) - if form_type == 'onboarding' - } - target_defaults = dict(defaults) - target_defaults.update(custom_targets) - existing = { - cfg.target_key: cfg - for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) - } - missing = [key for key in target_defaults.keys() if key not in existing] - if missing: - FormConditionalRuleConfig.objects.bulk_create( - [ - FormConditionalRuleConfig( - form_type=form_type, - target_key=key, - clauses=target_defaults[key].get('clauses', []), - is_active=bool(target_defaults[key].get('clauses')), - ) - for key in missing - ], - ignore_conflicts=True, - ) - existing = { - cfg.target_key: cfg - for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) - } - return existing - - -def evaluate_conditional_clauses(cleaned_data: dict, clauses: list[dict]) -> bool: - def clause_result(clause: dict) -> bool: - field_name = (clause.get('field') or '').strip() - operator = (clause.get('operator') or '').strip() - if not field_name or not operator: - return False - value = cleaned_data.get(field_name) - if operator == 'checked': - return bool(value) is bool(clause.get('value')) - normalized = '' if value is None else str(value).strip() - expected = '' if clause.get('value') is None else str(clause.get('value')).strip() - if operator == 'equals': - return normalized == expected - if operator == 'not_equals': - return normalized != expected - return False - - active_clauses = [clause for clause in (clauses or []) if clause.get('field') and clause.get('operator')] - return bool(active_clauses) and all(clause_result(clause) for clause in active_clauses) - - -def hidden_custom_field_names(form_type: str, cleaned_data: dict) -> set[str]: - if form_type != 'onboarding': - return set() - hidden = set() - for target_key, cfg in ensure_form_conditional_rule_configs(form_type).items(): - if not cfg.is_active or not is_custom_field_target_key(target_key): - continue - if not evaluate_conditional_clauses(cleaned_data, list(cfg.clauses or [])): - hidden.add(target_key) - return hidden - - -def apply_form_field_config(form_type: str, form) -> None: - field_names = list(form.fields.keys()) - configs = _ensure_configs(form_type, field_names) - section_configs = ensure_form_section_configs(form_type) - locked = LOCKED_FIELD_RULES.get(form_type, set()) - locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) - default_page_map = get_default_page_map(form_type) - language_code = get_language() - - for field_name, field in list(form.fields.items()): - cfg = configs.get(field_name) - if not cfg: - continue - - translated_label = cfg.translated_label_override(language_code) - if translated_label: - field.label = translated_label - - translated_help_text = cfg.translated_help_text_override(language_code) - if translated_help_text: - field.help_text = translated_help_text - - if field_name not in locked and cfg.is_required is not None: - field.required = cfg.is_required - - section_key = cfg.page_key or default_page_map.get(field_name, '') - section_hidden = ( - form_type in {'onboarding', 'offboarding'} - and section_key not in locked_sections - and section_key in section_configs - and not section_configs[section_key].is_visible - ) - if field_name not in locked and (not cfg.is_visible or section_hidden): - form.fields.pop(field_name, None) - - ordered_items = sorted( - form.fields.items(), - key=lambda item: ( - configs[item[0]].sort_order if item[0] in configs else _default_sort(form_type, item[0]), - item[0], - ), - ) - form.fields = OrderedDict(ordered_items) - if form_type in {'onboarding', 'offboarding'}: - form._field_page_keys = { - name: (configs[name].page_key or default_page_map.get(name, '')) - for name in form.fields.keys() - if name in configs - } - - -FORM_PRESETS = { - 'onboarding': { - 'standard': { - 'label': _('Standard'), - 'sections': { - 'stammdaten': True, - 'vertrag': True, - 'itsetup': True, - 'abschluss': True, - }, - 'fields': {}, - }, - 'lean': { - 'label': _('Lean'), - 'sections': { - 'stammdaten': True, - 'vertrag': True, - 'itsetup': False, - 'abschluss': True, - }, - 'fields': { - 'gender': {'is_visible': False}, - 'order_business_cards': {'is_visible': False}, - 'business_card_name': {'is_visible': False}, - 'business_card_title': {'is_visible': False}, - 'business_card_email': {'is_visible': False}, - 'business_card_phone': {'is_visible': False}, - 'employment_end_date': {'is_visible': False}, - 'group_mailboxes_required_choice': {'is_visible': False}, - 'group_mailboxes': {'is_visible': False}, - 'additional_notes': {'is_required': False}, - }, - }, - 'it_heavy': { - 'label': _('IT-heavy'), - 'sections': { - 'stammdaten': True, - 'vertrag': True, - 'itsetup': True, - 'abschluss': True, - }, - 'fields': { - 'needed_devices_multi': {'is_required': True}, - 'needed_software_multi': {'is_required': True}, - 'needed_accesses_multi': {'is_required': True}, - 'needed_workspace_groups_multi': {'is_required': True}, - 'needed_resources_multi': {'is_required': True}, - 'additional_hardware_needed_choice': {'is_visible': True}, - 'additional_software_needed_choice': {'is_visible': True}, - 'additional_access_needed_choice': {'is_visible': True}, - 'successor_required_choice': {'is_visible': True}, - }, - }, - }, - 'offboarding': { - 'standard': { - 'label': _('Standard'), - 'sections': { - 'mitarbeitende': True, - 'austritt': True, - 'abschluss': True, - }, - 'fields': {}, - }, - 'lean': { - 'label': _('Lean'), - 'sections': { - 'mitarbeitende': True, - 'austritt': True, - 'abschluss': False, - }, - 'fields': { - 'department': {'is_visible': False}, - 'job_title': {'is_visible': False}, - 'notes': {'is_visible': False}, - }, - }, - 'hr_heavy': { - 'label': _('HR-heavy'), - 'sections': { - 'mitarbeitende': True, - 'austritt': True, - 'abschluss': True, - }, - 'fields': { - 'department': {'is_visible': True, 'is_required': True}, - 'job_title': {'is_visible': True, 'is_required': True}, - 'notes': {'is_visible': True, 'is_required': True}, - }, - }, - }, -} - - -def apply_form_preset(form_type: str, preset_key: str) -> bool: - preset = FORM_PRESETS.get(form_type, {}).get(preset_key) - if not preset: - return False - - locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) - locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) - default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) - ensure_form_field_configs(form_type, default_names) - section_configs = ensure_form_section_configs(form_type) - - for section_key, is_visible in preset.get('sections', {}).items(): - cfg = section_configs.get(section_key) - if not cfg or section_key in locked_sections: - continue - cfg.is_visible = bool(is_visible) - cfg.save(update_fields=['is_visible']) - - for cfg in FormFieldConfig.objects.filter(form_type=form_type): - if cfg.field_name in locked_fields: - cfg.is_visible = True - cfg.is_required = None - else: - cfg.is_visible = True - cfg.is_required = None - override = preset.get('fields', {}).get(cfg.field_name, {}) - if 'is_visible' in override: - cfg.is_visible = bool(override['is_visible']) - if 'is_required' in override: - cfg.is_required = override['is_required'] - cfg.save(update_fields=['is_visible', 'is_required']) - - return True +from .form_builder_config import * +from .form_builder_runtime import * diff --git a/backend/workflows/form_builder_config.py b/backend/workflows/form_builder_config.py new file mode 100644 index 0000000..4ed6262 --- /dev/null +++ b/backend/workflows/form_builder_config.py @@ -0,0 +1,117 @@ +from django.utils.translation import gettext_lazy as _ + +DEFAULT_FIELD_ORDER = { + 'onboarding': [ + 'first_name', 'last_name', 'full_name', 'gender', 'job_title', 'department', 'work_email', + 'order_business_cards', 'business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone', + 'contract_start', 'employment_type', 'employment_end_date', 'handover_date', + 'group_mailboxes_required_choice', 'group_mailboxes', 'needed_devices_multi', + 'additional_hardware_needed_choice', 'additional_hardware_multi', 'additional_hardware_other', + 'needed_software_multi', 'additional_software_needed_choice', 'additional_software_multi', 'additional_software', + 'needed_accesses_multi', 'additional_access_needed_choice', 'additional_access_text', + 'needed_workspace_groups_multi', 'needed_resources_multi', 'successor_required_choice', 'successor_name', + 'inherit_phone_number_choice', 'phone_number_choice', 'additional_notes', 'signature_url', 'signature_image', + 'onboarded_by_email', 'agreement_confirm', + ], + 'offboarding': ['full_name', 'work_email', 'department', 'job_title', 'last_working_day', 'notes'], +} + +ONBOARDING_PAGE_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] +OFFBOARDING_PAGE_ORDER = ['mitarbeitende', 'austritt', 'abschluss'] +ONBOARDING_PAGE_LABELS = { + 'stammdaten': _('1. Stammdaten'), + 'vertrag': _('2. Vertrag'), + 'itsetup': _('3. IT-Setup'), + 'abschluss': _('4. Abschluss'), +} +OFFBOARDING_PAGE_LABELS = { + 'mitarbeitende': _('1. Mitarbeitende'), + 'austritt': _('2. Austritt'), + 'abschluss': _('3. Abschluss'), +} +CORE_SECTION_LABELS = {'onboarding': ONBOARDING_PAGE_LABELS, 'offboarding': OFFBOARDING_PAGE_LABELS} + +LOCKED_FIELD_RULES = { + 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, + 'offboarding': {'full_name', 'work_email', 'last_working_day'}, +} + +LOCKED_SECTION_RULES = { + 'onboarding': {'stammdaten', 'vertrag', 'abschluss'}, + 'offboarding': {'mitarbeitende', 'austritt'}, +} + +ONBOARDING_DEFAULT_PAGE = { + 'first_name': 'stammdaten', 'last_name': 'stammdaten', 'full_name': 'stammdaten', 'gender': 'stammdaten', + 'job_title': 'stammdaten', 'department': 'stammdaten', 'work_email': 'stammdaten', 'order_business_cards': 'stammdaten', + 'business_card_name': 'stammdaten', 'business_card_title': 'stammdaten', 'business_card_email': 'stammdaten', 'business_card_phone': 'stammdaten', + 'contract_start': 'vertrag', 'employment_type': 'vertrag', 'employment_end_date': 'vertrag', 'handover_date': 'vertrag', + 'group_mailboxes_required_choice': 'vertrag', 'group_mailboxes': 'vertrag', 'needed_devices_multi': 'itsetup', + 'additional_hardware_needed_choice': 'itsetup', 'additional_hardware_multi': 'itsetup', 'additional_hardware_other': 'itsetup', + 'needed_software_multi': 'itsetup', 'additional_software_needed_choice': 'itsetup', 'additional_software_multi': 'itsetup', 'additional_software': 'itsetup', + 'needed_accesses_multi': 'itsetup', 'additional_access_needed_choice': 'itsetup', 'additional_access_text': 'itsetup', + 'needed_workspace_groups_multi': 'itsetup', 'needed_resources_multi': 'itsetup', 'successor_required_choice': 'itsetup', + 'successor_name': 'itsetup', 'inherit_phone_number_choice': 'itsetup', 'phone_number_choice': 'itsetup', + 'additional_notes': 'abschluss', 'signature_url': 'abschluss', 'signature_image': 'abschluss', 'onboarded_by_email': 'abschluss', 'agreement_confirm': 'abschluss', +} +OFFBOARDING_DEFAULT_PAGE = { + 'full_name': 'mitarbeitende', 'work_email': 'mitarbeitende', 'department': 'mitarbeitende', 'job_title': 'mitarbeitende', + 'last_working_day': 'austritt', 'notes': 'abschluss', +} + +DEFAULT_CONDITIONAL_RULES = { + 'onboarding': { + 'business-card-box': {'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}]}, + 'employment-end-box': {'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}]}, + 'group-mailboxes-box': {'clauses': [{'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'extra-hardware-box': {'clauses': [{'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'extra-software-box': {'clauses': [{'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'extra-access-box': {'clauses': [{'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}]}, + 'successor-box': {'clauses': [{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}]}, + }, +} + +FORM_PRESETS = { + 'onboarding': { + 'standard': {'label': _('Standard'), 'sections': {'stammdaten': True, 'vertrag': True, 'itsetup': True, 'abschluss': True}, 'fields': {}}, + 'lean': { + 'label': _('Lean'), + 'sections': {'stammdaten': True, 'vertrag': True, 'itsetup': False, 'abschluss': True}, + 'fields': { + 'gender': {'is_visible': False}, 'order_business_cards': {'is_visible': False}, 'business_card_name': {'is_visible': False}, + 'business_card_title': {'is_visible': False}, 'business_card_email': {'is_visible': False}, 'business_card_phone': {'is_visible': False}, + 'employment_end_date': {'is_visible': False}, 'group_mailboxes_required_choice': {'is_visible': False}, 'group_mailboxes': {'is_visible': False}, + 'additional_notes': {'is_required': False}, + }, + }, + 'it_heavy': { + 'label': _('IT-heavy'), + 'sections': {'stammdaten': True, 'vertrag': True, 'itsetup': True, 'abschluss': True}, + 'fields': { + 'needed_devices_multi': {'is_required': True}, 'needed_software_multi': {'is_required': True}, 'needed_accesses_multi': {'is_required': True}, + 'needed_workspace_groups_multi': {'is_required': True}, 'needed_resources_multi': {'is_required': True}, + 'additional_hardware_needed_choice': {'is_visible': True}, 'additional_software_needed_choice': {'is_visible': True}, + 'additional_access_needed_choice': {'is_visible': True}, 'successor_required_choice': {'is_visible': True}, + }, + }, + }, + 'offboarding': { + 'standard': {'label': _('Standard'), 'sections': {'mitarbeitende': True, 'austritt': True, 'abschluss': True}, 'fields': {}}, + 'lean': { + 'label': _('Lean'), + 'sections': {'mitarbeitende': True, 'austritt': True, 'abschluss': False}, + 'fields': {'department': {'is_visible': False}, 'job_title': {'is_visible': False}, 'notes': {'is_visible': False}}, + }, + 'hr_heavy': { + 'label': _('HR-heavy'), + 'sections': {'mitarbeitende': True, 'austritt': True, 'abschluss': True}, + 'fields': { + 'department': {'is_visible': True, 'is_required': True}, + 'job_title': {'is_visible': True, 'is_required': True}, + 'notes': {'is_visible': True, 'is_required': True}, + }, + }, + }, +} + +CUSTOM_FIELD_PREFIX = 'custom__' diff --git a/backend/workflows/form_builder_runtime.py b/backend/workflows/form_builder_runtime.py new file mode 100644 index 0000000..da4504f --- /dev/null +++ b/backend/workflows/form_builder_runtime.py @@ -0,0 +1,382 @@ +from collections import OrderedDict +from django import forms +from django.utils.text import slugify +from django.utils.translation import get_language + +from .model_forms import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig +from .form_builder_config import ( + CORE_SECTION_LABELS, + CUSTOM_FIELD_PREFIX, + DEFAULT_CONDITIONAL_RULES, + DEFAULT_FIELD_ORDER, + FORM_PRESETS, + LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + OFFBOARDING_DEFAULT_PAGE, + ONBOARDING_DEFAULT_PAGE, +) + + +def get_section_order(form_type: str) -> list[str]: + return [item['key'] for item in get_section_definitions(form_type)] + + +def get_section_labels(form_type: str) -> dict[str, str]: + return {item['key']: item['title'] for item in get_section_definitions(form_type)} + + +def get_default_page_map(form_type: str) -> dict[str, str]: + if form_type == 'onboarding': + return ONBOARDING_DEFAULT_PAGE + if form_type == 'offboarding': + return OFFBOARDING_DEFAULT_PAGE + return {} + + +def get_custom_section_configs(form_type: str, include_inactive: bool = False) -> list[FormCustomSectionConfig]: + qs = FormCustomSectionConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'section_key')) + + +def get_section_definitions(form_type: str, include_inactive_custom: bool = False) -> list[dict[str, object]]: + definitions: list[dict[str, object]] = [] + section_configs = ensure_form_section_configs(form_type) + for cfg in sorted(section_configs.values(), key=lambda item: (item.sort_order, item.section_key)): + label_map = CORE_SECTION_LABELS.get(form_type, {}) + definitions.append( + { + 'key': cfg.section_key, + 'title': label_map.get(cfg.section_key, cfg.section_key), + 'locked': cfg.section_key in LOCKED_SECTION_RULES.get(form_type, set()), + 'is_custom': False, + 'sort_order': cfg.sort_order, + } + ) + for cfg in get_custom_section_configs(form_type, include_inactive=include_inactive_custom): + definitions.append( + { + 'key': cfg.section_key, + 'title': cfg.translated_title(get_language()), + 'locked': False, + 'is_custom': True, + 'is_active': cfg.is_active, + 'sort_order': cfg.sort_order, + } + ) + definitions.sort(key=lambda item: (item.get('sort_order', 9999), item['key'])) + return definitions + + +def get_default_conditional_rules(form_type: str) -> dict[str, dict]: + return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) + + +def custom_field_target_key(field_key: str) -> str: + return f'custom__{field_key}' + + +def is_custom_field_target_key(target_key: str) -> bool: + return target_key.startswith(CUSTOM_FIELD_PREFIX) + + +def custom_field_form_name(field_key: str) -> str: + return f'{CUSTOM_FIELD_PREFIX}{field_key}' + + +def is_custom_field_name(field_name: str) -> bool: + return field_name.startswith(CUSTOM_FIELD_PREFIX) + + +def custom_field_key_from_name(field_name: str) -> str: + return field_name[len(CUSTOM_FIELD_PREFIX):] if is_custom_field_name(field_name) else field_name + + +def build_custom_field_key(label: str) -> str: + return slugify(label).replace('-', '_')[:60] or 'custom_field' + + +def get_custom_field_configs(form_type: str, include_inactive: bool = False): + qs = FormCustomFieldConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'field_key')) + + +def add_custom_form_fields(form_type: str, form, initial_values: dict | None = None) -> None: + language_code = get_language() + initial_values = initial_values or {} + field_page_keys = getattr(form, '_field_page_keys', {}) + sort_map = {} + for cfg in get_custom_field_configs(form_type): + field_name = custom_field_form_name(cfg.field_key) + initial = initial_values.get(cfg.field_key) + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_TEXTAREA: + field = forms.CharField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial, + widget=forms.Textarea(attrs={'rows': 3}), + ) + elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT: + field = forms.ChoiceField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial or '', + choices=[('', '--')] + cfg.translated_select_options(language_code), + ) + elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX: + field = forms.BooleanField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=False, + initial=bool(initial), + ) + else: + field = forms.CharField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial, + ) + form.fields[field_name] = field + field_page_keys[field_name] = cfg.section_key + sort_map[field_name] = cfg.sort_order + + if form.fields: + core_configs = ensure_form_field_configs(form_type, [name for name in form.fields.keys() if not is_custom_field_name(name)]) + for name in form.fields.keys(): + if is_custom_field_name(name): + continue + cfg = core_configs.get(name) + if cfg: + sort_map[name] = cfg.sort_order + form.fields = OrderedDict( + (name, form.fields[name]) + for name in sorted(form.fields.keys(), key=lambda name: (sort_map.get(name, 9999), name)) + ) + form._field_page_keys = field_page_keys + + +def _default_sort(form_type: str, field_name: str) -> int: + ordered = DEFAULT_FIELD_ORDER.get(form_type, []) + if field_name in ordered: + return ordered.index(field_name) + return len(ordered) + 500 + + +def _ensure_configs(form_type: str, field_names: list[str]) -> dict[str, FormFieldConfig]: + existing = { + cfg.field_name: cfg + for cfg in FormFieldConfig.objects.filter(form_type=form_type, field_name__in=field_names) + } + missing_names = [name for name in field_names if name not in existing] + if missing_names: + FormFieldConfig.objects.bulk_create( + [ + FormFieldConfig( + form_type=form_type, + field_name=name, + sort_order=_default_sort(form_type, name), + page_key=get_default_page_map(form_type).get(name, ''), + ) + for name in missing_names + ], + ignore_conflicts=True, + ) + existing = { + cfg.field_name: cfg + for cfg in FormFieldConfig.objects.filter(form_type=form_type, field_name__in=field_names) + } + return existing + + +def ensure_form_field_configs(form_type: str, field_names: list[str]) -> dict[str, FormFieldConfig]: + return _ensure_configs(form_type, field_names) + + +def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: + section_order = list(CORE_SECTION_LABELS.get(form_type, {}).keys()) + if not section_order: + return {} + existing = { + cfg.section_key: cfg + for cfg in FormSectionConfig.objects.filter(form_type=form_type) + } + missing = [key for key in section_order if key not in existing] + if missing: + FormSectionConfig.objects.bulk_create( + [ + FormSectionConfig( + form_type=form_type, + section_key=key, + sort_order=section_order.index(key), + is_visible=True, + ) + for key in missing + ], + ignore_conflicts=True, + ) + existing = { + cfg.section_key: cfg + for cfg in FormSectionConfig.objects.filter(form_type=form_type) + } + return existing + + +def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]: + defaults = get_default_conditional_rules(form_type) + if form_type != 'onboarding' and not defaults: + return {} + custom_targets = { + custom_field_target_key(cfg.field_key): {'clauses': []} + for cfg in get_custom_field_configs(form_type) + if form_type == 'onboarding' + } + target_defaults = dict(defaults) + target_defaults.update(custom_targets) + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + missing = [key for key in target_defaults.keys() if key not in existing] + if missing: + FormConditionalRuleConfig.objects.bulk_create( + [ + FormConditionalRuleConfig( + form_type=form_type, + target_key=key, + clauses=target_defaults[key].get('clauses', []), + is_active=bool(target_defaults[key].get('clauses')), + ) + for key in missing + ], + ignore_conflicts=True, + ) + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + return existing + + +def evaluate_conditional_clauses(cleaned_data: dict, clauses: list[dict]) -> bool: + def clause_result(clause: dict) -> bool: + field_name = (clause.get('field') or '').strip() + operator = (clause.get('operator') or '').strip() + if not field_name or not operator: + return False + value = cleaned_data.get(field_name) + if operator == 'checked': + return bool(value) is bool(clause.get('value')) + normalized = '' if value is None else str(value).strip() + expected = '' if clause.get('value') is None else str(clause.get('value')).strip() + if operator == 'equals': + return normalized == expected + if operator == 'not_equals': + return normalized != expected + return False + + active_clauses = [clause for clause in (clauses or []) if clause.get('field') and clause.get('operator')] + return bool(active_clauses) and all(clause_result(clause) for clause in active_clauses) + + +def hidden_custom_field_names(form_type: str, cleaned_data: dict) -> set[str]: + if form_type != 'onboarding': + return set() + hidden = set() + for target_key, cfg in ensure_form_conditional_rule_configs(form_type).items(): + if not cfg.is_active or not is_custom_field_target_key(target_key): + continue + if not evaluate_conditional_clauses(cleaned_data, list(cfg.clauses or [])): + hidden.add(target_key) + return hidden + + +def apply_form_field_config(form_type: str, form) -> None: + field_names = list(form.fields.keys()) + configs = _ensure_configs(form_type, field_names) + section_configs = ensure_form_section_configs(form_type) + locked = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + default_page_map = get_default_page_map(form_type) + language_code = get_language() + + for field_name, field in list(form.fields.items()): + cfg = configs.get(field_name) + if not cfg: + continue + + translated_label = cfg.translated_label_override(language_code) + if translated_label: + field.label = translated_label + + translated_help_text = cfg.translated_help_text_override(language_code) + if translated_help_text: + field.help_text = translated_help_text + + if field_name not in locked and cfg.is_required is not None: + field.required = cfg.is_required + + section_key = cfg.page_key or default_page_map.get(field_name, '') + section_hidden = ( + form_type in {'onboarding', 'offboarding'} + and section_key not in locked_sections + and section_key in section_configs + and not section_configs[section_key].is_visible + ) + if field_name not in locked and (not cfg.is_visible or section_hidden): + form.fields.pop(field_name, None) + + ordered_items = sorted( + form.fields.items(), + key=lambda item: ( + configs[item[0]].sort_order if item[0] in configs else _default_sort(form_type, item[0]), + item[0], + ), + ) + form.fields = OrderedDict(ordered_items) + if form_type in {'onboarding', 'offboarding'}: + form._field_page_keys = { + name: (configs[name].page_key or default_page_map.get(name, '')) + for name in form.fields.keys() + if name in configs + } + + +def apply_form_preset(form_type: str, preset_key: str) -> bool: + preset = FORM_PRESETS.get(form_type, {}).get(preset_key) + if not preset: + return False + + locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) + ensure_form_field_configs(form_type, default_names) + section_configs = ensure_form_section_configs(form_type) + + for section_key, is_visible in preset.get('sections', {}).items(): + cfg = section_configs.get(section_key) + if not cfg or section_key in locked_sections: + continue + cfg.is_visible = bool(is_visible) + cfg.save(update_fields=['is_visible']) + + for cfg in FormFieldConfig.objects.filter(form_type=form_type): + if cfg.field_name in locked_fields: + cfg.is_visible = True + cfg.is_required = None + else: + cfg.is_visible = True + cfg.is_required = None + override = preset.get('fields', {}).get(cfg.field_name, {}) + if 'is_visible' in override: + cfg.is_visible = bool(override['is_visible']) + if 'is_required' in override: + cfg.is_required = override['is_required'] + cfg.save(update_fields=['is_visible', 'is_required']) + + return True