from collections import OrderedDict from django import forms from django.utils.text import slugify from django.utils.translation import get_language from .models import FormConditionalRuleConfig, FormCustomFieldConfig, 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', } 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'}], }, 'phone-box': { 'clauses': [ {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, ], }, }, } CUSTOM_FIELD_PREFIX = 'custom__' def get_section_order(form_type: str) -> list[str]: if form_type == 'onboarding': return ONBOARDING_PAGE_ORDER if form_type == 'offboarding': return OFFBOARDING_PAGE_ORDER return [] def get_section_labels(form_type: str) -> dict[str, str]: if form_type == 'onboarding': return ONBOARDING_PAGE_LABELS if form_type == 'offboarding': return OFFBOARDING_PAGE_LABELS return {} 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_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 = get_section_order(form_type) 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, 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