refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter

This commit is contained in:
Jonas Miederer 2026-09-28 18:03:07 +02:00
parent 06ab25fb47
commit 03af41e837
74 changed files with 6089 additions and 5183 deletions

View file

@ -44,10 +44,30 @@ derives a JSON Schema + uiSchema from the node's template
The API watcher syncs + deploys it automatically; the form schema is
regenerated per version.
## Branched questionnaires (`visible_if`)
## Structured `visible_if` (preferred) + markers (fallback)
A field can appear only when other fields have certain values — nested
to any depth. Declare it in the code editor with a marker in `info`:
to any depth. Preferred: structured `visible_if` key (no quoting
mini-language, rename-refactorable, validated at sync time with warnings
instead of silent fail-open):
```python
(
DropdownInput(
name="bestandteil",
display_name="Welcher Bestandteil der Kirche?",
options=["Glocke", "Orgel"],
value=[],
info="Mehrfachauswahl möglich",
visible_if=[{"field": "gebaeude", "value": "Kirche"}],
required=True,
),
)
```
Legacy `[visible_if ...]` markers in `info` still work as deprecated
fallback (same syntax, stripped from help text). The parser emits
`allOf`/`if`/`then` branches either way (see below).
```python
(
@ -149,20 +169,23 @@ envelope and merge normally), and unasked code defaults stay out of
the output. Without the list — raw dicts, Langflow playground — all
visible declared defaults apply as before.
## Variant flows (own component per flow)
## Variant flows (own component per flow, shared core)
A flow whose fields differ from `CustomInputForm` gets its own
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
in the palette): same class pattern, own `inputs` list. Standalone
means it: no imports between form modules — the LFX runtime executes
the embedded module source without our `kirche` package, so every form
component must be self-contained (stdlib + `lfx` only). Copy the file
as the starting point for future forms.
A flow whose fields differ from `CustomInputForm` gets its own thin
component (see `bauvorhaben_form.py` → "Bauvorhaben Form" in the
palette): same class pattern, own `inputs` list, shared runtime from
`kirche._form_core` (`from kirche._form_core import ...` plus the
`# <-- _form_core inlined ... -->` marker). The LFX runtime executes
the embedded module source without our `kirche` package, so
`scripts/make_flow_fixtures.py:kirche_node()` inlines the core into the
exported `code` — one truth in the repo, self-contained JSON. Copy the
file as the starting point for future forms.
Rule: code-declared inputs must equal template fields
(`test_embedded_code_matches_template_fields` enforces it; required
Multiselect uses the `[" "]` sentinel). Generate node + flow with
`kirche_node(..., component_cls, code_override)` and
(`test_embedded_code_matches_template_fields` enforces it, plus
no-sibling-import + core-present asserts; required Multiselect uses
the `[" "]` sentinel). Generate node + flow with `kirche_node(...)`
(which composes thin source + `_inline_core()` automatically) and
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
rather than hand-editing JSON. Restart Langflow afterwards so the
palette picks up the new component, then build/upload the flow.

View file

@ -1,11 +1,9 @@
"""Bauvorhaben form component for kirche-einfach flows.
Standalone by design: this module shares NO code with ``form_inputs``
(no imports from it). The LFX runtime executes the embedded module
source without our ``kirche`` package, so every form component must be
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
machinery below into a shared import — it would break flow execution.
Copy this file as the starting point for future forms.
Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
inlines the core into the exported ``code`` so the LFX runtime stays
self-contained. Copy this file as the starting point for future forms.
A flow that starts with :class:`CustomInputBauvorhaben` declares the
fields of its web form. The API reads the exported node template
@ -32,8 +30,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations
import json
import re
import json # noqa: F401 -- used by inlined _form_core at runtime
import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@ -48,94 +46,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
from kirche._form_core import (
PLUMBING_INPUTS,
_field_conditions,
_json_safe,
collect_form_data,
parse_envelope,
)
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str):
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output. Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
# (LFX executes embedded source without the kirche package) -->
class CustomInputBauvorhaben(Component):
@ -246,9 +166,11 @@ class CustomInputBauvorhaben(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
structured = getattr(inputs[name], "visible_if", None)
for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@ -262,33 +184,14 @@ class CustomInputBauvorhaben(Component):
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code

View file

@ -34,8 +34,8 @@ upload comes later.
from __future__ import annotations
import json
import re
import json # noqa: F401 -- used by inlined _form_core at runtime
import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@ -56,97 +56,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
from kirche._form_core import (
PLUMBING_INPUTS,
_field_conditions,
_json_safe,
collect_form_data,
parse_envelope,
)
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str):
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output (otherwise the whole envelope JSON would
#: leak in as a field value on every path without an asked-fields list).
#: Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
#: stay import-light for the LFX runtime and cannot import it).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
# (LFX executes embedded source without the kirche package) -->
class CustomInputForm(Component):
@ -272,9 +191,11 @@ class CustomInputForm(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
structured = getattr(inputs[name], "visible_if", None)
for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@ -288,33 +209,14 @@ class CustomInputForm(Component):
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code

View file

@ -1,11 +1,9 @@
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
Standalone by design: this module shares NO code with the other form
modules (no imports from them). The LFX runtime executes the embedded
module source without our ``kirche`` package, so every form component
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
duplicated machinery below into a shared import — it would break flow
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
inlines the core into the exported ``code`` so the LFX runtime stays
self-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting
point for future forms.
A flow that starts with :class:`CustomInputMitarbeiter` declares the
@ -33,8 +31,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations
import json
import re
import json # noqa: F401 -- used by inlined _form_core at runtime
import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@ -46,94 +44,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
from kirche._form_core import (
PLUMBING_INPUTS,
_field_conditions,
_json_safe,
collect_form_data,
parse_envelope,
)
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str):
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output. Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
# (LFX executes embedded source without the kirche package) -->
class CustomInputMitarbeiter(Component):
@ -223,9 +143,11 @@ class CustomInputMitarbeiter(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
structured = getattr(inputs[name], "visible_if", None)
for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@ -239,33 +161,14 @@ class CustomInputMitarbeiter(Component):
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code