feat: asked-field envelope, standalone form components, branched forms
This commit is contained in:
parent
0d43b0e8fb
commit
f27be044b4
26 changed files with 3651 additions and 104 deletions
|
|
@ -317,6 +317,9 @@ async def _run_stream(
|
|||
callback_base_url=settings.callback_base_url,
|
||||
continuation_flow_id=definition.continuation_flow_id,
|
||||
callback_token=settings.callback_token,
|
||||
fields=form_schema.schema_field_names(
|
||||
version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
),
|
||||
)
|
||||
run_id = run.id
|
||||
lfx_flow_id = version.lfx_flow_id
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session
|
||||
|
|
@ -13,6 +14,8 @@ from . import export, sync
|
|||
from .langflow_client import LangflowClient, LangflowError
|
||||
from .lfx_client import LfxClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict:
|
||||
"""Reduce a Langflow FlowRead to the canonical fields we persist."""
|
||||
|
|
@ -40,6 +43,7 @@ async def pull_all_from_langflow(
|
|||
"""
|
||||
flows_dir = Path(flows_dir)
|
||||
headers = await langflow.list_flows()
|
||||
ignored = export.ignored_stems(flows_dir)
|
||||
pulled: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
for header in headers:
|
||||
|
|
@ -49,6 +53,9 @@ async def pull_all_from_langflow(
|
|||
try:
|
||||
exported = await langflow.export_flow(flow_id)
|
||||
doc = normalize_exported_flow(exported, flow_id)
|
||||
if export.filename_stem(doc["name"]) in ignored:
|
||||
logger.warning("pull skips ignored flow %r", doc["name"])
|
||||
continue
|
||||
reuse, identical = export.find_reuse_path(flows_dir, doc)
|
||||
if identical and reuse is not None:
|
||||
pulled.append(reuse.stem)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ from pathlib import Path
|
|||
|
||||
FLOWS_GLOB = "*.json"
|
||||
|
||||
#: Opt-out list in the flows dir (one stem per line, `#` comments):
|
||||
#: listed flows stay in Langflow only — never synced, deployed,
|
||||
#: validated, or shown in the frontend.
|
||||
IGNORE_FILENAME = ".flowsignore"
|
||||
|
||||
|
||||
def ignored_stems(flows_dir: str | Path) -> set[str]:
|
||||
"""Stems excluded from the pipeline (empty when no ignore file)."""
|
||||
try:
|
||||
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return set()
|
||||
return {
|
||||
line.strip()
|
||||
for line in text.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
}
|
||||
|
||||
|
||||
def filename_stem(name: str) -> str:
|
||||
"""Map a flow name to a filename stem, keeping the original name.
|
||||
|
|
@ -113,6 +131,7 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
|
|||
return write_flow_bytes(flow_path(flows_dir, stem), doc)
|
||||
|
||||
|
||||
def iter_flow_files(flows_dir: str | Path) -> list[Path]:
|
||||
"""Sorted list of candidate flow files (top level only)."""
|
||||
return sorted(Path(flows_dir).glob(FLOWS_GLOB))
|
||||
def iter_flow_files(flows_dir: str | Path, *, ignored: set[str] | None = None) -> list[Path]:
|
||||
"""Sorted list of candidate flow files (top level only, minus ignored)."""
|
||||
skip = ignored if ignored is not None else ignored_stems(flows_dir)
|
||||
return sorted(p for p in Path(flows_dir).glob(FLOWS_GLOB) if p.stem not in skip)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""Derive a web-form schema from a Langflow export.
|
||||
|
||||
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||
``display_name`` starting with ``CustomInput`` — see
|
||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
||||
Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner
|
||||
``display_name`` starting with ``CustomInput``, see
|
||||
``backend/components/kirche/form_inputs.py``) plus user-built
|
||||
``CustomComponent`` nodes whose display name ends with ``"Form"``
|
||||
(e.g. ``Mitarbeiter Einstellen Form``) — and maps their ``template``
|
||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
||||
|
||||
* ``name`` → property key (freely renamable in the Langflow code editor;
|
||||
|
|
@ -39,7 +41,7 @@ _VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
|||
|
||||
|
||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
||||
"""Inner node dicts (``data.node``) of all form nodes."""
|
||||
if not isinstance(langflow_json, dict):
|
||||
return []
|
||||
data = langflow_json.get("data") or {}
|
||||
|
|
@ -54,6 +56,10 @@ def _form_templates(langflow_json: dict) -> list[dict]:
|
|||
display = str(inner.get("display_name") or "")
|
||||
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
||||
found.append(inner)
|
||||
elif node_type == "CustomComponent" and display.endswith("Form"):
|
||||
# User-built form components (e.g. "Mitarbeiter Einstellen
|
||||
# Form"): same template contract, no kirche base needed.
|
||||
found.append(inner)
|
||||
return found
|
||||
|
||||
|
||||
|
|
@ -328,3 +334,37 @@ def extract_form_schema(langflow_json: dict) -> dict:
|
|||
elif advanced:
|
||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||
return {"json_schema": json_schema, "ui_schema": ui_schema}
|
||||
|
||||
|
||||
def schema_field_names(cached: dict) -> list[str]:
|
||||
"""All asked field names of a cached form schema (root + branches).
|
||||
|
||||
Used for the run envelope's ``fields`` list so the runtime emits
|
||||
defaults only for asked fields. Order: root properties first, then
|
||||
branch discoveries.
|
||||
"""
|
||||
if not isinstance(cached, dict):
|
||||
return []
|
||||
json_schema = cached.get("json_schema") or {}
|
||||
names: list[str] = []
|
||||
|
||||
def _walk(node: object) -> None:
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
for subschema in (node.get("then"), node.get("else")):
|
||||
if isinstance(subschema, dict):
|
||||
for key in subschema.get("properties") or {}:
|
||||
if key not in names:
|
||||
names.append(key)
|
||||
_walk(subschema)
|
||||
for key in ("allOf", "oneOf", "anyOf"):
|
||||
items = node.get(key) or []
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
_walk(item)
|
||||
|
||||
for key in json_schema.get("properties") or {}:
|
||||
if key not in names:
|
||||
names.append(key)
|
||||
_walk(json_schema)
|
||||
return names
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlmodel import Session, select
|
|||
|
||||
from app.models import ApprovalTask, FlowRun, FlowVersion
|
||||
|
||||
from . import input_schema as form_schema
|
||||
from . import store
|
||||
from .lfx_client import LfxClient, LfxRunFailed
|
||||
from .store import InvalidError
|
||||
|
|
@ -21,16 +22,21 @@ def build_envelope(
|
|||
callback_base_url: str,
|
||||
continuation_flow_id: UUID | None,
|
||||
callback_token: str,
|
||||
fields: list[str] | None = None,
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"payload": payload,
|
||||
"run_id": str(run_id),
|
||||
"callback_url": callback_base_url.rstrip("/"),
|
||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||
"callback_token": callback_token,
|
||||
}
|
||||
)
|
||||
"""Run envelope for LFX. ``fields`` (asked form-field names, from the
|
||||
version's cached input schema) lets form components emit defaults only
|
||||
for asked fields; omitted (None) preserves the legacy shape."""
|
||||
body: dict = {
|
||||
"payload": payload,
|
||||
"run_id": str(run_id),
|
||||
"callback_url": callback_base_url.rstrip("/"),
|
||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||
"callback_token": callback_token,
|
||||
}
|
||||
if fields is not None:
|
||||
body["fields"] = list(fields)
|
||||
return json.dumps(body)
|
||||
|
||||
|
||||
def _task_exists(session: Session, run_id: UUID) -> bool:
|
||||
|
|
@ -66,12 +72,14 @@ async def execute_run(
|
|||
run = store.create_run(
|
||||
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
|
||||
)
|
||||
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
envelope = build_envelope(
|
||||
payload=payload,
|
||||
run_id=run.id,
|
||||
callback_base_url=callback_base_url,
|
||||
continuation_flow_id=continuation_flow_id,
|
||||
callback_token=callback_token,
|
||||
fields=form_schema.schema_field_names(cached),
|
||||
)
|
||||
try:
|
||||
result = await lfx.run_flow(
|
||||
|
|
|
|||
|
|
@ -211,7 +211,11 @@ def run_sync_blocking(
|
|||
|
||||
class _JsonFilter(DefaultFilter):
|
||||
def __call__(self, change, path: str) -> bool: # type: ignore[override]
|
||||
return super().__call__(change, path) and path.endswith(".json")
|
||||
if not (super().__call__(change, path) and path.endswith(".json")):
|
||||
return False
|
||||
# The ignore file itself is tiny; re-read per event so no restart
|
||||
# is needed after editing it.
|
||||
return Path(path).stem not in export.ignored_stems(Path(path).parent)
|
||||
|
||||
|
||||
async def watch_forever(
|
||||
|
|
|
|||
|
|
@ -105,6 +105,68 @@ See `backend/flows/bauvorhaben.json` for a full 4-level example
|
|||
(Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and
|
||||
bool trigger).
|
||||
|
||||
## User-built form components (no kirche base needed)
|
||||
|
||||
A plain `CustomComponent` whose display name ends with `"Form"` is also
|
||||
parsed into a web form — same template contract, no base class. To make
|
||||
submitted values reach it at runtime, add an envelope input and merge it
|
||||
in the output method (pattern):
|
||||
|
||||
```python
|
||||
from lfx.io import MessageTextInput # add to imports
|
||||
import json
|
||||
|
||||
# add to inputs:
|
||||
MessageTextInput(name="form_values", display_name="Form values", value="", show=False)
|
||||
|
||||
# at the top of the output method:
|
||||
try:
|
||||
raw = self.form_values.text if hasattr(self.form_values, "text") else self.form_values
|
||||
submitted = json.loads(raw or "{}")
|
||||
except Exception:
|
||||
submitted = {}
|
||||
if isinstance(submitted.get("payload"), dict):
|
||||
submitted = submitted["payload"]
|
||||
|
||||
|
||||
def val(name, default):
|
||||
return submitted.get(name, default)
|
||||
```
|
||||
|
||||
Then use `val("rolle", self.rolle)` etc., and wire a `ChatInput` node
|
||||
into `form_values` on the canvas. Branch markers (`[visible_if ...]`
|
||||
in `info`) work here too — the parser reads the template only.
|
||||
|
||||
## Asked fields only (run envelope `fields`)
|
||||
|
||||
The API sends the asked field names (from the version's form schema)
|
||||
in the run envelope's `fields` list. `collect()` then emits defaults
|
||||
only for asked AND currently visible fields (branch conditions are
|
||||
evaluated against submitted values over code defaults, so hidden
|
||||
fields stay out exactly as the frontend never submits them): the
|
||||
template may declare fields unknown to this code (they arrive via
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
`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.
|
||||
|
||||
## Output type: Data (JSON), not Message
|
||||
|
||||
`collect()` returns `Data(data={...})` — a real JSON object. Consequences:
|
||||
|
|
@ -119,7 +181,8 @@ bool trigger).
|
|||
- `required=True` on a template field: `lfx validate --level 4` demands a
|
||||
value or an edge, so the canonical-flow generator
|
||||
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
||||
sentinel value for such fields. The form parser treats whitespace-only
|
||||
sentinel value for such fields (`[" "]` for Multiselect, which rejects
|
||||
a plain string at runtime). The form parser treats whitespace-only
|
||||
values as "no default" — don't ship real whitespace defaults.
|
||||
- Field key = `name` (stable, freely renamable but must stay unique and
|
||||
non-empty; collisions get a `_2`/`_3` suffix in the form schema);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ template into a web form (``app/flows/input_schema.py``); the frontend
|
|||
renders that schema with RJSF.
|
||||
"""
|
||||
|
||||
from .bauvorhaben_form import CustomInputBauvorhaben
|
||||
from .form_inputs import CustomInputForm
|
||||
from .mitarbeiter_form import CustomInputMitarbeiter
|
||||
|
||||
__all__ = ["CustomInputForm"]
|
||||
__all__ = ["CustomInputBauvorhaben", "CustomInputForm", "CustomInputMitarbeiter"]
|
||||
|
|
|
|||
299
backend/components/kirche/bauvorhaben_form.py
Normal file
299
backend/components/kirche/bauvorhaben_form.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""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.
|
||||
|
||||
A flow that starts with :class:`CustomInputBauvorhaben` declares the
|
||||
fields of its web form. The API reads the exported node template
|
||||
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``
|
||||
from the ``lfx.io`` input definitions below — one JSON-Schema property
|
||||
per field, keyed by ``name``:
|
||||
|
||||
* ``display_name`` → ``title``, ``info`` → ``description``
|
||||
(``[visible_if ...]`` markers declare conditional branches, see below),
|
||||
``value`` → ``default``, ``options`` → ``enum``
|
||||
* ``required`` → required array (branch-required applies only while visible)
|
||||
|
||||
``collect()`` is fully dynamic: it emits every input except the plumbing
|
||||
one (``form_values``), so fields can be renamed, added, or removed in
|
||||
the Langflow code editor without touching this method.
|
||||
|
||||
When the run envelope names the asked fields (``fields`` list, sent by
|
||||
the API from the version's form schema), defaults are emitted only for
|
||||
asked AND currently visible fields. Without the list (raw dict,
|
||||
Langflow playground), all visible declared defaults apply.
|
||||
|
||||
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
from lfx.io import (
|
||||
BoolInput,
|
||||
DropdownInput,
|
||||
IntInput,
|
||||
MessageTextInput,
|
||||
MultiselectInput,
|
||||
Output,
|
||||
StrInput,
|
||||
)
|
||||
from lfx.schema.data import Data
|
||||
|
||||
|
||||
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+([^\]]+)\]")
|
||||
|
||||
|
||||
class CustomInputBauvorhaben(Component):
|
||||
display_name = "Bauvorhaben Form"
|
||||
description = "Collects Bauvorhaben form values into a single JSON object."
|
||||
icon = "Church"
|
||||
|
||||
inputs: ClassVar[list] = [
|
||||
# Plumbing, not a form field: the API posts submitted web-form
|
||||
# values as the run envelope (same {"payload": {...}} shape the
|
||||
# other kirche components use); they arrive here through a ChatInput
|
||||
# entry node and override the defaults below. Hidden in the GUI and
|
||||
# skipped by the form parser (show=False).
|
||||
MessageTextInput(
|
||||
name="form_values",
|
||||
display_name="Form values (JSON envelope)",
|
||||
info="Submitted values as JSON; merged over the defaults",
|
||||
value="",
|
||||
show=False,
|
||||
),
|
||||
DropdownInput(
|
||||
name="gebaeude",
|
||||
display_name="Welches Gebäude soll verändert werden?",
|
||||
info="",
|
||||
options=["Kirche", "Anderes Gebäude"],
|
||||
value="",
|
||||
required=True,
|
||||
),
|
||||
MultiselectInput(
|
||||
name="bestandteil",
|
||||
display_name="Welcher Bestandteil der Kirche?",
|
||||
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
|
||||
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
|
||||
value=[],
|
||||
required=True,
|
||||
),
|
||||
DropdownInput(
|
||||
name="glockenmassnahme",
|
||||
display_name="Welche Maßnahme an der Glocke?",
|
||||
info="[visible_if bestandteil=Glocke]",
|
||||
options=["Reinigung", "Reparatur", "Neuguss"],
|
||||
value="Reinigung",
|
||||
),
|
||||
IntInput(
|
||||
name="gussjahr",
|
||||
display_name="Gussjahr (nur bei Neuguss)?",
|
||||
info="[visible_if glockenmassnahme=Neuguss]",
|
||||
value=0,
|
||||
),
|
||||
DropdownInput(
|
||||
name="gebaeudeart",
|
||||
display_name="Art des anderen Gebäudes?",
|
||||
info='[visible_if gebaeude="Anderes Gebäude"]',
|
||||
options=["Wohnhaus", "Scheune", "Sonstiges"],
|
||||
value="Wohnhaus",
|
||||
),
|
||||
BoolInput(
|
||||
name="denkmalschutz",
|
||||
display_name="Steht das Gebäude unter Denkmalschutz?",
|
||||
info="",
|
||||
value=False,
|
||||
),
|
||||
StrInput(
|
||||
name="denkmalnummer",
|
||||
display_name="Aktenzeichen Denkmalschutz?",
|
||||
info="[visible_if denkmalschutz=true]",
|
||||
value="",
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
outputs: ClassVar[list] = [
|
||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||
]
|
||||
|
||||
def _field_names(self) -> list:
|
||||
"""Names of all form fields (every input except the plumbing ones)."""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
if isinstance(inputs, dict) and inputs:
|
||||
return [name for name in inputs if name not in PLUMBING_INPUTS]
|
||||
seen = []
|
||||
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
|
||||
for inp in declared:
|
||||
name = getattr(inp, "name", None)
|
||||
if name and name not in PLUMBING_INPUTS and name not in seen:
|
||||
seen.append(name)
|
||||
return seen
|
||||
|
||||
def _field_value(self, name: str):
|
||||
"""Runtime value of one input.
|
||||
|
||||
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
|
||||
otherwise resolve to the component attribute instead of its value.
|
||||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
return getattr(inputs[name], "value", None)
|
||||
try:
|
||||
return getattr(self, name, None)
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
|
||||
def _is_visible(self, name: str, effective: dict) -> bool:
|
||||
"""Whether a field's branch conditions hold.
|
||||
|
||||
Single pass over effective values (submitted first, code defaults
|
||||
after): no recursion, so trigger cycles cannot hang — a stale or
|
||||
cyclic marker simply matches or not. Unknown triggers never match.
|
||||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
info = ""
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
info = getattr(inputs[name], "info", "") or ""
|
||||
for trigger, expected in _field_conditions(info):
|
||||
if trigger == name or trigger not in effective:
|
||||
# Self-reference or unknown trigger: fail open (visible),
|
||||
# mirroring the backend parser.
|
||||
continue
|
||||
actual = effective[trigger]
|
||||
if isinstance(actual, list):
|
||||
if expected not in actual:
|
||||
return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
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
|
||||
# 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)
|
||||
# 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
|
||||
# revision does not declare.
|
||||
for key, val in payload.items():
|
||||
if isinstance(key, str) and key:
|
||||
data[key] = _json_safe(val)
|
||||
return Data(data=data)
|
||||
|
|
@ -17,6 +17,12 @@ removed in the Langflow code editor without touching this method.
|
|||
``name`` is the stable JSON key (freely renamable, must stay unique and
|
||||
non-empty); downstream components read the keys as-is.
|
||||
|
||||
When the run envelope names the asked fields (``fields`` list, sent by
|
||||
the API from the version's form schema), defaults are emitted only for
|
||||
asked fields — the template may declare fields unknown to this code and
|
||||
unasked code defaults stay out of the output. Without the list (raw dict,
|
||||
Langflow playground), all declared defaults apply.
|
||||
|
||||
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the
|
||||
values travel as a real JSON object. A terminal ``ChatOutput`` renders it
|
||||
as a fenced ```json block; components consuming it should read ``.data``
|
||||
|
|
@ -29,6 +35,7 @@ upload comes later.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
|
|
@ -75,8 +82,71 @@ def _json_safe(value):
|
|||
return str(value)
|
||||
|
||||
|
||||
#: Inputs that are plumbing, not form fields (never part of the output).
|
||||
PLUMBING_INPUTS = frozenset({"form_values", "code"})
|
||||
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+([^\]]+)\]")
|
||||
|
||||
|
||||
class CustomInputForm(Component):
|
||||
|
|
@ -193,22 +263,63 @@ class CustomInputForm(Component):
|
|||
except (AttributeError, ValueError):
|
||||
return None
|
||||
|
||||
def _is_visible(self, name: str, effective: dict) -> bool:
|
||||
"""Whether a field's branch conditions hold.
|
||||
|
||||
Single pass over effective values (submitted first, code defaults
|
||||
after): no recursion, so trigger cycles cannot hang — a stale or
|
||||
cyclic marker simply matches or not. Unknown triggers never match.
|
||||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
info = ""
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
info = getattr(inputs[name], "info", "") or ""
|
||||
for trigger, expected in _field_conditions(info):
|
||||
if trigger == name or trigger not in effective:
|
||||
# Self-reference or unknown trigger: fail open (visible),
|
||||
# mirroring the backend parser.
|
||||
continue
|
||||
actual = effective[trigger]
|
||||
if isinstance(actual, list):
|
||||
if expected not in actual:
|
||||
return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
def collect(self) -> Data:
|
||||
data = {}
|
||||
for name in self._field_names():
|
||||
data[name] = _json_safe(self._field_value(name))
|
||||
# Submitted values (via form_values) win over the field defaults.
|
||||
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
||||
# Unknown keys are kept: the frontend may already know renamed
|
||||
# fields this code revision does not declare.
|
||||
try:
|
||||
override = json.loads(_text(self.form_values) or "{}")
|
||||
envelope = json.loads(_text(self.form_values) or "{}")
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
override = {}
|
||||
if isinstance(override, dict):
|
||||
if isinstance(override.get("payload"), dict):
|
||||
override = override["payload"]
|
||||
for key, val in override.items():
|
||||
if isinstance(key, str) and key:
|
||||
data[key] = _json_safe(val)
|
||||
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
|
||||
# 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)
|
||||
# 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
|
||||
# revision does not declare.
|
||||
for key, val in payload.items():
|
||||
if isinstance(key, str) and key:
|
||||
data[key] = _json_safe(val)
|
||||
return Data(data=data)
|
||||
|
|
|
|||
276
backend/components/kirche/mitarbeiter_form.py
Normal file
276
backend/components/kirche/mitarbeiter_form.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""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
|
||||
point for future forms.
|
||||
|
||||
A flow that starts with :class:`CustomInputMitarbeiter` declares the
|
||||
fields of its web form. The API reads the exported node template
|
||||
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``
|
||||
from the ``lfx.io`` input definitions below — one JSON-Schema property
|
||||
per field, keyed by ``name``:
|
||||
|
||||
* ``display_name`` → ``title``, ``info`` → ``description``
|
||||
(``[visible_if ...]`` markers declare conditional branches),
|
||||
``value`` → ``default``, ``options`` → ``enum``
|
||||
* ``required`` → required array (branch-required applies only while visible)
|
||||
|
||||
``collect()`` is fully dynamic: it emits every input except the plumbing
|
||||
one (``form_values``), so fields can be renamed, added, or removed in
|
||||
the Langflow code editor without touching this method.
|
||||
|
||||
When the run envelope names the asked fields (``fields`` list, sent by
|
||||
the API from the version's form schema), defaults are emitted only for
|
||||
asked AND currently visible fields. Without the list (raw dict,
|
||||
Langflow playground), all visible declared defaults apply.
|
||||
|
||||
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
from lfx.io import (
|
||||
DropdownInput,
|
||||
MessageTextInput,
|
||||
Output,
|
||||
StrInput,
|
||||
)
|
||||
from lfx.schema.data import Data
|
||||
|
||||
|
||||
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+([^\]]+)\]")
|
||||
|
||||
|
||||
class CustomInputMitarbeiter(Component):
|
||||
display_name = "Mitarbeiter Einstellen Form"
|
||||
description = "Collects hiring form values into a single JSON object."
|
||||
icon = "UserPlus"
|
||||
|
||||
inputs: ClassVar[list] = [
|
||||
# Plumbing, not a form field: the API posts submitted web-form
|
||||
# values as the run envelope (same {"payload": {...}} shape the
|
||||
# other kirche components use); they arrive here through a ChatInput
|
||||
# entry node and override the defaults below. Hidden in the GUI and
|
||||
# skipped by the form parser (show=False).
|
||||
MessageTextInput(
|
||||
name="form_values",
|
||||
display_name="Form values (JSON envelope)",
|
||||
info="Submitted values as JSON; merged over the defaults",
|
||||
value="",
|
||||
show=False,
|
||||
),
|
||||
DropdownInput(
|
||||
name="rolle",
|
||||
display_name="Rolle",
|
||||
info="",
|
||||
options=["Assistenz", "Pfarrer:in", "Vikar:in", "Messner:in"],
|
||||
value="Assistenz",
|
||||
required=True,
|
||||
),
|
||||
DropdownInput(
|
||||
name="einstufung",
|
||||
display_name="Einstufung",
|
||||
info="Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]",
|
||||
options=["Stufe 1", "Stufe 2", "Stufe 3"],
|
||||
value="Stufe 1",
|
||||
),
|
||||
StrInput(
|
||||
name="mitarbeiter_name",
|
||||
display_name="Name",
|
||||
info="Vor- und Nachname des Mitarbeiters",
|
||||
value="",
|
||||
required=True,
|
||||
),
|
||||
StrInput(
|
||||
name="geburtsdatum",
|
||||
display_name="Geburtsdatum",
|
||||
info="Datum eingeben (z. B. JJJJ-MM-TT)",
|
||||
value="",
|
||||
),
|
||||
]
|
||||
outputs: ClassVar[list] = [
|
||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||
]
|
||||
|
||||
def _field_names(self) -> list:
|
||||
"""Names of all form fields (every input except the plumbing ones)."""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
if isinstance(inputs, dict) and inputs:
|
||||
return [name for name in inputs if name not in PLUMBING_INPUTS]
|
||||
seen = []
|
||||
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
|
||||
for inp in declared:
|
||||
name = getattr(inp, "name", None)
|
||||
if name and name not in PLUMBING_INPUTS and name not in seen:
|
||||
seen.append(name)
|
||||
return seen
|
||||
|
||||
def _field_value(self, name: str):
|
||||
"""Runtime value of one input.
|
||||
|
||||
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
|
||||
otherwise resolve to the component attribute instead of its value.
|
||||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
return getattr(inputs[name], "value", None)
|
||||
try:
|
||||
return getattr(self, name, None)
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
|
||||
def _is_visible(self, name: str, effective: dict) -> bool:
|
||||
"""Whether a field's branch conditions hold.
|
||||
|
||||
Single pass over effective values (submitted first, code defaults
|
||||
after): no recursion, so trigger cycles cannot hang — a stale or
|
||||
cyclic marker simply matches or not. Unknown triggers never match.
|
||||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
info = ""
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
info = getattr(inputs[name], "info", "") or ""
|
||||
for trigger, expected in _field_conditions(info):
|
||||
if trigger == name or trigger not in effective:
|
||||
# Self-reference or unknown trigger: fail open (visible),
|
||||
# mirroring the backend parser.
|
||||
continue
|
||||
actual = effective[trigger]
|
||||
if isinstance(actual, list):
|
||||
if expected not in actual:
|
||||
return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
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
|
||||
# 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)
|
||||
# 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
|
||||
# revision does not declare.
|
||||
for key, val in payload.items():
|
||||
if isinstance(key, str) and key:
|
||||
data[key] = _json_safe(val)
|
||||
return Data(data=data)
|
||||
3
backend/flows/.flowsignore
Normal file
3
backend/flows/.flowsignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Flows excluded from the pipeline (sync, deploy, validation, frontend).
|
||||
# One filename stem per line; these stay Langflow-only experiments.
|
||||
i_love_this_app
|
||||
File diff suppressed because one or more lines are too long
991
backend/flows/mitarbeiter_einstellen.json
Normal file
991
backend/flows/mitarbeiter_einstellen.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -91,6 +91,8 @@ def kirche_node(
|
|||
node_id: str,
|
||||
values: dict | None = None,
|
||||
position: tuple[int, int] = (0, 0),
|
||||
component_cls=None,
|
||||
code_override: str | None = None,
|
||||
) -> dict:
|
||||
"""A node from our GUI-editable category lib (backend/components/kirche).
|
||||
|
||||
|
|
@ -101,15 +103,22 @@ def kirche_node(
|
|||
the same way). The template dict before that round-trip is complete and
|
||||
faithful, so we use it directly and append the ``code`` field by hand
|
||||
(mirroring the shape ``build_custom_component_template`` produces).
|
||||
|
||||
``component_cls`` builds a node from a variant class (e.g. a flow whose
|
||||
fields differ from ``CustomInputForm``); ``code_override`` embeds that
|
||||
variant's source (``set_class_code`` cannot inspect exec-built classes).
|
||||
"""
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
from kirche.form_inputs import CustomInputForm
|
||||
if component_cls is None:
|
||||
from kirche.form_inputs import CustomInputForm as component_cls
|
||||
from lfx.template.frontend_node.custom_components import (
|
||||
ComponentFrontendNode,
|
||||
)
|
||||
|
||||
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
|
||||
inst = CustomInputForm()
|
||||
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
|
||||
inst = component_cls()
|
||||
if code_override is not None:
|
||||
inst._code = code_override
|
||||
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
|
||||
inst._map_parameters_on_frontend_node(frontend_node)
|
||||
node = frontend_node.to_dict(keep_name=False)
|
||||
|
|
@ -147,14 +156,18 @@ def kirche_node(
|
|||
# form_values envelope (collect() merges), never via edges — so required
|
||||
# template fields carry a single-space sentinel value. The form parser
|
||||
# (app/flows/input_schema.py) treats whitespace-only values as "no
|
||||
# default", keeping the web form clean.
|
||||
# default", keeping the web form clean. Multiselect needs a list to
|
||||
# survive runtime assignment, so it gets [" "] (skipped the same way
|
||||
# via the enum-mismatch rule).
|
||||
for field_def in node_dict["data"]["node"]["template"].values():
|
||||
if (
|
||||
isinstance(field_def, dict)
|
||||
and field_def.get("required")
|
||||
and field_def.get("value") in (None, "", [], {})
|
||||
):
|
||||
field_def["value"] = " "
|
||||
field_def["value"] = (
|
||||
[" "] if field_def.get("_input_type") == "MultiselectInput" else " "
|
||||
)
|
||||
return node_dict
|
||||
|
||||
|
||||
|
|
@ -406,6 +419,128 @@ class ApprovalCallbackComponent(Component):
|
|||
"""
|
||||
|
||||
|
||||
GEHALT_CODE = """from lfx.custom import Component
|
||||
from lfx.io import HandleInput, Output
|
||||
from lfx.schema import Data, Message
|
||||
|
||||
try:
|
||||
from lfx.schema import DataFrame
|
||||
except Exception: # pragma: no cover
|
||||
DataFrame = None
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class GehaltsrechnerComponent(Component):
|
||||
display_name = "Gehaltsrechner"
|
||||
description = (
|
||||
"Calculates the annual salary (Gehalt) for church employees based on their role "
|
||||
"(rolle) and level (einstufung). Use this when you need to determine the yearly "
|
||||
"salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in."
|
||||
)
|
||||
icon = "Calculator"
|
||||
|
||||
inputs = [
|
||||
HandleInput(
|
||||
name="input_data",
|
||||
display_name="Employee Data",
|
||||
input_types=["JSON", "Data", "Message", "DataFrame", "Table"],
|
||||
info=(
|
||||
"Employee data as JSON/Data/Message/DataFrame. Must contain "
|
||||
"'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
outputs = [
|
||||
Output(
|
||||
name="salary_text",
|
||||
display_name="Salary Text",
|
||||
method="calculate_salary",
|
||||
),
|
||||
]
|
||||
|
||||
# Salary mapping for simple roles
|
||||
BASE_SALARY = {
|
||||
"Assistenz": 42000,
|
||||
"Vikar:in": 48000,
|
||||
"Messner:in": 36000,
|
||||
}
|
||||
|
||||
# Salary mapping for Pfarrer:in per Einstufung
|
||||
PFARRER_SALARY = {
|
||||
"Stufe 1": 55000,
|
||||
"Stufe 2": 65000,
|
||||
"Stufe 3": 75000,
|
||||
}
|
||||
|
||||
def _extract_data(self, value) -> dict | None:
|
||||
'''Normalize the incoming value into a plain dict.'''
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, Data):
|
||||
return value.data if isinstance(value.data, dict) else None
|
||||
if isinstance(value, Message):
|
||||
value = value.text
|
||||
if DataFrame is not None and isinstance(value, DataFrame):
|
||||
try:
|
||||
records = value.to_dict(orient="records")
|
||||
if records:
|
||||
return records[0]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
||||
return parsed[0]
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
def calculate_salary(self) -> Message:
|
||||
try:
|
||||
data = self._extract_data(self.input_data)
|
||||
if not isinstance(data, dict):
|
||||
return Message(
|
||||
text="Error: Could not parse employee data. Please provide a valid JSON object."
|
||||
)
|
||||
rolle = data.get("rolle")
|
||||
einstufung = data.get("einstufung")
|
||||
name = data.get("mitarbeiter_name", "unknown")
|
||||
if not rolle:
|
||||
return Message(
|
||||
text="Error: Missing required field 'rolle' in employee data."
|
||||
)
|
||||
if rolle in self.BASE_SALARY:
|
||||
salary = self.BASE_SALARY[rolle]
|
||||
elif rolle == "Pfarrer:in":
|
||||
if einstufung not in self.PFARRER_SALARY:
|
||||
return Message(
|
||||
text=(
|
||||
"Error: For role 'Pfarrer:in', a valid 'einstufung' "
|
||||
"(Stufe 1, Stufe 2 or Stufe 3) is required."
|
||||
)
|
||||
)
|
||||
salary = self.PFARRER_SALARY[einstufung]
|
||||
else:
|
||||
return Message(text=f"Error: Unknown role '{rolle}'.")
|
||||
einstufung_display = einstufung if einstufung else "-"
|
||||
return Message(
|
||||
text=(
|
||||
f"Mitarbeiter:{name} | Rolle:{rolle} | "
|
||||
f"Einstufung:{einstufung_display} | Gehalt:{salary} EUR pro Jahr"
|
||||
)
|
||||
)
|
||||
except Exception as exc: # never raise, always return a clear error string
|
||||
return Message(text=f"Error: {exc}")
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# flows
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -544,6 +679,108 @@ def build_intake_form() -> dict:
|
|||
)
|
||||
|
||||
|
||||
def build_bauvorhaben() -> dict:
|
||||
"""Branched Bauvorhaben form via the standalone Bauvorhaven component."""
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
from kirche.bauvorhaben_form import CustomInputBauvorhaben
|
||||
|
||||
entry = builtin_node(
|
||||
"lfx.components.input_output.chat",
|
||||
"ChatInput",
|
||||
"ChatInput-entry",
|
||||
position=(0, 200),
|
||||
)
|
||||
inst = CustomInputBauvorhaben()
|
||||
if not getattr(inst, "_code", None):
|
||||
inst.set_class_code()
|
||||
form = kirche_node(
|
||||
"CustomInputBauvorhaben-bauvorhaben",
|
||||
position=(400, 200),
|
||||
component_cls=CustomInputBauvorhaben,
|
||||
code_override=inst._code,
|
||||
)
|
||||
# Logical question order: RJSF renders schema properties in insertion
|
||||
# order and the parser derives ui:order from it (lfx emits alphabetical).
|
||||
order = [
|
||||
"form_values",
|
||||
"gebaeude",
|
||||
"bestandteil",
|
||||
"glockenmassnahme",
|
||||
"gussjahr",
|
||||
"gebaeudeart",
|
||||
"denkmalschutz",
|
||||
"denkmalnummer",
|
||||
]
|
||||
template = form["data"]["node"]["template"]
|
||||
form["data"]["node"]["template"] = {k: template[k] for k in order} | {
|
||||
k: v for k, v in template.items() if k not in order
|
||||
}
|
||||
out = builtin_node(
|
||||
"lfx.components.input_output.chat_output",
|
||||
"ChatOutput",
|
||||
"ChatOutput-collected",
|
||||
position=(800, 200),
|
||||
)
|
||||
nodes = [entry, form, out]
|
||||
edges = [
|
||||
edge(entry, "message", form, "form_values"),
|
||||
edge(form, "form_data", out, "input_value"),
|
||||
]
|
||||
return flow_doc(
|
||||
"bauvorhaben",
|
||||
"Bauvorhaben: verzweigtes Formular mit mehrstufigen Abhängigkeiten.",
|
||||
nodes,
|
||||
edges,
|
||||
)
|
||||
|
||||
|
||||
def build_mitarbeiter() -> dict:
|
||||
"""Hiring form via the standalone Mitarbeiter component + Gehaltsrechner."""
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
from kirche.mitarbeiter_form import CustomInputMitarbeiter
|
||||
|
||||
entry = builtin_node(
|
||||
"lfx.components.input_output.chat",
|
||||
"ChatInput",
|
||||
"ChatInput-entry",
|
||||
position=(0, 200),
|
||||
)
|
||||
inst = CustomInputMitarbeiter()
|
||||
if not getattr(inst, "_code", None):
|
||||
inst.set_class_code()
|
||||
form = kirche_node(
|
||||
"CustomInputMitarbeiter-mitarbeiter",
|
||||
position=(400, 200),
|
||||
component_cls=CustomInputMitarbeiter,
|
||||
code_override=inst._code,
|
||||
)
|
||||
# Logical question order (see build_bauvorhaben).
|
||||
order = ["form_values", "rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
|
||||
template = form["data"]["node"]["template"]
|
||||
form["data"]["node"]["template"] = {k: template[k] for k in order} | {
|
||||
k: v for k, v in template.items() if k not in order
|
||||
}
|
||||
gehalt = custom_node(GEHALT_CODE, "Gehaltsrechner-gehalt", position=(800, 200))
|
||||
out = builtin_node(
|
||||
"lfx.components.input_output.chat_output",
|
||||
"ChatOutput",
|
||||
"ChatOutput-collected",
|
||||
position=(1200, 200),
|
||||
)
|
||||
nodes = [entry, form, gehalt, out]
|
||||
edges = [
|
||||
edge(entry, "message", form, "form_values"),
|
||||
edge(form, "form_data", gehalt, "input_data"),
|
||||
edge(gehalt, "salary_text", out, "input_value"),
|
||||
]
|
||||
return flow_doc(
|
||||
"mitarbeiter_einstellen",
|
||||
"Mitarbeiter einstellen: Formular mit Gehaltsrechner.",
|
||||
nodes,
|
||||
edges,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -551,6 +788,8 @@ def main() -> None:
|
|||
"simple_rule.json": build_simple_rule(),
|
||||
"approval_handoff.json": build_approval_handoff(),
|
||||
"intake_form.json": build_intake_form(),
|
||||
"bauvorhaben.json": build_bauvorhaben(),
|
||||
"mitarbeiter_einstellen.json": build_mitarbeiter(),
|
||||
}
|
||||
for filename, doc in flows.items():
|
||||
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||
|
|
|
|||
44
backend/tests/fixtures/flows/bauvorhaben.json
vendored
44
backend/tests/fixtures/flows/bauvorhaben.json
vendored
File diff suppressed because one or more lines are too long
991
backend/tests/fixtures/flows/mitarbeiter_einstellen.json
vendored
Normal file
991
backend/tests/fixtures/flows/mitarbeiter_einstellen.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -43,6 +43,28 @@ def test_iter_flow_files_only_top_level_json(tmp_path):
|
|||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||
|
||||
|
||||
def test_flowsignore_parsing_and_filtering(tmp_path):
|
||||
assert export.ignored_stems(tmp_path) == set()
|
||||
(tmp_path / export.IGNORE_FILENAME).write_text(
|
||||
"# experiments stay Langflow-only\n\ni_love_this_app\n", encoding="utf-8"
|
||||
)
|
||||
assert export.ignored_stems(tmp_path) == {"i_love_this_app"}
|
||||
(tmp_path / "a.json").write_text("{}")
|
||||
(tmp_path / "i_love_this_app.json").write_text("{}")
|
||||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||
# Explicit override still works.
|
||||
assert export.iter_flow_files(tmp_path, ignored=set()) == [
|
||||
tmp_path / "a.json",
|
||||
tmp_path / "i_love_this_app.json",
|
||||
]
|
||||
|
||||
|
||||
def test_find_reuse_path_ignores_ignored_files(tmp_path):
|
||||
(tmp_path / export.IGNORE_FILENAME).write_text("alt\n", encoding="utf-8")
|
||||
(tmp_path / "alt.json").write_text(json.dumps({"name": "Neu"}))
|
||||
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)
|
||||
|
||||
|
||||
def test_find_reuse_path_matches_identical_content_first(tmp_path):
|
||||
doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
|
||||
other = tmp_path / "other-name.json"
|
||||
|
|
|
|||
|
|
@ -93,6 +93,121 @@ def test_collect_keeps_unknown_envelope_keys():
|
|||
assert collected["department"] == "Music" # default kept
|
||||
|
||||
|
||||
def test_collect_fields_list_restricts_defaults():
|
||||
"""Asked-field list: unasked code defaults stay out of the output."""
|
||||
form = CustomInputForm()
|
||||
form.form_values = json.dumps(
|
||||
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "age"]}
|
||||
)
|
||||
collected = form.collect().data
|
||||
assert collected == {"full_name": "Maria", "age": 18}
|
||||
|
||||
|
||||
def test_collect_fields_list_fills_missing_from_defaults():
|
||||
form = CustomInputForm()
|
||||
form.form_values = json.dumps(
|
||||
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "department"]}
|
||||
)
|
||||
collected = form.collect().data
|
||||
assert collected == {"full_name": "Maria", "department": "Music"}
|
||||
|
||||
|
||||
def test_collect_fields_list_ignores_undeclared_names():
|
||||
"""Asked-but-undeclared template fields contribute no Nones."""
|
||||
form = CustomInputForm()
|
||||
form.form_values = json.dumps({"payload": {"room": "Chapel"}, "fields": ["full_name", "room"]})
|
||||
collected = form.collect().data
|
||||
assert collected == {"full_name": "", "room": "Chapel"}
|
||||
|
||||
|
||||
def test_collect_raw_dict_ignores_fields_key():
|
||||
"""A raw dict is payload, not envelope: 'fields' is just a value."""
|
||||
form = CustomInputForm()
|
||||
form.form_values = json.dumps({"fields": ["x"], "full_name": "Maria"})
|
||||
collected = form.collect().data
|
||||
assert collected["fields"] == ["x"]
|
||||
assert collected["full_name"] == "Maria"
|
||||
assert collected["age"] == 18 # all defaults apply without envelope
|
||||
|
||||
|
||||
def test_collect_hides_branch_default_until_trigger_matches():
|
||||
"""Hidden branch fields contribute no defaults (frontend parity)."""
|
||||
form = CustomInputForm()
|
||||
form._inputs["notes"].info = "Anything [visible_if department=Youth]"
|
||||
assert "notes" not in form.collect().data # department defaults to Music
|
||||
form.form_values = json.dumps({"payload": {"department": "Youth"}})
|
||||
assert form.collect().data["notes"] == ""
|
||||
|
||||
|
||||
def test_collect_branch_visibility_uses_submitted_triggers():
|
||||
form = CustomInputForm()
|
||||
form._inputs["notes"].info = "[visible_if age=41]"
|
||||
form.form_values = json.dumps({"payload": {"age": 41}, "fields": ["age", "notes"]})
|
||||
assert form.collect().data["notes"] == ""
|
||||
form.form_values = json.dumps({"payload": {"age": 18}, "fields": ["age", "notes"]})
|
||||
assert "notes" not in form.collect().data
|
||||
|
||||
|
||||
def test_collect_dangling_marker_stays_visible():
|
||||
form = CustomInputForm()
|
||||
form._inputs["notes"].info = "[visible_if nope=x]"
|
||||
assert "notes" in form.collect().data
|
||||
|
||||
|
||||
def test_bauvorhaben_component_contract():
|
||||
from kirche.bauvorhaben_form import CustomInputBauvorhaben
|
||||
|
||||
assert CustomInputBauvorhaben.display_name == "Bauvorhaben Form"
|
||||
inputs = {i.name: i for i in CustomInputBauvorhaben.inputs}
|
||||
assert sorted(n for n in inputs if n != "form_values") == sorted(
|
||||
[
|
||||
"gebaeude",
|
||||
"bestandteil",
|
||||
"glockenmassnahme",
|
||||
"gussjahr",
|
||||
"gebaeudeart",
|
||||
"denkmalschutz",
|
||||
"denkmalnummer",
|
||||
]
|
||||
)
|
||||
assert inputs["form_values"].show is False
|
||||
outputs = {o.name: o for o in CustomInputBauvorhaben.outputs}
|
||||
assert set(outputs) == {"form_data"}
|
||||
|
||||
|
||||
def test_bauvorhaben_collect_only_own_fields():
|
||||
"""Standalone component: no intake leftovers, hidden branches dropped."""
|
||||
from kirche.bauvorhaben_form import CustomInputBauvorhaben
|
||||
|
||||
form = CustomInputBauvorhaben()
|
||||
default = form.collect().data
|
||||
assert "full_name" not in default and "age" not in default
|
||||
assert default["gebaeude"] == "" # class default (template ships " " sentinel)
|
||||
assert "bestandteil" not in default # hidden (gebaeude default "").
|
||||
assert "gebaeudeart" not in default
|
||||
|
||||
form.form_values = json.dumps(
|
||||
{
|
||||
"payload": {"gebaeude": "Kirche", "bestandteil": ["Glocke"]},
|
||||
"fields": [
|
||||
"gebaeude",
|
||||
"bestandteil",
|
||||
"glockenmassnahme",
|
||||
"gussjahr",
|
||||
"gebaeudeart",
|
||||
"denkmalschutz",
|
||||
"denkmalnummer",
|
||||
],
|
||||
}
|
||||
)
|
||||
collected = form.collect().data
|
||||
assert collected["bestandteil"] == ["Glocke"]
|
||||
assert collected["glockenmassnahme"] == "Reinigung" # visible branch default
|
||||
assert "gussjahr" not in collected # hidden branch
|
||||
assert "gebaeudeart" not in collected
|
||||
assert "full_name" not in collected
|
||||
|
||||
|
||||
def test_intake_fixture_shape():
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||
|
|
@ -152,12 +267,44 @@ def _parse_chat_json(text: str) -> dict:
|
|||
return json.loads(text)
|
||||
|
||||
|
||||
def test_embedded_code_matches_template_fields():
|
||||
"""Node template fields == code-declared inputs (no unasked defaults).
|
||||
|
||||
The envelope tolerates drift at runtime, but authoring must stay
|
||||
honest: what Langflow shows in the code editor must be what the form
|
||||
asks. Building blocks: lfx's own create_class on the embedded code.
|
||||
"""
|
||||
from lfx.custom import validate as validate_mod
|
||||
|
||||
cases = {
|
||||
"intake_form.json": ("CustomInputForm-intake", "CustomInputForm"),
|
||||
"bauvorhaben.json": ("CustomInputBauvorhaben-bauvorhaben", "CustomInputBauvorhaben"),
|
||||
"mitarbeiter_einstellen.json": (
|
||||
"CustomInputMitarbeiter-mitarbeiter",
|
||||
"CustomInputMitarbeiter",
|
||||
),
|
||||
}
|
||||
for filename, (node_id, class_name) in cases.items():
|
||||
doc = json.loads((FIXTURES / filename).read_text(encoding="utf-8"))
|
||||
node = next(n for n in doc["data"]["nodes"] if n["id"] == node_id)
|
||||
assert node["data"]["type"] == class_name, filename
|
||||
template = node["data"]["node"]["template"]
|
||||
template_names = {
|
||||
field["name"] for field in template.values() if isinstance(field, dict)
|
||||
} - {"code"}
|
||||
code = template["code"]["value"]
|
||||
cls = validate_mod.create_class(code, class_name)
|
||||
code_names = {inp.name for inp in cls.inputs}
|
||||
assert code_names == template_names, filename
|
||||
|
||||
|
||||
def test_bauvorhaben_fixture_shape():
|
||||
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
||||
assert doc["name"] == "bauvorhaben"
|
||||
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||
form = nodes["CustomInputForm-bauvorhaben"]
|
||||
assert form["data"]["type"] == "CustomInputForm"
|
||||
form = nodes["CustomInputBauvorhaben-bauvorhaben"]
|
||||
assert form["data"]["type"] == "CustomInputBauvorhaben"
|
||||
assert form["data"]["node"]["display_name"] == "Bauvorhaben Form"
|
||||
template = form["data"]["node"]["template"]
|
||||
assert set(template) >= {
|
||||
"form_values",
|
||||
|
|
@ -173,8 +320,8 @@ def test_bauvorhaben_fixture_shape():
|
|||
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
|
||||
assert outputs["form_data"]["types"] == ["JSON"]
|
||||
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
||||
assert ("ChatInput-entry", "CustomInputForm-bauvorhaben") in edges
|
||||
assert ("CustomInputForm-bauvorhaben", "ChatOutput-collected") in edges
|
||||
assert ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben") in edges
|
||||
assert ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected") in edges
|
||||
|
||||
|
||||
def test_bauvorhaben_schema_branches():
|
||||
|
|
@ -262,3 +409,92 @@ async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, logi
|
|||
assert result["bestandteil"] == ["Glocke", "Orgel"]
|
||||
assert result["gussjahr"] == 1890
|
||||
assert result["denkmalnummer"] == "D-123"
|
||||
# Only asked fields: unasked code defaults (age, amount, ...) stay out.
|
||||
assert set(result) == {
|
||||
"gebaeude",
|
||||
"bestandteil",
|
||||
"glockenmassnahme",
|
||||
"gussjahr",
|
||||
"denkmalschutz",
|
||||
"denkmalnummer",
|
||||
}
|
||||
|
||||
|
||||
def test_mitarbeiter_component_contract():
|
||||
from kirche.mitarbeiter_form import CustomInputMitarbeiter
|
||||
|
||||
assert CustomInputMitarbeiter.display_name == "Mitarbeiter Einstellen Form"
|
||||
inputs = {i.name: i for i in CustomInputMitarbeiter.inputs}
|
||||
assert sorted(n for n in inputs if n != "form_values") == sorted(
|
||||
["rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
|
||||
)
|
||||
assert inputs["form_values"].show is False
|
||||
outputs = {o.name: o for o in CustomInputMitarbeiter.outputs}
|
||||
assert set(outputs) == {"form_data"}
|
||||
|
||||
|
||||
def test_mitarbeiter_collect_branch_default():
|
||||
"""Einstufung default applies only when Pfarrer:in is selected."""
|
||||
from kirche.mitarbeiter_form import CustomInputMitarbeiter
|
||||
|
||||
form = CustomInputMitarbeiter()
|
||||
assert "einstufung" not in form.collect().data # rolle defaults to Assistenz
|
||||
form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}})
|
||||
assert form.collect().data["einstufung"] == "Stufe 1"
|
||||
|
||||
|
||||
def test_mitarbeiter_fixture_shape():
|
||||
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
|
||||
assert doc["name"] == "mitarbeiter_einstellen"
|
||||
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||
form = nodes["CustomInputMitarbeiter-mitarbeiter"]
|
||||
assert form["data"]["type"] == "CustomInputMitarbeiter"
|
||||
assert form["data"]["node"]["display_name"] == "Mitarbeiter Einstellen Form"
|
||||
template = form["data"]["node"]["template"]
|
||||
assert set(template) >= {
|
||||
"form_values",
|
||||
"rolle",
|
||||
"einstufung",
|
||||
"mitarbeiter_name",
|
||||
"geburtsdatum",
|
||||
"code",
|
||||
}
|
||||
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
|
||||
assert outputs["form_data"]["types"] == ["JSON"]
|
||||
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
||||
assert ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter") in edges
|
||||
assert ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt") in edges
|
||||
assert ("Gehaltsrechner-gehalt", "ChatOutput-collected") in edges
|
||||
|
||||
|
||||
async def test_mitarbeiter_deploy_and_run_salary(client, lfx_server, login):
|
||||
await login()
|
||||
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
|
||||
created = await client.post("/api/flows", json={"name": "mitarbeiter-e2e"})
|
||||
assert created.status_code == 201, created.text
|
||||
flow_id = created.json()["id"]
|
||||
|
||||
version = await client.post(
|
||||
f"/api/flows/{flow_id}/versions",
|
||||
json={"langflow_json": doc, "author": "test"},
|
||||
)
|
||||
assert version.status_code == 201, version.text
|
||||
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
||||
assert deployed.status_code == 200, deployed.text
|
||||
|
||||
run = await client.post(
|
||||
f"/api/flows/{flow_id}/run",
|
||||
json={
|
||||
"input": {
|
||||
"rolle": "Pfarrer:in",
|
||||
"einstufung": "Stufe 2",
|
||||
"mitarbeiter_name": "Maria Muster",
|
||||
"geburtsdatum": "1980-01-01",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert run.status_code == 200, run.text
|
||||
assert run.json()["status"] == "completed"
|
||||
result = run.json()["output"]["result"]
|
||||
assert "65000" in result
|
||||
assert "Maria Muster" in result
|
||||
|
|
|
|||
|
|
@ -347,3 +347,64 @@ def test_no_markers_no_allof():
|
|||
schema = parser.extract_form_schema(doc)["json_schema"]
|
||||
assert "allOf" not in schema
|
||||
assert set(schema["properties"]) == {"a"}
|
||||
|
||||
|
||||
def test_custom_component_form_display_match():
|
||||
"""User-built * Form components parse without a kirche base class."""
|
||||
doc = _doc(
|
||||
_field("mitarbeiter_name", "StrInput", info="Vor- und Nachname"),
|
||||
_dropdown("rolle", ["Assistenz", "Pfarrer:in"]),
|
||||
node_type="CustomComponent",
|
||||
display="Mitarbeiter Einstellen Form",
|
||||
)
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert set(props) == {"mitarbeiter_name", "rolle"}
|
||||
assert props["rolle"]["enum"] == ["Assistenz", "Pfarrer:in"]
|
||||
assert props["mitarbeiter_name"]["description"] == "Vor- und Nachname"
|
||||
|
||||
|
||||
def test_custom_component_non_form_falls_back():
|
||||
doc = _doc(
|
||||
_field("payload", "StrInput"),
|
||||
node_type="CustomComponent",
|
||||
display="Rule Check",
|
||||
)
|
||||
assert parser.extract_form_schema(doc)["fallback"] == "textarea"
|
||||
|
||||
|
||||
def test_real_mitarbeiter_file_parses():
|
||||
path = Path(__file__).parent.parent / "flows" / "mitarbeiter_einstellen.json"
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert set(props) == {"rolle", "mitarbeiter_name", "geburtsdatum"}
|
||||
assert schema["json_schema"]["required"] == ["rolle", "mitarbeiter_name"]
|
||||
# Einstufung is branched (visible only for Pfarrer:in).
|
||||
assert len(schema["json_schema"]["allOf"]) == 1
|
||||
rule = schema["json_schema"]["allOf"][0]
|
||||
assert rule["if"]["properties"] == {"rolle": {"const": "Pfarrer:in"}}
|
||||
assert set(rule["then"]["properties"]) == {"einstufung"}
|
||||
|
||||
|
||||
def test_schema_field_names_covers_branches():
|
||||
doc = _doc(
|
||||
_dropdown("a", ["x", "y"]),
|
||||
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
|
||||
_field("c", "IntInput", info="[visible_if b=p]"),
|
||||
)
|
||||
cached = parser.extract_form_schema(doc)
|
||||
assert parser.schema_field_names(cached) == ["a", "b", "c"]
|
||||
plain = parser.extract_form_schema(
|
||||
{
|
||||
"data": {
|
||||
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
||||
"edges": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert parser.schema_field_names(plain) == []
|
||||
assert parser.schema_field_names({}) == []
|
||||
assert parser.schema_field_names({"json_schema": None}) == []
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import subprocess
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.flows import export
|
||||
|
||||
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
||||
|
||||
|
||||
def test_canonical_flows_pass_lfx_validate():
|
||||
files = sorted(FLOW_DIR.glob("*.json"))
|
||||
files = export.iter_flow_files(FLOW_DIR)
|
||||
assert files, "no canonical flows found"
|
||||
process = subprocess.run(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
|
|||
assert versions[0].deployed_at is not None
|
||||
|
||||
|
||||
async def test_sync_skips_ignored_files(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
(tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8")
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.added == [] and report.errors == {}
|
||||
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
|
||||
|
||||
|
||||
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue