snapshot: preserve custom field parity across forms timeline and pdf
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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)
|
||||
|
||||
123
backend/workflows/tests/test_observability_ui.py
Normal file
123
backend/workflows/tests/test_observability_ui.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
73
backend/workflows/tests/test_request_timeline.py
Normal file
73
backend/workflows/tests/test_request_timeline.py
Normal 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.')
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user