Files
workdock-platform/backend/workflows/form_builder.py

593 lines
21 KiB
Python

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