refactor: finish declutter - test_flow removal, NOT NULL schema, parametrized tests, smoke harness, docs

This commit is contained in:
Jonas Miederer 2026-10-01 00:11:43 +02:00
parent 696db34c90
commit 92bb3bacba
23 changed files with 552 additions and 1261 deletions

View file

@ -7,10 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- Declutter: shared `BaseApiClient` (LFX/Langflow) with tenacity transport retry, single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`, atomicwrites file writes, `secure`-package headers. - Declutter: shared `BaseApiClient` (LFX/Langflow) with tenacity transport retry, single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`, atomicwrites file writes, `secure`-package headers.
- Auth: stateless JWT sessions (HS256, 30d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`. - Auth: stateless JWT sessions (HS256, 7d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`.
- Forms: thin variant components over shared `components/kirche/_form_core.py` (codegen-inlined by `kirche_node()` for LFX; all 5 flows regenerated + level-4 validated), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI). - Forms: thin variant components over shared `components/kirche/_form_core.py` (codegen-inlined by `kirche_node()` for LFX; all 5 flows regenerated + level-4 validated), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI).
- Store/sync: single `next_version_no` query per version, validation only at the `create_version` choke point, mtime-cached `.flowsignore`. - Store/sync: single `next_version_no` query per version, validation only at the `create_version` choke point, mtime-cached `.flowsignore`.
- Fix batch: typer CLIs exit non-zero via `typer.Exit` (`check_prod` gate works again); JWT expiry 30d→7d with startup warning and new expiry/deleted-user tests; deleted dead `resolve_tenant`, test alias helpers now delegate to `conftest` (flows/approvals/sync/deploy suites migrated to shared fixtures); `AdminTab` reads via react-query; Mantine aligned to 9.6.3 (consistent lockfile, `npm ci` green). - Fix batch: typer CLIs exit non-zero via `typer.Exit` (`check_prod` gate works again); JWT expiry 7d with startup warning and new expiry/deleted-user tests; deleted dead `resolve_tenant`, test alias helpers now delegate to `conftest` (flows/approvals/sync/deploy suites migrated to shared fixtures); `AdminTab` reads via react-query; Mantine aligned to 9.6.3 (consistent lockfile, `npm ci` green).
- Removed `backend/flows/test_flow.json` (legacy Message-shape demo, superseded by `intake_form.json`; Langflow copy deleted first, sync cascades runs by design); `FlowVersion.input_schema` is now NOT NULL (backfill migration); `smoke_e2e.py` reuses `tests/_harness.py` and runs green; `FlowHelp` docs moved to `docs/flow-authoring.md`; shared `StatusBadge` status union, single `RunsTable`, `photos.css` class via one constant.
- Tests (full real LFX kept): shared `conftest` helpers + `tests/_harness.py` adopted by fixtures and smoke script, parametrized matrices (export stems, check_prod env, deploy failures, sync listing failures, API auth probes, input_schema types), relaxed brittle asserts (ui:order sets, per-flag cookie asserts, SSE any-match). - Tests (full real LFX kept): shared `conftest` helpers + `tests/_harness.py` adopted by fixtures and smoke script, parametrized matrices (export stems, check_prod env, deploy failures, sync listing failures, API auth probes, input_schema types), relaxed brittle asserts (ui:order sets, per-flag cookie asserts, SSE any-match).
- Frontend: react-query reads + notifications, `useForm`+zod static forms, shared `DecisionButtons`/`ErrorAlert`/`JsonView`/`FormattedDate`/`RunsTable`/`Landing` blocks, new aggregate `GET /api/runs` kills the Tasks N+1, `Promise.all` version fetches, `sharp` icons, selector-based screenshots, dead photo removed. - Frontend: react-query reads + notifications, `useForm`+zod static forms, shared `DecisionButtons`/`ErrorAlert`/`JsonView`/`FormattedDate`/`RunsTable`/`Landing` blocks, new aggregate `GET /api/runs` kills the Tasks N+1, `Promise.all` version fetches, `sharp` icons, selector-based screenshots, dead photo removed.
### Added ### Added

View file

@ -24,9 +24,12 @@ needs judgment. Every step is documented and traceable.
can automatically start a follow-up ("continuation") flow. can automatically start a follow-up ("continuation") flow.
- **Access management** — public landing page (German) with an access-request - **Access management** — public landing page (German) with an access-request
form (request + approval). Approved users get a passwordless magic-link form (request + approval). Approved users get a passwordless magic-link
invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants invite by email (SMTP via `.env`); sessions are stateless JWTs (HS256,
come from the session. First admin via `POST /api/auth/admin-bootstrap` 7-day expiry) in HttpOnly cookies — no server-side revocation, logout
(only on an empty user table, email must be in `ADMIN_EMAILS`). clears the cookie, deleted users fail closed. Tenants come from the
session. `JWT_SECRET` (long random value) signs the cookies. First admin
via `POST /api/auth/admin-bootstrap` (only on an empty user table, email
must be in `ADMIN_EMAILS`).
- **Dynamic forms** — flows declare form fields as typed inputs on a - **Dynamic forms** — flows declare form fields as typed inputs on a
`CustomInput*` custom component (reusable library in `CustomInput*` custom component (reusable library in
`backend/components/`, mounted into Langflow via `backend/components/`, mounted into Langflow via
@ -125,6 +128,8 @@ Frontend (node lives in the `frontend` container):
running via compose; this is for a second instance or flags). running via compose; this is for a second instance or flags).
- `docker compose exec frontend npm run build` — typecheck + build. - `docker compose exec frontend npm run build` — typecheck + build.
- Without a docker socket: VS Code "Attach to Running Container" → frontend. - Without a docker socket: VS Code "Attach to Running Container" → frontend.
- Local `npm install`: use `--legacy-peer-deps` (Mantine 9 + React 19
peer range); `npm ci` inside the image works off the committed lockfile.
Flow workflow: design in Langflow → press "Aus Langflow synchronisieren" Flow workflow: design in Langflow → press "Aus Langflow synchronisieren"
in Verwaltung (manual full pass: Langflow → `backend/flows/<name>.json` → in Verwaltung (manual full pass: Langflow → `backend/flows/<name>.json` →
@ -160,7 +165,9 @@ proxy — nothing here substitutes for it):
critical unauthenticated RCEs — treat its UI as privileged tooling, critical unauthenticated RCEs — treat its UI as privileged tooling,
never share flows publicly ("Shareable Playground" = RCE risk). never share flows publicly ("Shareable Playground" = RCE risk).
- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate - Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate
after any incident or staff change; re-run `check_prod.py`. after any incident or staff change; re-run `check_prod.py`. `JWT_SECRET`
signs session cookies — rotating it logs everyone out (same on every
deploy that drops session state: users re-login via magic link).
- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION` - Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION`
(`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then (`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then
`lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on `lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on

View file

@ -254,7 +254,7 @@ def _jwt_secret() -> str:
def mint_session_token(user: User) -> str: def mint_session_token(user: User) -> str:
"""Issue a stateless session JWT for a user (30-day expiry).""" """Issue a stateless session JWT for a user (7-day expiry)."""
now = utcnow() now = utcnow()
return jwt.encode( return jwt.encode(
{ {

View file

@ -27,7 +27,6 @@ from app.middleware import limiter, run_limit
from app.models import User from app.models import User
from app.settings import settings from app.settings import settings
from . import input_schema as form_schema
from . import runner, store from . import runner, store
from .deploy import import_from_langflow from .deploy import import_from_langflow
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable
@ -363,7 +362,7 @@ def get_input_schema(
version = store.get_latest_deployed_version(session, flow_id) version = store.get_latest_deployed_version(session, flow_id)
except (store.NotFoundError, store.InvalidError) as exc: except (store.NotFoundError, store.InvalidError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json) cached = version.input_schema
return InputSchemaRead( return InputSchemaRead(
flow_id=flow_id, flow_id=flow_id,
version_no=version.version_no, version_no=version.version_no,

View file

@ -70,7 +70,7 @@ async def execute_run(
run = store.create_run( run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id 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) cached = version.input_schema
envelope = build_envelope( envelope = build_envelope(
payload=payload, payload=payload,
run_id=run.id, run_id=run.id,
@ -119,7 +119,7 @@ async def stream_run(
run = store.create_run( run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id 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) cached = version.input_schema
envelope = build_envelope( envelope = build_envelope(
payload=payload, payload=payload,
run_id=run.id, run_id=run.id,

View file

@ -37,7 +37,7 @@ class FlowVersionRead(BaseModel):
deployed_at: datetime | None deployed_at: datetime | None
author: str author: str
created_at: datetime created_at: datetime
input_schema: dict | None = None input_schema: dict
class InputSchemaRead(BaseModel): class InputSchemaRead(BaseModel):

View file

@ -47,8 +47,8 @@ class FlowVersion(SQLModel, table=True):
author: str = Field(default="", max_length=200) author: str = Field(default="", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
# Cached form schema for this exact JSON (see app/flows/input_schema.py). # Cached form schema for this exact JSON (see app/flows/input_schema.py).
# Computed at version creation; nullable for pre-Phase-3 rows. # Computed at version creation; backfilled NOT NULL (e8f3a1c2b4d6).
input_schema: dict | None = Field(default=None, sa_column=_json_column()) input_schema: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
class FlowRun(SQLModel, table=True): class FlowRun(SQLModel, table=True):

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,65 @@
"""Backfill + require FlowVersion.input_schema.
Revision ID: e8f3a1c2b4d6
Revises: c4d8f2a1e6b9
Create Date: 2026-09-30 00:00:00.000000
Pre-Phase-3 rows have input_schema NULL. Backfill them from the stored
langflow_json (same parser the write choke point uses); unparseable rows
get the textarea fallback so reads never crash. Then enforce NOT NULL.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e8f3a1c2b4d6"
down_revision: str | Sequence[str] | None = "c4d8f2a1e6b9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FALLBACK = {
"json_schema": {"type": "object", "properties": {}},
"ui_schema": {},
"fallback": "textarea",
}
def _derive(langflow_json) -> dict:
import json as stdlib_json
try:
from app.flows import input_schema as parser
if isinstance(langflow_json, str):
# SQLite stores JSON columns as TEXT; Postgres returns dict.
langflow_json = stdlib_json.loads(langflow_json)
return parser.extract_form_schema(langflow_json)
except Exception: # noqa: BLE001 -- backfill must never fail the migration
return dict(_FALLBACK)
def upgrade() -> None:
"""Upgrade schema."""
import json as stdlib_json
connection = op.get_bind()
rows = connection.execute(
sa.text("SELECT id, langflow_json FROM flow_versions WHERE input_schema IS NULL")
).all()
for row_id, langflow_json in rows:
schema = _derive(langflow_json)
connection.execute(
sa.text("UPDATE flow_versions SET input_schema = :schema WHERE id = :id"),
{"schema": stdlib_json.dumps(schema), "id": str(row_id)},
)
with op.batch_alter_table("flow_versions") as batch:
batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=False)
def downgrade() -> None:
"""Downgrade schema (data kept, nullability restored)."""
with op.batch_alter_table("flow_versions") as batch:
batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=True)

View file

@ -11,48 +11,36 @@ from __future__ import annotations
import json import json
import os import os
import shutil import shutil
import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.request
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tests._harness import (
free_port,
migrate_throwaway,
plant_session_jwt,
spawn_lfx,
wait_healthy,
)
BACKEND = Path(__file__).resolve().parent.parent BACKEND = Path(__file__).resolve().parent.parent
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH. # Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin") VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
FIXTURES = BACKEND / "tests" / "fixtures" / "flows" FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
# Ephemeral ports: the dev API/LFX may already occupy the defaults
def _free_port() -> int: # (e.g. when smoke runs inside the api container itself).
# Ephemeral ports: the dev API/LFX may already occupy the defaults LFX_PORT = free_port()
# (e.g. when smoke runs inside the api container itself). API_PORT = free_port()
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
LFX_PORT = _free_port()
API_PORT = _free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs" LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
# Stateless admin session JWT, minted in main() after planting the user. # Stateless admin session JWT, minted in main() after planting the user.
_SMOKE_JWT = "" _SMOKE_JWT = ""
def wait_healthy(url: str, timeout: int = 180) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=3) as r:
if r.status == 200:
return
except OSError:
time.sleep(1)
raise RuntimeError(f"never healthy: {url}")
def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]: def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]:
import httpx import httpx
@ -75,35 +63,19 @@ def check(name: str, cond: bool, detail: str = "") -> None:
def main() -> None: def main() -> None:
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-")) tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
flow_dir = Path("/tmp/smoke-e2e-flows") flow_dir = Path(tempfile.mkdtemp(prefix="kirche-smoke-flows-"))
flow_dir.mkdir(exist_ok=True)
(flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text()) (flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
# Migrate a throwaway SQLite schema to head before starting the API. # Migrate a throwaway SQLite schema to head before starting the API.
from alembic import command as alembic_command
from alembic.config import Config
db_url = f"sqlite:///{tmp / 'smoke.db'}" db_url = f"sqlite:///{tmp / 'smoke.db'}"
cfg = Config() migrate_throwaway(BACKEND, db_url)
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
cfg.set_main_option("sqlalchemy.url", db_url)
# migrations/env.py takes the URL from settings (not the ini value):
# point this process at the throwaway DB before migrating.
from app.settings import settings as app_settings
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head")
# Plant an admin user + mint its session JWT: management + runs # Plant an admin user + mint its session JWT: management + runs
# require login, and there is no SMTP in smoke to deliver a magic link. # require login, and there is no SMTP in smoke to deliver a magic link.
# Sessions are stateless (see app/auth/service.py) — the cookie value # Sessions are stateless (see app/auth/service.py) — the cookie value
# below is the JWT sent as `kirche_session`. # below is the JWT sent as `kirche_session`.
global _SMOKE_JWT global _SMOKE_JWT
import os
from sqlmodel import Session, create_engine from sqlmodel import Session, create_engine
from app.auth import service as auth_service
from app.models import User
from app.settings import settings as app_settings from app.settings import settings as app_settings
if not app_settings.jwt_secret: if not app_settings.jwt_secret:
@ -112,52 +84,34 @@ def main() -> None:
) )
engine = create_engine(db_url) engine = create_engine(db_url)
with Session(engine) as s: with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin") _, _SMOKE_JWT = plant_session_jwt(s, email="smoke@example.com", name="smoke")
s.add(user) # Smoke predates the shared harness key; keep its long serve key.
s.commit()
s.refresh(user)
_SMOKE_JWT = auth_service.mint_session_token(user)
print("smoke admin JWT minted") print("smoke admin JWT minted")
engine.dispose() engine.dispose()
env = { env_extra = {
**os.environ,
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
# it. Clear it so the serve key below is the one we send.
"LANGFLOW_API_KEY": "",
"LFX_API_KEY": LFX_KEY,
"DATABASE_URL": db_url, "DATABASE_URL": db_url,
"FLOWS_WATCH_ENABLED": "false", "FLOWS_WATCH_ENABLED": "false",
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}", "LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
"CALLBACK_TOKEN": "smoke-callback-token", "CALLBACK_TOKEN": "smoke-callback-token",
} }
lfx = subprocess.Popen( lfx = spawn_lfx(flow_dir, LFX_PORT, env_extra=env_extra, api_key=LFX_KEY)
[
str(VENV_BIN / "lfx"),
"serve",
"--host",
"127.0.0.1",
"--port",
str(LFX_PORT),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
uvicorn = subprocess.Popen( uvicorn = subprocess.Popen(
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)], [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
cwd=str(BACKEND), cwd=str(BACKEND),
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env, env={
**os.environ,
"LANGFLOW_API_KEY": "",
"LFX_API_KEY": LFX_KEY,
**env_extra,
},
) )
try: try:
wait_healthy(f"http://127.0.0.1:{LFX_PORT}/health") wait_healthy(f"http://127.0.0.1:{LFX_PORT}", deadline_s=180)
wait_healthy(f"http://127.0.0.1:{API_PORT}/health") wait_healthy(f"http://127.0.0.1:{API_PORT}", deadline_s=180)
s, flows = api("GET", "/api/flows") s, flows = api("GET", "/api/flows")
check("list flows", s == 200 and isinstance(flows, list)) check("list flows", s == 200 and isinstance(flows, list))

View file

@ -1,7 +1,7 @@
"""Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions. """Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions.
Single place for logic duplicated between tests/conftest.py and Single place for logic shared between tests/conftest.py and
scripts/smoke_e2e.py (~60 lines). Full real-LFX runs are kept. scripts/smoke_e2e.py. Full real-LFX runs are kept.
""" """
from __future__ import annotations from __future__ import annotations
@ -15,6 +15,10 @@ import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the
# repo .env value must be cleared wherever a test key is used.
TEST_LFX_KEY = "test-lfx-key"
def free_port() -> int: def free_port() -> int:
sock = socket.socket() sock = socket.socket()
@ -36,10 +40,14 @@ def wait_healthy(url: str, deadline_s: float = 240.0) -> None:
raise RuntimeError(f"server did not become healthy: {url}") raise RuntimeError(f"server did not become healthy: {url}")
def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen: def spawn_lfx(
flow_dir: Path, port: int, env_extra: dict | None = None, api_key: str = TEST_LFX_KEY
) -> subprocess.Popen:
bindir = Path(sys.executable).parent bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx" lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key"} env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": api_key}
if env_extra:
env.update(env_extra)
return subprocess.Popen( return subprocess.Popen(
[ [
lfx_bin, lfx_bin,
@ -59,20 +67,21 @@ def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen:
) )
def migrate_throwaway(backend_dir: Path) -> None: def migrate_throwaway(backend_dir: Path, db_url: str | None = None) -> None:
from alembic import command as alembic_command from alembic import command as alembic_command
from alembic.config import Config from alembic.config import Config
cfg = Config() cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations")) cfg.set_main_option("script_location", str(backend_dir / "migrations"))
if db_url is not None:
cfg.set_main_option("sqlalchemy.url", db_url)
# migrations/env.py takes the URL from settings (not the ini value).
from app.settings import settings as app_settings
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head") alembic_command.upgrade(cfg, "head")
# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the
# repo .env value must be cleared wherever a test key is used.
TEST_LFX_KEY = "test-lfx-key"
def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def] def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def]
"""Real uvicorn server in a daemon thread. Returns (server, thread).""" """Real uvicorn server in a daemon thread. Returns (server, thread)."""
import threading import threading
@ -91,7 +100,7 @@ def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[
return server, thread return server, thread
def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object: def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> tuple:
"""Idempotent test user + session JWT (shared with smoke pattern).""" """Idempotent test user + session JWT (shared with smoke pattern)."""
from sqlmodel import select from sqlmodel import select

View file

@ -4,6 +4,7 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
from lfx.custom import Component from lfx.custom import Component
REPO_ROOT = Path(__file__).resolve().parent.parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent.parent
@ -208,28 +209,212 @@ def test_bauvorhaben_collect_only_own_fields():
assert "full_name" not in collected assert "full_name" not in collected
def test_intake_fixture_shape(): def _parse_chat_json(text: str) -> dict:
doc = json.loads((FIXTURES / "intake_form.json").read_text()) """Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
assert lines[0].startswith("```"), text[:60]
assert lines[-1].startswith("```"), text[-60:]
text = "\n".join(lines[1:-1])
return json.loads(text)
@pytest.mark.parametrize(
(
"fixture_file",
"doc_name",
"node_id",
"expected_type",
"expected_display",
"display_mode",
"expected_fields",
"expected_edges",
),
[
(
"intake_form.json",
None,
"CustomInputForm-intake",
"CustomInputForm",
"CustomInput",
"startswith",
{
"full_name",
"notes",
"password",
"age",
"amount",
"newsletter",
"department",
"topics",
"satisfaction",
"attachment",
},
[
("ChatInput-entry", "CustomInputForm-intake"),
("CustomInputForm-intake", "ChatOutput-collected"),
],
),
(
"bauvorhaben.json",
"bauvorhaben",
"CustomInputBauvorhaben-bauvorhaben",
"CustomInputBauvorhaben",
"Bauvorhaben Form",
"exact",
{
"form_values",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"code",
},
[
("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben"),
("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected"),
],
),
(
"mitarbeiter_einstellen.json",
"mitarbeiter_einstellen",
"CustomInputMitarbeiter-mitarbeiter",
"CustomInputMitarbeiter",
"Mitarbeiter Einstellen Form",
"exact",
{
"form_values",
"rolle",
"einstufung",
"mitarbeiter_name",
"geburtsdatum",
"code",
},
[
("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter"),
("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt"),
("Gehaltsrechner-gehalt", "ChatOutput-collected"),
],
),
],
ids=["intake", "bauvorhaben", "mitarbeiter"],
)
def test_fixture_shape(
fixture_file,
doc_name,
node_id,
expected_type,
expected_display,
display_mode,
expected_fields,
expected_edges,
):
doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8"))
if doc_name is not None:
assert doc["name"] == doc_name
nodes = {n["id"]: n for n in doc["data"]["nodes"]} nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputForm-intake"] form = nodes[node_id]
assert form["data"]["type"] == "CustomInputForm" assert form["data"]["type"] == expected_type
assert form["data"]["node"]["display_name"].startswith("CustomInput") display = form["data"]["node"]["display_name"]
if display_mode == "startswith":
assert display.startswith(expected_display)
else:
assert display == expected_display
template = form["data"]["node"]["template"] template = form["data"]["node"]["template"]
for field in FORM_FIELDS: assert set(template) >= expected_fields
assert field in template, field
assert template["form_values"]["show"] is False assert template["form_values"]["show"] is False
assert len(template["code"]["value"]) > 200 assert len(template["code"]["value"]) > 200
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]} outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]} edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputForm-intake") in edges for edge in expected_edges:
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges assert edge in edges
async def test_intake_deploy_and_run_with_values(client, lfx_server, login): async def _check_intake_result(client, flow_id, version, run):
# collect() returns Data: the terminal ChatOutput renders it as a
# fenced ```json block, so unwrap before parsing.
result = _parse_chat_json(run["output"]["result"])
assert result["full_name"] == "Maria Muster"
assert result["age"] == 41
assert result["department"] == "Music" # default preserved
async def _check_bauvorhaben_result(client, flow_id, version, run):
# Branch rules are cached per version and served via input-schema.
cached = version["input_schema"]
assert len(cached["json_schema"]["allOf"]) == 5
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
assert schema.status_code == 200, schema.text
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
result = _parse_chat_json(run["output"]["result"])
assert result["gebaeude"] == "Kirche"
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",
}
async def _check_mitarbeiter_result(client, flow_id, version, run):
result = run["output"]["result"]
assert "65000" in result
assert "Maria Muster" in result
@pytest.mark.parametrize(
("fixture_file", "flow_name", "run_input", "check_result"),
[
(
"intake_form.json",
"intake-e2e",
{"full_name": "Maria Muster", "age": 41},
_check_intake_result,
),
(
"bauvorhaben.json",
"bauvorhaben-e2e",
{
"gebaeude": "Kirche",
"bestandteil": ["Glocke", "Orgel"],
"glockenmassnahme": "Neuguss",
"gussjahr": 1890,
"denkmalschutz": True,
"denkmalnummer": "D-123",
},
_check_bauvorhaben_result,
),
(
"mitarbeiter_einstellen.json",
"mitarbeiter-e2e",
{
"rolle": "Pfarrer:in",
"einstufung": "Stufe 2",
"mitarbeiter_name": "Maria Muster",
"geburtsdatum": "1980-01-01",
},
_check_mitarbeiter_result,
),
],
ids=["intake", "bauvorhaben", "mitarbeiter"],
)
async def test_deploy_and_run(
client, lfx_server, login, fixture_file, flow_name, run_input, check_result
):
await login() await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text()) doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "intake-e2e"}) created = await client.post("/api/flows", json={"name": flow_name})
assert created.status_code == 201, created.text assert created.status_code == 201, created.text
flow_id = created.json()["id"] flow_id = created.json()["id"]
@ -242,29 +427,10 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text assert deployed.status_code == 200, deployed.text
run = await client.post( run = await client.post(f"/api/flows/{flow_id}/run", json={"input": run_input})
f"/api/flows/{flow_id}/run",
json={"input": {"full_name": "Maria Muster", "age": 41}},
)
assert run.status_code == 200, run.text assert run.status_code == 200, run.text
assert run.json()["status"] == "completed" assert run.json()["status"] == "completed"
# collect() returns Data: the terminal ChatOutput renders it as a await check_result(client, flow_id, version.json(), run.json())
# fenced ```json block, so unwrap before parsing.
result = _parse_chat_json(run.json()["output"]["result"])
assert result["full_name"] == "Maria Muster"
assert result["age"] == 41
assert result["department"] == "Music" # default preserved
def _parse_chat_json(text: str) -> dict:
"""Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
assert lines[0].startswith("```"), text[:60]
assert lines[-1].startswith("```"), text[-60:]
text = "\n".join(lines[1:-1])
return json.loads(text)
def test_embedded_code_matches_template_fields(): def test_embedded_code_matches_template_fields():
@ -341,32 +507,6 @@ def test_kirche_node_inlines_core():
assert "from kirche" not in composed and "def parse_envelope" in composed assert "from kirche" not in composed and "def parse_envelope" in composed
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["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",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"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", "CustomInputBauvorhaben-bauvorhaben") in edges
assert ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected") in edges
def test_bauvorhaben_schema_branches(): def test_bauvorhaben_schema_branches():
from app.flows import input_schema as parser from app.flows import input_schema as parser
@ -411,59 +551,6 @@ def test_bauvorhaben_schema_branches():
} }
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "bauvorhaben-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
# Branch rules are cached per version and served via input-schema.
cached = version.json()["input_schema"]
assert len(cached["json_schema"]["allOf"]) == 5
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
assert schema.status_code == 200, schema.text
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
run = await client.post(
f"/api/flows/{flow_id}/run",
json={
"input": {
"gebaeude": "Kirche",
"bestandteil": ["Glocke", "Orgel"],
"glockenmassnahme": "Neuguss",
"gussjahr": 1890,
"denkmalschutz": True,
"denkmalnummer": "D-123",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = _parse_chat_json(run.json()["output"]["result"])
assert result["gebaeude"] == "Kirche"
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(): def test_mitarbeiter_component_contract():
from kirche.mitarbeiter_form import CustomInputMitarbeiter from kirche.mitarbeiter_form import CustomInputMitarbeiter
@ -485,60 +572,3 @@ def test_mitarbeiter_collect_branch_default():
assert "einstufung" not in form.collect().data # rolle defaults to Assistenz assert "einstufung" not in form.collect().data # rolle defaults to Assistenz
form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}}) form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}})
assert form.collect().data["einstufung"] == "Stufe 1" 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

View file

@ -1,5 +1,7 @@
"""Model-level tests: defaults, constraints, JSON roundtrip, linkage.""" """Model-level tests: defaults, constraints, JSON roundtrip, linkage."""
import uuid
import pytest import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import select from sqlmodel import select
@ -86,3 +88,48 @@ def test_run_and_task_linkage_with_tenant_default(session):
tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all() tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all()
assert len(tasks) == 1 assert len(tasks) == 1
assert tasks[0].payload == {} assert tasks[0].payload == {}
def test_create_version_populates_input_schema(session):
from app.flows import store
from app.flows.schemas import FlowVersionCreate
d = FlowDefinition(name="cached")
session.add(d)
session.commit()
doc = {
"name": "cached",
"data": {
"nodes": [
{
"id": "n1",
"data": {
"type": "ChatInput",
"node": {"display_name": "Chat", "template": {}},
},
}
],
"edges": [],
},
}
version = store.create_version(session, d.id, FlowVersionCreate(langflow_json=doc))
assert version.input_schema["fallback"] == "textarea"
def test_input_schema_null_rejected(session):
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
d = FlowDefinition(name="nonnull")
session.add(d)
session.commit()
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO flow_versions (id, flow_id, version_no, langflow_json,"
" author, input_schema) VALUES (:id, :flow_id, 1, :doc, :author, NULL)"
),
{"id": str(uuid.uuid4()), "flow_id": str(d.id), "doc": "{}", "author": "t"},
)
session.commit()
session.rollback()

View file

@ -136,73 +136,78 @@ async def test_sync_endpoint_returns_report(
assert response.json()["skipped"] == ["simple_rule"] assert response.json()["skipped"] == ["simple_rule"]
async def test_sync_endpoint_pulls_from_langflow_then_syncs( @pytest.mark.parametrize(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow ("seed", "flow_name", "flow_id", "extra_flows", "extra_docs", "expected_file"),
[
(
None,
"Gehaltserhöhung",
"22222222-3333-4444-8555-666666666666",
[],
{},
"Gehaltserhöhung.json",
),
(
# Seeded from simple_rule.json: updates in place instead of
# duplicating as simple-rule.json (slug drift).
"simple_rule.json",
"simple_rule",
"44444444-5555-4666-8777-888888888888",
[],
{},
"simple_rule.json",
),
(
None,
"Gut",
"33333333-4444-4555-8666-777777777777",
[{"id": "kaputt-id", "name": "Kaputt"}],
{},
"Gut.json",
),
],
ids=["pull-new", "pull-reuse", "pull-error-continues"],
)
async def test_sync_endpoint_pull_matrix(
client,
lfx_server,
tmp_path,
monkeypatch,
login,
override_langflow,
seed,
flow_name,
flow_id,
extra_flows,
extra_docs,
expected_file,
): ):
await login() await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
langflow_id = "22222222-3333-4444-8555-666666666666" if seed is not None:
_seed_dir(tmp_path, seed)
override_langflow( override_langflow(
flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}], flows=[{"id": flow_id, "name": flow_name}, *extra_flows],
docs={langflow_id: langflow_export_doc("Gehaltserhöhung", langflow_id)}, docs={flow_id: langflow_export_doc(flow_name, flow_id), **extra_docs},
) )
response = await client.post("/api/flows/sync") response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["pulled"] == ["Gehaltserhöhung"] assert body["pulled"] == [flow_name]
assert body["added"] == ["Gehaltserhöhung"] assert body["added"] == [flow_name]
assert (tmp_path / expected_file).is_file()
if flow_name == "Gut":
assert "kaputt-id" in body["errors"]
else:
assert body["errors"] == {} assert body["errors"] == {}
assert (tmp_path / "Gehaltserhöhung.json").is_file() assert not (tmp_path / "simple-rule.json").exists()
# Second pass: same content pulls again but syncs nothing new. # Second pass: same content pulls again but syncs nothing new.
response = await client.post("/api/flows/sync") response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["pulled"] == ["Gehaltserhöhung"] assert body["pulled"] == [flow_name]
assert body["skipped"] == ["Gehaltserhöhung"] assert body["skipped"] == [flow_name]
async def test_sync_endpoint_pull_reuses_same_named_file(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
"""A pulled flow seeded from simple_rule.json updates it in place
instead of duplicating it as simple-rule.json (slug drift)."""
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
_seed_dir(tmp_path) # fixture shape, name "simple_rule"
langflow_id = "44444444-5555-4666-8777-888888888888"
override_langflow(
flows=[{"id": langflow_id, "name": "simple_rule"}],
docs={langflow_id: langflow_export_doc("simple_rule", langflow_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["simple_rule"]
assert body["added"] == ["simple_rule"]
assert body["errors"] == {}
assert not (tmp_path / "simple-rule.json").exists()
response = await client.post("/api/flows/sync")
assert response.json()["skipped"] == ["simple_rule"]
async def test_sync_endpoint_pull_error_continues(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
good_id = "33333333-4444-4555-8666-777777777777"
override_langflow(
flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}],
docs={good_id: langflow_export_doc("Gut", good_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["Gut"]
assert body["added"] == ["Gut"]
assert "kaputt-id" in body["errors"]
@pytest.mark.parametrize( @pytest.mark.parametrize(

33
docs/flow-authoring.md Normal file
View file

@ -0,0 +1,33 @@
# Flow authoring (Langflow → Webapp)
So kommt ein neuer Flow in die Webapp:
1. **In Langflow entwerfen und speichern** (bestehende Flows sind dort
bereits geladen). Name vergeben, speichern.
2. **Sync-Button in der Verwaltung klicken** ("Aus Langflow
synchronisieren"): holt alle Flows aus Langflow nach
`backend/flows/*.json` und deployed sie danach automatisch (neue
Version nur bei geändertem Inhalt, sonst übersprungen).
3. **Prüfen:** Die Versions-Tabelle zeigt Version, Autor und Status.
Danach erscheint der Flow automatisch auf der Seite Workflows mit
seinem Formular. Flows ohne Formular-Knoten zeigen ein Freitext-Feld.
## Regeln
- Die Datei heißt wie der Flow in Langflow (z. B. `intake_form.json`).
Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt
bestehen und muss bei Bedarf gelöscht werden.
- Eine gelöschte Datei löscht den Flow inklusive seiner Läufe
(Datei bleibt in Git erhalten).
- Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON).
Der automatische Beobachter im Hintergrund syncet nur Dateien,
Langflow holt nur dieser Button.
## Formular-Felder
Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten:
jeder Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename
= Label). Die Verbindung ChatInput nach `form_values` bitte behalten —
darüber kommen die ausgefüllten Werte an. Unbekannte Feldtypen fallen
automatisch auf ein Textfeld zurück. Details:
`backend/components/kirche/README.md`.

View file

@ -1,78 +0,0 @@
"""Fetch landing-page photos (one-time + re-runnable).
Downloads royalty-free originals (see SOURCES.md next to the photos),
resizes to max 1600px wide and stores them as WebP q80 in
frontend/public/photos/ (self-hosted: no hotlinking, no tracking).
Run in the api container (has network + Pillow):
docker compose exec api /opt/venv/bin/python \\
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
"""
from __future__ import annotations
import sys
import urllib.request
from io import BytesIO
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.exit("Pillow required (present in the api image)")
ROOT = Path(__file__).resolve().parent.parent.parent
OUT = ROOT / "frontend" / "public" / "photos"
def pexels(photo_id: int) -> str:
return (
f"https://images.pexels.com/photos/{photo_id}/"
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600"
)
PHOTOS = {
# target file -> (direct file URL, source page for SOURCES.md)
"hero-church.webp": (
pexels(31630127),
"https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/",
),
"church-pews.webp": (
pexels(7566207),
"https://www.pexels.com/photo/church-pews-7566207/",
),
"paperwork-desk.webp": (
pexels(18687845),
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
),
"pilot-meeting.webp": (
pexels(5668858),
"https://www.pexels.com/photo/two-people-shaking-hands-5668858/",
),
}
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"})
with urllib.request.urlopen(req, timeout=120) as response:
return response.read()
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
for filename, (url, _page) in PHOTOS.items():
raw = fetch(url)
with Image.open(BytesIO(raw)) as img:
img = img.convert("RGB")
if img.width > 1600:
img = img.resize(
(1600, round(img.height * 1600 / img.width)), Image.LANCZOS
)
target = OUT / Path(filename).with_suffix(".webp").name
img.save(target, "WEBP", quality=80, method=6)
print(f"wrote {target} ({target.stat().st_size // 1024} KB)")
if __name__ == "__main__":
main()

View file

@ -1,5 +1,19 @@
/* Typed client for the kirche-einfach API (served via vite /api proxy). */ /* Typed client for the kirche-einfach API (served via vite /api proxy). */
/** All statuses the backend can report (single color language in StatusBadge). */
export type Status =
| "completed"
| "approved"
| "genehmigt"
| "deployed"
| "running"
| "open"
| "pending"
| "pending_approval"
| "failed"
| "rejected"
| "abgelehnt";
export interface FlowDefinition { export interface FlowDefinition {
id: string; id: string;
name: string; name: string;
@ -33,7 +47,7 @@ export interface Run {
id: string; id: string;
version_id: string; version_id: string;
tenant_id: string; tenant_id: string;
status: string; status: Status;
input: unknown; input: unknown;
output: RunOutput | null; output: RunOutput | null;
session_id: string | null; session_id: string | null;
@ -50,7 +64,7 @@ export interface ApprovalTask {
payload: unknown; payload: unknown;
continuation_flow_id: string | null; continuation_flow_id: string | null;
approver: string | null; approver: string | null;
status: string; status: Status;
decision_note: string; decision_note: string;
created_at: string; created_at: string;
decided_at: string | null; decided_at: string | null;
@ -88,7 +102,7 @@ export interface AccessRequest {
name: string; name: string;
email: string; email: string;
reason: string; reason: string;
status: string; status: Status;
created_at: string; created_at: string;
decided_at: string | null; decided_at: string | null;
decided_by: string | null; decided_by: string | null;

View file

@ -1,12 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { import { Anchor, Button, Card, List, Text, Title } from "@mantine/core";
Anchor,
Button,
Card,
List,
Text,
Title,
} from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
import { LANGFLOW_URL } from "../api"; import { LANGFLOW_URL } from "../api";
@ -35,56 +28,32 @@ export default function FlowHelp() {
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer"> <Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
Langflow öffnen <IconExternalLink size={14} /> Langflow öffnen <IconExternalLink size={14} />
</Anchor> </Anchor>
. Die bestehenden Flows sind dort bereits geladen. Name vergeben, speichern. . Die bestehenden Flows sind dort bereits geladen.
</List.Item> </List.Item>
<List.Item> <List.Item>
<Text component="span" fw={600}> <Text component="span" fw={600}>
2. Sync-Button unten klicken:{" "} 2. Sync-Button unten klicken:{" "}
</Text> </Text>
holt alle Flows aus Langflow nach{" "} holt alle Flows nach{" "}
<Text component="span" ff="monospace"> <Text component="span" ff="monospace">
backend/flows/*.json backend/flows/*.json
</Text>{" "} </Text>{" "}
und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt, und deployed sie automatisch.
sonst übersprungen).
</List.Item> </List.Item>
<List.Item> <List.Item>
<Text component="span" fw={600}> <Text component="span" fw={600}>
3. Prüfen:{" "} 3. Prüfen:{" "}
</Text> </Text>
Tabelle unten zeigt Version, Autor und Status. Danach erscheint der Flow danach erscheint der Flow auf der Seite Workflows. Flows ohne
automatisch auf der Seite Workflows mit seinem Formular. Flows ohne
Formular-Knoten zeigen ein Freitext-Feld. Formular-Knoten zeigen ein Freitext-Feld.
</List.Item> </List.Item>
</List> </List>
<Title order={5} mt="md" mb="xs"> <Text size="sm" c="dimmed" mt="sm">
Regeln Regeln, Formular-Felder und Sync-Sicherheit: siehe{" "}
</Title> <Text component="span" ff="monospace">
<List size="sm" spacing="xs"> docs/flow-authoring.md
<List.Item> </Text>{" "}
Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json). im Repo.
Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt
bestehen und muss bei Bedarf gelöscht werden.
</List.Item>
<List.Item>
Eine gelöschte Datei löscht den Flow inklusive seiner Läufe
(Datei bleibt in Git erhalten).
</List.Item>
<List.Item>
Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON).
Der automatische Beobachter im Hintergrund syncet nur Dateien,
Langflow holt nur dieser Button.
</List.Item>
</List>
<Title order={5} mt="md" mb="xs">
Formular-Felder
</Title>
<Text size="sm">
Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: jeder
Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename = Label).
Die Verbindung ChatInput nach form_values bitte behalten — darüber kommen
die ausgefüllten Werte an. Unbekannte Feldtypen fallen automatisch auf ein
Textfeld zurück.
</Text> </Text>
</div> </div>
)} )}

View file

@ -4,12 +4,19 @@ import { FormattedDate } from "./FormattedDate";
import { JsonView } from "./JsonView"; import { JsonView } from "./JsonView";
import type { Run } from "../api"; import type { Run } from "../api";
export function RunsTable({ runs }: { runs: Run[] }) { export function RunsTable({
runs,
flowName,
}: {
runs: Run[];
flowName?: (run: Run) => string;
}) {
return ( return (
<Table> <Table>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Status</Table.Th> <Table.Th>Status</Table.Th>
{flowName && <Table.Th>Flow</Table.Th>}
<Table.Th>Ergebnis</Table.Th> <Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th> <Table.Th>Erstellt</Table.Th>
</Table.Tr> </Table.Tr>
@ -20,6 +27,7 @@ export function RunsTable({ runs }: { runs: Run[] }) {
<Table.Td> <Table.Td>
<StatusBadge status={r.status} /> <StatusBadge status={r.status} />
</Table.Td> </Table.Td>
{flowName && <Table.Td>{flowName(r)}</Table.Td>}
<Table.Td> <Table.Td>
<JsonView value={r.output?.result ?? r.output?.error ?? "–"} /> <JsonView value={r.output?.result ?? r.output?.error ?? "–"} />
</Table.Td> </Table.Td>

View file

@ -1,8 +1,9 @@
import { Badge } from "@mantine/core"; import { Badge } from "@mantine/core";
import type { Status } from "../api";
/* Single color language for every status in the app. */ /* Single color language for every status in the app. */
const COLORS: Record<string, string> = { const COLORS: Record<Status, string> = {
completed: "green", completed: "green",
approved: "green", approved: "green",
genehmigt: "green", genehmigt: "green",
@ -16,6 +17,6 @@ const COLORS: Record<string, string> = {
abgelehnt: "red", abgelehnt: "red",
}; };
export default function StatusBadge({ status }: { status: string }) { export default function StatusBadge({ status }: { status: Status }) {
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>; return <Badge color={COLORS[status]}>{status}</Badge>;
} }

View file

@ -39,6 +39,7 @@ import {
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { import {
FeatureCard, FeatureCard,
PHOTO_CLASS,
PhotoImage, PhotoImage,
SectionCard, SectionCard,
SectionTitle, SectionTitle,
@ -404,7 +405,7 @@ export default function StartPage() {
src="/screenshots/workflows-light.png" src="/screenshots/workflows-light.png"
alt="Portal von Kirche Einfach mit Vorgangsformular" alt="Portal von Kirche Einfach mit Vorgangsformular"
radius="md" radius="md"
className="photo" className={PHOTO_CLASS}
loading="lazy" loading="lazy"
bd="1px solid var(--mantine-color-default-border)" bd="1px solid var(--mantine-color-default-border)"
/> />

View file

@ -1,13 +1,11 @@
import { Table, Text, Title } from "@mantine/core"; import { Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { formatApiError } from "../utils"; import { formatApiError } from "../utils";
import ApprovalsTab from "../components/ApprovalsTab"; import ApprovalsTab from "../components/ApprovalsTab";
import { ErrorAlert } from "../components/ErrorAlert"; import { ErrorAlert } from "../components/ErrorAlert";
import { JsonView } from "../components/JsonView"; import { RunsTable } from "../components/RunsTable";
import StatusBadge from "../components/StatusBadge";
import { useEffect, useState } from "react";
export default function TasksPage() { export default function TasksPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -19,6 +17,7 @@ export default function TasksPage() {
useEffect(() => { useEffect(() => {
if (runsQuery.error) setError(formatApiError(runsQuery.error)); if (runsQuery.error) setError(formatApiError(runsQuery.error));
}, [runsQuery.error]); }, [runsQuery.error]);
const byId = new Map(runs.map(({ flow_name, run }) => [run.id, flow_name]));
return ( return (
<div> <div>
@ -34,34 +33,10 @@ export default function TasksPage() {
Noch keine Läufe. Noch keine Läufe.
</Text> </Text>
) : ( ) : (
<Table striped highlightOnHover mb="md"> <RunsTable
<Table.Thead> runs={runs.map(({ run }) => run)}
<Table.Tr> flowName={(r) => byId.get(r.id) ?? "–"}
<Table.Th>Status</Table.Th>
<Table.Th>Flow</Table.Th>
<Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map(({ flow_name, run }) => (
<Table.Tr key={run.id}>
<Table.Td>
<StatusBadge status={run.status} />
</Table.Td>
<Table.Td>{flow_name}</Table.Td>
<Table.Td>
<JsonView
value={run.output?.result ?? run.output?.error ?? "–"}
/> />
</Table.Td>
<Table.Td>
{dayjs(run.created_at).format("DD.MM.YYYY HH:mm")}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)} )}
<ApprovalsTab /> <ApprovalsTab />
</div> </div>

View file

@ -1,9 +1,7 @@
/* Shared frontend helpers: error formatting, config (was scattered). */ /* Shared frontend helpers: error formatting (was scattered). */
import { ApiError } from "./api"; import { ApiError } from "./api";
export function formatApiError(e: unknown): string { export function formatApiError(e: unknown): string {
if (e instanceof ApiError) return `${e.status}: ${e.message}`; if (e instanceof ApiError) return `${e.status}: ${e.message}`;
return String(e); return String(e);
} }
export const LANGFLOW_URL_FALLBACK = "http://localhost:7860";