snapshot: preserve configurable onboarding conditional logic
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user