snapshot: preserve configurable onboarding conditional logic

This commit is contained in:
Md Bayazid Bostame
2026-03-27 12:54:47 +01:00
parent eb0fb811e4
commit 2e5e941d41
9 changed files with 431 additions and 41 deletions

View File

@@ -1,7 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from django.utils.translation import get_language from django.utils.translation import get_language
from .models import FormFieldConfig, FormSectionConfig from .models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig
DEFAULT_FIELD_ORDER = { DEFAULT_FIELD_ORDER = {
@@ -132,6 +132,38 @@ OFFBOARDING_DEFAULT_PAGE = {
'notes': 'abschluss', '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'},
],
},
},
}
def get_section_order(form_type: str) -> list[str]: def get_section_order(form_type: str) -> list[str]:
if form_type == 'onboarding': if form_type == 'onboarding':
@@ -157,6 +189,10 @@ def get_default_page_map(form_type: str) -> dict[str, str]:
return {} return {}
def get_default_conditional_rules(form_type: str) -> dict[str, dict]:
return DEFAULT_CONDITIONAL_RULES.get(form_type, {})
def _default_sort(form_type: str, field_name: str) -> int: def _default_sort(form_type: str, field_name: str) -> int:
ordered = DEFAULT_FIELD_ORDER.get(form_type, []) ordered = DEFAULT_FIELD_ORDER.get(form_type, [])
if field_name in ordered: if field_name in ordered:
@@ -215,6 +251,35 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]:
return existing return existing
def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]:
defaults = get_default_conditional_rules(form_type)
if not defaults:
return {}
existing = {
cfg.target_key: cfg
for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type)
}
missing = [key for key in defaults.keys() if key not in existing]
if missing:
FormConditionalRuleConfig.objects.bulk_create(
[
FormConditionalRuleConfig(
form_type=form_type,
target_key=key,
clauses=defaults[key].get('clauses', []),
is_active=True,
)
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 apply_form_field_config(form_type: str, form) -> None: def apply_form_field_config(form_type: str, form) -> None:
field_names = list(form.fields.keys()) field_names = list(form.fields.keys())
configs = _ensure_configs(form_type, field_names) configs = _ensure_configs(form_type, field_names)

View File

@@ -0,0 +1,67 @@
from django.db import migrations, models
DEFAULT_RULES = {
'business-card-box': [
{'field': 'order_business_cards', 'operator': 'checked', 'value': True},
],
'employment-end-box': [
{'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'},
],
'group-mailboxes-box': [
{'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'},
],
'extra-hardware-box': [
{'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'},
],
'extra-software-box': [
{'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'},
],
'extra-access-box': [
{'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'},
],
'successor-box': [
{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'},
],
'phone-box': [
{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'},
{'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'},
],
}
def seed_conditional_rules(apps, schema_editor):
FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig')
for target_key, clauses in DEFAULT_RULES.items():
FormConditionalRuleConfig.objects.get_or_create(
form_type='onboarding',
target_key=target_key,
defaults={'clauses': clauses, 'is_active': True},
)
class Migration(migrations.Migration):
dependencies = [
('workflows', '0053_formsectionconfig'),
]
operations = [
migrations.CreateModel(
name='FormConditionalRuleConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)),
('target_key', models.CharField(max_length=80)),
('clauses', models.JSONField(blank=True, default=list)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Formular-Bedingungsregel',
'verbose_name_plural': 'Formular-Bedingungsregeln',
'ordering': ['form_type', 'target_key'],
'unique_together': {('form_type', 'target_key')},
},
),
migrations.RunPython(seed_conditional_rules, migrations.RunPython.noop),
]

View File

@@ -546,6 +546,26 @@ class FormSectionConfig(models.Model):
return f'{self.form_type}: {self.section_key}' return f'{self.form_type}: {self.section_key}'
class FormConditionalRuleConfig(models.Model):
FORM_CHOICES = [
('onboarding', _('Onboarding')),
]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES)
target_key = models.CharField(max_length=80)
clauses = models.JSONField(default=list, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['form_type', 'target_key']
unique_together = ('form_type', 'target_key')
verbose_name = 'Formular-Bedingungsregel'
verbose_name_plural = 'Formular-Bedingungsregeln'
def __str__(self) -> str:
return f'{self.form_type}: {self.target_key}'
class NotificationTemplate(models.Model): class NotificationTemplate(models.Model):
TEMPLATE_CHOICES = [ TEMPLATE_CHOICES = [
('onboarding_it', _('Onboarding: IT')), ('onboarding_it', _('Onboarding: IT')),

View File

@@ -6,6 +6,8 @@
const btnSubmit = document.getElementById('btn-submit'); const btnSubmit = document.getElementById('btn-submit');
const form = document.getElementById('onboarding-form'); const form = document.getElementById('onboarding-form');
const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim(); const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim();
const conditionalRulesNode = document.getElementById('onboarding-conditional-rules');
const conditionalRules = conditionalRulesNode ? JSON.parse(conditionalRulesNode.textContent || '{}') : {};
let current = 0; let current = 0;
form.setAttribute('novalidate', 'novalidate'); form.setAttribute('novalidate', 'novalidate');
@@ -14,34 +16,39 @@
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
el.classList.toggle('hidden', !state); el.classList.toggle('hidden', !state);
el.setAttribute('aria-hidden', state ? 'false' : 'true');
}
function fieldState(name) {
const field = byName(name);
if (!field) return { exists: false, value: '', checked: false };
return {
exists: true,
value: (field.value || '').trim(),
checked: !!field.checked,
};
}
function evaluateClause(clause) {
const state = fieldState(clause.field);
if (!state.exists) return false;
if (clause.operator === 'checked') return state.checked === !!clause.value;
if (clause.operator === 'equals') return state.value === String(clause.value);
if (clause.operator === 'not_equals') return state.value !== String(clause.value);
return false;
}
function evaluateRule(rule) {
const all = Array.isArray(rule.all) ? rule.all : [];
return all.every(evaluateClause);
} }
function syncConditionals() { function syncConditionals() {
const orderCards = byName('order_business_cards'); Object.entries(conditionalRules).forEach(function (entry) {
toggle('business-card-box', orderCards && orderCards.checked); const targetId = entry[0];
const rule = entry[1] || {};
const employmentType = byName('employment_type'); toggle(targetId, evaluateRule(rule));
toggle('employment-end-box', employmentType && employmentType.value === 'befristet'); });
const groupMailbox = byName('group_mailboxes_required_choice');
toggle('group-mailboxes-box', groupMailbox && groupMailbox.value === 'ja');
const extraHardware = byName('additional_hardware_needed_choice');
toggle('extra-hardware-box', extraHardware && extraHardware.value === 'ja');
const extraSoftware = byName('additional_software_needed_choice');
toggle('extra-software-box', extraSoftware && extraSoftware.value === 'ja');
const extraAccess = byName('additional_access_needed_choice');
toggle('extra-access-box', extraAccess && extraAccess.value === 'ja');
const successor = byName('successor_required_choice');
const showSuccessor = successor && successor.value === 'ja';
toggle('successor-box', showSuccessor);
const inheritPhone = byName('inherit_phone_number_choice');
const hidePhone = showSuccessor && inheritPhone && inheritPhone.value === 'ja';
toggle('phone-box', !hidePhone);
// Hidden conditional groups must not block submit with invisible required fields. // Hidden conditional groups must not block submit with invisible required fields.
document.querySelectorAll('.field-group').forEach(function (group) { document.querySelectorAll('.field-group').forEach(function (group) {
@@ -60,6 +67,22 @@
}); });
} }
function setupConditionalBindings() {
const watched = new Set();
Object.values(conditionalRules).forEach(function (rule) {
const all = Array.isArray(rule.all) ? rule.all : [];
all.forEach(function (clause) {
if (clause.field) watched.add(clause.field);
});
});
watched.forEach(function (name) {
const field = byName(name);
if (!field) return;
field.addEventListener('change', syncConditionals);
field.addEventListener('input', syncConditionals);
});
}
function slugifyForEmail(value) { function slugifyForEmail(value) {
const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' }; const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' };
const lower = (value || '').toLowerCase(); const lower = (value || '').toLowerCase();
@@ -224,7 +247,6 @@
current = Math.max(0, step); current = Math.max(0, step);
} }
document.addEventListener('change', syncConditionals);
navItems.forEach((n, idx) => { navItems.forEach((n, idx) => {
n.addEventListener('click', function () { current = idx; updateStep(); }); n.addEventListener('click', function () { current = idx; updateStep(); });
n.addEventListener('keydown', function (e) { n.addEventListener('keydown', function (e) {
@@ -245,6 +267,7 @@
}); });
syncConditionals(); syncConditionals();
setupConditionalBindings();
setupWorkEmailAutofill(); setupWorkEmailAutofill();
setupBusinessCardAutofill(); setupBusinessCardAutofill();
setupChecklistToggles(); setupChecklistToggles();

View File

@@ -246,6 +246,70 @@
</div> </div>
</form> </form>
</section> </section>
{% if form_type == 'onboarding' %}
<section class="options-panel">
<div class="options-head">
<h2>{% trans "Bedingte Logik" %}</h2>
</div>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="field-rule-groups">
{% for item in conditional_rule_items %}
<section class="field-rule-group">
<div class="field-rule-group-head">
<h3>{{ item.title }}</h3>
<span class="column-count">{{ item.target_fields|join:", " }}</span>
</div>
<div class="field-rule-list">
<div class="field-rule-row">
<div class="field-rule-main">
<strong>{{ item.title }}</strong>
<div class="mini">{{ item.description }}</div>
</div>
<label class="field-rule-control">
<span>{% trans "Aktiv" %}</span>
<input type="checkbox" name="conditional_active_{{ item.target_key }}" {% if item.is_active %}checked{% endif %} />
</label>
</div>
{% for clause in item.clauses %}
<div class="field-rule-row">
<div class="field-rule-main">
<strong>{% blocktrans trimmed with number=forloop.counter %}Bedingung {{ number }}{% endblocktrans %}</strong>
</div>
<label class="field-rule-control">
<span>{% trans "Feld" %}</span>
<select name="conditional_field_{{ item.target_key }}_{{ forloop.counter0 }}">
<option value="">{% trans "Keine" %}</option>
{% for value, label in item.field_choices %}
<option value="{{ value }}" {% if clause.field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field-rule-control">
<span>{% trans "Operator" %}</span>
<select name="conditional_operator_{{ item.target_key }}_{{ forloop.counter0 }}">
{% for value, label in item.operator_choices %}
<option value="{{ value }}" {% if clause.operator == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field-rule-control">
<span>{% trans "Wert" %}</span>
<input type="text" name="conditional_value_{{ item.target_key }}_{{ forloop.counter0 }}" value="{{ clause.value }}" {% if clause.operator == 'checked' %}placeholder="{% trans 'wird ignoriert' %}"{% endif %} />
</label>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_conditional_rules">{% trans "Bedingte Logik speichern" %}</button>
</div>
</form>
</section>
{% endif %}
</div> </div>
</div> </div>
</details> </details>

View File

@@ -44,6 +44,7 @@
<form method="post" id="onboarding-form" enctype="multipart/form-data" data-email-domain="{{ portal_email_domain }}"> <form method="post" id="onboarding-form" enctype="multipart/form-data" data-email-domain="{{ portal_email_domain }}">
{% csrf_token %} {% csrf_token %}
{{ onboarding_conditional_rules|json_script:"onboarding-conditional-rules" }}
{% for section in onboarding_sections %} {% for section in onboarding_sections %}
<section class="page {% if forloop.first %}active{% endif %}" data-step="{{ forloop.counter }}"> <section class="page {% if forloop.first %}active{% endif %}" data-step="{{ forloop.counter }}">
@@ -92,7 +93,11 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% else %} {% else %}
<div id="{{ block.id }}" class="field-group field-full {% if block.hidden_default %}hidden{% endif %}"> <div
id="{{ block.id }}"
class="field-group field-full {% if block.hidden_default %}hidden{% endif %}"
data-conditional-target="{{ block.id }}"
>
<div class="grid-2"> <div class="grid-2">
{% for field in block.fields %} {% for field in block.fields %}
{% if field.is_hidden %} {% if field.is_hidden %}

View File

@@ -3,7 +3,7 @@ import json
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from workflows.models import FormFieldConfig, FormOption, FormSectionConfig from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig
class FormBuilderAdminTests(TestCase): class FormBuilderAdminTests(TestCase):
@@ -172,3 +172,33 @@ class FormBuilderAdminTests(TestCase):
self.assertEqual(notes.is_visible, True) self.assertEqual(notes.is_visible, True)
self.assertEqual(notes.is_required, True) self.assertEqual(notes.is_required, True)
self.assertEqual(department.is_required, True) self.assertEqual(department.is_required, True)
def test_staff_can_save_onboarding_conditional_rules(self):
self.client.force_login(self.staff)
self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost')
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={
'builder_action': 'save_conditional_rules',
'conditional_active_employment-end-box': 'on',
'conditional_field_employment-end-box_0': 'employment_type',
'conditional_operator_employment-end-box_0': 'equals',
'conditional_value_employment-end-box_0': 'befristet',
'conditional_active_phone-box': 'on',
'conditional_field_phone-box_0': 'successor_required_choice',
'conditional_operator_phone-box_0': 'equals',
'conditional_value_phone-box_0': 'ja',
'conditional_field_phone-box_1': 'inherit_phone_number_choice',
'conditional_operator_phone-box_1': 'not_equals',
'conditional_value_phone-box_1': 'ja',
},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='phone-box')
self.assertEqual(rule.is_active, True)
self.assertEqual(len(rule.clauses), 2)
self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice')
self.assertEqual(rule.clauses[1]['operator'], 'not_equals')

View File

@@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from workflows.branding import get_company_email_domain from workflows.branding import get_company_email_domain
from workflows.models import FormFieldConfig, FormSectionConfig, OnboardingRequest from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest
class OnboardingFlowTests(TestCase): class OnboardingFlowTests(TestCase):
@@ -143,3 +143,31 @@ class OnboardingFlowTests(TestCase):
self.assertEqual(submit_response.status_code, 302) self.assertEqual(submit_response.status_code, 302)
self.assertTrue(OnboardingRequest.objects.filter(work_email=f'nora.section@{self.company_domain}').exists()) self.assertTrue(OnboardingRequest.objects.filter(work_email=f'nora.section@{self.company_domain}').exists())
mock_delay.assert_called_once() mock_delay.assert_called_once()
def test_onboarding_page_renders_conditional_rules_payload(self):
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
html = response.content.decode('utf-8')
self.assertEqual(response.status_code, 200)
self.assertIn('id="onboarding-conditional-rules"', html)
self.assertIn('business-card-box', html)
self.assertIn('employment-end-box', html)
self.assertIn('data-conditional-target="business-card-box"', html)
self.assertIn('data-conditional-target="phone-box"', html)
def test_onboarding_page_uses_stored_conditional_rule_config(self):
FormConditionalRuleConfig.objects.update_or_create(
form_type='onboarding',
target_key='employment-end-box',
defaults={
'is_active': True,
'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}],
},
)
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
html = response.content.decode('utf-8')
self.assertEqual(response.status_code, 200)
self.assertIn('employment-end-box', html)
self.assertIn('"value": "unbefristet"', html)

View File

@@ -37,6 +37,7 @@ from .branding import get_branding_email_copy, get_company_email_domain, get_def
from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import ( from .form_builder import (
DEFAULT_FIELD_ORDER, DEFAULT_FIELD_ORDER,
DEFAULT_CONDITIONAL_RULES,
FORM_PRESETS, FORM_PRESETS,
LOCKED_FIELD_RULES, LOCKED_FIELD_RULES,
LOCKED_SECTION_RULES, LOCKED_SECTION_RULES,
@@ -46,13 +47,14 @@ from .form_builder import (
ONBOARDING_PAGE_LABELS, ONBOARDING_PAGE_LABELS,
ONBOARDING_PAGE_ORDER, ONBOARDING_PAGE_ORDER,
ensure_form_field_configs, ensure_form_field_configs,
ensure_form_conditional_rule_configs,
ensure_form_section_configs, ensure_form_section_configs,
get_default_page_map, get_default_page_map,
get_section_labels, get_section_labels,
get_section_order, get_section_order,
apply_form_preset, apply_form_preset,
) )
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig
from .emailing import send_system_email from .emailing import send_system_email
from .notifications import notify_user from .notifications import notify_user
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
@@ -103,15 +105,7 @@ ONBOARDING_GROUPS = {
'phone-box': ['phone_number_choice'], 'phone-box': ['phone_number_choice'],
} }
ONBOARDING_HIDDEN_BY_DEFAULT = { ONBOARDING_HIDDEN_BY_DEFAULT = set(DEFAULT_CONDITIONAL_RULES.get('onboarding', {}).keys())
'business-card-box',
'employment-end-box',
'group-mailboxes-box',
'extra-hardware-box',
'extra-software-box',
'extra-access-box',
'successor-box',
}
ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'}
ONBOARDING_CHECKBOX_LISTS = { ONBOARDING_CHECKBOX_LISTS = {
@@ -131,6 +125,24 @@ ONBOARDING_SECTION_META = {
'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Notizen und Freigabe')}, '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 _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 healthz(request): def healthz(request):
db_ok = True db_ok = True
@@ -1825,6 +1837,7 @@ def onboarding_create(request):
if key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible if key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible
} }
onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys) onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys)
onboarding_conditional_rules = _normalized_conditional_rule_payload('onboarding')
return render( return render(
request, request,
@@ -1835,6 +1848,7 @@ def onboarding_create(request):
'onboarding_sections': onboarding_sections, 'onboarding_sections': onboarding_sections,
'onboarding_inline_checks': ONBOARDING_INLINE_CHECKS, 'onboarding_inline_checks': ONBOARDING_INLINE_CHECKS,
'onboarding_checkbox_lists': ONBOARDING_CHECKBOX_LISTS, 'onboarding_checkbox_lists': ONBOARDING_CHECKBOX_LISTS,
'onboarding_conditional_rules': onboarding_conditional_rules,
'legal_text': legal_text, 'legal_text': legal_text,
'saved': request.GET.get('saved') == '1', 'saved': request.GET.get('saved') == '1',
'saved_request_id': request.GET.get('id', ''), 'saved_request_id': request.GET.get('id', ''),
@@ -2179,6 +2193,27 @@ def form_builder_page(request):
_audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) _audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated})
messages.success(request, 'Abschnittsregeln wurden gespeichert.') messages.success(request, 'Abschnittsregeln wurden gespeichert.')
elif action == 'save_conditional_rules' and form_type == 'onboarding':
rule_configs = ensure_form_conditional_rule_configs(form_type)
updated = 0
for target_key, cfg in rule_configs.items():
cfg.is_active = request.POST.get(f'conditional_active_{target_key}') == 'on'
clauses = []
clause_total = 2
for index in range(clause_total):
field_name = (request.POST.get(f'conditional_field_{target_key}_{index}') or '').strip()
operator = (request.POST.get(f'conditional_operator_{target_key}_{index}') or '').strip()
value = (request.POST.get(f'conditional_value_{target_key}_{index}') or '').strip()
if not field_name or not operator:
continue
parsed_value = True if operator == 'checked' else value
clauses.append({'field': field_name, 'operator': operator, 'value': parsed_value})
cfg.clauses = clauses
cfg.save(update_fields=['is_active', 'clauses'])
updated += 1
_audit(request, 'form_conditional_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated})
messages.success(request, 'Bedingte Logik wurde gespeichert.')
elif action == 'apply_preset': elif action == 'apply_preset':
preset_key = (request.POST.get('preset_key') or '').strip() preset_key = (request.POST.get('preset_key') or '').strip()
if apply_form_preset(form_type, preset_key): if apply_form_preset(form_type, preset_key):
@@ -2194,7 +2229,7 @@ def form_builder_page(request):
active_subpanel = 'options' active_subpanel = 'options'
elif action == 'save_field_texts': elif action == 'save_field_texts':
active_subpanel = 'field-texts' active_subpanel = 'field-texts'
elif action in {'save_field_rules', 'save_section_rules'}: elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}:
active_panel = 'builder-rules' active_panel = 'builder-rules'
redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}"
if active_panel: if active_panel:
@@ -2217,6 +2252,7 @@ def form_builder_page(request):
ensure_form_field_configs(form_type, default_names) ensure_form_field_configs(form_type, default_names)
section_configs = ensure_form_section_configs(form_type) section_configs = ensure_form_section_configs(form_type)
conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {}
section_order = get_section_order(form_type) section_order = get_section_order(form_type)
section_labels = get_section_labels(form_type) section_labels = get_section_labels(form_type)
default_page_map = get_default_page_map(form_type) default_page_map = get_default_page_map(form_type)
@@ -2343,6 +2379,57 @@ def form_builder_page(request):
} }
) )
conditional_rule_items = []
if form_type == 'onboarding':
conditional_field_choices = []
for field_name in [
'order_business_cards',
'employment_type',
'group_mailboxes_required_choice',
'additional_hardware_needed_choice',
'additional_software_needed_choice',
'additional_access_needed_choice',
'successor_required_choice',
'inherit_phone_number_choice',
]:
conditional_field_choices.append((field_name, labels.get(field_name, field_name)))
conditional_target_titles = {
'business-card-box': _('Visitenkarten-Details'),
'employment-end-box': _('Vertragsende'),
'group-mailboxes-box': _('Gruppenpostfächer'),
'extra-hardware-box': _('Zusätzliche Hardware'),
'extra-software-box': _('Zusätzliche Software'),
'extra-access-box': _('Zusätzliche Zugänge'),
'successor-box': _('Nachfolge'),
'phone-box': _('Direktwahl'),
}
conditional_target_descriptions = {
'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'),
'employment-end-box': _('Steuert das Enddatum bei befristeter Beschäftigung.'),
'group-mailboxes-box': _('Steuert das Freitextfeld für Gruppenpostfächer.'),
'extra-hardware-box': _('Steuert zusätzliche Hardware-Felder.'),
'extra-software-box': _('Steuert zusätzliche Software-Felder.'),
'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'),
'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'),
'phone-box': _('Steuert die manuelle Direktwahl.'),
}
for target_key, cfg in conditional_rule_configs.items():
clauses = list(cfg.clauses or [])
while len(clauses) < 2:
clauses.append({'field': '', 'operator': 'equals', 'value': ''})
conditional_rule_items.append(
{
'target_key': target_key,
'title': conditional_target_titles.get(target_key, target_key),
'description': conditional_target_descriptions.get(target_key, ''),
'is_active': cfg.is_active,
'clauses': clauses[:2],
'field_choices': conditional_field_choices,
'operator_choices': CONDITIONAL_RULE_OPERATOR_CHOICES,
'target_fields': [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])],
}
)
preview_sections = [] preview_sections = []
if section_order: if section_order:
field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups} field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups}
@@ -2391,6 +2478,7 @@ def form_builder_page(request):
'preview_sections': preview_sections, 'preview_sections': preview_sections,
'section_rule_items': section_rule_items, 'section_rule_items': section_rule_items,
'builder_summary': builder_summary, 'builder_summary': builder_summary,
'conditional_rule_items': conditional_rule_items,
'active_panel': active_panel, 'active_panel': active_panel,
'active_subpanel': active_subpanel, 'active_subpanel': active_subpanel,
'available_presets': FORM_PRESETS.get(form_type, {}), 'available_presets': FORM_PRESETS.get(form_type, {}),