refactor: finish declutter - test_flow removal, NOT NULL schema, parametrized tests, smoke harness, docs
This commit is contained in:
parent
696db34c90
commit
92bb3bacba
23 changed files with 552 additions and 1261 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 body["errors"] == {}
|
assert (tmp_path / expected_file).is_file()
|
||||||
assert (tmp_path / "Gehaltserhöhung.json").is_file()
|
if flow_name == "Gut":
|
||||||
|
assert "kaputt-id" in body["errors"]
|
||||||
|
else:
|
||||||
|
assert body["errors"] == {}
|
||||||
|
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
33
docs/flow-authoring.md
Normal 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`.
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
@ -23,70 +16,46 @@ export default function FlowHelp() {
|
||||||
{open ? "Anleitung verbergen" : "Anleitung: Neuer Flow in Langflow"}
|
{open ? "Anleitung verbergen" : "Anleitung: Neuer Flow in Langflow"}
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{open && (
|
||||||
<div>
|
<div>
|
||||||
<Title order={4} mt="sm" mb="xs">
|
<Title order={4} mt="sm" mb="xs">
|
||||||
So kommt ein neuer Flow in die Webapp
|
So kommt ein neuer Flow in die Webapp
|
||||||
</Title>
|
</Title>
|
||||||
<List size="sm" spacing="xs">
|
<List size="sm" spacing="xs">
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Text component="span" fw={600}>
|
<Text component="span" fw={600}>
|
||||||
1. In Langflow entwerfen und speichern:{" "}
|
1. In Langflow entwerfen und speichern:{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<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">
|
||||||
|
backend/flows/*.json
|
||||||
|
</Text>{" "}
|
||||||
|
und deployed sie automatisch.
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
<Text component="span" fw={600}>
|
||||||
|
3. Prüfen:{" "}
|
||||||
|
</Text>
|
||||||
|
danach erscheint der Flow auf der Seite Workflows. Flows ohne
|
||||||
|
Formular-Knoten zeigen ein Freitext-Feld.
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
<Text size="sm" c="dimmed" mt="sm">
|
||||||
|
Regeln, Formular-Felder und Sync-Sicherheit: siehe{" "}
|
||||||
<Text component="span" ff="monospace">
|
<Text component="span" ff="monospace">
|
||||||
backend/flows/*.json
|
docs/flow-authoring.md
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt,
|
im Repo.
|
||||||
sonst übersprungen).
|
</Text>
|
||||||
</List.Item>
|
</div>
|
||||||
<List.Item>
|
|
||||||
<Text component="span" fw={600}>
|
|
||||||
3. Prüfen:{" "}
|
|
||||||
</Text>
|
|
||||||
Tabelle unten 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.
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
<Title order={5} mt="md" mb="xs">
|
|
||||||
Regeln
|
|
||||||
</Title>
|
|
||||||
<List size="sm" spacing="xs">
|
|
||||||
<List.Item>
|
|
||||||
Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json).
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue