snapshot: preserve custom field parity across forms timeline and pdf

This commit is contained in:
Md Bayazid Bostame
2026-03-27 13:21:25 +01:00
parent 2e5e941d41
commit fdc27f2123
20 changed files with 2294 additions and 545 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
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, FormFieldConfig, FormSectionConfig
from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig
DEFAULT_FIELD_ORDER = {
@@ -164,6 +166,8 @@ DEFAULT_CONDITIONAL_RULES = {
},
}
CUSTOM_FIELD_PREFIX = 'custom__'
def get_section_order(form_type: str) -> list[str]:
if form_type == 'onboarding':
@@ -193,6 +197,94 @@ 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:
@@ -253,21 +345,28 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]:
def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]:
defaults = get_default_conditional_rules(form_type)
if not defaults:
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 defaults.keys() if key not in existing]
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=defaults[key].get('clauses', []),
is_active=True,
clauses=target_defaults[key].get('clauses', []),
is_active=bool(target_defaults[key].get('clauses')),
)
for key in missing
],
@@ -280,6 +379,39 @@ def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormCondit
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)

View File

@@ -7,7 +7,7 @@ from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy
from .branding import get_company_email_domain
from .form_builder import apply_form_field_config
from .form_builder import add_custom_form_fields, apply_form_field_config, custom_field_key_from_name, hidden_custom_field_names, is_custom_field_name
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability
from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token
@@ -814,6 +814,7 @@ class OnboardingRequestForm(forms.ModelForm):
self.fields['needed_resources_multi'].choices = self._choices_from_options('resource', RESOURCE_CHOICES)
self.fields['signature_image'].required = False
apply_form_field_config('onboarding', self)
add_custom_form_fields('onboarding', self, getattr(self.instance, 'custom_field_values', None))
def clean_work_email(self):
value = (self.cleaned_data.get('work_email') or '').strip().lower()
@@ -883,6 +884,11 @@ class OnboardingRequestForm(forms.ModelForm):
},
)
hidden_custom = hidden_custom_field_names('onboarding', cleaned)
for field_name in hidden_custom:
cleaned[field_name] = False if self.fields.get(field_name) and self.fields[field_name].widget.input_type == 'checkbox' else ''
self._errors.pop(field_name, None)
return cleaned
def save(self, commit=True):
@@ -922,6 +928,11 @@ class OnboardingRequestForm(forms.ModelForm):
instance.agreement = 'accepted' if self.cleaned_data.get('agreement_confirm') else ''
instance.onboarded_by_email = self.requester_email
instance.custom_field_values = {
custom_field_key_from_name(name): self.cleaned_data.get(name)
for name in self.fields.keys()
if is_custom_field_name(name)
}
if commit:
instance.save()
@@ -962,6 +973,7 @@ class OffboardingRequestForm(forms.ModelForm):
self.fields['department'].initial = prefill_profile.department
self.fields['job_title'].initial = prefill_profile.job_title
apply_form_field_config('offboarding', self)
add_custom_form_fields('offboarding', self, getattr(self.instance, 'custom_field_values', None))
def clean_work_email(self):
value = (self.cleaned_data.get('work_email') or '').strip().lower()
@@ -971,3 +983,16 @@ class OffboardingRequestForm(forms.ModelForm):
if self.email_domain and not value.endswith(expected_suffix):
raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain})
return value
def save(self, commit=True):
instance = super().save(commit=False)
if not (instance.preferred_language or '').strip():
instance.preferred_language = (get_language() or 'de').split('-')[0]
instance.custom_field_values = {
custom_field_key_from_name(name): self.cleaned_data.get(name)
for name in self.fields.keys()
if is_custom_field_name(name)
}
if commit:
instance.save()
return instance

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.1.5 on 2026-03-27 12:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0054_formconditionalruleconfig'),
]
operations = [
migrations.AddField(
model_name='offboardingrequest',
name='custom_field_values',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='onboardingrequest',
name='custom_field_values',
field=models.JSONField(blank=True, default=dict),
),
migrations.AlterField(
model_name='formfieldconfig',
name='page_key',
field=models.CharField(blank=True, choices=[('', 'Automatisch'), ('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], default='', max_length=20),
),
migrations.AlterField(
model_name='formsectionconfig',
name='form_type',
field=models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20),
),
migrations.AlterField(
model_name='formsectionconfig',
name='section_key',
field=models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], max_length=20),
),
migrations.CreateModel(
name='FormCustomFieldConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('form_type', models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20)),
('field_key', models.SlugField(max_length=80)),
('section_key', models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], max_length=20)),
('sort_order', models.PositiveIntegerField(default=0)),
('field_type', models.CharField(choices=[('text', 'Text'), ('textarea', 'Mehrzeilig'), ('select', 'Auswahl'), ('checkbox', 'Checkbox')], default='text', max_length=20)),
('is_active', models.BooleanField(default=True)),
('is_required', models.BooleanField(default=False)),
('label', models.CharField(max_length=255)),
('label_en', models.CharField(blank=True, max_length=255)),
('help_text', models.TextField(blank=True)),
('help_text_en', models.TextField(blank=True)),
('select_options', models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: wert|Label')),
('select_options_en', models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: value|Label')),
],
options={
'verbose_name': 'Benutzerdefiniertes Formularfeld',
'verbose_name_plural': 'Benutzerdefinierte Formularfelder',
'ordering': ['form_type', 'section_key', 'sort_order', 'field_key'],
'unique_together': {('form_type', 'field_key')},
},
),
]

View File

@@ -422,6 +422,7 @@ class OnboardingRequest(models.Model):
verbose_name='Personalisierter Text für PDF',
help_text='Optionaler individueller Textblock im Onboarding PDF.',
)
custom_field_values = models.JSONField(default=dict, blank=True)
generated_pdf_path = models.CharField(max_length=500, blank=True)
intro_pdf_path = models.CharField(max_length=500, blank=True)
@@ -566,6 +567,82 @@ class FormConditionalRuleConfig(models.Model):
return f'{self.form_type}: {self.target_key}'
class FormCustomFieldConfig(models.Model):
FIELD_TYPE_TEXT = 'text'
FIELD_TYPE_TEXTAREA = 'textarea'
FIELD_TYPE_SELECT = 'select'
FIELD_TYPE_CHECKBOX = 'checkbox'
FIELD_TYPE_CHOICES = [
(FIELD_TYPE_TEXT, _('Text')),
(FIELD_TYPE_TEXTAREA, _('Mehrzeilig')),
(FIELD_TYPE_SELECT, _('Auswahl')),
(FIELD_TYPE_CHECKBOX, _('Checkbox')),
]
FORM_CHOICES = [
('onboarding', _('Onboarding')),
('offboarding', _('Offboarding')),
]
SECTION_CHOICES = [
('stammdaten', _('Stammdaten')),
('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')),
('mitarbeitende', _('Mitarbeitende')),
('austritt', _('Austritt')),
]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES)
field_key = models.SlugField(max_length=80)
section_key = models.CharField(max_length=20, choices=SECTION_CHOICES)
sort_order = models.PositiveIntegerField(default=0)
field_type = models.CharField(max_length=20, choices=FIELD_TYPE_CHOICES, default=FIELD_TYPE_TEXT)
is_active = models.BooleanField(default=True)
is_required = models.BooleanField(default=False)
label = models.CharField(max_length=255)
label_en = models.CharField(max_length=255, blank=True)
help_text = models.TextField(blank=True)
help_text_en = models.TextField(blank=True)
select_options = models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: wert|Label')
select_options_en = models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: value|Label')
class Meta:
ordering = ['form_type', 'section_key', 'sort_order', 'field_key']
unique_together = ('form_type', 'field_key')
verbose_name = 'Benutzerdefiniertes Formularfeld'
verbose_name_plural = 'Benutzerdefinierte Formularfelder'
def __str__(self) -> str:
return f'{self.form_type}: {self.label}'
def translated_label(self, language_code: str | None = None) -> str:
lang = (language_code or get_language() or 'de').split('-')[0]
if lang == 'en' and self.label_en.strip():
return self.label_en.strip()
return self.label.strip()
def translated_help_text(self, language_code: str | None = None) -> str:
lang = (language_code or get_language() or 'de').split('-')[0]
if lang == 'en' and self.help_text_en.strip():
return self.help_text_en.strip()
return self.help_text.strip()
def translated_select_options(self, language_code: str | None = None) -> list[tuple[str, str]]:
lang = (language_code or get_language() or 'de').split('-')[0]
raw = self.select_options_en if lang == 'en' and self.select_options_en.strip() else self.select_options
options = []
for line in (raw or '').splitlines():
line = line.strip()
if not line:
continue
if '|' in line:
value, label = [part.strip() for part in line.split('|', 1)]
else:
value = label = line
if value:
options.append((value, label or value))
return options
class NotificationTemplate(models.Model):
TEMPLATE_CHOICES = [
('onboarding_it', _('Onboarding: IT')),
@@ -844,6 +921,7 @@ class OffboardingRequest(models.Model):
requested_by_name = models.CharField(max_length=255, blank=True, verbose_name='Name der anfordernden Person')
preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de')
generated_pdf_path = models.CharField(max_length=500, blank=True)
custom_field_values = models.JSONField(default=dict, blank=True)
processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted')
last_error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -9,6 +9,7 @@ from django.utils.translation import override
from .form_builder import (
LOCKED_FIELD_RULES,
LOCKED_SECTION_RULES,
get_custom_field_configs,
ensure_form_field_configs,
ensure_form_section_configs,
get_default_page_map,
@@ -16,6 +17,7 @@ from .form_builder import (
get_section_order,
)
from .forms import OffboardingRequestForm, OnboardingRequestForm
from .models import FormCustomFieldConfig
PDF_SECTION_TITLES = {
"onboarding": {
@@ -251,6 +253,25 @@ def build_pdf_sections(form_type: str, request_obj, language_code: str | None =
}
)
custom_values = getattr(request_obj, 'custom_field_values', {}) or {}
for cfg in get_custom_field_configs(form_type):
if cfg.section_key not in sections:
continue
raw_value = custom_values.get(cfg.field_key)
if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX:
raw_value = _yes_no_text(language_code)[0] if raw_value else ''
sections[cfg.section_key]["fields"].append(
{
"name": f"custom__{cfg.field_key}",
"label": cfg.translated_label(language_code),
"help_text": cfg.translated_help_text(language_code),
"kind": _field_kind(raw_value),
"value": raw_value,
"is_empty": _is_empty_value(raw_value),
"is_locked": False,
}
)
not_available = _not_available_text(language_code)
result = []
for section in sections.values():

View File

@@ -149,6 +149,114 @@
line-height: 1.55;
}
.ops-overview-card {
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: 18px;
background:
radial-gradient(120% 120% at 100% 0%, rgba(0, 0, 120, 0.08), rgba(0, 0, 120, 0)),
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95));
padding: 18px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
}
.ops-overview-head h2 {
margin: 0;
color: #17345e;
font-size: 20px;
}
.ops-overview-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.ops-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 12px;
margin-top: 14px;
}
.ops-stat-card {
border: 1px solid #dce6f2;
border-radius: 16px;
background: rgba(255,255,255,0.86);
padding: 14px;
display: grid;
gap: 6px;
}
.ops-stat-label {
color: #60738d;
font-size: 12px;
font-weight: 700;
}
.ops-stat-card strong {
color: #17345e;
font-size: 22px;
line-height: 1.1;
}
.ops-stat-card strong.is-error {
color: #a32020;
}
.ops-failure-list {
margin-top: 16px;
display: grid;
gap: 10px;
}
.ops-failure-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.ops-failure-head h3 {
margin: 0;
color: #17345e;
font-size: 16px;
}
.ops-failure-items {
display: grid;
gap: 10px;
}
.ops-failure-item {
border: 1px solid #ead9d9;
border-radius: 14px;
background: rgba(255,248,248,0.92);
padding: 12px;
display: grid;
gap: 4px;
}
.ops-failure-item strong {
color: #7f1d1d;
font-size: 14px;
}
.ops-failure-item span {
color: #6f5b5b;
font-size: 13px;
}
.ops-failure-item code {
color: #6a1f1f;
background: rgba(255,255,255,0.6);
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
overflow-wrap: anywhere;
}
.status-row {
margin-top: 16px;
display: flex;
@@ -605,6 +713,7 @@
@media (max-width: 1080px) {
.hero-grid { grid-template-columns: 1fr; }
.ops-overview-grid { grid-template-columns: 1fr 1fr; }
.apps-grid { grid-template-columns: 1fr 1fr; }
.admin-grid { grid-template-columns: 1fr 1fr; }
}

View File

@@ -51,6 +51,10 @@
<span class="builder-stat-label">{% trans "Aktuell ausgeblendet" %}</span>
<strong>{{ builder_summary.hidden_field_count }}</strong>
</article>
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Eigene Felder" %}</span>
<strong>{{ builder_summary.custom_field_count }}</strong>
</article>
{% if form_type == 'onboarding' %}
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Versteckte Abschnitte" %}</span>
@@ -129,6 +133,7 @@
<div class="field-name">{{ item.field_name }}</div>
</div>
<div class="badges">
{% if item.is_custom %}<span class="badge">{% trans "Eigen" %}</span>{% endif %}
{% if item.locked %}<span class="badge locked">{% trans "Fix" %}</span>{% endif %}
{% if not item.is_visible %}<span class="badge hidden">{% trans "Ausgeblendet" %}</span>{% endif %}
{% if item.is_required %}<span class="badge required">{% trans "Pflicht" %}</span>{% endif %}
@@ -451,22 +456,134 @@
</form>
</div>
</details>
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-content-subpanels" {% if active_subpanel == 'custom-fields' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Eigene Felder" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<form class="add-option-form" method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<input type="hidden" name="builder_action" value="add_custom_field" />
<input type="text" name="custom_label" placeholder="{% trans 'Label (DE)' %}" required />
<input type="text" name="custom_label_en" placeholder="{% trans 'Label (EN, optional)' %}" />
<select name="custom_section_key">
{% for group in custom_field_groups %}
<option value="{{ group.key }}">{{ group.title }}</option>
{% endfor %}
</select>
<select name="custom_field_type">
{% for value, label in custom_field_type_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<input type="number" name="custom_sort_order" min="0" value="0" placeholder="{% trans 'Sortierung' %}" />
<label class="field-rule-control compact-inline">
<span>{% trans "Pflicht" %}</span>
<input type="checkbox" name="custom_is_required" />
</label>
<input type="text" name="custom_help_text" placeholder="{% trans 'Hilfetext (DE, optional)' %}" />
<input type="text" name="custom_help_text_en" placeholder="{% trans 'Hilfetext (EN, optional)' %}" />
<textarea name="custom_select_options" rows="3" placeholder="{% trans 'Optionen (eine pro Zeile, optional: wert|Label)' %}"></textarea>
<textarea name="custom_select_options_en" rows="3" placeholder="{% trans 'Optionen EN (eine pro Zeile, optional: value|Label)' %}"></textarea>
<button class="btn btn-primary" type="submit">{% trans "Eigenes Feld hinzufügen" %}</button>
</form>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="option-table-wrap">
<table class="option-table">
<thead>
<tr>
<th>{% trans "Schlüssel" %}</th>
<th>{% trans "Abschnitt" %}</th>
<th>{% trans "Typ" %}</th>
<th>{% trans "Sortierung" %}</th>
<th>{% trans "Label (DE)" %}</th>
<th>{% trans "Label (EN)" %}</th>
<th>{% trans "Pflicht" %}</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Löschen" %}</th>
</tr>
</thead>
<tbody>
{% for group in custom_field_groups %}
<tr class="option-table-group-row">
<th colspan="9">{{ group.title }}</th>
</tr>
{% for item in group.items %}
<tr>
<td>
<input type="hidden" name="custom_field_ids" value="{{ item.id }}" />
<strong>{{ item.field_key }}</strong>
</td>
<td>
<select name="custom_section_key_{{ item.id }}">
{% for group_choice in custom_field_groups %}
<option value="{{ group_choice.key }}" {% if group_choice.key == item.section_key %}selected{% endif %}>{{ group_choice.title }}</option>
{% endfor %}
</select>
</td>
<td>
<select name="custom_field_type_{{ item.id }}">
{% for value, label in custom_field_type_choices %}
<option value="{{ value }}" {% if value == item.field_type %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
<td><input type="number" min="0" name="custom_sort_order_{{ item.id }}" value="{{ item.sort_order }}" /></td>
<td>
<input type="text" name="custom_label_{{ item.id }}" value="{{ item.label }}" required />
<input type="text" name="custom_help_text_{{ item.id }}" value="{{ item.help_text }}" placeholder="{% trans 'Hilfetext (DE)' %}" />
<textarea name="custom_select_options_{{ item.id }}" rows="2" placeholder="{% trans 'Optionen (DE)' %}">{{ item.select_options }}</textarea>
</td>
<td>
<input type="text" name="custom_label_en_{{ item.id }}" value="{{ item.label_en }}" />
<input type="text" name="custom_help_text_en_{{ item.id }}" value="{{ item.help_text_en }}" placeholder="{% trans 'Hilfetext (EN)' %}" />
<textarea name="custom_select_options_en_{{ item.id }}" rows="2" placeholder="{% trans 'Optionen (EN)' %}">{{ item.select_options_en }}</textarea>
</td>
<td><input type="checkbox" name="custom_is_required_{{ item.id }}" {% if item.is_required %}checked{% endif %} /></td>
<td><input type="checkbox" name="custom_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
<td>
<button class="btn btn-secondary" type="submit" name="delete_custom_field_id" value="{{ item.id }}" data-confirm="{% trans 'Eigenes Feld wirklich löschen?' %}">{% trans "Löschen" %}</button>
</td>
</tr>
{% empty %}
<tr><td colspan="9">{% trans "Keine eigenen Felder vorhanden." %}</td></tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_custom_fields">{% trans "Eigene Felder speichern" %}</button>
</div>
</form>
</div>
</details>
</div>
</div>
</details>
<script>
(() => {
const accordions = document.querySelectorAll('.js-single-accordion[data-accordion-group]');
accordions.forEach((accordion) => {
accordion.addEventListener('toggle', () => {
if (!accordion.open) return;
const group = accordion.dataset.accordionGroup;
document.querySelectorAll(`.js-single-accordion[data-accordion-group="${group}"]`).forEach((peer) => {
if (peer !== accordion) peer.open = false;
});
{% endblock %}
{% block extra_scripts %}
<script src="{% static 'workflows/js/form_builder.js' %}"></script>
<script>
(() => {
const accordions = document.querySelectorAll('.js-single-accordion[data-accordion-group]');
accordions.forEach((accordion) => {
accordion.addEventListener('toggle', () => {
if (!accordion.open) return;
const group = accordion.dataset.accordionGroup;
document.querySelectorAll(`.js-single-accordion[data-accordion-group="${group}"]`).forEach((peer) => {
if (peer !== accordion) peer.open = false;
});
});
})();
</script>
});
})();
</script>
{% endblock %}

View File

@@ -37,6 +37,57 @@
<main class="main">
{% include 'workflows/includes/messages.html' %}
{% if ops_summary.show %}
<section class="ops-overview-card">
<div class="ops-overview-head">
<div>
<h2>{% trans "Operations Overview" %}</h2>
<p>{% trans "Letzte Laufzeit- und Backup-Signale auf einen Blick." %}</p>
</div>
</div>
<div class="ops-overview-grid">
{% if ops_summary.can_view_jobs %}
<article class="ops-stat-card">
<span class="ops-stat-label">{% trans "Fehlgeschlagene Jobs (24h)" %}</span>
<strong class="{% if ops_summary.failed_count_24h %}is-error{% endif %}">{{ ops_summary.failed_count_24h }}</strong>
</article>
<article class="ops-stat-card">
<span class="ops-stat-label">{% trans "Erfolgreiche Jobs (24h)" %}</span>
<strong>{{ ops_summary.success_count_24h }}</strong>
</article>
<article class="ops-stat-card">
<span class="ops-stat-label">{% trans "Offene Starts (24h)" %}</span>
<strong>{{ ops_summary.started_count_24h }}</strong>
</article>
{% endif %}
{% if ops_summary.can_manage_backups and ops_summary.backup_health %}
<article class="ops-stat-card">
<span class="ops-stat-label">{% trans "Backup-Status" %}</span>
<strong>{{ ops_summary.backup_health.label }}</strong>
<span class="mini">{{ ops_summary.backup_health.summary }}</span>
</article>
{% endif %}
</div>
{% if ops_summary.can_view_jobs and ops_summary.recent_failed_logs %}
<div class="ops-failure-list">
<div class="ops-failure-head">
<h3>{% trans "Letzte Fehler" %}</h3>
<a class="btn btn-secondary" href="/admin-tools/jobs/">{% trans "Job Monitor öffnen" %}</a>
</div>
<div class="ops-failure-items">
{% for log in ops_summary.recent_failed_logs %}
<article class="ops-failure-item">
<strong>{{ log.task_name }}</strong>
<span>{{ log.target_label|default:log.target_type }}</span>
<code>{{ log.error_message|truncatechars:120 }}</code>
</article>
{% endfor %}
</div>
</div>
{% endif %}
</section>
{% endif %}
{% for section in portal_app_sections %}
{% if not forloop.first %}
<div class="section-divider" aria-hidden="true"></div>

View File

@@ -14,6 +14,45 @@
{% include 'workflows/includes/messages.html' %}
<section class="card">
<div class="grid">
<div class="field">
<label>{% trans "Fehlgeschlagene Jobs (24h)" %}</label>
<div class="branding-inline-value">{{ job_summary.failed_count_24h }}</div>
</div>
<div class="field">
<label>{% trans "Erfolgreiche Jobs (24h)" %}</label>
<div class="branding-inline-value">{{ job_summary.success_count_24h }}</div>
</div>
<div class="field">
<label>{% trans "Offene Starts (24h)" %}</label>
<div class="branding-inline-value">{{ job_summary.started_count_24h }}</div>
</div>
</div>
{% if job_summary.recent_failed %}
<div class="table-wrap" style="margin-top:12px;">
<table>
<thead>
<tr>
<th>{% trans "Zuletzt fehlgeschlagen" %}</th>
<th>{% trans "Ziel" %}</th>
<th>{% trans "Fehler" %}</th>
</tr>
</thead>
<tbody>
{% for log in job_summary.recent_failed %}
<tr>
<td>{{ log.task_name }}</td>
<td>{{ log.target_label|default:log.target_type }}</td>
<td><code>{{ log.error_message|truncatechars:140 }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
<section class="card">
<form method="get" class="app-registry-filters">
<div class="field">

View File

@@ -60,12 +60,20 @@
</div>
<div class="grid">
{% for field in section.fields %}
{% if field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% else %}
<div class="field {% if field.name == 'notes' %}field-full{% endif %}">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% endif %}
{% endfor %}
</div>
</section>

View File

@@ -59,7 +59,7 @@
{% with field=block.field %}
{% if field.is_hidden %}
{{ field }}
{% elif field.name in onboarding_inline_checks %}
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
@@ -102,7 +102,7 @@
{% for field in block.fields %}
{% if field.is_hidden %}
{{ field }}
{% elif field.name in onboarding_inline_checks %}
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}

View File

@@ -37,9 +37,15 @@
.timeline-detail-row { display:grid; grid-template-columns:160px 1fr; gap:12px; font-size:13px; }
.timeline-detail-row strong { color:#566886; }
.timeline-detail-list { margin:0; padding-left:18px; color:#4f617f; }
.timeline-custom-fields { margin: 0 0 20px; padding: 18px 20px; border: 1px solid #d9e3f8; border-radius: 20px; background: linear-gradient(180deg,#ffffff 0%,#f7faff 100%); box-shadow: 0 18px 40px rgba(23,39,90,.08); }
.timeline-custom-fields h2 { margin: 0 0 14px; font-size: 18px; color: #20345f; }
.timeline-custom-grid { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px 16px; }
.timeline-custom-item { padding: 12px 14px; border: 1px solid #d8e1f5; border-radius: 16px; background: #fff; }
.timeline-custom-item strong { display:block; margin-bottom: 4px; color:#566886; font-size:12px; letter-spacing:.05em; text-transform:uppercase; }
.timeline-custom-item span { color:#22324d; font-size:14px; line-height:1.45; }
@media (max-width: 1160px) { .timeline-summary-grid { grid-template-columns:repeat(3, minmax(0,1fr)); } }
@media (max-width: 820px) { .timeline-summary-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
@media (max-width: 700px) { .timeline-summary-grid { grid-template-columns:1fr; } .timeline-head { flex-direction:column; } .timeline-stamp { white-space:normal; } .timeline-detail-row { grid-template-columns:1fr; } }
@media (max-width: 700px) { .timeline-summary-grid { grid-template-columns:1fr; } .timeline-custom-grid { grid-template-columns:1fr; } .timeline-head { flex-direction:column; } .timeline-stamp { white-space:normal; } .timeline-detail-row { grid-template-columns:1fr; } }
</style>
{% endblock %}
@@ -80,6 +86,20 @@
</div>
</div>
{% if custom_field_details %}
<section class="timeline-custom-fields">
<h2>{% trans "Benutzerdefinierte Felder" %}</h2>
<div class="timeline-custom-grid">
{% for item in custom_field_details %}
<div class="timeline-custom-item">
<strong>{{ item.label }}</strong>
<span>{{ item.value }}</span>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<div class="timeline-list">
{% for row in timeline_rows %}
<article class="timeline-item" data-kind="{{ row.kind }}">

View File

@@ -3,7 +3,7 @@ import json
from django.contrib.auth import get_user_model
from django.test import TestCase
from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig
from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig
class FormBuilderAdminTests(TestCase):
@@ -202,3 +202,59 @@ class FormBuilderAdminTests(TestCase):
self.assertEqual(len(rule.clauses), 2)
self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice')
self.assertEqual(rule.clauses[1]['operator'], 'not_equals')
def test_staff_can_add_custom_field(self):
self.client.force_login(self.staff)
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={
'builder_action': 'add_custom_field',
'custom_label': 'Laptop-Tag',
'custom_label_en': 'Laptop tag',
'custom_section_key': 'itsetup',
'custom_field_type': 'text',
'custom_sort_order': '3',
'custom_is_required': 'on',
},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
field = FormCustomFieldConfig.objects.get(form_type='onboarding', field_key='laptop_tag')
self.assertEqual(field.section_key, 'itsetup')
self.assertEqual(field.field_type, 'text')
self.assertEqual(field.is_required, True)
def test_save_order_updates_custom_field_section_and_sort_order(self):
self.client.force_login(self.staff)
custom_field = FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='laptop_tag',
section_key='itsetup',
sort_order=99,
field_type='text',
label='Laptop-Tag',
)
self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost')
payload = {
'form_type': 'onboarding',
'columns': {
'stammdaten': ['department'],
'vertrag': ['contract_start'],
'itsetup': ['custom__laptop_tag'],
'abschluss': [],
},
}
response = self.client.post(
'/admin-tools/form-builder/save-order/',
data=json.dumps(payload),
content_type='application/json',
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 200)
custom_field.refresh_from_db()
self.assertEqual(custom_field.section_key, 'itsetup')
self.assertEqual(custom_field.sort_order, 2)

View File

@@ -0,0 +1,123 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.utils import timezone
from workflows.models import AsyncTaskLog
from workflows.roles import ROLE_ADMIN, ROLE_STAFF, assign_user_role
class ObservabilityUITests(TestCase):
def setUp(self):
user_model = get_user_model()
self.admin = user_model.objects.create_user(
username='ops_admin',
email='ops-admin@example.com',
password='secret123',
)
assign_user_role(self.admin, ROLE_ADMIN)
self.staff = user_model.objects.create_user(
username='ops_staff',
email='ops-staff@example.com',
password='secret123',
)
assign_user_role(self.staff, ROLE_STAFF)
def _create_log(self, *, status: str, task_name: str, target_label: str, error_message: str = '') -> AsyncTaskLog:
log = AsyncTaskLog.objects.create(
task_name=task_name,
status=status,
target_type='request',
target_id=1,
target_label=target_label,
error_message=error_message,
)
AsyncTaskLog.objects.filter(id=log.id).update(
started_at=timezone.now() - timedelta(hours=2),
finished_at=timezone.now() - timedelta(hours=1, minutes=45),
)
return AsyncTaskLog.objects.get(id=log.id)
def test_home_shows_operations_overview_for_admin(self):
self._create_log(
status='failed',
task_name='send_scheduled_welcome_email',
target_label='Request A',
error_message='smtp failed hard',
)
self._create_log(
status='succeeded',
task_name='process_onboarding_request',
target_label='Request B',
)
self._create_log(
status='started',
task_name='process_offboarding_request',
target_label='Request C',
)
client = Client()
client.force_login(self.admin)
response = client.get('/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Operations Overview')
self.assertContains(response, 'Fehlgeschlagene Jobs (24h)')
self.assertContains(response, '<strong class="is-error">1</strong>', html=True)
self.assertContains(response, 'send_scheduled_welcome_email')
self.assertContains(response, 'Backup-Status')
def test_home_hides_operations_overview_for_staff(self):
self._create_log(
status='failed',
task_name='process_onboarding_request',
target_label='Request A',
error_message='pdf failed',
)
client = Client()
client.force_login(self.staff)
response = client.get('/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'Operations Overview')
self.assertNotContains(response, 'Job Monitor öffnen')
def test_job_monitor_summary_shows_recent_counts(self):
self._create_log(
status='failed',
task_name='process_onboarding_request',
target_label='Request A',
error_message='pdf failed',
)
self._create_log(
status='succeeded',
task_name='process_offboarding_request',
target_label='Request B',
)
self._create_log(
status='started',
task_name='send_scheduled_welcome_email',
target_label='Request C',
)
client = Client()
client.force_login(self.admin)
response = client.get('/admin-tools/jobs/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Fehlgeschlagene Jobs (24h)')
self.assertContains(response, 'Erfolgreiche Jobs (24h)')
self.assertContains(response, 'Offene Starts (24h)')
self.assertContains(response, 'Zuletzt fehlgeschlagen')
self.assertContains(response, 'pdf failed')
def test_job_monitor_requires_capability(self):
client = Client()
client.force_login(self.staff)
response = client.get('/admin-tools/jobs/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 302)

View File

@@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from workflows.branding import get_company_email_domain
from workflows.models import EmployeeProfile, OffboardingRequest
from workflows.models import EmployeeProfile, FormCustomFieldConfig, OffboardingRequest
class OffboardingFlowTests(TestCase):
@@ -59,3 +59,37 @@ class OffboardingFlowTests(TestCase):
self.assertEqual(obj.requested_by_email, f'operator@{self.company_domain}')
self.assertEqual(obj.requested_by_name, 'Nina Admin')
mock_delay.assert_called_once_with(obj.id)
@patch('workflows.views.process_offboarding_request.delay')
def test_offboarding_custom_field_is_saved(self, mock_delay):
FormCustomFieldConfig.objects.create(
form_type='offboarding',
field_key='return_comment',
section_key='abschluss',
sort_order=0,
field_type='textarea',
is_active=True,
is_required=False,
label='Rückgabehinweis',
)
payload = {
'full_name': self.profile.full_name,
'work_email': self.profile.work_email,
'department': self.profile.department,
'job_title': self.profile.job_title,
'last_working_day': '2026-12-31',
'notes': 'Bitte Accounts sperren.',
'custom__return_comment': 'Abholung durch IT am Freitag.',
}
response = self.client.post(
f'/offboarding/new/?profile={self.profile.id}',
payload,
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
obj = OffboardingRequest.objects.get(work_email=self.profile.work_email)
self.assertEqual(obj.custom_field_values, {'return_comment': 'Abholung durch IT am Freitag.'})
mock_delay.assert_called_once_with(obj.id)

View File

@@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from workflows.branding import get_company_email_domain
from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest
from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest
class OnboardingFlowTests(TestCase):
@@ -171,3 +171,168 @@ class OnboardingFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertIn('employment-end-box', html)
self.assertIn('"value": "unbefristet"', html)
def test_onboarding_custom_field_uses_combined_order(self):
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='office_location',
section_key='stammdaten',
sort_order=1,
field_type='text',
is_active=True,
label='Bürostandort',
)
FormFieldConfig.objects.update_or_create(
form_type='onboarding',
field_name='gender',
defaults={'sort_order': 2, 'page_key': 'stammdaten'},
)
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
html = response.content.decode('utf-8')
self.assertLess(html.index('Bürostandort'), html.index('Anrede'))
@patch('workflows.views.process_onboarding_request.delay')
def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay):
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='office_location',
section_key='stammdaten',
sort_order=0,
field_type='text',
is_active=True,
is_required=True,
label='Bürostandort',
)
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
self.assertContains(response, 'Bürostandort')
payload = {
'first_name': 'Mara',
'last_name': 'Muster',
'gender': 'frau',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': f'mara.muster@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
'additional_hardware_needed_choice': 'nein',
'additional_software_needed_choice': 'nein',
'additional_access_needed_choice': 'nein',
'successor_required_choice': 'nein',
'inherit_phone_number_choice': 'nein',
'custom__office_location': 'Berlin Mitte',
'agreement_confirm': 'on',
}
submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost')
self.assertEqual(submit_response.status_code, 302)
obj = OnboardingRequest.objects.get(work_email=f'mara.muster@{self.company_domain}')
self.assertEqual(obj.custom_field_values, {'office_location': 'Berlin Mitte'})
mock_delay.assert_called_once_with(obj.id)
@patch('workflows.views.process_onboarding_request.delay')
def test_hidden_required_custom_field_does_not_block_submission(self, mock_delay):
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='visitor_badge_name',
section_key='stammdaten',
sort_order=0,
field_type='text',
is_active=True,
is_required=True,
label='Besucherausweis',
)
FormConditionalRuleConfig.objects.update_or_create(
form_type='onboarding',
target_key='custom__visitor_badge_name',
defaults={
'is_active': True,
'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}],
},
)
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
html = response.content.decode('utf-8')
self.assertIn('custom__visitor_badge_name', html)
self.assertIn('"custom__visitor_badge_name"', html)
payload = {
'first_name': 'Lea',
'last_name': 'Leicht',
'gender': 'frau',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': f'lea.leicht@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
'additional_hardware_needed_choice': 'nein',
'additional_software_needed_choice': 'nein',
'additional_access_needed_choice': 'nein',
'successor_required_choice': 'nein',
'inherit_phone_number_choice': 'nein',
'agreement_confirm': 'on',
}
submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost')
self.assertEqual(submit_response.status_code, 302)
obj = OnboardingRequest.objects.get(work_email=f'lea.leicht@{self.company_domain}')
self.assertEqual(obj.custom_field_values, {'visitor_badge_name': ''})
mock_delay.assert_called_once_with(obj.id)
@patch('workflows.views.process_onboarding_request.delay')
def test_visible_required_custom_field_blocks_submission(self, mock_delay):
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='visitor_badge_name',
section_key='stammdaten',
sort_order=0,
field_type='text',
is_active=True,
is_required=True,
label='Besucherausweis',
)
FormConditionalRuleConfig.objects.update_or_create(
form_type='onboarding',
target_key='custom__visitor_badge_name',
defaults={
'is_active': True,
'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}],
},
)
payload = {
'first_name': 'Lia',
'last_name': 'Laut',
'gender': 'frau',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': f'lia.laut@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'order_business_cards': 'on',
'business_card_name': 'Lia Laut',
'business_card_title': 'Consultant',
'business_card_email': f'lia.laut@{self.company_domain}',
'business_card_phone': '030 123456',
'group_mailboxes_required_choice': 'nein',
'additional_hardware_needed_choice': 'nein',
'additional_software_needed_choice': 'nein',
'additional_access_needed_choice': 'nein',
'successor_required_choice': 'nein',
'inherit_phone_number_choice': 'nein',
'agreement_confirm': 'on',
}
response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Besucherausweis')
self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lia.laut@{self.company_domain}').exists())
mock_delay.assert_not_called()

View File

@@ -1,6 +1,6 @@
from django.test import TestCase
from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest
from workflows.models import FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest
from workflows.pdf_sections import build_pdf_sections
@@ -94,3 +94,32 @@ class PDFSectionBuilderTests(TestCase):
self.assertIn('last_working_day', [field['name'] for field in austritt['fields']])
date_field = next(field for field in austritt['fields'] if field['name'] == 'last_working_day')
self.assertTrue(date_field['display_value'])
def test_custom_fields_are_included_in_pdf_sections(self):
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='office_location',
section_key='stammdaten',
sort_order=0,
field_type='text',
is_active=True,
label='Bürostandort',
)
request_obj = OnboardingRequest.objects.create(
full_name='Max Mustermann',
gender='herr',
job_title='Consultant',
department='IT-Service',
work_email='max.mustermann@workdock.de',
contract_start='2026-11-01',
employment_type='unbefristet',
agreement='accepted',
custom_field_values={'office_location': 'Berlin Mitte'},
)
sections = build_pdf_sections('onboarding', request_obj, 'de')
stammdaten = next(section for section in sections if section['key'] == 'stammdaten')
custom_field = next(field for field in stammdaten['fields'] if field['name'] == 'custom__office_location')
self.assertEqual(custom_field['label'], 'Bürostandort')
self.assertEqual(custom_field['display_value'], 'Berlin Mitte')

View File

@@ -0,0 +1,73 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from workflows.models import FormCustomFieldConfig, OffboardingRequest, OnboardingRequest
from workflows.roles import ROLE_ADMIN, assign_user_role
class RequestTimelineCustomFieldTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='timeline_admin',
email='timeline-admin@example.com',
password='secret123',
)
assign_user_role(self.user, ROLE_ADMIN)
self.client.force_login(self.user)
def test_onboarding_timeline_renders_custom_field_values(self):
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='office_location',
section_key='stammdaten',
sort_order=0,
field_type='text',
is_active=True,
label='Bürostandort',
)
obj = OnboardingRequest.objects.create(
full_name='Max Mustermann',
gender='herr',
job_title='Consultant',
department='IT-Service',
work_email='max.mustermann@workdock.de',
contract_start='2026-11-01',
employment_type='unbefristet',
agreement='accepted',
custom_field_values={'office_location': 'Berlin Mitte'},
)
response = self.client.get(f'/requests/timeline/onboarding/{obj.id}/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Benutzerdefinierte Felder')
self.assertContains(response, 'Bürostandort')
self.assertContains(response, 'Berlin Mitte')
def test_offboarding_timeline_renders_custom_field_values(self):
FormCustomFieldConfig.objects.create(
form_type='offboarding',
field_key='return_comment',
section_key='abschluss',
sort_order=0,
field_type='textarea',
is_active=True,
label='Rückgabehinweis',
)
obj = OffboardingRequest.objects.create(
full_name='Lara Beispiel',
work_email='lara.beispiel@workdock.de',
department='IT-Service',
job_title='Engineer',
last_working_day='2026-12-31',
requested_by_email='admin@workdock.de',
custom_field_values={'return_comment': 'Abholung durch IT am Freitag.'},
)
response = self.client.get(f'/requests/timeline/offboarding/{obj.id}/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Benutzerdefinierte Felder')
self.assertContains(response, 'Rückgabehinweis')
self.assertContains(response, 'Abholung durch IT am Freitag.')

View File

@@ -10,6 +10,7 @@ from django.conf import settings
from django.db import connection
from django.db import IntegrityError
from django.db.models import Q
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib import messages
from django.contrib.auth import get_user_model, login as auth_login
@@ -46,15 +47,18 @@ from .form_builder import (
ONBOARDING_DEFAULT_PAGE,
ONBOARDING_PAGE_LABELS,
ONBOARDING_PAGE_ORDER,
build_custom_field_key,
custom_field_target_key,
ensure_form_field_configs,
ensure_form_conditional_rule_configs,
ensure_form_section_configs,
get_custom_field_configs,
get_default_page_map,
get_section_labels,
get_section_order,
apply_form_preset,
)
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 .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, 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 .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
@@ -105,8 +109,6 @@ ONBOARDING_GROUPS = {
'phone-box': ['phone_number_choice'],
}
ONBOARDING_HIDDEN_BY_DEFAULT = set(DEFAULT_CONDITIONAL_RULES.get('onboarding', {}).keys())
ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'}
ONBOARDING_CHECKBOX_LISTS = {
'needed_devices_multi',
@@ -144,6 +146,10 @@ def _normalized_conditional_rule_payload(form_type: str) -> dict[str, dict]:
return payload
def _active_conditional_target_keys(form_type: str) -> set[str]:
return set(_normalized_conditional_rule_payload(form_type).keys())
def healthz(request):
db_ok = True
try:
@@ -450,6 +456,36 @@ def _request_status_label(status_key: str, language_code: str | None = None) ->
return labels.get(status_key, status_key)
def _request_custom_field_details(obj, kind: str, language_code: str | None = None) -> list[dict[str, str]]:
form_type = 'onboarding' if kind == 'onboarding' else 'offboarding'
language_code = ((language_code or getattr(obj, 'preferred_language', '') or get_language() or 'de').split('-')[0]).lower()
values = getattr(obj, 'custom_field_values', {}) or {}
rows = []
yes_label = 'Ja' if language_code == 'de' else 'Yes'
for cfg in get_custom_field_configs(form_type, include_inactive=True):
raw_value = values.get(cfg.field_key)
if raw_value in (None, '', False, []):
continue
if isinstance(raw_value, bool):
display_value = str(yes_label) if raw_value else ''
elif isinstance(raw_value, list):
display_value = ', '.join(str(item).strip() for item in raw_value if str(item).strip())
else:
display_value = str(raw_value).strip()
if not display_value:
continue
rows.append(
{
'label': cfg.translated_label(language_code),
'value': display_value,
'section': cfg.section_key,
'sort_order': cfg.sort_order,
}
)
rows.sort(key=lambda item: (item['section'], item['sort_order'], item['label']))
return rows
def _audit_action_label(action: str) -> str:
labels = {
'requests_deleted': _('Vorgänge gelöscht'),
@@ -505,6 +541,7 @@ def _build_onboarding_layout(form) -> list[dict]:
for group_id, group_fields in ONBOARDING_GROUPS.items():
for name in group_fields:
group_by_field[name] = group_id
conditional_target_keys = _active_conditional_target_keys('onboarding')
rendered_groups = set()
consumed = set()
@@ -529,7 +566,7 @@ def _build_onboarding_layout(form) -> list[dict]:
{
'kind': 'group',
'id': group_id,
'hidden_default': group_id in ONBOARDING_HIDDEN_BY_DEFAULT,
'hidden_default': group_id in conditional_target_keys,
'fields': group_fields,
}
)
@@ -537,6 +574,18 @@ def _build_onboarding_layout(form) -> list[dict]:
consumed.update([f.name for f in group_fields])
continue
if field_name.startswith('custom__') and field_name in conditional_target_keys:
blocks.append(
{
'kind': 'group',
'id': field_name,
'hidden_default': True,
'fields': [form[field_name]],
}
)
consumed.add(field_name)
continue
blocks.append({'kind': 'field', 'field': form[field_name]})
consumed.add(field_name)
@@ -600,10 +649,42 @@ def _build_offboarding_sections(form, visible_section_keys: set[str] | None = No
]
def _ops_summary_for_user(user) -> dict[str, object]:
can_view_jobs = user_has_capability(user, 'view_job_monitor')
can_manage_backups = user_has_capability(user, 'manage_backups')
summary: dict[str, object] = {
'show': can_view_jobs or can_manage_backups,
'can_view_jobs': can_view_jobs,
'can_manage_backups': can_manage_backups,
'failed_count_24h': 0,
'started_count_24h': 0,
'success_count_24h': 0,
'recent_failed_logs': [],
'backup_health': latest_backup_health_snapshot() if can_manage_backups else None,
}
if not can_view_jobs:
return summary
since = timezone.now() - timedelta(hours=24)
logs = AsyncTaskLog.objects.filter(started_at__gte=since)
counts = {
row['status']: row['count']
for row in logs.values('status').annotate(count=Count('id'))
}
summary['failed_count_24h'] = counts.get('failed', 0)
summary['started_count_24h'] = counts.get('started', 0)
summary['success_count_24h'] = counts.get('succeeded', 0)
summary['recent_failed_logs'] = list(
AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5]
)
return summary
@login_required
def home(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
role_key = get_user_role_key(request.user)
ops_summary = _ops_summary_for_user(request.user)
return render(
request,
'workflows/home.html',
@@ -614,6 +695,7 @@ def home(request):
'role_label': get_user_role_label(request.user),
'role_key': role_key,
'portal_app_sections': build_portal_app_sections(request.user),
'ops_summary': ops_summary,
},
)
@@ -641,6 +723,13 @@ def job_monitor_page(request):
logs = logs.filter(task_name=task_filter)
logs = logs.order_by('-started_at', '-id')[:200]
task_names = list(AsyncTaskLog.objects.order_by('task_name').values_list('task_name', flat=True).distinct())
since = timezone.now() - timedelta(hours=24)
recent_logs = AsyncTaskLog.objects.filter(started_at__gte=since)
counts = {
row['status']: row['count']
for row in recent_logs.values('status').annotate(count=Count('id'))
}
recent_failed = list(AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5])
return render(
request,
'workflows/job_monitor.html',
@@ -650,6 +739,12 @@ def job_monitor_page(request):
'task_filter': task_filter,
'task_names': task_names,
'status_choices': [('started', _('Gestartet')), ('succeeded', _('Erfolgreich')), ('failed', _('Fehlgeschlagen'))],
'job_summary': {
'started_count_24h': counts.get('started', 0),
'success_count_24h': counts.get('succeeded', 0),
'failed_count_24h': counts.get('failed', 0),
'recent_failed': recent_failed,
},
},
)
@@ -1469,6 +1564,7 @@ def request_timeline_page(request, kind: str, request_id: int):
return redirect('requests_dashboard')
request_label = _request_target_label(obj, kind)
custom_field_details = _request_custom_field_details(obj, kind, getattr(request, 'LANGUAGE_CODE', None))
audit_rows = list(
AdminAuditLog.objects.select_related('actor')
.filter(target_type__in=[kind, 'request'])
@@ -1483,6 +1579,7 @@ def request_timeline_page(request, kind: str, request_id: int):
'title': _('Anfrage erstellt'),
'summary': request_label,
'meta': _('Status: %(status)s') % {'status': obj.get_processing_status_display()},
'details': {item['label']: item['value'] for item in custom_field_details},
}
]
@@ -1569,6 +1666,7 @@ def request_timeline_page(request, kind: str, request_id: int):
'request_obj': obj,
'request_label': request_label,
'timeline_rows': timeline_rows,
'custom_field_details': custom_field_details,
'contract_start': getattr(obj, 'contract_start', None),
'handover_date': getattr(obj, 'handover_date', None),
},
@@ -2076,6 +2174,7 @@ def form_builder_page(request):
if request.method == 'POST':
delete_option_id = request.POST.get('delete_option_id', '').strip()
delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip()
if delete_option_id:
option = FormOption.objects.filter(id=delete_option_id).first()
if not option:
@@ -2088,6 +2187,17 @@ def form_builder_page(request):
_audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label)
messages.success(request, 'Option wurde gelöscht.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=options#builder-content")
if delete_custom_field_id:
custom_field = FormCustomFieldConfig.objects.filter(id=delete_custom_field_id, form_type=form_type).first()
if not custom_field:
messages.error(request, 'Benutzerdefiniertes Feld nicht gefunden.')
else:
deleted_label = custom_field.label
deleted_id = custom_field.id
custom_field.delete()
_audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label)
messages.success(request, 'Benutzerdefiniertes Feld wurde gelöscht.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content")
action = request.POST.get('builder_action', '')
if action == 'add_option':
@@ -2157,6 +2267,91 @@ def form_builder_page(request):
_audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)})
messages.success(request, 'Feldtexte wurden gespeichert.')
elif action == 'add_custom_field':
label = (request.POST.get('custom_label') or '').strip()
label_en = (request.POST.get('custom_label_en') or '').strip()
section_key = (request.POST.get('custom_section_key') or '').strip()
field_type = (request.POST.get('custom_field_type') or '').strip()
sort_order_raw = (request.POST.get('custom_sort_order') or '').strip()
help_text = (request.POST.get('custom_help_text') or '').strip()
help_text_en = (request.POST.get('custom_help_text_en') or '').strip()
select_options = (request.POST.get('custom_select_options') or '').strip()
select_options_en = (request.POST.get('custom_select_options_en') or '').strip()
section_choices = {key for key in get_section_order(form_type)}
field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES}
if not label:
messages.error(request, 'Bitte eine Bezeichnung für das benutzerdefinierte Feld angeben.')
elif section_key not in section_choices:
messages.error(request, 'Ungültiger Abschnitt für das benutzerdefinierte Feld.')
elif field_type not in field_type_choices:
messages.error(request, 'Ungültiger Feldtyp.')
elif field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not select_options:
messages.error(request, 'Auswahlfelder benötigen mindestens eine Option.')
else:
field_key_base = build_custom_field_key(label)
field_key = field_key_base
suffix = 2
while FormCustomFieldConfig.objects.filter(form_type=form_type, field_key=field_key).exists():
field_key = f'{field_key_base}_{suffix}'
suffix += 1
try:
sort_order = int(sort_order_raw or 0)
except ValueError:
sort_order = 0
FormCustomFieldConfig.objects.create(
form_type=form_type,
field_key=field_key,
section_key=section_key,
sort_order=max(0, sort_order),
field_type=field_type,
is_active=True,
is_required=request.POST.get('custom_is_required') == 'on',
label=label,
label_en=label_en,
help_text=help_text,
help_text_en=help_text_en,
select_options=select_options,
select_options_en=select_options_en,
)
_audit(request, 'form_custom_field_added', target_type='form_custom_field', target_label=label, details={'form_type': form_type, 'field_type': field_type, 'section_key': section_key})
messages.success(request, 'Benutzerdefiniertes Feld wurde hinzugefügt.')
elif action == 'save_custom_fields':
custom_ids = request.POST.getlist('custom_field_ids')
updated = 0
section_choices = {key for key in get_section_order(form_type)}
field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES}
for raw_id in custom_ids:
cfg = FormCustomFieldConfig.objects.filter(id=raw_id, form_type=form_type).first()
if not cfg:
continue
field_type = (request.POST.get(f'custom_field_type_{cfg.id}') or '').strip()
section_key = (request.POST.get(f'custom_section_key_{cfg.id}') or '').strip()
try:
sort_order = int((request.POST.get(f'custom_sort_order_{cfg.id}') or '').strip() or cfg.sort_order)
except ValueError:
sort_order = cfg.sort_order
cfg.label = (request.POST.get(f'custom_label_{cfg.id}') or '').strip() or cfg.label
cfg.label_en = (request.POST.get(f'custom_label_en_{cfg.id}') or '').strip()
cfg.help_text = (request.POST.get(f'custom_help_text_{cfg.id}') or '').strip()
cfg.help_text_en = (request.POST.get(f'custom_help_text_en_{cfg.id}') or '').strip()
cfg.is_required = request.POST.get(f'custom_is_required_{cfg.id}') == 'on'
cfg.is_active = request.POST.get(f'custom_is_active_{cfg.id}') == 'on'
if field_type in field_type_choices:
cfg.field_type = field_type
if section_key in section_choices:
cfg.section_key = section_key
cfg.sort_order = max(0, sort_order)
cfg.select_options = (request.POST.get(f'custom_select_options_{cfg.id}') or '').strip()
cfg.select_options_en = (request.POST.get(f'custom_select_options_en_{cfg.id}') or '').strip()
if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not cfg.select_options:
messages.error(request, f'Auswahlfeld "{cfg.label}" benötigt mindestens eine Option.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content")
cfg.save()
updated += 1
_audit(request, 'form_custom_fields_saved', target_type='form_custom_field', target_label=form_type, details={'count': updated})
messages.success(request, 'Benutzerdefinierte Felder wurden gespeichert.')
elif action == 'save_field_rules':
field_ids = request.POST.getlist('field_rule_ids')
locked_fields = LOCKED_FIELD_RULES.get(form_type, set())
@@ -2223,12 +2418,14 @@ def form_builder_page(request):
else:
messages.error(request, 'Preset konnte nicht angewendet werden.')
if action in {'add_option', 'save_options', 'save_field_texts'}:
if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields'}:
active_panel = 'builder-content'
if action in {'add_option', 'save_options'}:
active_subpanel = 'options'
elif action == 'save_field_texts':
active_subpanel = 'field-texts'
elif action in {'add_custom_field', 'save_custom_fields'}:
active_subpanel = 'custom-fields'
elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}:
active_panel = 'builder-rules'
redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}"
@@ -2263,6 +2460,7 @@ def form_builder_page(request):
labels = _form_field_labels(form_type)
locked = LOCKED_FIELD_RULES.get(form_type, set())
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key'))
if form_type == 'onboarding':
columns = [
@@ -2289,8 +2487,30 @@ def form_builder_page(request):
'is_required': cfg.is_required,
'locked': cfg.field_name in locked,
'page_key': page_key,
'is_custom': False,
'sort_order': cfg.sort_order,
}
)
for cfg in custom_field_configs:
page_key = cfg.section_key or fallback
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': f'custom__{cfg.field_key}',
'label': cfg.translated_label(language_code),
'label_de': cfg.label,
'label_en': cfg.label_en,
'is_visible': cfg.is_active,
'is_required': cfg.is_required,
'locked': False,
'page_key': page_key,
'is_custom': True,
'sort_order': cfg.sort_order,
}
)
for column in columns:
column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name']))
else:
columns = [
{
@@ -2316,8 +2536,30 @@ def form_builder_page(request):
'is_required': cfg.is_required,
'locked': cfg.field_name in locked,
'page_key': page_key,
'is_custom': False,
'sort_order': cfg.sort_order,
}
)
for cfg in custom_field_configs:
page_key = cfg.section_key or fallback
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': f'custom__{cfg.field_key}',
'label': cfg.translated_label(language_code),
'label_de': cfg.label,
'label_en': cfg.label_en,
'is_visible': cfg.is_active,
'is_required': cfg.is_required,
'locked': False,
'page_key': page_key,
'is_custom': True,
'sort_order': cfg.sort_order,
}
)
for column in columns:
column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name']))
section_rule_items = []
if section_order:
@@ -2350,6 +2592,20 @@ def form_builder_page(request):
}
)
custom_field_groups = []
if section_order:
grouped_custom = {key: [] for key in section_order}
for cfg in custom_field_configs:
grouped_custom.setdefault(cfg.section_key, []).append(cfg)
for key in section_order:
custom_field_groups.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': grouped_custom.get(key, []),
}
)
field_rule_groups = []
if section_order:
grouped_rules = {key: [] for key in section_order}
@@ -2393,6 +2649,8 @@ def form_builder_page(request):
'inherit_phone_number_choice',
]:
conditional_field_choices.append((field_name, labels.get(field_name, field_name)))
for cfg in custom_field_configs:
conditional_field_choices.append((f'custom__{cfg.field_key}', cfg.translated_label(language_code)))
conditional_target_titles = {
'business-card-box': _('Visitenkarten-Details'),
'employment-end-box': _('Vertragsende'),
@@ -2417,16 +2675,26 @@ def form_builder_page(request):
clauses = list(cfg.clauses or [])
while len(clauses) < 2:
clauses.append({'field': '', 'operator': 'equals', 'value': ''})
if target_key.startswith('custom__'):
custom_field_key = target_key.replace('custom__', '', 1)
custom_field = next((item for item in custom_field_configs if item.field_key == custom_field_key), None)
target_title = custom_field.translated_label(language_code) if custom_field else target_key
target_description = _('Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes.')
target_fields = [target_title]
else:
target_title = conditional_target_titles.get(target_key, target_key)
target_description = conditional_target_descriptions.get(target_key, '')
target_fields = [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])]
conditional_rule_items.append(
{
'target_key': target_key,
'title': conditional_target_titles.get(target_key, target_key),
'description': conditional_target_descriptions.get(target_key, ''),
'title': target_title,
'description': target_description,
'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, [])],
'target_fields': target_fields,
}
)
@@ -2441,6 +2709,16 @@ def form_builder_page(request):
item for item in field_rule_group_map.get(key, [])
if item['locked'] or item['is_visible']
]
visible_items.extend(
[
{
'label': cfg.translated_label(language_code),
'locked': False,
}
for cfg in custom_field_configs
if cfg.section_key == key and cfg.is_active
]
)
if section_visible:
preview_sections.append(
{
@@ -2459,6 +2737,7 @@ def form_builder_page(request):
'configurable_field_count': configurable_field_count,
'hidden_field_count': hidden_field_count,
'hidden_section_count': hidden_section_count,
'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]),
}
return render(
@@ -2479,6 +2758,8 @@ def form_builder_page(request):
'section_rule_items': section_rule_items,
'builder_summary': builder_summary,
'conditional_rule_items': conditional_rule_items,
'custom_field_groups': custom_field_groups,
'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES),
'active_panel': active_panel,
'active_subpanel': active_subpanel,
'available_presets': FORM_PRESETS.get(form_type, {}),
@@ -2921,22 +3202,24 @@ def form_builder_save_order(request):
form_type = payload.get('form_type')
if form_type not in DEFAULT_FIELD_ORDER:
return JsonResponse({'ok': False, 'error': 'Ungültiger Formulartyp.'}, status=400)
default_page_map = get_default_page_map(form_type)
columns = payload.get('columns')
if not isinstance(columns, dict):
return JsonResponse({'ok': False, 'error': 'Spalten-Daten fehlen.'}, status=400)
configs = list(FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name'))
allowed_names = {cfg.field_name for cfg in configs}
custom_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_key'))
allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs}
seen = set()
ordered_names = []
if form_type == 'onboarding':
allowed_columns = ONBOARDING_PAGE_ORDER
else:
allowed_columns = ['all']
allowed_columns = OFFBOARDING_PAGE_ORDER
name_to_cfg = {cfg.field_name: cfg for cfg in configs}
custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs}
sort_order = 0
for column_key in allowed_columns:
@@ -2950,14 +3233,15 @@ def form_builder_save_order(request):
if name not in allowed_names or name in seen:
continue
seen.add(name)
ordered_names.append(name)
cfg = name_to_cfg[name]
cfg.sort_order = sort_order
sort_order += 1
if form_type == 'onboarding':
if name in name_to_cfg:
cfg = name_to_cfg[name]
cfg.sort_order = sort_order
cfg.page_key = column_key
else:
cfg.page_key = ''
cfg = custom_name_to_cfg[name]
cfg.sort_order = sort_order
cfg.section_key = column_key
sort_order += 1
missing = [cfg.field_name for cfg in configs if cfg.field_name not in seen]
for name in missing:
@@ -2967,11 +3251,24 @@ def form_builder_save_order(request):
if form_type == 'onboarding':
cfg.page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss')
else:
cfg.page_key = ''
cfg.page_key = cfg.page_key or default_page_map.get(name, OFFBOARDING_PAGE_ORDER[-1])
missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen]
for name in missing_custom:
cfg = custom_name_to_cfg[name]
cfg.sort_order = sort_order
sort_order += 1
if form_type == 'onboarding':
cfg.section_key = cfg.section_key or 'abschluss'
else:
cfg.section_key = cfg.section_key or OFFBOARDING_PAGE_ORDER[-1]
FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key'])
_audit(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': len(configs)})
return JsonResponse({'ok': True, 'saved_count': len(configs)})
if custom_configs:
FormCustomFieldConfig.objects.bulk_update(custom_configs, ['sort_order', 'section_key'])
saved_count = len(configs) + len(custom_configs)
_audit(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': saved_count})
return JsonResponse({'ok': True, 'saved_count': saved_count})
@_require_capability('manage_integrations')