Compare commits
4 commits
0b03399c1a
...
06ab25fb47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ab25fb47 | ||
|
|
6688fb08c6 | ||
|
|
f27be044b4 | ||
|
|
0d43b0e8fb |
53 changed files with 6949 additions and 490 deletions
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -5,6 +5,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Changed
|
||||||
|
- Declutter: shared `BaseApiClient` (LFX/Langflow), 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`.
|
||||||
|
- Forms: shared `components/kirche/_form_core.py` single source (codegen-inlined for LFX), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI).
|
||||||
|
- Tests (full real LFX kept, 146 green): shared `conftest` helpers (`load_flow_fixture`, `flow_helpers`, `lfx_client`, `anon_client`), `tests/_harness.py` shared with smoke script, parametrized `filename_stem`.
|
||||||
|
- Frontend: shared `formatApiError`, `<ErrorAlert/>`, `<RunsTable/>`, `URLSearchParams` queries, localized run dates.
|
||||||
|
### Added
|
||||||
|
- Branched questionnaires: `[visible_if feld=Wert]` markers in a
|
||||||
|
`CustomInputForm` field's `info` (code editor) show the field only when
|
||||||
|
the condition holds — nested to any depth, with `&` for AND conditions
|
||||||
|
and quotes for values with spaces. The parser emits `allOf`/`if`/`then`
|
||||||
|
branches (branch-scoped `required`, template-order placement); unknown
|
||||||
|
triggers, cycles, and unsupported trigger types fail open (always
|
||||||
|
visible). New demo flow `bauvorhaben.json` (4 levels + sibling branch +
|
||||||
|
bool trigger). Values of hidden fields are kept and submitted.
|
||||||
|
- Run envelope carries the asked field names (`fields`, from the
|
||||||
|
version's form schema): `collect()` emits defaults only for asked
|
||||||
|
AND currently visible fields (hidden branch fields stay out, like the
|
||||||
|
frontend never submits them), so templates may declare fields unknown
|
||||||
|
to the component code and unasked code defaults stay out of the run
|
||||||
|
output. Without the list (raw dicts, Langflow playground) all visible
|
||||||
|
declared defaults apply.
|
||||||
|
- Standalone `CustomInputBauvorhaben` component ("Bauvorhaben Form" in
|
||||||
|
the kirche palette, zero shared code with `CustomInputForm` — the LFX
|
||||||
|
runtime executes embedded module source without our package); the
|
||||||
|
bauvorhaben flow uses it, and Langflow no longer normalizes the node
|
||||||
|
back to the default form on import.
|
||||||
|
- Form parser also recognizes user-built `CustomComponent` nodes whose
|
||||||
|
display name ends with `"Form"` (e.g. Mitarbeiter Einstellen Form) —
|
||||||
|
same template contract, no kirche base needed.
|
||||||
|
- Standalone `CustomInputMitarbeiter` component ("Mitarbeiter Einstellen
|
||||||
|
Form") with hiring flow incl. Gehaltsrechner, rebuilt generator-owned.
|
||||||
|
- Admin flow table shows one row per flow (latest version, with live
|
||||||
|
version note when an older version is deployed) instead of one row
|
||||||
|
per version.
|
||||||
|
- Step-by-step guide for new workflow modules: `docs/new_workflow_module.md`
|
||||||
|
(linked from README).
|
||||||
|
### Changed
|
||||||
|
- `CustomInputForm.collect()` is now dynamic and returns `Data` (JSON)
|
||||||
|
instead of `Message`: fields can be renamed, added, or removed in the
|
||||||
|
Langflow code editor with no `collect()` change, and values travel as a
|
||||||
|
real JSON object. The form parser keeps `name` keys verbatim (empty
|
||||||
|
names skipped, collisions suffixed) and the run envelope merges unknown
|
||||||
|
keys. Note: a terminal `ChatOutput` renders `Data` as a fenced ```json
|
||||||
|
block, and downstream components should read `.data`, not `.text`.
|
||||||
### Added
|
### Added
|
||||||
- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync`
|
- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync`
|
||||||
(Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle
|
(Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,14 @@ DB + LFX deploy) → commit. File-only alternatives: `scripts/sync_flows.py
|
||||||
watcher deploys file edits automatically. Deleting a file removes the flow
|
watcher deploys file edits automatically. Deleting a file removes the flow
|
||||||
including its runs (git keeps the JSON).
|
including its runs (git keeps the JSON).
|
||||||
|
|
||||||
|
New workflow module? Step-by-step guide: `docs/new_workflow_module.md`.
|
||||||
|
|
||||||
|
Pull safety: sync matches Langflow flows to files **by name** and
|
||||||
|
overwrites silently — renaming or repurposing a flow in Langflow (or a
|
||||||
|
duplicate with the same name) lands in the matched file. Commit before
|
||||||
|
syncing and review the `pulled` report with `git diff`; uncommitted
|
||||||
|
work in `backend/flows/` can be overwritten.
|
||||||
|
|
||||||
## Production (self-hosted — TLS/reverse proxy required, not included)
|
## Production (self-hosted — TLS/reverse proxy required, not included)
|
||||||
|
|
||||||
1. Check out the repo on the host, create `.env` from `.env.example` with
|
1. Check out the repo on the host, create `.env` from `.env.example` with
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,11 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
from . import mail, service
|
from . import mail, service
|
||||||
from .deps import (
|
from .deps import SESSION_COOKIE, SESSION_MAX_AGE, get_current_user, require_admin
|
||||||
SESSION_COOKIE,
|
|
||||||
SESSION_MAX_AGE,
|
|
||||||
get_current_user,
|
|
||||||
get_db,
|
|
||||||
require_admin,
|
|
||||||
)
|
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
AccessDecisionRead,
|
AccessDecisionRead,
|
||||||
AccessRequestCreate,
|
AccessRequestCreate,
|
||||||
|
|
@ -33,26 +28,11 @@ auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
def _read_request(r) -> AccessRequestRead:
|
def _read_request(r) -> AccessRequestRead:
|
||||||
return AccessRequestRead(
|
return AccessRequestRead.model_validate(r, from_attributes=True)
|
||||||
id=r.id,
|
|
||||||
name=r.name,
|
|
||||||
email=r.email,
|
|
||||||
reason=r.reason,
|
|
||||||
status=r.status,
|
|
||||||
created_at=r.created_at,
|
|
||||||
decided_at=r.decided_at,
|
|
||||||
decided_by=r.decided_by,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_me(user: User) -> MeRead:
|
def _read_me(user: User) -> MeRead:
|
||||||
return MeRead(
|
return MeRead.model_validate(user, from_attributes=True)
|
||||||
id=user.id,
|
|
||||||
email=user.email,
|
|
||||||
name=user.name,
|
|
||||||
role=user.role,
|
|
||||||
tenant_id=user.tenant_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, session_id: str) -> None:
|
def _set_session_cookie(response: Response, session_id: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
"""Session-cookie dependencies. Handlers stay thin; DB via get_session."""
|
"""Session-cookie dependencies. Handlers stay thin; DB via app.db.get_db."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.db import get_session
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
from . import service
|
from . import service
|
||||||
|
|
@ -17,10 +16,6 @@ SESSION_COOKIE = "kirche_session"
|
||||||
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
|
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Iterator[Session]:
|
|
||||||
yield from get_session()
|
|
||||||
|
|
||||||
|
|
||||||
def get_optional_user(
|
def get_optional_user(
|
||||||
request: Request, session: Annotated[Session, Depends(get_db)]
|
request: Request, session: Annotated[Session, Depends(get_db)]
|
||||||
) -> User | None:
|
) -> User | None:
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,6 @@ BLUE_LIGHT = "#eef4fb"
|
||||||
INK = "#162f4c"
|
INK = "#162f4c"
|
||||||
MUTED = "#5b6b7c"
|
MUTED = "#5b6b7c"
|
||||||
|
|
||||||
BLUE = "#1d4a79"
|
|
||||||
BLUE_LIGHT = "#eef4fb"
|
|
||||||
INK = "#162f4c"
|
|
||||||
MUTED = "#5b6b7c"
|
|
||||||
|
|
||||||
|
|
||||||
def magic_link(token: str) -> str:
|
def magic_link(token: str) -> str:
|
||||||
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from uuid import UUID
|
||||||
from sqlmodel import Session, func, select
|
from sqlmodel import Session, func, select
|
||||||
|
|
||||||
from app.models import AccessRequest, MagicLinkToken, User, UserSession
|
from app.models import AccessRequest, MagicLinkToken, User, UserSession
|
||||||
|
from app.models.base import utcnow
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -60,10 +61,6 @@ class RateLimitedError(AuthError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_email(email: str) -> str:
|
def normalize_email(email: str) -> str:
|
||||||
return email.strip().lower()
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
@ -92,18 +89,22 @@ def user_count(session: Session) -> int:
|
||||||
return session.exec(select(func.count()).select_from(User)).one()
|
return session.exec(select(func.count()).select_from(User)).one()
|
||||||
|
|
||||||
|
|
||||||
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
|
def _recent_count(session: Session, model, *filters, hours: int = 1) -> int:
|
||||||
since = utcnow() - timedelta(hours=hours)
|
since = utcnow() - timedelta(hours=hours)
|
||||||
return session.exec(
|
return session.exec(
|
||||||
select(func.count())
|
select(func.count()).select_from(model).where(*filters, model.created_at >= since)
|
||||||
.select_from(AccessRequest)
|
|
||||||
.where(
|
|
||||||
AccessRequest.email == normalize_email(email),
|
|
||||||
AccessRequest.created_at >= since,
|
|
||||||
)
|
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
|
|
||||||
|
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
|
||||||
|
return _recent_count(
|
||||||
|
session,
|
||||||
|
AccessRequest,
|
||||||
|
AccessRequest.email == normalize_email(email),
|
||||||
|
hours=hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_access_request(
|
def create_access_request(
|
||||||
session: Session, name: str, email: str, reason: str = ""
|
session: Session, name: str, email: str, reason: str = ""
|
||||||
) -> AccessRequest:
|
) -> AccessRequest:
|
||||||
|
|
@ -165,15 +166,7 @@ def issue_magic_token(session: Session, user: User) -> str:
|
||||||
|
|
||||||
|
|
||||||
def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
|
def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
|
||||||
since = utcnow() - timedelta(hours=hours)
|
return _recent_count(session, MagicLinkToken, MagicLinkToken.user_id == user.id, hours=hours)
|
||||||
return session.exec(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(MagicLinkToken)
|
|
||||||
.where(
|
|
||||||
MagicLinkToken.user_id == user.id,
|
|
||||||
MagicLinkToken.created_at >= since,
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
|
|
||||||
|
|
||||||
def request_magic_link(session: Session, email: str) -> tuple[User, str]:
|
def request_magic_link(session: Session, email: str) -> tuple[User, str]:
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ def get_session() -> Iterator[Session]:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Iterator[Session]:
|
||||||
|
"""Single FastAPI DB dependency (all routers import this)."""
|
||||||
|
yield from get_session()
|
||||||
|
|
||||||
|
|
||||||
def create_all(url: str | None = None) -> None:
|
def create_all(url: str | None = None) -> None:
|
||||||
"""Create tables directly (dev convenience; migrations own prod schema)."""
|
"""Create tables directly (dev convenience; migrations own prod schema)."""
|
||||||
SQLModel.metadata.create_all(get_engine(url))
|
SQLModel.metadata.create_all(get_engine(url))
|
||||||
|
|
|
||||||
|
|
@ -12,30 +12,24 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user, require_admin
|
from app.auth.deps import get_current_user, require_admin
|
||||||
from app.db import get_engine, get_session
|
from app.db import get_db
|
||||||
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 input_schema as form_schema
|
||||||
from . import runner, store
|
from . import runner, store
|
||||||
from .deploy import import_from_langflow, pull_all_from_langflow
|
from .deploy import import_from_langflow
|
||||||
from .langflow_client import (
|
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable
|
||||||
LangflowClient,
|
|
||||||
LangflowError,
|
|
||||||
LangflowNotFound,
|
|
||||||
LangflowUnavailable,
|
|
||||||
)
|
|
||||||
from .lfx_client import (
|
from .lfx_client import (
|
||||||
LfxClient,
|
LfxClient,
|
||||||
LfxConflict,
|
LfxConflict,
|
||||||
|
|
@ -60,7 +54,7 @@ from .schemas import (
|
||||||
RunRequest,
|
RunRequest,
|
||||||
SyncReport,
|
SyncReport,
|
||||||
)
|
)
|
||||||
from .sync import lfx_id_for, sync_from_directory
|
from .sync import lfx_id_for
|
||||||
|
|
||||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||||
|
|
@ -68,10 +62,6 @@ approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Iterator[Session]:
|
|
||||||
yield from get_session()
|
|
||||||
|
|
||||||
|
|
||||||
def get_lfx() -> LfxClient:
|
def get_lfx() -> LfxClient:
|
||||||
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||||
|
|
||||||
|
|
@ -81,60 +71,40 @@ def get_langflow() -> LangflowClient:
|
||||||
|
|
||||||
|
|
||||||
def _read_definition(d) -> FlowDefinitionRead:
|
def _read_definition(d) -> FlowDefinitionRead:
|
||||||
return FlowDefinitionRead(
|
return FlowDefinitionRead.model_validate(d, from_attributes=True)
|
||||||
id=d.id,
|
|
||||||
name=d.name,
|
|
||||||
description=d.description,
|
|
||||||
status=d.status,
|
|
||||||
source_path=d.source_path,
|
|
||||||
continuation_flow_id=d.continuation_flow_id,
|
|
||||||
created_at=d.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_version(v) -> FlowVersionRead:
|
def _read_version(v) -> FlowVersionRead:
|
||||||
return FlowVersionRead(
|
return FlowVersionRead.model_validate(v, from_attributes=True)
|
||||||
id=v.id,
|
|
||||||
flow_id=v.flow_id,
|
|
||||||
version_no=v.version_no,
|
|
||||||
langflow_json=v.langflow_json,
|
|
||||||
lfx_flow_id=v.lfx_flow_id,
|
|
||||||
deployed_at=v.deployed_at,
|
|
||||||
author=v.author,
|
|
||||||
created_at=v.created_at,
|
|
||||||
input_schema=v.input_schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_run(r) -> RunRead:
|
def _read_run(r) -> RunRead:
|
||||||
return RunRead(
|
return RunRead.model_validate(r, from_attributes=True)
|
||||||
id=r.id,
|
|
||||||
version_id=r.version_id,
|
|
||||||
tenant_id=r.tenant_id,
|
|
||||||
status=r.status,
|
|
||||||
input=r.input,
|
|
||||||
output=r.output,
|
|
||||||
session_id=r.session_id,
|
|
||||||
created_at=r.created_at,
|
|
||||||
finished_at=r.finished_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_task(t) -> ApprovalRead:
|
def _read_task(t) -> ApprovalRead:
|
||||||
return ApprovalRead(
|
return ApprovalRead.model_validate(t, from_attributes=True)
|
||||||
id=t.id,
|
|
||||||
run_id=t.run_id,
|
|
||||||
version_id=t.version_id,
|
def _lfx_http(exc: LfxError) -> HTTPException:
|
||||||
tenant_id=t.tenant_id,
|
"""Map LFX domain errors to HTTP (single place, was 10x duplicated)."""
|
||||||
title=t.title,
|
if isinstance(exc, LfxInvalidFlow):
|
||||||
payload=t.payload,
|
return HTTPException(status_code=422, detail=str(exc))
|
||||||
continuation_flow_id=t.continuation_flow_id,
|
if isinstance(exc, LfxConflict):
|
||||||
approver=t.approver,
|
return HTTPException(status_code=409, detail=str(exc))
|
||||||
status=t.status,
|
if isinstance(exc, LfxUnavailable):
|
||||||
decision_note=t.decision_note,
|
logger.warning("lfx unavailable: %s", exc)
|
||||||
created_at=t.created_at,
|
return HTTPException(status_code=503, detail="flow runtime unavailable")
|
||||||
decided_at=t.decided_at,
|
logger.warning("lfx error: %s", exc)
|
||||||
)
|
return HTTPException(status_code=502, detail="flow runtime error")
|
||||||
|
|
||||||
|
|
||||||
|
def _langflow_http(exc: LangflowError) -> HTTPException:
|
||||||
|
if isinstance(exc, LangflowUnavailable):
|
||||||
|
logger.warning("langflow unavailable: %s", exc)
|
||||||
|
return HTTPException(status_code=503, detail="authoring server unavailable")
|
||||||
|
logger.warning("langflow error: %s", exc)
|
||||||
|
return HTTPException(status_code=502, detail="authoring server error")
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
||||||
|
|
@ -164,21 +134,14 @@ async def sync_flows(
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
|
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
|
||||||
|
from .sync import sync_full_pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pulled_result = await pull_all_from_langflow(langflow, Path(settings.flows_dir))
|
return await sync_full_pass(session, lfx, langflow, Path(settings.flows_dir))
|
||||||
except LangflowUnavailable as exc:
|
|
||||||
logger.warning("langflow unavailable: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
|
||||||
except LangflowError as exc:
|
except LangflowError as exc:
|
||||||
logger.warning("langflow error: %s", exc)
|
raise _langflow_http(exc) from exc
|
||||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
|
||||||
try:
|
|
||||||
report = await sync_from_directory(session, lfx, Path(settings.flows_dir))
|
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
report.pulled = pulled_result["pulled"]
|
|
||||||
report.errors = {**pulled_result["errors"], **report.errors}
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
||||||
|
|
@ -236,16 +199,8 @@ async def deploy_version(
|
||||||
flow_id=lfx_id_for(flow_id, version_no),
|
flow_id=lfx_id_for(flow_id, version_no),
|
||||||
replace=True,
|
replace=True,
|
||||||
)
|
)
|
||||||
except LfxInvalidFlow as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
except LfxConflict as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except LfxUnavailable as exc:
|
|
||||||
logger.warning("lfx unavailable: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
logger.warning("lfx error: %s", exc)
|
raise _lfx_http(exc) from exc
|
||||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
|
||||||
return _read_version(store.mark_deployed(session, version, uploaded.id))
|
return _read_version(store.mark_deployed(session, version, uploaded.id))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -290,66 +245,35 @@ async def run_flow(
|
||||||
)
|
)
|
||||||
except LfxNotFound as exc:
|
except LfxNotFound as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||||
except LfxUnavailable as exc:
|
|
||||||
logger.warning("lfx unavailable: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
logger.warning("lfx error: %s", exc)
|
raise _lfx_http(exc) from exc
|
||||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
|
||||||
return _read_run(run)
|
return _read_run(run)
|
||||||
|
|
||||||
|
|
||||||
async def _run_stream(
|
async def _run_stream(
|
||||||
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
|
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
|
||||||
):
|
):
|
||||||
if not version.lfx_flow_id:
|
try:
|
||||||
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
|
run, envelope, lfx_flow_id, sid = await runner.stream_run(
|
||||||
run = store.create_run(
|
|
||||||
session,
|
session,
|
||||||
|
lfx,
|
||||||
version,
|
version,
|
||||||
input=data.input,
|
payload=data.input,
|
||||||
tenant_id=user.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
session_id=data.session_id,
|
session_id=data.session_id,
|
||||||
)
|
|
||||||
envelope = runner.build_envelope(
|
|
||||||
payload=data.input,
|
|
||||||
run_id=run.id,
|
|
||||||
callback_base_url=settings.callback_base_url,
|
callback_base_url=settings.callback_base_url,
|
||||||
continuation_flow_id=definition.continuation_flow_id,
|
continuation_flow_id=definition.continuation_flow_id,
|
||||||
callback_token=settings.callback_token,
|
callback_token=settings.callback_token,
|
||||||
)
|
)
|
||||||
|
except store.InvalidError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
run_id = run.id
|
run_id = run.id
|
||||||
lfx_flow_id = version.lfx_flow_id
|
|
||||||
session_id = data.session_id or str(run.id)
|
|
||||||
|
|
||||||
async def event_gen() -> AsyncIterator[str]:
|
async def event_gen() -> AsyncIterator[str]:
|
||||||
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
||||||
try:
|
async for chunk in runner.stream_lfx_chunks(lfx, lfx_flow_id, envelope, sid):
|
||||||
async with (
|
|
||||||
httpx.AsyncClient(
|
|
||||||
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
|
||||||
) as client,
|
|
||||||
client.stream(
|
|
||||||
"POST",
|
|
||||||
f"/flows/{lfx_flow_id}/stream",
|
|
||||||
json={"input_value": envelope, "session_id": session_id},
|
|
||||||
) as response,
|
|
||||||
):
|
|
||||||
if response.status_code == 404:
|
|
||||||
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
|
|
||||||
response.raise_for_status()
|
|
||||||
async for chunk in response.aiter_text():
|
|
||||||
yield chunk
|
yield chunk
|
||||||
except (LfxNotFound, httpx.HTTPError) as exc:
|
status = runner.finalize_streamed_run(run_id)
|
||||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
|
|
||||||
# Finalize from a fresh session: the request session may be closed.
|
|
||||||
with Session(get_engine()) as fresh:
|
|
||||||
try:
|
|
||||||
fresh_run = store.get_run(fresh, run_id)
|
|
||||||
status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed"
|
|
||||||
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
|
|
||||||
except store.NotFoundError:
|
|
||||||
status = "failed"
|
|
||||||
yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n"
|
yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(event_gen(), media_type="text/event-stream")
|
return StreamingResponse(event_gen(), media_type="text/event-stream")
|
||||||
|
|
@ -376,12 +300,8 @@ async def list_langflow_flows(
|
||||||
"""Browse flows on the Langflow authoring server (admin import source)."""
|
"""Browse flows on the Langflow authoring server (admin import source)."""
|
||||||
try:
|
try:
|
||||||
return await langflow.list_flows()
|
return await langflow.list_flows()
|
||||||
except LangflowUnavailable as exc:
|
|
||||||
logger.warning("langflow unavailable: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
|
||||||
except LangflowError as exc:
|
except LangflowError as exc:
|
||||||
logger.warning("langflow error: %s", exc)
|
raise _langflow_http(exc) from exc
|
||||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
||||||
|
|
@ -405,26 +325,14 @@ async def import_flow(
|
||||||
)
|
)
|
||||||
except LangflowNotFound as exc:
|
except LangflowNotFound as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except LangflowUnavailable as exc:
|
|
||||||
logger.warning("langflow unavailable: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
|
||||||
except LangflowError as exc:
|
except LangflowError as exc:
|
||||||
logger.warning("langflow error: %s", exc)
|
raise _langflow_http(exc) from exc
|
||||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
|
||||||
except store.ConflictError as exc:
|
except store.ConflictError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
except store.InvalidError as exc:
|
except store.InvalidError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
except LfxInvalidFlow as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
except LfxConflict as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except LfxUnavailable as exc:
|
|
||||||
logger.warning("lfx unavailable: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
logger.warning("lfx error: %s", exc)
|
raise _lfx_http(exc) from exc
|
||||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
|
||||||
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
|
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -561,10 +469,8 @@ async def decide_approval(
|
||||||
)
|
)
|
||||||
except LfxNotFound as exc:
|
except LfxNotFound as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||||
except LfxUnavailable as exc:
|
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise _lfx_http(exc) from exc
|
||||||
return DecisionRead(
|
return DecisionRead(
|
||||||
task=_read_task(task),
|
task=_read_task(task),
|
||||||
continuation_run=_read_run(continuation_run) if continuation_run else None,
|
continuation_run=_read_run(continuation_run) if continuation_run else None,
|
||||||
|
|
|
||||||
27
backend/app/flows/base_client.py
Normal file
27
backend/app/flows/base_client.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Shared base for LFX + Langflow async HTTP clients.
|
||||||
|
|
||||||
|
Consolidates base_url stripping, header building, per-request client
|
||||||
|
construction, and HTTPError → domain-error mapping. Subclasses only
|
||||||
|
declare their error types and endpoint calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class BaseApiClient:
|
||||||
|
"""Thin wrapper around httpx.AsyncClient construction."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, timeout: float = 60.0, api_key: str | None = None) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.api_key = api_key or ""
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {"x-api-key": self.api_key} if self.api_key else {}
|
||||||
|
|
||||||
|
def _client(self) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
@ -13,6 +14,8 @@ from . import export, sync
|
||||||
from .langflow_client import LangflowClient, LangflowError
|
from .langflow_client import LangflowClient, LangflowError
|
||||||
from .lfx_client import LfxClient
|
from .lfx_client import LfxClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict:
|
def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict:
|
||||||
"""Reduce a Langflow FlowRead to the canonical fields we persist."""
|
"""Reduce a Langflow FlowRead to the canonical fields we persist."""
|
||||||
|
|
@ -40,6 +43,7 @@ async def pull_all_from_langflow(
|
||||||
"""
|
"""
|
||||||
flows_dir = Path(flows_dir)
|
flows_dir = Path(flows_dir)
|
||||||
headers = await langflow.list_flows()
|
headers = await langflow.list_flows()
|
||||||
|
ignored = export.ignored_stems(flows_dir)
|
||||||
pulled: list[str] = []
|
pulled: list[str] = []
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
for header in headers:
|
for header in headers:
|
||||||
|
|
@ -49,6 +53,9 @@ async def pull_all_from_langflow(
|
||||||
try:
|
try:
|
||||||
exported = await langflow.export_flow(flow_id)
|
exported = await langflow.export_flow(flow_id)
|
||||||
doc = normalize_exported_flow(exported, flow_id)
|
doc = normalize_exported_flow(exported, flow_id)
|
||||||
|
if export.filename_stem(doc["name"]) in ignored:
|
||||||
|
logger.warning("pull skips ignored flow %r", doc["name"])
|
||||||
|
continue
|
||||||
reuse, identical = export.find_reuse_path(flows_dir, doc)
|
reuse, identical = export.find_reuse_path(flows_dir, doc)
|
||||||
if identical and reuse is not None:
|
if identical and reuse is not None:
|
||||||
pulled.append(reuse.stem)
|
pulled.append(reuse.stem)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,24 @@ from pathlib import Path
|
||||||
|
|
||||||
FLOWS_GLOB = "*.json"
|
FLOWS_GLOB = "*.json"
|
||||||
|
|
||||||
|
#: Opt-out list in the flows dir (one stem per line, `#` comments):
|
||||||
|
#: listed flows stay in Langflow only — never synced, deployed,
|
||||||
|
#: validated, or shown in the frontend.
|
||||||
|
IGNORE_FILENAME = ".flowsignore"
|
||||||
|
|
||||||
|
|
||||||
|
def ignored_stems(flows_dir: str | Path) -> set[str]:
|
||||||
|
"""Stems excluded from the pipeline (empty when no ignore file)."""
|
||||||
|
try:
|
||||||
|
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return set()
|
||||||
|
return {
|
||||||
|
line.strip()
|
||||||
|
for line in text.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith("#")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def filename_stem(name: str) -> str:
|
def filename_stem(name: str) -> str:
|
||||||
"""Map a flow name to a filename stem, keeping the original name.
|
"""Map a flow name to a filename stem, keeping the original name.
|
||||||
|
|
@ -113,6 +131,7 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
|
||||||
return write_flow_bytes(flow_path(flows_dir, stem), doc)
|
return write_flow_bytes(flow_path(flows_dir, stem), doc)
|
||||||
|
|
||||||
|
|
||||||
def iter_flow_files(flows_dir: str | Path) -> list[Path]:
|
def iter_flow_files(flows_dir: str | Path, *, ignored: set[str] | None = None) -> list[Path]:
|
||||||
"""Sorted list of candidate flow files (top level only)."""
|
"""Sorted list of candidate flow files (top level only, minus ignored)."""
|
||||||
return sorted(Path(flows_dir).glob(FLOWS_GLOB))
|
skip = ignored if ignored is not None else ignored_stems(flows_dir)
|
||||||
|
return sorted(p for p in Path(flows_dir).glob(FLOWS_GLOB) if p.stem not in skip)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
"""Derive a web-form schema from a Langflow export.
|
"""Derive a web-form schema from a Langflow export.
|
||||||
|
|
||||||
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner
|
||||||
``display_name`` starting with ``CustomInput`` — see
|
``display_name`` starting with ``CustomInput``, see
|
||||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
``backend/components/kirche/form_inputs.py``) plus user-built
|
||||||
|
``CustomComponent`` nodes whose display name ends with ``"Form"``
|
||||||
|
(e.g. ``Mitarbeiter Einstellen Form``) — and maps their ``template``
|
||||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
||||||
|
|
||||||
* ``name`` → property key, ``display_name`` → ``title``,
|
* ``name`` → property key (freely renamable in the Langflow code editor;
|
||||||
|
kept verbatim so frontend payload keys match the runtime output —
|
||||||
|
only empty names are skipped and collisions get a ``_2``/``_3`` suffix),
|
||||||
|
``display_name`` → ``title``,
|
||||||
``info`` → ``description``, ``value`` → ``default``,
|
``info`` → ``description``, ``value`` → ``default``,
|
||||||
``options`` → ``enum``, ``required`` → required array
|
``options`` → ``enum``, ``required`` → required array
|
||||||
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
||||||
|
|
@ -13,6 +18,13 @@ Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||||
* ``advanced`` fields render last via ``ui:order``
|
* ``advanced`` fields render last via ``ui:order``
|
||||||
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
||||||
so the parser never crashes on new fields)
|
so the parser never crashes on new fields)
|
||||||
|
* ``[visible_if ...]`` markers in ``info`` → conditional branches
|
||||||
|
(``allOf`` with ``if``/``then`` per dependent field, arbitrarily nested;
|
||||||
|
see ``_extract_visibility``). The marker is stripped from the shown
|
||||||
|
description. Preferred: structured ``visible_if`` key (list of
|
||||||
|
``{field, value}`` or ``{field: value}``) — no quoting mini-language,
|
||||||
|
rename-refactorable, validated at sync time with warnings instead of
|
||||||
|
silent fail-open. Markers stay as deprecated fallback.
|
||||||
|
|
||||||
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
||||||
renders one free-text box instead of a generated form.
|
renders one free-text box instead of a generated form.
|
||||||
|
|
@ -20,9 +32,19 @@ renders one free-text box instead of a generated form.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: ``[visible_if gebaeude=Kirche & bestandteil="Glocke, Orgel"]`` in a
|
||||||
|
#: field's ``info``. Authored in the Langflow code editor; the parser strips
|
||||||
|
#: the marker from the shown description and turns the conditions into
|
||||||
|
#: ``allOf``/``if``/``then`` branches (draft-07 — the ``dependencies``/
|
||||||
|
#: ``if`` keywords this stack's RJSF + ajv build honors; ``dependentSchemas``
|
||||||
|
#: is ignored there, so it is never emitted).
|
||||||
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||||
|
|
||||||
|
|
||||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
def _form_templates(langflow_json: dict) -> list[dict]:
|
||||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
"""Inner node dicts (``data.node``) of all form nodes."""
|
||||||
if not isinstance(langflow_json, dict):
|
if not isinstance(langflow_json, dict):
|
||||||
return []
|
return []
|
||||||
data = langflow_json.get("data") or {}
|
data = langflow_json.get("data") or {}
|
||||||
|
|
@ -37,6 +59,10 @@ def _form_templates(langflow_json: dict) -> list[dict]:
|
||||||
display = str(inner.get("display_name") or "")
|
display = str(inner.get("display_name") or "")
|
||||||
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
||||||
found.append(inner)
|
found.append(inner)
|
||||||
|
elif node_type == "CustomComponent" and display.endswith("Form"):
|
||||||
|
# User-built form components (e.g. "Mitarbeiter Einstellen
|
||||||
|
# Form"): same template contract, no kirche base needed.
|
||||||
|
found.append(inner)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,6 +138,125 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
||||||
return prop, ui
|
return prop, ui
|
||||||
|
|
||||||
|
|
||||||
|
def _split_conditions(text: str) -> list[str]:
|
||||||
|
"""Split ``a=X & b="Y Z"`` on ``&`` outside double quotes."""
|
||||||
|
parts: list[str] = []
|
||||||
|
buf: list[str] = []
|
||||||
|
quoted = False
|
||||||
|
for char in text:
|
||||||
|
if char == '"':
|
||||||
|
quoted = not quoted
|
||||||
|
buf.append(char)
|
||||||
|
elif char == "&" and not quoted:
|
||||||
|
parts.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(char)
|
||||||
|
parts.append("".join(buf))
|
||||||
|
return [part.strip() for part in parts if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_condition(token: str) -> tuple[str, object] | None:
|
||||||
|
"""``feld=Wert`` → ``(feld, wert)`` (bool/number coerced, quotes stripped)."""
|
||||||
|
field, sep, value = token.partition("=")
|
||||||
|
field, value = field.strip(), value.strip()
|
||||||
|
if not sep or not field or not value:
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return field, True
|
||||||
|
if lowered == "false":
|
||||||
|
return field, False
|
||||||
|
try:
|
||||||
|
return field, int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return field, float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return field, value
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_visibility(
|
||||||
|
info: str, field: dict | None = None
|
||||||
|
) -> tuple[str, list[tuple[str, object]]]:
|
||||||
|
"""(marker-free description, conditions) for a field's ``info`` text.
|
||||||
|
|
||||||
|
Structured ``visible_if`` on the field wins (list of {field, value} or
|
||||||
|
dict); legacy ``[visible_if ...]`` markers in ``info`` stay as fallback.
|
||||||
|
"""
|
||||||
|
if field is not None:
|
||||||
|
structured = field.get("visible_if")
|
||||||
|
if structured:
|
||||||
|
conditions: list[tuple[str, object]] = []
|
||||||
|
items = structured.items() if isinstance(structured, dict) else structured
|
||||||
|
for item in items or []:
|
||||||
|
if isinstance(item, dict) and "field" in item:
|
||||||
|
conditions.append((item["field"], item.get("value")))
|
||||||
|
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
||||||
|
conditions.append((item[0], item[1]))
|
||||||
|
if conditions:
|
||||||
|
clean = re.sub(r"\s+", " ", (info or "")).strip()
|
||||||
|
return clean, conditions
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
def _collect(match: re.Match) -> str:
|
||||||
|
for token in _split_conditions(match.group(1)):
|
||||||
|
parsed = _parse_condition(token)
|
||||||
|
if parsed is not None:
|
||||||
|
conditions.append(parsed)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
clean = _VISIBLE_RE.sub(_collect, info or "")
|
||||||
|
clean = re.sub(r"\s+", " ", clean).strip()
|
||||||
|
return clean, conditions
|
||||||
|
|
||||||
|
|
||||||
|
def _match_schema(trigger_field: dict, value: object) -> dict | None:
|
||||||
|
"""JSON-Schema fragment matching one condition, or None (unsupported)."""
|
||||||
|
input_type = str(trigger_field.get("_input_type") or "")
|
||||||
|
if input_type == "MultiselectInput":
|
||||||
|
return {"contains": {"const": value}}
|
||||||
|
if input_type == "BoolInput":
|
||||||
|
return {"const": value} if isinstance(value, bool) else None
|
||||||
|
if input_type in (
|
||||||
|
"StrInput",
|
||||||
|
"MessageTextInput",
|
||||||
|
"MultilineInput",
|
||||||
|
"SecretStrInput",
|
||||||
|
"DropdownInput",
|
||||||
|
):
|
||||||
|
return {"const": value} if isinstance(value, str) else None
|
||||||
|
if input_type in ("IntInput", "FloatInput"):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return {"const": value}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cyclic_fields(edges: dict[str, set[str]]) -> set[str]:
|
||||||
|
"""Fields on a trigger→dependent cycle (fail-open candidates)."""
|
||||||
|
cyclic: set[str] = set()
|
||||||
|
visiting: list[str] = []
|
||||||
|
|
||||||
|
def _visit(node: str) -> None:
|
||||||
|
visiting.append(node)
|
||||||
|
for target in edges.get(node, ()):
|
||||||
|
if target in visiting:
|
||||||
|
cyclic.update(visiting[visiting.index(target) :])
|
||||||
|
elif target not in cyclic:
|
||||||
|
_visit(target)
|
||||||
|
visiting.pop()
|
||||||
|
|
||||||
|
for node in edges:
|
||||||
|
if node not in cyclic:
|
||||||
|
_visit(node)
|
||||||
|
return cyclic
|
||||||
|
|
||||||
|
|
||||||
def extract_form_schema(langflow_json: dict) -> dict:
|
def extract_form_schema(langflow_json: dict) -> dict:
|
||||||
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
||||||
templates = _form_templates(langflow_json)
|
templates = _form_templates(langflow_json)
|
||||||
|
|
@ -122,31 +267,126 @@ def extract_form_schema(langflow_json: dict) -> dict:
|
||||||
"fallback": "textarea",
|
"fallback": "textarea",
|
||||||
}
|
}
|
||||||
template = templates[0].get("template") or {}
|
template = templates[0].get("template") or {}
|
||||||
properties: dict = {}
|
# First pass: sanitize names (existing rules) and parse markers.
|
||||||
required: list[str] = []
|
records: list[dict] = []
|
||||||
ui_schema: dict = {}
|
taken: set[str] = set()
|
||||||
normal: list[str] = []
|
|
||||||
advanced: list[str] = []
|
|
||||||
for key, field in template.items():
|
for key, field in template.items():
|
||||||
if key == "code" or not isinstance(field, dict):
|
if key == "code" or not isinstance(field, dict):
|
||||||
continue
|
continue
|
||||||
if field.get("show") is False:
|
if field.get("show") is False:
|
||||||
continue
|
continue
|
||||||
name = field.get("name") or key
|
raw_name = field.get("name") or key
|
||||||
prop, ui = _field_schema(name, field)
|
name = str(raw_name).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if name in taken:
|
||||||
|
suffix = 2
|
||||||
|
while f"{name}_{suffix}" in taken:
|
||||||
|
suffix += 1
|
||||||
|
name = f"{name}_{suffix}"
|
||||||
|
taken.add(name)
|
||||||
|
clean_info, conditions = _extract_visibility(str(field.get("info") or ""), field)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"field": {**field, "info": clean_info},
|
||||||
|
"required": bool(field.get("required")),
|
||||||
|
"advanced": bool(field.get("advanced")),
|
||||||
|
"conditions": conditions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
by_name = {record["name"]: record for record in records}
|
||||||
|
# Validate conditions: known trigger + supported match. Anything else
|
||||||
|
# fails open (field stays always visible) so a stale marker can never
|
||||||
|
# hide data or break validation.
|
||||||
|
matches: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
for record in records:
|
||||||
|
resolved: list[tuple[str, dict]] = []
|
||||||
|
for trigger, value in record["conditions"]:
|
||||||
|
trigger_record = by_name.get(trigger)
|
||||||
|
match = _match_schema(trigger_record["field"], value) if trigger_record else None
|
||||||
|
if trigger_record is None or match is None or trigger == record["name"]:
|
||||||
|
resolved = []
|
||||||
|
break
|
||||||
|
resolved.append((trigger, match))
|
||||||
|
if resolved:
|
||||||
|
matches[record["name"]] = resolved
|
||||||
|
for name in _cyclic_fields(
|
||||||
|
{name: {trigger for trigger, _ in conds} for name, conds in matches.items()}
|
||||||
|
):
|
||||||
|
del matches[name]
|
||||||
|
# Emit: conditional fields live only in their if/then branch (that is
|
||||||
|
# what hides them in RJSF); everything else stays at root.
|
||||||
|
properties: dict = {}
|
||||||
|
required: list[str] = []
|
||||||
|
ui_schema: dict = {}
|
||||||
|
normal: list[str] = []
|
||||||
|
advanced: list[str] = []
|
||||||
|
for record in records:
|
||||||
|
name = record["name"]
|
||||||
|
prop, ui = _field_schema(name, record["field"])
|
||||||
|
if name not in matches:
|
||||||
properties[name] = prop
|
properties[name] = prop
|
||||||
if field.get("required"):
|
if record["required"]:
|
||||||
required.append(name)
|
required.append(name)
|
||||||
|
else:
|
||||||
|
record["branch_prop"] = prop
|
||||||
if ui:
|
if ui:
|
||||||
ui_schema[name] = ui
|
ui_schema[name] = ui
|
||||||
(advanced if field.get("advanced") else normal).append(name)
|
(advanced if record["advanced"] else normal).append(name)
|
||||||
if advanced:
|
json_schema: dict = {"type": "object", "properties": properties, "required": required}
|
||||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
if matches:
|
||||||
return {
|
rules = []
|
||||||
"json_schema": {
|
for name, conds in matches.items():
|
||||||
"type": "object",
|
record = by_name[name]
|
||||||
"properties": properties,
|
rules.append(
|
||||||
"required": required,
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {trigger: match for trigger, match in conds},
|
||||||
|
"required": [trigger for trigger, _ in conds],
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {name: record["branch_prop"]},
|
||||||
|
**({"required": [name]} if record["required"] else {}),
|
||||||
},
|
},
|
||||||
"ui_schema": ui_schema,
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
json_schema["allOf"] = rules
|
||||||
|
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||||
|
elif advanced:
|
||||||
|
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||||
|
return {"json_schema": json_schema, "ui_schema": ui_schema}
|
||||||
|
|
||||||
|
|
||||||
|
def schema_field_names(cached: dict) -> list[str]:
|
||||||
|
"""All asked field names of a cached form schema (root + branches).
|
||||||
|
|
||||||
|
Used for the run envelope's ``fields`` list so the runtime emits
|
||||||
|
defaults only for asked fields. Order: root properties first, then
|
||||||
|
branch discoveries.
|
||||||
|
"""
|
||||||
|
if not isinstance(cached, dict):
|
||||||
|
return []
|
||||||
|
json_schema = cached.get("json_schema") or {}
|
||||||
|
names: list[str] = []
|
||||||
|
|
||||||
|
def _walk(node: object) -> None:
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return
|
||||||
|
for subschema in (node.get("then"), node.get("else")):
|
||||||
|
if isinstance(subschema, dict):
|
||||||
|
for key in subschema.get("properties") or {}:
|
||||||
|
if key not in names:
|
||||||
|
names.append(key)
|
||||||
|
_walk(subschema)
|
||||||
|
for key in ("allOf", "oneOf", "anyOf"):
|
||||||
|
items = node.get(key) or []
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
_walk(item)
|
||||||
|
|
||||||
|
for key in json_schema.get("properties") or {}:
|
||||||
|
if key not in names:
|
||||||
|
names.append(key)
|
||||||
|
_walk(json_schema)
|
||||||
|
return names
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from .base_client import BaseApiClient
|
||||||
|
|
||||||
|
|
||||||
class LangflowError(Exception):
|
class LangflowError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
@ -21,23 +23,16 @@ class LangflowUnavailable(LangflowError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LangflowClient:
|
class LangflowClient(BaseApiClient):
|
||||||
"""Thin async wrapper around the Langflow `/api/v1/flows` endpoints."""
|
"""Thin async wrapper around the Langflow `/api/v1/flows` endpoints."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
|
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
super().__init__(base_url, timeout=timeout, api_key=api_key)
|
||||||
self.api_key = api_key
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
def _headers(self) -> dict[str, str]:
|
|
||||||
return {"x-api-key": self.api_key} if self.api_key else {}
|
|
||||||
|
|
||||||
async def list_flows(self) -> list[dict]:
|
async def list_flows(self) -> list[dict]:
|
||||||
"""List flows (header mode keeps payloads small)."""
|
"""List flows (header mode keeps payloads small)."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with self._client() as client:
|
||||||
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
|
|
||||||
) as client:
|
|
||||||
response = await client.get("/api/v1/flows/", params={"header_flows": "true"})
|
response = await client.get("/api/v1/flows/", params={"header_flows": "true"})
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
raise LangflowNotFound("flow list not found")
|
raise LangflowNotFound("flow list not found")
|
||||||
|
|
@ -52,9 +47,7 @@ class LangflowClient:
|
||||||
async def export_flow(self, langflow_flow_id: str) -> dict:
|
async def export_flow(self, langflow_flow_id: str) -> dict:
|
||||||
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
|
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with self._client() as client:
|
||||||
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
|
|
||||||
) as client:
|
|
||||||
response = await client.get(f"/api/v1/flows/{langflow_flow_id}")
|
response = await client.get(f"/api/v1/flows/{langflow_flow_id}")
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
raise LangflowNotFound(f"flow {langflow_flow_id} not found")
|
raise LangflowNotFound(f"flow {langflow_flow_id} not found")
|
||||||
|
|
@ -64,3 +57,24 @@ class LangflowClient:
|
||||||
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
|
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
|
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
|
||||||
|
|
||||||
|
async def upsert_flow(self, doc: dict) -> dict:
|
||||||
|
"""Create or update a flow in Langflow by name (authoring UI refresh).
|
||||||
|
|
||||||
|
Used after flow/component tightening so the visual editor shows the
|
||||||
|
latest canonical JSON without a container restart.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._client() as client:
|
||||||
|
existing = await self.list_flows()
|
||||||
|
match = next((f for f in existing if f.get("name") == doc.get("name")), None)
|
||||||
|
if match and match.get("id"):
|
||||||
|
response = await client.patch(f"/api/v1/flows/{match['id']}", json=doc)
|
||||||
|
else:
|
||||||
|
response = await client.post("/api/v1/flows/", json=doc)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from .base_client import BaseApiClient
|
||||||
|
|
||||||
|
|
||||||
class LfxError(Exception):
|
class LfxError(Exception):
|
||||||
"""Base error for LFX communication problems."""
|
"""Base error for LFX communication problems."""
|
||||||
|
|
@ -48,21 +50,11 @@ class LfxRunResult:
|
||||||
component: str
|
component: str
|
||||||
|
|
||||||
|
|
||||||
class LfxClient:
|
class LfxClient(BaseApiClient):
|
||||||
"""Thin async wrapper around the LFX serve HTTP API."""
|
"""Thin async wrapper around the LFX serve HTTP API."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
|
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
super().__init__(base_url, timeout=timeout, api_key=api_key)
|
||||||
self.timeout = timeout
|
|
||||||
self.api_key = api_key
|
|
||||||
|
|
||||||
def _headers(self) -> dict[str, str]:
|
|
||||||
return {"x-api-key": self.api_key} if self.api_key else {}
|
|
||||||
|
|
||||||
def _client(self) -> httpx.AsyncClient:
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def health(self) -> dict:
|
async def health(self) -> dict:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
import httpx
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.models import ApprovalTask, FlowRun, FlowVersion
|
from app.db import get_engine
|
||||||
|
from app.models import FlowRun, FlowVersion
|
||||||
|
|
||||||
|
from . import input_schema as form_schema
|
||||||
from . import store
|
from . import store
|
||||||
from .lfx_client import LfxClient, LfxRunFailed
|
from .lfx_client import LfxClient, LfxNotFound, LfxRunFailed
|
||||||
from .store import InvalidError
|
from .store import InvalidError
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,25 +25,25 @@ def build_envelope(
|
||||||
callback_base_url: str,
|
callback_base_url: str,
|
||||||
continuation_flow_id: UUID | None,
|
continuation_flow_id: UUID | None,
|
||||||
callback_token: str,
|
callback_token: str,
|
||||||
|
fields: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return json.dumps(
|
"""Run envelope for LFX. ``fields`` (asked form-field names, from the
|
||||||
{
|
version's cached input schema) lets form components emit defaults only
|
||||||
|
for asked fields; omitted (None) preserves the legacy shape."""
|
||||||
|
body: dict = {
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
"run_id": str(run_id),
|
"run_id": str(run_id),
|
||||||
"callback_url": callback_base_url.rstrip("/"),
|
"callback_url": callback_base_url.rstrip("/"),
|
||||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||||
"callback_token": callback_token,
|
"callback_token": callback_token,
|
||||||
}
|
}
|
||||||
)
|
if fields is not None:
|
||||||
|
body["fields"] = list(fields)
|
||||||
|
return json.dumps(body)
|
||||||
|
|
||||||
|
|
||||||
def _task_exists(session: Session, run_id: UUID) -> bool:
|
def _task_exists(session: Session, run_id: UUID) -> bool:
|
||||||
return (
|
return store.has_open_task(session, run_id)
|
||||||
session.exec(
|
|
||||||
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
|
|
||||||
).first()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_run(
|
async def execute_run(
|
||||||
|
|
@ -66,12 +70,14 @@ 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)
|
||||||
envelope = build_envelope(
|
envelope = build_envelope(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
callback_base_url=callback_base_url,
|
callback_base_url=callback_base_url,
|
||||||
continuation_flow_id=continuation_flow_id,
|
continuation_flow_id=continuation_flow_id,
|
||||||
callback_token=callback_token,
|
callback_token=callback_token,
|
||||||
|
fields=form_schema.schema_field_names(cached),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await lfx.run_flow(
|
result = await lfx.run_flow(
|
||||||
|
|
@ -89,3 +95,73 @@ async def execute_run(
|
||||||
if _task_exists(session, run.id):
|
if _task_exists(session, run.id):
|
||||||
return store.set_run_result(session, run, "pending_approval", output)
|
return store.set_run_result(session, run, "pending_approval", output)
|
||||||
return store.set_run_result(session, run, "completed", output)
|
return store.set_run_result(session, run, "completed", output)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_run(
|
||||||
|
session: Session,
|
||||||
|
lfx: LfxClient,
|
||||||
|
version: FlowVersion,
|
||||||
|
*,
|
||||||
|
payload: dict,
|
||||||
|
tenant_id: str,
|
||||||
|
session_id: str | None,
|
||||||
|
callback_base_url: str,
|
||||||
|
continuation_flow_id: UUID | None,
|
||||||
|
callback_token: str,
|
||||||
|
) -> tuple[FlowRun, str, str, str]:
|
||||||
|
"""Create a run + envelope for SSE streaming. Returns (run, envelope, lfx_id, sid).
|
||||||
|
|
||||||
|
The actual SSE bytes are produced by the API layer; finalization uses a
|
||||||
|
fresh session (the request session may be closed when the generator ends).
|
||||||
|
"""
|
||||||
|
if not version.lfx_flow_id:
|
||||||
|
raise InvalidError(f"version {version.id} is not deployed to LFX")
|
||||||
|
run = store.create_run(
|
||||||
|
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
|
||||||
|
)
|
||||||
|
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||||
|
envelope = build_envelope(
|
||||||
|
payload=payload,
|
||||||
|
run_id=run.id,
|
||||||
|
callback_base_url=callback_base_url,
|
||||||
|
continuation_flow_id=continuation_flow_id,
|
||||||
|
callback_token=callback_token,
|
||||||
|
fields=form_schema.schema_field_names(cached),
|
||||||
|
)
|
||||||
|
return run, envelope, version.lfx_flow_id, session_id or str(run.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_lfx_chunks(
|
||||||
|
lfx: LfxClient, lfx_flow_id: str, envelope: str, sid: str
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Yield raw SSE chunks from LFX, mapping transport errors to error events."""
|
||||||
|
try:
|
||||||
|
async with (
|
||||||
|
httpx.AsyncClient(
|
||||||
|
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
||||||
|
) as client,
|
||||||
|
client.stream(
|
||||||
|
"POST",
|
||||||
|
f"/flows/{lfx_flow_id}/stream",
|
||||||
|
json={"input_value": envelope, "session_id": sid},
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
|
||||||
|
response.raise_for_status()
|
||||||
|
async for chunk in response.aiter_text():
|
||||||
|
yield chunk
|
||||||
|
except (LfxNotFound, httpx.HTTPError) as exc:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_streamed_run(run_id: UUID) -> str:
|
||||||
|
"""Mark a streamed run completed/pending_approval from a fresh session."""
|
||||||
|
with Session(get_engine()) as fresh:
|
||||||
|
try:
|
||||||
|
fresh_run = store.get_run(fresh, run_id)
|
||||||
|
status = "pending_approval" if store.has_open_task(fresh, run_id) else "completed"
|
||||||
|
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
|
||||||
|
except store.NotFoundError:
|
||||||
|
status = "failed"
|
||||||
|
return status
|
||||||
|
|
|
||||||
|
|
@ -132,12 +132,9 @@ def validate_flow_json(flow_json: dict) -> None:
|
||||||
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
|
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
|
||||||
get_definition(session, flow_id)
|
get_definition(session, flow_id)
|
||||||
validate_flow_json(data.langflow_json)
|
validate_flow_json(data.langflow_json)
|
||||||
max_no = session.exec(
|
|
||||||
select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id)
|
|
||||||
).one()
|
|
||||||
version = FlowVersion(
|
version = FlowVersion(
|
||||||
flow_id=flow_id,
|
flow_id=flow_id,
|
||||||
version_no=(max_no or 0) + 1,
|
version_no=next_version_no(session, flow_id),
|
||||||
langflow_json=data.langflow_json,
|
langflow_json=data.langflow_json,
|
||||||
author=data.author,
|
author=data.author,
|
||||||
# Single choke point: sync, import, and API creation all cache the
|
# Single choke point: sync, import, and API creation all cache the
|
||||||
|
|
@ -229,6 +226,16 @@ def set_run_result(session: Session, run: FlowRun, status: str, output: dict | N
|
||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def has_open_task(session: Session, run_id: UUID) -> bool:
|
||||||
|
"""True when a run has an open approval task (terminal-state mapping)."""
|
||||||
|
return (
|
||||||
|
session.exec(
|
||||||
|
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_run(session: Session, run_id: UUID) -> FlowRun:
|
def get_run(session: Session, run_id: UUID) -> FlowRun:
|
||||||
run = session.get(FlowRun, run_id)
|
run = session.get(FlowRun, run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,14 @@ async def sync_from_directory(
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def format_report(report: SyncReport) -> str:
|
||||||
|
"""One-line summary for startup/watcher logs (keeps main.py thin)."""
|
||||||
|
return (
|
||||||
|
f"+{len(report.added)} ~{len(report.updated)} -{len(report.deleted)} "
|
||||||
|
f"skipped={len(report.skipped)} errors={len(report.errors)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def default_session_factory() -> Callable[[], Session]:
|
def default_session_factory() -> Callable[[], Session]:
|
||||||
return lambda: Session(get_engine())
|
return lambda: Session(get_engine())
|
||||||
|
|
||||||
|
|
@ -194,6 +202,27 @@ def default_lfx_factory() -> Callable[[], LfxClient]:
|
||||||
return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_full_pass(
|
||||||
|
session: Session,
|
||||||
|
lfx: LfxClient,
|
||||||
|
langflow,
|
||||||
|
flows_dir: str | Path,
|
||||||
|
) -> SyncReport:
|
||||||
|
"""Single orchestration: Langflow pull -> files -> DB + LFX.
|
||||||
|
|
||||||
|
Shared by POST /api/flows/sync, CLI, and watcher-adjacent paths so the
|
||||||
|
full-pass logic lives once. Pull errors merge with sync errors.
|
||||||
|
"""
|
||||||
|
from .deploy import pull_all_from_langflow
|
||||||
|
|
||||||
|
flows_dir = Path(flows_dir)
|
||||||
|
pulled_result = await pull_all_from_langflow(langflow, flows_dir)
|
||||||
|
report = await sync_from_directory(session, lfx, flows_dir)
|
||||||
|
report.pulled = pulled_result["pulled"] # type: ignore[typeddict-item]
|
||||||
|
report.errors = {**pulled_result["errors"], **report.errors} # type: ignore[typeddict-item]
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def run_sync_blocking(
|
def run_sync_blocking(
|
||||||
flows_dir: str | Path,
|
flows_dir: str | Path,
|
||||||
*,
|
*,
|
||||||
|
|
@ -211,7 +240,11 @@ def run_sync_blocking(
|
||||||
|
|
||||||
class _JsonFilter(DefaultFilter):
|
class _JsonFilter(DefaultFilter):
|
||||||
def __call__(self, change, path: str) -> bool: # type: ignore[override]
|
def __call__(self, change, path: str) -> bool: # type: ignore[override]
|
||||||
return super().__call__(change, path) and path.endswith(".json")
|
if not (super().__call__(change, path) and path.endswith(".json")):
|
||||||
|
return False
|
||||||
|
# The ignore file itself is tiny; re-read per event so no restart
|
||||||
|
# is needed after editing it.
|
||||||
|
return Path(path).stem not in export.ignored_stems(Path(path).parent)
|
||||||
|
|
||||||
|
|
||||||
async def watch_forever(
|
async def watch_forever(
|
||||||
|
|
@ -246,11 +279,4 @@ async def watch_forever(
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("flow sync failed")
|
logger.exception("flow sync failed")
|
||||||
continue
|
continue
|
||||||
logger.info(
|
logger.info("flow sync: %s", format_report(report))
|
||||||
"flow sync: +%d ~%d -%d skipped=%d errors=%d",
|
|
||||||
len(report.added),
|
|
||||||
len(report.updated),
|
|
||||||
len(report.deleted),
|
|
||||||
len(report.skipped),
|
|
||||||
len(report.errors),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from starlette.middleware.body_limit import RequestBodyLimitMiddleware
|
||||||
|
|
||||||
from .auth.api import access_router, auth_router
|
from .auth.api import access_router, auth_router
|
||||||
from .flows.api import approvals_router, flows_router
|
from .flows.api import approvals_router, flows_router
|
||||||
from .flows.sync import run_sync_blocking, watch_forever
|
from .flows.sync import format_report, run_sync_blocking, watch_forever
|
||||||
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
|
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
|
@ -20,14 +20,7 @@ def _blocking_startup_sync() -> None:
|
||||||
"""One-shot reconciliation at startup (fresh session, never fatal)."""
|
"""One-shot reconciliation at startup (fresh session, never fatal)."""
|
||||||
try:
|
try:
|
||||||
report = run_sync_blocking(Path(settings.flows_dir))
|
report = run_sync_blocking(Path(settings.flows_dir))
|
||||||
logger.info(
|
logger.info("startup flow sync: %s", format_report(report))
|
||||||
"startup flow sync: +%d ~%d -%d skipped=%d errors=%d",
|
|
||||||
len(report.added),
|
|
||||||
len(report.updated),
|
|
||||||
len(report.deleted),
|
|
||||||
len(report.skipped),
|
|
||||||
len(report.errors),
|
|
||||||
)
|
|
||||||
for filename, reason in report.errors.items():
|
for filename, reason in report.errors.items():
|
||||||
logger.warning("startup flow sync: %s: %s", filename, reason)
|
logger.warning("startup flow sync: %s: %s", filename, reason)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
|
|
|
||||||
18
backend/app/models/base.py
Normal file
18
backend/app/models/base.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Shared SQLModel base: UTC timestamps, no copy-paste across models."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime
|
||||||
|
from sqlmodel import Field
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def ts_column() -> Column:
|
||||||
|
return Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
def ts_field(*, default_factory=utcnow) -> datetime: # type: ignore[no-untyped-def]
|
||||||
|
return Field(default_factory=default_factory, sa_column=ts_column())
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
"""Flow engine domain models (SQLModel tables)."""
|
"""Flow engine domain models (SQLModel tables)."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
|
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from .base import ts_column, utcnow
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _ts_column() -> Column:
|
|
||||||
return Column(DateTime(timezone=True))
|
|
||||||
|
|
||||||
|
|
||||||
def _json_column() -> Column:
|
def _json_column() -> Column:
|
||||||
|
|
@ -34,7 +28,7 @@ class FlowDefinition(SQLModel, table=True):
|
||||||
source_path: str | None = Field(default=None, max_length=255)
|
source_path: str | None = Field(default=None, max_length=255)
|
||||||
# Flow to run after a human approval on this flow's runs (nullable).
|
# Flow to run after a human approval on this flow's runs (nullable).
|
||||||
continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
|
continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||||
|
|
||||||
|
|
||||||
class FlowVersion(SQLModel, table=True):
|
class FlowVersion(SQLModel, table=True):
|
||||||
|
|
@ -49,9 +43,9 @@ class FlowVersion(SQLModel, table=True):
|
||||||
langflow_json: dict = Field(default_factory=dict, sa_column=_json_column())
|
langflow_json: dict = Field(default_factory=dict, sa_column=_json_column())
|
||||||
# Flow id under which LFX serves this version (set on deploy).
|
# Flow id under which LFX serves this version (set on deploy).
|
||||||
lfx_flow_id: str | None = Field(default=None, max_length=200)
|
lfx_flow_id: str | None = Field(default=None, max_length=200)
|
||||||
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
deployed_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||||
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; nullable for pre-Phase-3 rows.
|
||||||
input_schema: dict | None = Field(default=None, sa_column=_json_column())
|
input_schema: dict | None = Field(default=None, sa_column=_json_column())
|
||||||
|
|
@ -70,8 +64,8 @@ class FlowRun(SQLModel, table=True):
|
||||||
input: dict | None = Field(default=None, sa_column=_json_column())
|
input: dict | None = Field(default=None, sa_column=_json_column())
|
||||||
output: dict | None = Field(default=None, sa_column=_json_column())
|
output: dict | None = Field(default=None, sa_column=_json_column())
|
||||||
session_id: str | None = Field(default=None, max_length=200)
|
session_id: str | None = Field(default=None, 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())
|
||||||
finished_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
finished_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||||
|
|
||||||
|
|
||||||
class ApprovalTask(SQLModel, table=True):
|
class ApprovalTask(SQLModel, table=True):
|
||||||
|
|
@ -90,5 +84,5 @@ class ApprovalTask(SQLModel, table=True):
|
||||||
# open | approved | rejected
|
# open | approved | rejected
|
||||||
status: str = Field(default="open", index=True, max_length=20)
|
status: str = Field(default="open", index=True, max_length=20)
|
||||||
decision_note: str = Field(default="", max_length=2000)
|
decision_note: str = Field(default="", max_length=2000)
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||||
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
decided_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
"""Access-management domain models (SQLModel tables)."""
|
"""Access-management domain models (SQLModel tables)."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime
|
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from .base import ts_column, utcnow
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _ts_column() -> Column:
|
|
||||||
return Column(DateTime(timezone=True))
|
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
class User(SQLModel, table=True):
|
||||||
|
|
@ -26,7 +19,7 @@ class User(SQLModel, table=True):
|
||||||
# member | admin
|
# member | admin
|
||||||
role: str = Field(default="member", max_length=20)
|
role: str = Field(default="member", max_length=20)
|
||||||
tenant_id: str = Field(default="default", max_length=200)
|
tenant_id: str = Field(default="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())
|
||||||
|
|
||||||
|
|
||||||
class AccessRequest(SQLModel, table=True):
|
class AccessRequest(SQLModel, table=True):
|
||||||
|
|
@ -40,8 +33,8 @@ class AccessRequest(SQLModel, table=True):
|
||||||
reason: str = Field(default="", max_length=2000)
|
reason: str = Field(default="", max_length=2000)
|
||||||
# pending | approved | rejected
|
# pending | approved | rejected
|
||||||
status: str = Field(default="pending", index=True, max_length=20)
|
status: str = Field(default="pending", index=True, max_length=20)
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||||
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
decided_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||||
decided_by: str | None = Field(default=None, max_length=320)
|
decided_by: str | None = Field(default=None, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,11 +46,11 @@ class MagicLinkToken(SQLModel, table=True):
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
user_id: UUID = Field(foreign_key="users.id", index=True)
|
||||||
token_hash: str = Field(index=True, unique=True, max_length=64)
|
token_hash: str = Field(index=True, unique=True, max_length=64)
|
||||||
expires_at: datetime = Field(sa_column=_ts_column())
|
expires_at: datetime = Field(sa_column=ts_column())
|
||||||
# Set before the session row is created, so a reused token can never
|
# Set before the session row is created, so a reused token can never
|
||||||
# mint a second session.
|
# mint a second session.
|
||||||
used_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
used_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||||
|
|
||||||
|
|
||||||
class UserSession(SQLModel, table=True):
|
class UserSession(SQLModel, table=True):
|
||||||
|
|
@ -67,6 +60,6 @@ class UserSession(SQLModel, table=True):
|
||||||
|
|
||||||
id: str = Field(primary_key=True, max_length=128)
|
id: str = Field(primary_key=True, max_length=128)
|
||||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
user_id: UUID = Field(foreign_key="users.id", index=True)
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||||
expires_at: datetime = Field(sa_column=_ts_column())
|
expires_at: datetime = Field(sa_column=ts_column())
|
||||||
revoked_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
revoked_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import computed_field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Repo-root .env (shared with the compose stack). Real environment variables
|
# Repo-root .env (shared with the compose stack). Real environment variables
|
||||||
|
|
@ -53,6 +54,7 @@ class Settings(BaseSettings):
|
||||||
smtp_username: str = ""
|
smtp_username: str = ""
|
||||||
smtp_password: str = ""
|
smtp_password: str = ""
|
||||||
|
|
||||||
|
@computed_field # type: ignore[misc]
|
||||||
@property
|
@property
|
||||||
def admin_email_list(self) -> list[str]:
|
def admin_email_list(self) -> list[str]:
|
||||||
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
|
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ derives a JSON Schema + uiSchema from the node's template
|
||||||
|
|
||||||
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
||||||
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
||||||
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
|
2. Edit the `CustomInputForm` node **in its code editor**: every entry in
|
||||||
one web-form field. Add an `lfx.io` input, e.g.
|
its `inputs` list is one web-form field. Add an `lfx.io` input, e.g.
|
||||||
```python
|
```python
|
||||||
from lfx.io import DropdownInput
|
from lfx.io import DropdownInput
|
||||||
|
|
||||||
DropdownInput(
|
DropdownInput(
|
||||||
name="room", # JSON-Schema property key
|
name="room", # JSON-Schema property key (freely renamable)
|
||||||
display_name="Room", # form label (title)
|
display_name="Room", # form label (title)
|
||||||
info="Where should it take place", # help text (description)
|
info="Where should it take place", # help text (description)
|
||||||
options=["Chapel", "Hall"], # dropdown choices (enum)
|
options=["Chapel", "Hall"], # dropdown choices (enum)
|
||||||
|
|
@ -29,23 +29,165 @@ derives a JSON Schema + uiSchema from the node's template
|
||||||
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
||||||
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
||||||
(real upload later).
|
(real upload later).
|
||||||
3. Add the new field to `collect()` so it reaches the JSON output.
|
3. Nothing else to change in code: `collect()` is dynamic — it emits
|
||||||
|
every input except the plumbing ones, so renames, additions, and
|
||||||
|
removals just work. (The inspector panel can tweak `display_name`,
|
||||||
|
`info`, `value`, and `options` of existing fields, but `name` and new
|
||||||
|
fields need the code editor.)
|
||||||
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
||||||
input, `show=False`). Submitted web-form values arrive there as
|
input, `show=False`). Submitted web-form values arrive there as
|
||||||
`{"payload": {...}}` and are merged over the defaults. Never make form
|
`{"payload": {...}}` and are merged over the defaults — including keys
|
||||||
fields required *with* an incoming edge — that's the envelope's job.
|
the node code does not declare (forward-compatible with renames).
|
||||||
|
Never make form fields required *with* an incoming edge — that's the
|
||||||
|
envelope's job.
|
||||||
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
||||||
The API watcher syncs + deploys it automatically; the form schema is
|
The API watcher syncs + deploys it automatically; the form schema is
|
||||||
regenerated per version.
|
regenerated per version.
|
||||||
|
|
||||||
|
## Branched questionnaires (`visible_if`)
|
||||||
|
|
||||||
|
A field can appear only when other fields have certain values — nested
|
||||||
|
to any depth. Declare it in the code editor with a marker in `info`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
(
|
||||||
|
DropdownInput(
|
||||||
|
name="gebaeude",
|
||||||
|
display_name="Welches Gebäude soll verändert werden?",
|
||||||
|
options=["Kirche", "Anderes Gebäude"],
|
||||||
|
value="Kirche",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
(
|
||||||
|
MultiselectInput(
|
||||||
|
name="bestandteil",
|
||||||
|
display_name="Welcher Bestandteil der Kirche?",
|
||||||
|
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
|
||||||
|
value=[],
|
||||||
|
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
(
|
||||||
|
DropdownInput(
|
||||||
|
name="glockenmassnahme",
|
||||||
|
display_name="Welche Maßnahme an der Glocke?",
|
||||||
|
options=["Reinigung", "Reparatur", "Neuguss"],
|
||||||
|
value="Reinigung",
|
||||||
|
info="[visible_if bestandteil=Glocke]",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Syntax: `[visible_if <feld>=<wert>]`, `&` for AND
|
||||||
|
(`[visible_if a=X & b=Y]`), double quotes for values with spaces
|
||||||
|
(`[visible_if gebaeude="Anderes Gebäude"]`). Trigger matching by input
|
||||||
|
type: Dropdown/Text `const`, Bool `const` (`true`/`false`), Multiselect
|
||||||
|
`contains`, Int/Float exact number. Nesting is automatic: a field whose
|
||||||
|
trigger is itself conditional renders one level deeper, without limit.
|
||||||
|
|
||||||
|
What the parser does with markers:
|
||||||
|
|
||||||
|
- The marker is stripped from the shown help text; the field leaves the
|
||||||
|
top-level schema and lives only in its `allOf`/`if`/`then` branch, so
|
||||||
|
RJSF hides it until the condition holds. `ui:order` keeps template
|
||||||
|
order, so new questions appear directly below their trigger.
|
||||||
|
- `required` on a branched field applies only while visible.
|
||||||
|
- Values are always kept: hidden fields still validate and are submitted
|
||||||
|
(stale values can keep deeper branches satisfied until changed).
|
||||||
|
- Safety is fail-open: unknown triggers (renamed?), unsupported trigger
|
||||||
|
types, self-references, and trigger cycles keep the field always
|
||||||
|
visible instead of hiding data. Renaming a trigger means updating its
|
||||||
|
markers.
|
||||||
|
|
||||||
|
See `backend/flows/bauvorhaben.json` for a full 4-level example
|
||||||
|
(Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and
|
||||||
|
bool trigger).
|
||||||
|
|
||||||
|
## User-built form components (no kirche base needed)
|
||||||
|
|
||||||
|
A plain `CustomComponent` whose display name ends with `"Form"` is also
|
||||||
|
parsed into a web form — same template contract, no base class. To make
|
||||||
|
submitted values reach it at runtime, add an envelope input and merge it
|
||||||
|
in the output method (pattern):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lfx.io import MessageTextInput # add to imports
|
||||||
|
import json
|
||||||
|
|
||||||
|
# add to inputs:
|
||||||
|
MessageTextInput(name="form_values", display_name="Form values", value="", show=False)
|
||||||
|
|
||||||
|
# at the top of the output method:
|
||||||
|
try:
|
||||||
|
raw = self.form_values.text if hasattr(self.form_values, "text") else self.form_values
|
||||||
|
submitted = json.loads(raw or "{}")
|
||||||
|
except Exception:
|
||||||
|
submitted = {}
|
||||||
|
if isinstance(submitted.get("payload"), dict):
|
||||||
|
submitted = submitted["payload"]
|
||||||
|
|
||||||
|
|
||||||
|
def val(name, default):
|
||||||
|
return submitted.get(name, default)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use `val("rolle", self.rolle)` etc., and wire a `ChatInput` node
|
||||||
|
into `form_values` on the canvas. Branch markers (`[visible_if ...]`
|
||||||
|
in `info`) work here too — the parser reads the template only.
|
||||||
|
|
||||||
|
## Asked fields only (run envelope `fields`)
|
||||||
|
|
||||||
|
The API sends the asked field names (from the version's form schema)
|
||||||
|
in the run envelope's `fields` list. `collect()` then emits defaults
|
||||||
|
only for asked AND currently visible fields (branch conditions are
|
||||||
|
evaluated against submitted values over code defaults, so hidden
|
||||||
|
fields stay out exactly as the frontend never submits them): the
|
||||||
|
template may declare fields unknown to this code (they arrive via
|
||||||
|
envelope and merge normally), and unasked code defaults stay out of
|
||||||
|
the output. Without the list — raw dicts, Langflow playground — all
|
||||||
|
visible declared defaults apply as before.
|
||||||
|
|
||||||
|
## Variant flows (own component per flow)
|
||||||
|
|
||||||
|
A flow whose fields differ from `CustomInputForm` gets its own
|
||||||
|
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
|
||||||
|
in the palette): same class pattern, own `inputs` list. Standalone
|
||||||
|
means it: no imports between form modules — the LFX runtime executes
|
||||||
|
the embedded module source without our `kirche` package, so every form
|
||||||
|
component must be self-contained (stdlib + `lfx` only). Copy the file
|
||||||
|
as the starting point for future forms.
|
||||||
|
|
||||||
|
Rule: code-declared inputs must equal template fields
|
||||||
|
(`test_embedded_code_matches_template_fields` enforces it; required
|
||||||
|
Multiselect uses the `[" "]` sentinel). Generate node + flow with
|
||||||
|
`kirche_node(..., component_cls, code_override)` and
|
||||||
|
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
|
||||||
|
rather than hand-editing JSON. Restart Langflow afterwards so the
|
||||||
|
palette picks up the new component, then build/upload the flow.
|
||||||
|
|
||||||
|
## Output type: Data (JSON), not Message
|
||||||
|
|
||||||
|
`collect()` returns `Data(data={...})` — a real JSON object. Consequences:
|
||||||
|
|
||||||
|
- A terminal `ChatOutput` renders it as a fenced ```json block; the run
|
||||||
|
result text is that block, not raw JSON (unwrap before `json.loads`).
|
||||||
|
- Components consuming `form_data` should read `.data` (or `get_text()`),
|
||||||
|
not `.text` like with `Message`.
|
||||||
|
|
||||||
## Conventions the pipeline relies on
|
## Conventions the pipeline relies on
|
||||||
|
|
||||||
- `required=True` on a template field: `lfx validate --level 4` demands a
|
- `required=True` on a template field: `lfx validate --level 4` demands a
|
||||||
value or an edge, so the canonical-flow generator
|
value or an edge, so the canonical-flow generator
|
||||||
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
||||||
sentinel value for such fields. The form parser treats whitespace-only
|
sentinel value for such fields (`[" "]` for Multiselect, which rejects
|
||||||
|
a plain string at runtime). The form parser treats whitespace-only
|
||||||
values as "no default" — don't ship real whitespace defaults.
|
values as "no default" — don't ship real whitespace defaults.
|
||||||
- Field key = `name` (stable); label = `display_name`; help = `info`.
|
- Field key = `name` (stable, freely renamable but must stay unique and
|
||||||
|
non-empty; collisions get a `_2`/`_3` suffix in the form schema);
|
||||||
|
label = `display_name`; help = `info`. Renames propagate to downstream
|
||||||
|
components, which read the JSON keys as-is.
|
||||||
- Unknown future input types degrade to a plain text field in the web
|
- Unknown future input types degrade to a plain text field in the web
|
||||||
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
||||||
|
|
||||||
|
|
@ -56,3 +198,9 @@ canonical + test-fixture exports:
|
||||||
|
|
||||||
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
||||||
docker compose exec -T api uv run pytest tests/test_lfx_validate.py
|
docker compose exec -T api uv run pytest tests/test_lfx_validate.py
|
||||||
|
|
||||||
|
Notes: the generator writes UTF-8/LF explicitly (Windows-safe), and
|
||||||
|
`backend/flows/*.json` may carry Langflow round-trip state (key order)
|
||||||
|
the generator does not reproduce — if a regen rewrites canonical files
|
||||||
|
beyond the component's own node, revert them and splice only the changed
|
||||||
|
node fields (code, description, output types, edge handles) instead.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ template into a web form (``app/flows/input_schema.py``); the frontend
|
||||||
renders that schema with RJSF.
|
renders that schema with RJSF.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .bauvorhaben_form import CustomInputBauvorhaben
|
||||||
from .form_inputs import CustomInputForm
|
from .form_inputs import CustomInputForm
|
||||||
|
from .mitarbeiter_form import CustomInputMitarbeiter
|
||||||
|
|
||||||
__all__ = ["CustomInputForm"]
|
__all__ = ["CustomInputBauvorhaben", "CustomInputForm", "CustomInputMitarbeiter"]
|
||||||
|
|
|
||||||
147
backend/components/kirche/_form_core.py
Normal file
147
backend/components/kirche/_form_core.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""Shared form runtime core (single source, codegen-inlined for LFX).
|
||||||
|
|
||||||
|
The LFX runtime executes embedded module source without our ``kirche``
|
||||||
|
package, so flow JSON must stay self-contained. Author components against
|
||||||
|
this module (``from kirche._form_core import ...`` or ``from ._form_core``
|
||||||
|
); ``scripts/make_flow_fixtures.py:kirche_node()`` inlines this file's text
|
||||||
|
into the exported ``code`` so the runtime keeps working with one truth in
|
||||||
|
the repo.
|
||||||
|
|
||||||
|
Supports both the legacy ``[visible_if feld=Wert]`` marker in ``info``
|
||||||
|
and the structured ``visible_if`` key (list of {field, value} or dict):
|
||||||
|
structured wins when present, markers stay as deprecated fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||||
|
|
||||||
|
#: Reserved input carrying the run envelope — never a form field name.
|
||||||
|
PLUMBING_INPUTS = frozenset({"form_values", "code"})
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if hasattr(value, "text"):
|
||||||
|
return value.text or ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
"""Plain JSON value: unwrap LFX wrappers, keep scalars."""
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_safe(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||||
|
text = getattr(value, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
data = getattr(value, "data", None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return _json_safe(data)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_conditions(text: str) -> list:
|
||||||
|
parts: list = []
|
||||||
|
buf: list = []
|
||||||
|
quoted = False
|
||||||
|
for char in text:
|
||||||
|
if char == '"':
|
||||||
|
quoted = not quoted
|
||||||
|
buf.append(char)
|
||||||
|
elif char == "&" and not quoted:
|
||||||
|
parts.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(char)
|
||||||
|
parts.append("".join(buf))
|
||||||
|
return [part.strip() for part in parts if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_condition(token: str):
|
||||||
|
field, sep, value = token.partition("=")
|
||||||
|
field, value = field.strip(), value.strip()
|
||||||
|
if not sep or not field or not value:
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return field, True
|
||||||
|
if lowered == "false":
|
||||||
|
return field, False
|
||||||
|
try:
|
||||||
|
return field, int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return field, float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return field, value
|
||||||
|
|
||||||
|
|
||||||
|
def _field_conditions(info: object, structured=None) -> list:
|
||||||
|
"""[(trigger, value)] from structured key or info markers.
|
||||||
|
|
||||||
|
Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``
|
||||||
|
dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as
|
||||||
|
deprecated fallback.
|
||||||
|
"""
|
||||||
|
if structured:
|
||||||
|
conditions: list = []
|
||||||
|
items = structured.items() if isinstance(structured, dict) else structured
|
||||||
|
for item in items or []:
|
||||||
|
if isinstance(item, dict) and "field" in item:
|
||||||
|
conditions.append((item["field"], item.get("value")))
|
||||||
|
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
||||||
|
conditions.append((item[0], item[1]))
|
||||||
|
if conditions:
|
||||||
|
return conditions
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
def _collect(match) -> str:
|
||||||
|
for token in _split_conditions(match.group(1)):
|
||||||
|
parsed = _parse_condition(token)
|
||||||
|
if parsed is not None:
|
||||||
|
conditions.append(parsed)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||||
|
return conditions
|
||||||
|
|
||||||
|
|
||||||
|
def collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:
|
||||||
|
"""Shared collect(): effective values, visibility scope, submitted wins."""
|
||||||
|
effective = dict(field_values)
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
effective[key] = _json_safe(val)
|
||||||
|
scope = [n for n in (asked if asked is not None else field_names) if n in field_names]
|
||||||
|
return effective, scope
|
||||||
|
|
||||||
|
|
||||||
|
def parse_envelope(text_value) -> tuple[dict, list | None]:
|
||||||
|
"""(payload, asked-fields) from the run envelope text."""
|
||||||
|
try:
|
||||||
|
envelope = json.loads(_text(text_value) or "{}")
|
||||||
|
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||||
|
return {}, None
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
return {}, None
|
||||||
|
if isinstance(envelope.get("payload"), dict):
|
||||||
|
fields = envelope.get("fields")
|
||||||
|
asked = (
|
||||||
|
[i for i in fields if i]
|
||||||
|
if isinstance(fields, list) and all(isinstance(i, str) for i in fields)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return envelope["payload"], asked
|
||||||
|
return envelope, None
|
||||||
299
backend/components/kirche/bauvorhaben_form.py
Normal file
299
backend/components/kirche/bauvorhaben_form.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
"""Bauvorhaben form component for kirche-einfach flows.
|
||||||
|
|
||||||
|
Standalone by design: this module shares NO code with ``form_inputs``
|
||||||
|
(no imports from it). The LFX runtime executes the embedded module
|
||||||
|
source without our ``kirche`` package, so every form component must be
|
||||||
|
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
|
||||||
|
machinery below into a shared import — it would break flow execution.
|
||||||
|
Copy this file as the starting point for future forms.
|
||||||
|
|
||||||
|
A flow that starts with :class:`CustomInputBauvorhaben` declares the
|
||||||
|
fields of its web form. The API reads the exported node template
|
||||||
|
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``
|
||||||
|
from the ``lfx.io`` input definitions below — one JSON-Schema property
|
||||||
|
per field, keyed by ``name``:
|
||||||
|
|
||||||
|
* ``display_name`` → ``title``, ``info`` → ``description``
|
||||||
|
(``[visible_if ...]`` markers declare conditional branches, see below),
|
||||||
|
``value`` → ``default``, ``options`` → ``enum``
|
||||||
|
* ``required`` → required array (branch-required applies only while visible)
|
||||||
|
|
||||||
|
``collect()`` is fully dynamic: it emits every input except the plumbing
|
||||||
|
one (``form_values``), so fields can be renamed, added, or removed in
|
||||||
|
the Langflow code editor without touching this method.
|
||||||
|
|
||||||
|
When the run envelope names the asked fields (``fields`` list, sent by
|
||||||
|
the API from the version's form schema), defaults are emitted only for
|
||||||
|
asked AND currently visible fields. Without the list (raw dict,
|
||||||
|
Langflow playground), all visible declared defaults apply.
|
||||||
|
|
||||||
|
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from lfx.custom import Component
|
||||||
|
from lfx.io import (
|
||||||
|
BoolInput,
|
||||||
|
DropdownInput,
|
||||||
|
IntInput,
|
||||||
|
MessageTextInput,
|
||||||
|
MultiselectInput,
|
||||||
|
Output,
|
||||||
|
StrInput,
|
||||||
|
)
|
||||||
|
from lfx.schema.data import Data
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if hasattr(value, "text"):
|
||||||
|
return value.text or ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_safe(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||||
|
text = getattr(value, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
data = getattr(value, "data", None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return _json_safe(data)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_conditions(text: str) -> list:
|
||||||
|
parts: list = []
|
||||||
|
buf: list = []
|
||||||
|
quoted = False
|
||||||
|
for char in text:
|
||||||
|
if char == '"':
|
||||||
|
quoted = not quoted
|
||||||
|
buf.append(char)
|
||||||
|
elif char == "&" and not quoted:
|
||||||
|
parts.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(char)
|
||||||
|
parts.append("".join(buf))
|
||||||
|
return [part.strip() for part in parts if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_condition(token: str):
|
||||||
|
field, sep, value = token.partition("=")
|
||||||
|
field, value = field.strip(), value.strip()
|
||||||
|
if not sep or not field or not value:
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return field, True
|
||||||
|
if lowered == "false":
|
||||||
|
return field, False
|
||||||
|
try:
|
||||||
|
return field, int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return field, float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return field, value
|
||||||
|
|
||||||
|
|
||||||
|
def _field_conditions(info: object) -> list:
|
||||||
|
"""``[(trigger, value)]`` from a field's ``info`` markers."""
|
||||||
|
conditions: list = []
|
||||||
|
|
||||||
|
def _collect(match) -> str:
|
||||||
|
for token in _split_conditions(match.group(1)):
|
||||||
|
parsed = _parse_condition(token)
|
||||||
|
if parsed is not None:
|
||||||
|
conditions.append(parsed)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||||
|
return conditions
|
||||||
|
|
||||||
|
|
||||||
|
#: Reserved input carrying the run envelope — a real runtime input, so it
|
||||||
|
#: must stay out of the output. Never name a form field ``form_values``.
|
||||||
|
PLUMBING_INPUTS = frozenset({"form_values"})
|
||||||
|
|
||||||
|
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
|
||||||
|
#: ``app/flows/input_schema.py`` — keep the two in sync).
|
||||||
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomInputBauvorhaben(Component):
|
||||||
|
display_name = "Bauvorhaben Form"
|
||||||
|
description = "Collects Bauvorhaben form values into a single JSON object."
|
||||||
|
icon = "Church"
|
||||||
|
|
||||||
|
inputs: ClassVar[list] = [
|
||||||
|
# Plumbing, not a form field: the API posts submitted web-form
|
||||||
|
# values as the run envelope (same {"payload": {...}} shape the
|
||||||
|
# other kirche components use); they arrive here through a ChatInput
|
||||||
|
# entry node and override the defaults below. Hidden in the GUI and
|
||||||
|
# skipped by the form parser (show=False).
|
||||||
|
MessageTextInput(
|
||||||
|
name="form_values",
|
||||||
|
display_name="Form values (JSON envelope)",
|
||||||
|
info="Submitted values as JSON; merged over the defaults",
|
||||||
|
value="",
|
||||||
|
show=False,
|
||||||
|
),
|
||||||
|
DropdownInput(
|
||||||
|
name="gebaeude",
|
||||||
|
display_name="Welches Gebäude soll verändert werden?",
|
||||||
|
info="",
|
||||||
|
options=["Kirche", "Anderes Gebäude"],
|
||||||
|
value="",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
MultiselectInput(
|
||||||
|
name="bestandteil",
|
||||||
|
display_name="Welcher Bestandteil der Kirche?",
|
||||||
|
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
|
||||||
|
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
|
||||||
|
value=[],
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
DropdownInput(
|
||||||
|
name="glockenmassnahme",
|
||||||
|
display_name="Welche Maßnahme an der Glocke?",
|
||||||
|
info="[visible_if bestandteil=Glocke]",
|
||||||
|
options=["Reinigung", "Reparatur", "Neuguss"],
|
||||||
|
value="Reinigung",
|
||||||
|
),
|
||||||
|
IntInput(
|
||||||
|
name="gussjahr",
|
||||||
|
display_name="Gussjahr (nur bei Neuguss)?",
|
||||||
|
info="[visible_if glockenmassnahme=Neuguss]",
|
||||||
|
value=0,
|
||||||
|
),
|
||||||
|
DropdownInput(
|
||||||
|
name="gebaeudeart",
|
||||||
|
display_name="Art des anderen Gebäudes?",
|
||||||
|
info='[visible_if gebaeude="Anderes Gebäude"]',
|
||||||
|
options=["Wohnhaus", "Scheune", "Sonstiges"],
|
||||||
|
value="Wohnhaus",
|
||||||
|
),
|
||||||
|
BoolInput(
|
||||||
|
name="denkmalschutz",
|
||||||
|
display_name="Steht das Gebäude unter Denkmalschutz?",
|
||||||
|
info="",
|
||||||
|
value=False,
|
||||||
|
),
|
||||||
|
StrInput(
|
||||||
|
name="denkmalnummer",
|
||||||
|
display_name="Aktenzeichen Denkmalschutz?",
|
||||||
|
info="[visible_if denkmalschutz=true]",
|
||||||
|
value="",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
outputs: ClassVar[list] = [
|
||||||
|
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _field_names(self) -> list:
|
||||||
|
"""Names of all form fields (every input except the plumbing ones)."""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
if isinstance(inputs, dict) and inputs:
|
||||||
|
return [name for name in inputs if name not in PLUMBING_INPUTS]
|
||||||
|
seen = []
|
||||||
|
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
|
||||||
|
for inp in declared:
|
||||||
|
name = getattr(inp, "name", None)
|
||||||
|
if name and name not in PLUMBING_INPUTS and name not in seen:
|
||||||
|
seen.append(name)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def _field_value(self, name: str):
|
||||||
|
"""Runtime value of one input.
|
||||||
|
|
||||||
|
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
|
||||||
|
otherwise resolve to the component attribute instead of its value.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
return getattr(inputs[name], "value", None)
|
||||||
|
try:
|
||||||
|
return getattr(self, name, None)
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_visible(self, name: str, effective: dict) -> bool:
|
||||||
|
"""Whether a field's branch conditions hold.
|
||||||
|
|
||||||
|
Single pass over effective values (submitted first, code defaults
|
||||||
|
after): no recursion, so trigger cycles cannot hang — a stale or
|
||||||
|
cyclic marker simply matches or not. Unknown triggers never match.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
info = ""
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
info = getattr(inputs[name], "info", "") or ""
|
||||||
|
for trigger, expected in _field_conditions(info):
|
||||||
|
if trigger == name or trigger not in effective:
|
||||||
|
# Self-reference or unknown trigger: fail open (visible),
|
||||||
|
# mirroring the backend parser.
|
||||||
|
continue
|
||||||
|
actual = effective[trigger]
|
||||||
|
if isinstance(actual, list):
|
||||||
|
if expected not in actual:
|
||||||
|
return False
|
||||||
|
elif actual != expected:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def collect(self) -> Data:
|
||||||
|
try:
|
||||||
|
envelope = json.loads(_text(self.form_values) or "{}")
|
||||||
|
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||||
|
envelope = {}
|
||||||
|
payload: dict = {}
|
||||||
|
asked: list | None = None
|
||||||
|
if isinstance(envelope, dict):
|
||||||
|
# Asked-field list counts only in true envelope shape
|
||||||
|
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
|
||||||
|
# field literally named "fields" must not confuse it.
|
||||||
|
if isinstance(envelope.get("payload"), dict):
|
||||||
|
payload = envelope["payload"]
|
||||||
|
fields = envelope.get("fields")
|
||||||
|
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
|
||||||
|
asked = [item for item in fields if item]
|
||||||
|
else:
|
||||||
|
payload = envelope
|
||||||
|
# Effective trigger values: submitted first, code defaults after.
|
||||||
|
declared = self._field_names()
|
||||||
|
effective = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
effective[key] = _json_safe(val)
|
||||||
|
# Defaults only for asked AND visible fields (hidden branch fields
|
||||||
|
# stay out, like the frontend never submits them); without an asked
|
||||||
|
# list, all visible declared fields (Langflow playground).
|
||||||
|
scope = [name for name in (asked if asked is not None else declared) if name in declared]
|
||||||
|
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
|
||||||
|
# Submitted values win over the field defaults. Unknown keys are
|
||||||
|
# kept: the frontend may already know renamed fields this code
|
||||||
|
# revision does not declare.
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
data[key] = _json_safe(val)
|
||||||
|
return Data(data=data)
|
||||||
|
|
@ -11,6 +11,23 @@ keyed by ``name``. Field metadata maps as follows:
|
||||||
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
|
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
|
||||||
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
|
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
|
||||||
|
|
||||||
|
``collect()`` is fully dynamic: it emits every input except the plumbing
|
||||||
|
ones (``form_values``, ``code``), so fields can be renamed, added, or
|
||||||
|
removed in the Langflow code editor without touching this method.
|
||||||
|
``name`` is the stable JSON key (freely renamable, must stay unique and
|
||||||
|
non-empty); downstream components read the keys as-is.
|
||||||
|
|
||||||
|
When the run envelope names the asked fields (``fields`` list, sent by
|
||||||
|
the API from the version's form schema), defaults are emitted only for
|
||||||
|
asked fields — the template may declare fields unknown to this code and
|
||||||
|
unasked code defaults stay out of the output. Without the list (raw dict,
|
||||||
|
Langflow playground), all declared defaults apply.
|
||||||
|
|
||||||
|
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the
|
||||||
|
values travel as a real JSON object. A terminal ``ChatOutput`` renders it
|
||||||
|
as a fenced ```json block; components consuming it should read ``.data``
|
||||||
|
(or ``get_text()``), not ``.text``.
|
||||||
|
|
||||||
``FileInput`` is a stub for now: it carries a string reference only, real
|
``FileInput`` is a stub for now: it carries a string reference only, real
|
||||||
upload comes later.
|
upload comes later.
|
||||||
"""
|
"""
|
||||||
|
|
@ -18,6 +35,7 @@ upload comes later.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from lfx.custom import Component
|
from lfx.custom import Component
|
||||||
|
|
@ -36,7 +54,7 @@ from lfx.io import (
|
||||||
SliderInput,
|
SliderInput,
|
||||||
StrInput,
|
StrInput,
|
||||||
)
|
)
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.data import Data
|
||||||
|
|
||||||
|
|
||||||
def _text(value) -> str:
|
def _text(value) -> str:
|
||||||
|
|
@ -47,9 +65,93 @@ def _text(value) -> str:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_safe(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||||
|
text = getattr(value, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
data = getattr(value, "data", None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return _json_safe(data)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_conditions(text: str) -> list:
|
||||||
|
parts: list = []
|
||||||
|
buf: list = []
|
||||||
|
quoted = False
|
||||||
|
for char in text:
|
||||||
|
if char == '"':
|
||||||
|
quoted = not quoted
|
||||||
|
buf.append(char)
|
||||||
|
elif char == "&" and not quoted:
|
||||||
|
parts.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(char)
|
||||||
|
parts.append("".join(buf))
|
||||||
|
return [part.strip() for part in parts if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_condition(token: str):
|
||||||
|
field, sep, value = token.partition("=")
|
||||||
|
field, value = field.strip(), value.strip()
|
||||||
|
if not sep or not field or not value:
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return field, True
|
||||||
|
if lowered == "false":
|
||||||
|
return field, False
|
||||||
|
try:
|
||||||
|
return field, int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return field, float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return field, value
|
||||||
|
|
||||||
|
|
||||||
|
def _field_conditions(info: object) -> list:
|
||||||
|
"""``[(trigger, value)]`` from a field's ``info`` markers."""
|
||||||
|
conditions: list = []
|
||||||
|
|
||||||
|
def _collect(match) -> str:
|
||||||
|
for token in _split_conditions(match.group(1)):
|
||||||
|
parsed = _parse_condition(token)
|
||||||
|
if parsed is not None:
|
||||||
|
conditions.append(parsed)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||||
|
return conditions
|
||||||
|
|
||||||
|
|
||||||
|
#: Reserved input carrying the run envelope — a real runtime input, so it
|
||||||
|
#: must stay out of the output (otherwise the whole envelope JSON would
|
||||||
|
#: leak in as a field value on every path without an asked-fields list).
|
||||||
|
#: Never name a form field ``form_values``.
|
||||||
|
PLUMBING_INPUTS = frozenset({"form_values"})
|
||||||
|
|
||||||
|
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
|
||||||
|
#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
|
||||||
|
#: stay import-light for the LFX runtime and cannot import it).
|
||||||
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||||
|
|
||||||
|
|
||||||
class CustomInputForm(Component):
|
class CustomInputForm(Component):
|
||||||
display_name = "CustomInput Form"
|
display_name = "CustomInput Form"
|
||||||
description = "Collects structured form values into a single JSON message."
|
description = "Collects structured form values into a single JSON object."
|
||||||
icon = "ClipboardList"
|
icon = "ClipboardList"
|
||||||
|
|
||||||
inputs: ClassVar[list] = [
|
inputs: ClassVar[list] = [
|
||||||
|
|
@ -134,29 +236,90 @@ class CustomInputForm(Component):
|
||||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def collect(self) -> Message:
|
def _field_names(self) -> list:
|
||||||
data = {
|
"""Names of all form fields (every input except the plumbing ones)."""
|
||||||
"full_name": _text(self.full_name),
|
inputs = getattr(self, "_inputs", None)
|
||||||
"notes": _text(self.notes),
|
if isinstance(inputs, dict) and inputs:
|
||||||
"password": _text(self.password),
|
return [name for name in inputs if name not in PLUMBING_INPUTS]
|
||||||
"age": self.age,
|
seen = []
|
||||||
"amount": self.amount,
|
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
|
||||||
"newsletter": self.newsletter,
|
for inp in declared:
|
||||||
"department": _text(self.department),
|
name = getattr(inp, "name", None)
|
||||||
"topics": self.topics,
|
if name and name not in PLUMBING_INPUTS and name not in seen:
|
||||||
"satisfaction": self.satisfaction,
|
seen.append(name)
|
||||||
"attachment": _text(self.attachment),
|
return seen
|
||||||
}
|
|
||||||
# Submitted values (via form_values) win over the field defaults.
|
def _field_value(self, name: str):
|
||||||
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
"""Runtime value of one input.
|
||||||
|
|
||||||
|
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
|
||||||
|
otherwise resolve to the component attribute instead of its value.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
return getattr(inputs[name], "value", None)
|
||||||
try:
|
try:
|
||||||
override = json.loads(_text(self.form_values) or "{}")
|
return getattr(self, name, None)
|
||||||
except json.JSONDecodeError:
|
except (AttributeError, ValueError):
|
||||||
override = {}
|
return None
|
||||||
if isinstance(override, dict):
|
|
||||||
if isinstance(override.get("payload"), dict):
|
def _is_visible(self, name: str, effective: dict) -> bool:
|
||||||
override = override["payload"]
|
"""Whether a field's branch conditions hold.
|
||||||
for key in data:
|
|
||||||
if key in override:
|
Single pass over effective values (submitted first, code defaults
|
||||||
data[key] = override[key]
|
after): no recursion, so trigger cycles cannot hang — a stale or
|
||||||
return Message(text=json.dumps(data, ensure_ascii=False))
|
cyclic marker simply matches or not. Unknown triggers never match.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
info = ""
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
info = getattr(inputs[name], "info", "") or ""
|
||||||
|
for trigger, expected in _field_conditions(info):
|
||||||
|
if trigger == name or trigger not in effective:
|
||||||
|
# Self-reference or unknown trigger: fail open (visible),
|
||||||
|
# mirroring the backend parser.
|
||||||
|
continue
|
||||||
|
actual = effective[trigger]
|
||||||
|
if isinstance(actual, list):
|
||||||
|
if expected not in actual:
|
||||||
|
return False
|
||||||
|
elif actual != expected:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def collect(self) -> Data:
|
||||||
|
try:
|
||||||
|
envelope = json.loads(_text(self.form_values) or "{}")
|
||||||
|
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||||
|
envelope = {}
|
||||||
|
payload: dict = {}
|
||||||
|
asked: list | None = None
|
||||||
|
if isinstance(envelope, dict):
|
||||||
|
# Asked-field list counts only in true envelope shape
|
||||||
|
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
|
||||||
|
# field literally named "fields" must not confuse it.
|
||||||
|
if isinstance(envelope.get("payload"), dict):
|
||||||
|
payload = envelope["payload"]
|
||||||
|
fields = envelope.get("fields")
|
||||||
|
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
|
||||||
|
asked = [item for item in fields if item]
|
||||||
|
else:
|
||||||
|
payload = envelope
|
||||||
|
# Effective trigger values: submitted first, code defaults after.
|
||||||
|
declared = self._field_names()
|
||||||
|
effective = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
effective[key] = _json_safe(val)
|
||||||
|
# Defaults only for asked AND visible fields (hidden branch fields
|
||||||
|
# stay out, like the frontend never submits them); without an asked
|
||||||
|
# list, all visible declared fields (Langflow playground).
|
||||||
|
scope = [name for name in (asked if asked is not None else declared) if name in declared]
|
||||||
|
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
|
||||||
|
# Submitted values win over the field defaults. Unknown keys are
|
||||||
|
# kept: the frontend may already know renamed fields this code
|
||||||
|
# revision does not declare.
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
data[key] = _json_safe(val)
|
||||||
|
return Data(data=data)
|
||||||
|
|
|
||||||
276
backend/components/kirche/mitarbeiter_form.py
Normal file
276
backend/components/kirche/mitarbeiter_form.py
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
|
||||||
|
|
||||||
|
Standalone by design: this module shares NO code with the other form
|
||||||
|
modules (no imports from them). The LFX runtime executes the embedded
|
||||||
|
module source without our ``kirche`` package, so every form component
|
||||||
|
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
|
||||||
|
duplicated machinery below into a shared import — it would break flow
|
||||||
|
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
|
||||||
|
point for future forms.
|
||||||
|
|
||||||
|
A flow that starts with :class:`CustomInputMitarbeiter` declares the
|
||||||
|
fields of its web form. The API reads the exported node template
|
||||||
|
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``
|
||||||
|
from the ``lfx.io`` input definitions below — one JSON-Schema property
|
||||||
|
per field, keyed by ``name``:
|
||||||
|
|
||||||
|
* ``display_name`` → ``title``, ``info`` → ``description``
|
||||||
|
(``[visible_if ...]`` markers declare conditional branches),
|
||||||
|
``value`` → ``default``, ``options`` → ``enum``
|
||||||
|
* ``required`` → required array (branch-required applies only while visible)
|
||||||
|
|
||||||
|
``collect()`` is fully dynamic: it emits every input except the plumbing
|
||||||
|
one (``form_values``), so fields can be renamed, added, or removed in
|
||||||
|
the Langflow code editor without touching this method.
|
||||||
|
|
||||||
|
When the run envelope names the asked fields (``fields`` list, sent by
|
||||||
|
the API from the version's form schema), defaults are emitted only for
|
||||||
|
asked AND currently visible fields. Without the list (raw dict,
|
||||||
|
Langflow playground), all visible declared defaults apply.
|
||||||
|
|
||||||
|
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from lfx.custom import Component
|
||||||
|
from lfx.io import (
|
||||||
|
DropdownInput,
|
||||||
|
MessageTextInput,
|
||||||
|
Output,
|
||||||
|
StrInput,
|
||||||
|
)
|
||||||
|
from lfx.schema.data import Data
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if hasattr(value, "text"):
|
||||||
|
return value.text or ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_safe(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||||
|
text = getattr(value, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
data = getattr(value, "data", None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return _json_safe(data)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_conditions(text: str) -> list:
|
||||||
|
parts: list = []
|
||||||
|
buf: list = []
|
||||||
|
quoted = False
|
||||||
|
for char in text:
|
||||||
|
if char == '"':
|
||||||
|
quoted = not quoted
|
||||||
|
buf.append(char)
|
||||||
|
elif char == "&" and not quoted:
|
||||||
|
parts.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(char)
|
||||||
|
parts.append("".join(buf))
|
||||||
|
return [part.strip() for part in parts if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_condition(token: str):
|
||||||
|
field, sep, value = token.partition("=")
|
||||||
|
field, value = field.strip(), value.strip()
|
||||||
|
if not sep or not field or not value:
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return field, True
|
||||||
|
if lowered == "false":
|
||||||
|
return field, False
|
||||||
|
try:
|
||||||
|
return field, int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return field, float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return field, value
|
||||||
|
|
||||||
|
|
||||||
|
def _field_conditions(info: object) -> list:
|
||||||
|
"""``[(trigger, value)]`` from a field's ``info`` markers."""
|
||||||
|
conditions: list = []
|
||||||
|
|
||||||
|
def _collect(match) -> str:
|
||||||
|
for token in _split_conditions(match.group(1)):
|
||||||
|
parsed = _parse_condition(token)
|
||||||
|
if parsed is not None:
|
||||||
|
conditions.append(parsed)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||||
|
return conditions
|
||||||
|
|
||||||
|
|
||||||
|
#: Reserved input carrying the run envelope — a real runtime input, so it
|
||||||
|
#: must stay out of the output. Never name a form field ``form_values``.
|
||||||
|
PLUMBING_INPUTS = frozenset({"form_values"})
|
||||||
|
|
||||||
|
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
|
||||||
|
#: ``app/flows/input_schema.py`` — keep the two in sync).
|
||||||
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomInputMitarbeiter(Component):
|
||||||
|
display_name = "Mitarbeiter Einstellen Form"
|
||||||
|
description = "Collects hiring form values into a single JSON object."
|
||||||
|
icon = "UserPlus"
|
||||||
|
|
||||||
|
inputs: ClassVar[list] = [
|
||||||
|
# Plumbing, not a form field: the API posts submitted web-form
|
||||||
|
# values as the run envelope (same {"payload": {...}} shape the
|
||||||
|
# other kirche components use); they arrive here through a ChatInput
|
||||||
|
# entry node and override the defaults below. Hidden in the GUI and
|
||||||
|
# skipped by the form parser (show=False).
|
||||||
|
MessageTextInput(
|
||||||
|
name="form_values",
|
||||||
|
display_name="Form values (JSON envelope)",
|
||||||
|
info="Submitted values as JSON; merged over the defaults",
|
||||||
|
value="",
|
||||||
|
show=False,
|
||||||
|
),
|
||||||
|
DropdownInput(
|
||||||
|
name="rolle",
|
||||||
|
display_name="Rolle",
|
||||||
|
info="",
|
||||||
|
options=["Assistenz", "Pfarrer:in", "Vikar:in", "Messner:in"],
|
||||||
|
value="Assistenz",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
DropdownInput(
|
||||||
|
name="einstufung",
|
||||||
|
display_name="Einstufung",
|
||||||
|
info="Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]",
|
||||||
|
options=["Stufe 1", "Stufe 2", "Stufe 3"],
|
||||||
|
value="Stufe 1",
|
||||||
|
),
|
||||||
|
StrInput(
|
||||||
|
name="mitarbeiter_name",
|
||||||
|
display_name="Name",
|
||||||
|
info="Vor- und Nachname des Mitarbeiters",
|
||||||
|
value="",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
StrInput(
|
||||||
|
name="geburtsdatum",
|
||||||
|
display_name="Geburtsdatum",
|
||||||
|
info="Datum eingeben (z. B. JJJJ-MM-TT)",
|
||||||
|
value="",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
outputs: ClassVar[list] = [
|
||||||
|
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _field_names(self) -> list:
|
||||||
|
"""Names of all form fields (every input except the plumbing ones)."""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
if isinstance(inputs, dict) and inputs:
|
||||||
|
return [name for name in inputs if name not in PLUMBING_INPUTS]
|
||||||
|
seen = []
|
||||||
|
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
|
||||||
|
for inp in declared:
|
||||||
|
name = getattr(inp, "name", None)
|
||||||
|
if name and name not in PLUMBING_INPUTS and name not in seen:
|
||||||
|
seen.append(name)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def _field_value(self, name: str):
|
||||||
|
"""Runtime value of one input.
|
||||||
|
|
||||||
|
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
|
||||||
|
otherwise resolve to the component attribute instead of its value.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
return getattr(inputs[name], "value", None)
|
||||||
|
try:
|
||||||
|
return getattr(self, name, None)
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_visible(self, name: str, effective: dict) -> bool:
|
||||||
|
"""Whether a field's branch conditions hold.
|
||||||
|
|
||||||
|
Single pass over effective values (submitted first, code defaults
|
||||||
|
after): no recursion, so trigger cycles cannot hang — a stale or
|
||||||
|
cyclic marker simply matches or not. Unknown triggers never match.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
info = ""
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
info = getattr(inputs[name], "info", "") or ""
|
||||||
|
for trigger, expected in _field_conditions(info):
|
||||||
|
if trigger == name or trigger not in effective:
|
||||||
|
# Self-reference or unknown trigger: fail open (visible),
|
||||||
|
# mirroring the backend parser.
|
||||||
|
continue
|
||||||
|
actual = effective[trigger]
|
||||||
|
if isinstance(actual, list):
|
||||||
|
if expected not in actual:
|
||||||
|
return False
|
||||||
|
elif actual != expected:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def collect(self) -> Data:
|
||||||
|
try:
|
||||||
|
envelope = json.loads(_text(self.form_values) or "{}")
|
||||||
|
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||||
|
envelope = {}
|
||||||
|
payload: dict = {}
|
||||||
|
asked: list | None = None
|
||||||
|
if isinstance(envelope, dict):
|
||||||
|
# Asked-field list counts only in true envelope shape
|
||||||
|
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
|
||||||
|
# field literally named "fields" must not confuse it.
|
||||||
|
if isinstance(envelope.get("payload"), dict):
|
||||||
|
payload = envelope["payload"]
|
||||||
|
fields = envelope.get("fields")
|
||||||
|
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
|
||||||
|
asked = [item for item in fields if item]
|
||||||
|
else:
|
||||||
|
payload = envelope
|
||||||
|
# Effective trigger values: submitted first, code defaults after.
|
||||||
|
declared = self._field_names()
|
||||||
|
effective = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
effective[key] = _json_safe(val)
|
||||||
|
# Defaults only for asked AND visible fields (hidden branch fields
|
||||||
|
# stay out, like the frontend never submits them); without an asked
|
||||||
|
# list, all visible declared fields (Langflow playground).
|
||||||
|
scope = [name for name in (asked if asked is not None else declared) if name in declared]
|
||||||
|
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
|
||||||
|
# Submitted values win over the field defaults. Unknown keys are
|
||||||
|
# kept: the frontend may already know renamed fields this code
|
||||||
|
# revision does not declare.
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
data[key] = _json_safe(val)
|
||||||
|
return Data(data=data)
|
||||||
3
backend/flows/.flowsignore
Normal file
3
backend/flows/.flowsignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Flows excluded from the pipeline (sync, deploy, validation, frontend).
|
||||||
|
# One filename stem per line; these stay Langflow-only experiments.
|
||||||
|
i_love_this_app
|
||||||
921
backend/flows/bauvorhaben.json
Normal file
921
backend/flows/bauvorhaben.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
991
backend/flows/mitarbeiter_einstellen.json
Normal file
991
backend/flows/mitarbeiter_einstellen.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -10,21 +10,20 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
def post(api: str, path: str, body: dict) -> dict:
|
def post(api: str, path: str, body: dict) -> dict:
|
||||||
request = urllib.request.Request(
|
|
||||||
api.rstrip("/") + path,
|
|
||||||
data=json.dumps(body).encode(),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request) as response:
|
response = httpx.post(api.rstrip("/") + path, json=body, timeout=60.0)
|
||||||
return {"status": response.status, "body": json.loads(response.read())}
|
try:
|
||||||
except urllib.error.HTTPError as exc:
|
payload = response.json()
|
||||||
return {"status": exc.code, "body": exc.read().decode()[:2000]}
|
except ValueError:
|
||||||
|
payload = response.text[:2000]
|
||||||
|
return {"status": response.status_code, "body": payload}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return {"status": 0, "body": str(exc)[:2000]}
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ def kirche_node(
|
||||||
node_id: str,
|
node_id: str,
|
||||||
values: dict | None = None,
|
values: dict | None = None,
|
||||||
position: tuple[int, int] = (0, 0),
|
position: tuple[int, int] = (0, 0),
|
||||||
|
component_cls=None,
|
||||||
|
code_override: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""A node from our GUI-editable category lib (backend/components/kirche).
|
"""A node from our GUI-editable category lib (backend/components/kirche).
|
||||||
|
|
||||||
|
|
@ -101,15 +103,22 @@ def kirche_node(
|
||||||
the same way). The template dict before that round-trip is complete and
|
the same way). The template dict before that round-trip is complete and
|
||||||
faithful, so we use it directly and append the ``code`` field by hand
|
faithful, so we use it directly and append the ``code`` field by hand
|
||||||
(mirroring the shape ``build_custom_component_template`` produces).
|
(mirroring the shape ``build_custom_component_template`` produces).
|
||||||
|
|
||||||
|
``component_cls`` builds a node from a variant class (e.g. a flow whose
|
||||||
|
fields differ from ``CustomInputForm``); ``code_override`` embeds that
|
||||||
|
variant's source (``set_class_code`` cannot inspect exec-built classes).
|
||||||
"""
|
"""
|
||||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||||
from kirche.form_inputs import CustomInputForm
|
if component_cls is None:
|
||||||
|
from kirche.form_inputs import CustomInputForm as component_cls
|
||||||
from lfx.template.frontend_node.custom_components import (
|
from lfx.template.frontend_node.custom_components import (
|
||||||
ComponentFrontendNode,
|
ComponentFrontendNode,
|
||||||
)
|
)
|
||||||
|
|
||||||
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
|
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
|
||||||
inst = CustomInputForm()
|
inst = component_cls()
|
||||||
|
if code_override is not None:
|
||||||
|
inst._code = code_override
|
||||||
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
|
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
|
||||||
inst._map_parameters_on_frontend_node(frontend_node)
|
inst._map_parameters_on_frontend_node(frontend_node)
|
||||||
node = frontend_node.to_dict(keep_name=False)
|
node = frontend_node.to_dict(keep_name=False)
|
||||||
|
|
@ -147,14 +156,18 @@ def kirche_node(
|
||||||
# form_values envelope (collect() merges), never via edges — so required
|
# form_values envelope (collect() merges), never via edges — so required
|
||||||
# template fields carry a single-space sentinel value. The form parser
|
# template fields carry a single-space sentinel value. The form parser
|
||||||
# (app/flows/input_schema.py) treats whitespace-only values as "no
|
# (app/flows/input_schema.py) treats whitespace-only values as "no
|
||||||
# default", keeping the web form clean.
|
# default", keeping the web form clean. Multiselect needs a list to
|
||||||
|
# survive runtime assignment, so it gets [" "] (skipped the same way
|
||||||
|
# via the enum-mismatch rule).
|
||||||
for field_def in node_dict["data"]["node"]["template"].values():
|
for field_def in node_dict["data"]["node"]["template"].values():
|
||||||
if (
|
if (
|
||||||
isinstance(field_def, dict)
|
isinstance(field_def, dict)
|
||||||
and field_def.get("required")
|
and field_def.get("required")
|
||||||
and field_def.get("value") in (None, "", [], {})
|
and field_def.get("value") in (None, "", [], {})
|
||||||
):
|
):
|
||||||
field_def["value"] = " "
|
field_def["value"] = (
|
||||||
|
[" "] if field_def.get("_input_type") == "MultiselectInput" else " "
|
||||||
|
)
|
||||||
return node_dict
|
return node_dict
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -406,6 +419,128 @@ class ApprovalCallbackComponent(Component):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
GEHALT_CODE = """from lfx.custom import Component
|
||||||
|
from lfx.io import HandleInput, Output
|
||||||
|
from lfx.schema import Data, Message
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lfx.schema import DataFrame
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
DataFrame = None
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class GehaltsrechnerComponent(Component):
|
||||||
|
display_name = "Gehaltsrechner"
|
||||||
|
description = (
|
||||||
|
"Calculates the annual salary (Gehalt) for church employees based on their role "
|
||||||
|
"(rolle) and level (einstufung). Use this when you need to determine the yearly "
|
||||||
|
"salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in."
|
||||||
|
)
|
||||||
|
icon = "Calculator"
|
||||||
|
|
||||||
|
inputs = [
|
||||||
|
HandleInput(
|
||||||
|
name="input_data",
|
||||||
|
display_name="Employee Data",
|
||||||
|
input_types=["JSON", "Data", "Message", "DataFrame", "Table"],
|
||||||
|
info=(
|
||||||
|
"Employee data as JSON/Data/Message/DataFrame. Must contain "
|
||||||
|
"'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
outputs = [
|
||||||
|
Output(
|
||||||
|
name="salary_text",
|
||||||
|
display_name="Salary Text",
|
||||||
|
method="calculate_salary",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Salary mapping for simple roles
|
||||||
|
BASE_SALARY = {
|
||||||
|
"Assistenz": 42000,
|
||||||
|
"Vikar:in": 48000,
|
||||||
|
"Messner:in": 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Salary mapping for Pfarrer:in per Einstufung
|
||||||
|
PFARRER_SALARY = {
|
||||||
|
"Stufe 1": 55000,
|
||||||
|
"Stufe 2": 65000,
|
||||||
|
"Stufe 3": 75000,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_data(self, value) -> dict | None:
|
||||||
|
'''Normalize the incoming value into a plain dict.'''
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if isinstance(value, Data):
|
||||||
|
return value.data if isinstance(value.data, dict) else None
|
||||||
|
if isinstance(value, Message):
|
||||||
|
value = value.text
|
||||||
|
if DataFrame is not None and isinstance(value, DataFrame):
|
||||||
|
try:
|
||||||
|
records = value.to_dict(orient="records")
|
||||||
|
if records:
|
||||||
|
return records[0]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
||||||
|
return parsed[0]
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_salary(self) -> Message:
|
||||||
|
try:
|
||||||
|
data = self._extract_data(self.input_data)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return Message(
|
||||||
|
text="Error: Could not parse employee data. Please provide a valid JSON object."
|
||||||
|
)
|
||||||
|
rolle = data.get("rolle")
|
||||||
|
einstufung = data.get("einstufung")
|
||||||
|
name = data.get("mitarbeiter_name", "unknown")
|
||||||
|
if not rolle:
|
||||||
|
return Message(
|
||||||
|
text="Error: Missing required field 'rolle' in employee data."
|
||||||
|
)
|
||||||
|
if rolle in self.BASE_SALARY:
|
||||||
|
salary = self.BASE_SALARY[rolle]
|
||||||
|
elif rolle == "Pfarrer:in":
|
||||||
|
if einstufung not in self.PFARRER_SALARY:
|
||||||
|
return Message(
|
||||||
|
text=(
|
||||||
|
"Error: For role 'Pfarrer:in', a valid 'einstufung' "
|
||||||
|
"(Stufe 1, Stufe 2 or Stufe 3) is required."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
salary = self.PFARRER_SALARY[einstufung]
|
||||||
|
else:
|
||||||
|
return Message(text=f"Error: Unknown role '{rolle}'.")
|
||||||
|
einstufung_display = einstufung if einstufung else "-"
|
||||||
|
return Message(
|
||||||
|
text=(
|
||||||
|
f"Mitarbeiter:{name} | Rolle:{rolle} | "
|
||||||
|
f"Einstufung:{einstufung_display} | Gehalt:{salary} EUR pro Jahr"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # never raise, always return a clear error string
|
||||||
|
return Message(text=f"Error: {exc}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# flows
|
# flows
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -544,6 +679,108 @@ def build_intake_form() -> dict:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bauvorhaben() -> dict:
|
||||||
|
"""Branched Bauvorhaben form via the standalone Bauvorhaven component."""
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||||
|
from kirche.bauvorhaben_form import CustomInputBauvorhaben
|
||||||
|
|
||||||
|
entry = builtin_node(
|
||||||
|
"lfx.components.input_output.chat",
|
||||||
|
"ChatInput",
|
||||||
|
"ChatInput-entry",
|
||||||
|
position=(0, 200),
|
||||||
|
)
|
||||||
|
inst = CustomInputBauvorhaben()
|
||||||
|
if not getattr(inst, "_code", None):
|
||||||
|
inst.set_class_code()
|
||||||
|
form = kirche_node(
|
||||||
|
"CustomInputBauvorhaben-bauvorhaben",
|
||||||
|
position=(400, 200),
|
||||||
|
component_cls=CustomInputBauvorhaben,
|
||||||
|
code_override=inst._code,
|
||||||
|
)
|
||||||
|
# Logical question order: RJSF renders schema properties in insertion
|
||||||
|
# order and the parser derives ui:order from it (lfx emits alphabetical).
|
||||||
|
order = [
|
||||||
|
"form_values",
|
||||||
|
"gebaeude",
|
||||||
|
"bestandteil",
|
||||||
|
"glockenmassnahme",
|
||||||
|
"gussjahr",
|
||||||
|
"gebaeudeart",
|
||||||
|
"denkmalschutz",
|
||||||
|
"denkmalnummer",
|
||||||
|
]
|
||||||
|
template = form["data"]["node"]["template"]
|
||||||
|
form["data"]["node"]["template"] = {k: template[k] for k in order} | {
|
||||||
|
k: v for k, v in template.items() if k not in order
|
||||||
|
}
|
||||||
|
out = builtin_node(
|
||||||
|
"lfx.components.input_output.chat_output",
|
||||||
|
"ChatOutput",
|
||||||
|
"ChatOutput-collected",
|
||||||
|
position=(800, 200),
|
||||||
|
)
|
||||||
|
nodes = [entry, form, out]
|
||||||
|
edges = [
|
||||||
|
edge(entry, "message", form, "form_values"),
|
||||||
|
edge(form, "form_data", out, "input_value"),
|
||||||
|
]
|
||||||
|
return flow_doc(
|
||||||
|
"bauvorhaben",
|
||||||
|
"Bauvorhaben: verzweigtes Formular mit mehrstufigen Abhängigkeiten.",
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mitarbeiter() -> dict:
|
||||||
|
"""Hiring form via the standalone Mitarbeiter component + Gehaltsrechner."""
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||||
|
from kirche.mitarbeiter_form import CustomInputMitarbeiter
|
||||||
|
|
||||||
|
entry = builtin_node(
|
||||||
|
"lfx.components.input_output.chat",
|
||||||
|
"ChatInput",
|
||||||
|
"ChatInput-entry",
|
||||||
|
position=(0, 200),
|
||||||
|
)
|
||||||
|
inst = CustomInputMitarbeiter()
|
||||||
|
if not getattr(inst, "_code", None):
|
||||||
|
inst.set_class_code()
|
||||||
|
form = kirche_node(
|
||||||
|
"CustomInputMitarbeiter-mitarbeiter",
|
||||||
|
position=(400, 200),
|
||||||
|
component_cls=CustomInputMitarbeiter,
|
||||||
|
code_override=inst._code,
|
||||||
|
)
|
||||||
|
# Logical question order (see build_bauvorhaben).
|
||||||
|
order = ["form_values", "rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
|
||||||
|
template = form["data"]["node"]["template"]
|
||||||
|
form["data"]["node"]["template"] = {k: template[k] for k in order} | {
|
||||||
|
k: v for k, v in template.items() if k not in order
|
||||||
|
}
|
||||||
|
gehalt = custom_node(GEHALT_CODE, "Gehaltsrechner-gehalt", position=(800, 200))
|
||||||
|
out = builtin_node(
|
||||||
|
"lfx.components.input_output.chat_output",
|
||||||
|
"ChatOutput",
|
||||||
|
"ChatOutput-collected",
|
||||||
|
position=(1200, 200),
|
||||||
|
)
|
||||||
|
nodes = [entry, form, gehalt, out]
|
||||||
|
edges = [
|
||||||
|
edge(entry, "message", form, "form_values"),
|
||||||
|
edge(form, "form_data", gehalt, "input_data"),
|
||||||
|
edge(gehalt, "salary_text", out, "input_value"),
|
||||||
|
]
|
||||||
|
return flow_doc(
|
||||||
|
"mitarbeiter_einstellen",
|
||||||
|
"Mitarbeiter einstellen: Formular mit Gehaltsrechner.",
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -551,11 +788,16 @@ def main() -> None:
|
||||||
"simple_rule.json": build_simple_rule(),
|
"simple_rule.json": build_simple_rule(),
|
||||||
"approval_handoff.json": build_approval_handoff(),
|
"approval_handoff.json": build_approval_handoff(),
|
||||||
"intake_form.json": build_intake_form(),
|
"intake_form.json": build_intake_form(),
|
||||||
|
"bauvorhaben.json": build_bauvorhaben(),
|
||||||
|
"mitarbeiter_einstellen.json": build_mitarbeiter(),
|
||||||
}
|
}
|
||||||
for filename, doc in flows.items():
|
for filename, doc in flows.items():
|
||||||
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||||
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
|
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
|
||||||
target.write_text(payload)
|
# Explicit encoding + LF: the default locale/newline handling
|
||||||
|
# corrupts non-ASCII flow code on Windows (cp1252) and dirties
|
||||||
|
# every line with CRLF.
|
||||||
|
target.write_text(payload, encoding="utf-8", newline="\n")
|
||||||
print(f"wrote {target}")
|
print(f"wrote {target}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
40
backend/scripts/push_flows_to_langflow.py
Normal file
40
backend/scripts/push_flows_to_langflow.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Push canonical backend/flows/*.json into Langflow via API (no restart)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.flows.langflow_client import LangflowClient, LangflowError
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
base = os.environ.get("LANGFLOW_URL", "http://localhost:7860")
|
||||||
|
key = settings.langflow_api_key or os.environ.get("LANGFLOW_API_KEY", "")
|
||||||
|
print(f"Langflow: {base} key_len={len(key)}")
|
||||||
|
client = LangflowClient(base, api_key=key)
|
||||||
|
try:
|
||||||
|
flows = await client.list_flows()
|
||||||
|
print(f"existing: {len(flows)} flows")
|
||||||
|
for f in flows[:20]:
|
||||||
|
print(" -", f.get("name"), f.get("id"))
|
||||||
|
except LangflowError as exc:
|
||||||
|
print(f"list failed: {exc}")
|
||||||
|
return
|
||||||
|
flows_dir = Path(settings.flows_dir)
|
||||||
|
for path in sorted(flows_dir.glob("*.json")):
|
||||||
|
if path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
try:
|
||||||
|
result = await client.upsert_flow(doc)
|
||||||
|
print(f"pushed {path.name} id={result.get('id', 'ok')}")
|
||||||
|
except LangflowError as exc:
|
||||||
|
print(f"push {path.name} failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -42,8 +42,28 @@ async def run_sync(flows_dir: Path) -> SyncReport:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_full_pass(flows_dir: Path) -> SyncReport:
|
||||||
|
"""Single orchestration: Langflow pull + file sync (shared with API)."""
|
||||||
|
reset_engine()
|
||||||
|
session = Session(get_engine())
|
||||||
|
try:
|
||||||
|
lfx = LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||||
|
langflow = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key)
|
||||||
|
return await sync.sync_full_pass(session, lfx, langflow, flows_dir)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
async def main_async(args) -> int:
|
async def main_async(args) -> int:
|
||||||
flows_dir = Path(args.dir or settings.flows_dir)
|
flows_dir = Path(args.dir or settings.flows_dir)
|
||||||
|
if args.pull and args.sync:
|
||||||
|
try:
|
||||||
|
report = await run_full_pass(flows_dir)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(report.model_dump_json(indent=2))
|
||||||
|
return 1 if report.errors else 0
|
||||||
if args.pull:
|
if args.pull:
|
||||||
pulled = await pull_from_langflow(flows_dir)
|
pulled = await pull_from_langflow(flows_dir)
|
||||||
print(json.dumps(pulled, indent=2, ensure_ascii=False))
|
print(json.dumps(pulled, indent=2, ensure_ascii=False))
|
||||||
|
|
|
||||||
68
backend/tests/_harness.py
Normal file
68
backend/tests/_harness.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions.
|
||||||
|
|
||||||
|
Single place for logic duplicated between tests/conftest.py and
|
||||||
|
scripts/smoke_e2e.py (~60 lines). Full real-LFX runs are kept.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def wait_healthy(url: str, deadline_s: float = 240.0) -> None:
|
||||||
|
deadline = time.time() + deadline_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url + "/health", timeout=3) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError(f"server did not become healthy: {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen:
|
||||||
|
bindir = Path(sys.executable).parent
|
||||||
|
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
|
||||||
|
env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key"}
|
||||||
|
return subprocess.Popen(
|
||||||
|
[
|
||||||
|
lfx_bin,
|
||||||
|
"serve",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--flow-dir",
|
||||||
|
str(flow_dir),
|
||||||
|
str(flow_dir),
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_throwaway(backend_dir: Path) -> None:
|
||||||
|
from alembic import command as alembic_command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
|
||||||
|
alembic_command.upgrade(cfg, "head")
|
||||||
|
|
@ -278,3 +278,89 @@ def live_server():
|
||||||
yield f"http://127.0.0.1:{port}"
|
yield f"http://127.0.0.1:{port}"
|
||||||
server.should_exit = True
|
server.should_exit = True
|
||||||
thread.join(timeout=15)
|
thread.join(timeout=15)
|
||||||
|
|
||||||
|
|
||||||
|
# --- shared declutter helpers (single place, was copy-pasted across files) ---
|
||||||
|
|
||||||
|
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "flows"
|
||||||
|
|
||||||
|
|
||||||
|
def load_flow_fixture(name: str) -> dict:
|
||||||
|
"""Load a fixture flow JSON (shared; was load_fixture() x3)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
|
||||||
|
"""Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
|
||||||
|
return {
|
||||||
|
"id": flow_id,
|
||||||
|
"name": name,
|
||||||
|
"description": "",
|
||||||
|
"data": {"nodes": [], "edges": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def anon_client():
|
||||||
|
"""Unauthenticated ASGI client (was 3x copy-pasted ASGITransport blocks)."""
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
async def _client():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def flow_helpers(client, login):
|
||||||
|
"""API flow factory: create flow/version/deploy/deployed_flow in one place.
|
||||||
|
|
||||||
|
Collapses _create_flow/_create_version/_deploy x4 files (~60 lines).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _create_flow(name: str = "regeltest") -> dict:
|
||||||
|
response = await client.post("/api/flows", json={"name": name, "description": "t"})
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def _create_version(flow_id: str, fixture: str = "simple_rule.json") -> dict:
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/flows/{flow_id}/versions",
|
||||||
|
json={"langflow_json": load_flow_fixture(fixture), "author": "test"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def _deploy(flow_id: str, version_no: int = 1) -> dict:
|
||||||
|
response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def _deployed_flow(name: str = "regeltest", fixture: str = "simple_rule.json") -> tuple:
|
||||||
|
await login()
|
||||||
|
flow = await _create_flow(name)
|
||||||
|
version = await _create_version(flow["id"], fixture)
|
||||||
|
deployed = await _deploy(flow["id"], version["version_no"])
|
||||||
|
return flow, deployed
|
||||||
|
|
||||||
|
return {
|
||||||
|
"create_flow": _create_flow,
|
||||||
|
"create_version": _create_version,
|
||||||
|
"deploy": _deploy,
|
||||||
|
"deployed_flow": _deployed_flow,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def lfx_client():
|
||||||
|
"""LFX client pointed at the real test server (was _lfx() x2)."""
|
||||||
|
from app.flows.lfx_client import LfxClient
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||||
|
|
|
||||||
921
backend/tests/fixtures/flows/bauvorhaben.json
vendored
Normal file
921
backend/tests/fixtures/flows/bauvorhaben.json
vendored
Normal file
File diff suppressed because one or more lines are too long
14
backend/tests/fixtures/flows/intake_form.json
vendored
14
backend/tests/fixtures/flows/intake_form.json
vendored
File diff suppressed because one or more lines are too long
991
backend/tests/fixtures/flows/mitarbeiter_einstellen.json
vendored
Normal file
991
backend/tests/fixtures/flows/mitarbeiter_einstellen.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2,18 +2,26 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.flows import export
|
from app.flows import export
|
||||||
|
|
||||||
|
|
||||||
def test_filename_stem_keeps_original_names():
|
@pytest.mark.parametrize(
|
||||||
assert export.filename_stem("simple_rule") == "simple_rule"
|
("name", "expected"),
|
||||||
assert export.filename_stem("test_flow") == "test_flow"
|
[
|
||||||
assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung"
|
("simple_rule", "simple_rule"),
|
||||||
assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k"
|
("test_flow", "test_flow"),
|
||||||
assert export.filename_stem(" A/B\\C ") == "A-B-C"
|
("Gehaltserhöhung", "Gehaltserhöhung"),
|
||||||
assert export.filename_stem('a:b*c?d"e<f>g|h') == "a-b-c-d-e-f-g-h"
|
("Dachsanierung 200k", "Dachsanierung 200k"),
|
||||||
assert export.filename_stem("trailing...") == "trailing"
|
(" A/B\\C ", "A-B-C"),
|
||||||
assert export.filename_stem("") == "flow"
|
('a:b*c?d"e<f>g|h', "a-b-c-d-e-f-g-h"),
|
||||||
|
("trailing...", "trailing"),
|
||||||
|
("", "flow"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_filename_stem_keeps_original_names(name, expected):
|
||||||
|
assert export.filename_stem(name) == expected
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_bytes_stable_under_key_reorder():
|
def test_canonical_bytes_stable_under_key_reorder():
|
||||||
|
|
@ -43,6 +51,28 @@ def test_iter_flow_files_only_top_level_json(tmp_path):
|
||||||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_flowsignore_parsing_and_filtering(tmp_path):
|
||||||
|
assert export.ignored_stems(tmp_path) == set()
|
||||||
|
(tmp_path / export.IGNORE_FILENAME).write_text(
|
||||||
|
"# experiments stay Langflow-only\n\ni_love_this_app\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert export.ignored_stems(tmp_path) == {"i_love_this_app"}
|
||||||
|
(tmp_path / "a.json").write_text("{}")
|
||||||
|
(tmp_path / "i_love_this_app.json").write_text("{}")
|
||||||
|
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||||
|
# Explicit override still works.
|
||||||
|
assert export.iter_flow_files(tmp_path, ignored=set()) == [
|
||||||
|
tmp_path / "a.json",
|
||||||
|
tmp_path / "i_love_this_app.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_reuse_path_ignores_ignored_files(tmp_path):
|
||||||
|
(tmp_path / export.IGNORE_FILENAME).write_text("alt\n", encoding="utf-8")
|
||||||
|
(tmp_path / "alt.json").write_text(json.dumps({"name": "Neu"}))
|
||||||
|
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)
|
||||||
|
|
||||||
|
|
||||||
def test_find_reuse_path_matches_identical_content_first(tmp_path):
|
def test_find_reuse_path_matches_identical_content_first(tmp_path):
|
||||||
doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
|
doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
|
||||||
other = tmp_path / "other-name.json"
|
other = tmp_path / "other-name.json"
|
||||||
|
|
|
||||||
|
|
@ -39,26 +39,173 @@ def test_component_contract():
|
||||||
|
|
||||||
|
|
||||||
def test_collect_merges_submitted_values_over_defaults():
|
def test_collect_merges_submitted_values_over_defaults():
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.data import Data
|
||||||
|
|
||||||
form = CustomInputForm()
|
form = CustomInputForm()
|
||||||
default = json.loads(form.collect().text)
|
default = form.collect()
|
||||||
assert default["full_name"] == ""
|
assert isinstance(default, Data)
|
||||||
assert default["department"] == "Music"
|
assert default.data["full_name"] == ""
|
||||||
assert isinstance(form.collect(), Message)
|
assert default.data["department"] == "Music"
|
||||||
|
|
||||||
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
||||||
merged = json.loads(form.collect().text)
|
merged = form.collect().data
|
||||||
assert merged["full_name"] == "Maria"
|
assert merged["full_name"] == "Maria"
|
||||||
assert merged["age"] == 41
|
assert merged["age"] == 41
|
||||||
assert merged["department"] == "Music" # default kept
|
assert merged["department"] == "Music" # default kept
|
||||||
|
|
||||||
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
||||||
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
||||||
assert json.loads(form.collect().text)["notes"] == "hi"
|
assert form.collect().data["notes"] == "hi"
|
||||||
|
|
||||||
form.form_values = "not json"
|
form.form_values = "not json"
|
||||||
assert json.loads(form.collect().text)["full_name"] == ""
|
assert form.collect().data["full_name"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_picks_up_langflow_added_field():
|
||||||
|
"""A field added in the Langflow code editor needs no collect() change."""
|
||||||
|
from lfx.io import StrInput
|
||||||
|
|
||||||
|
form = CustomInputForm()
|
||||||
|
form._inputs["room"] = StrInput(name="room", display_name="Room", info="Where", value="Chapel")
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected["room"] == "Chapel"
|
||||||
|
assert collected["full_name"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_survives_rename():
|
||||||
|
"""Renaming `full_name` to `name` drops the old key, keeps the new one."""
|
||||||
|
from lfx.io import StrInput
|
||||||
|
|
||||||
|
form = CustomInputForm()
|
||||||
|
del form._inputs["full_name"]
|
||||||
|
form._inputs["name"] = StrInput(name="name", display_name="Name", info="", value="Maria")
|
||||||
|
collected = form.collect().data
|
||||||
|
assert "full_name" not in collected
|
||||||
|
assert collected["name"] == "Maria"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_keeps_unknown_envelope_keys():
|
||||||
|
"""Frontend payloads with renamed keys merge even if undeclared here."""
|
||||||
|
form = CustomInputForm()
|
||||||
|
form.form_values = json.dumps({"payload": {"nickname": "Mia"}})
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected["nickname"] == "Mia"
|
||||||
|
assert collected["department"] == "Music" # default kept
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_fields_list_restricts_defaults():
|
||||||
|
"""Asked-field list: unasked code defaults stay out of the output."""
|
||||||
|
form = CustomInputForm()
|
||||||
|
form.form_values = json.dumps(
|
||||||
|
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "age"]}
|
||||||
|
)
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected == {"full_name": "Maria", "age": 18}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_fields_list_fills_missing_from_defaults():
|
||||||
|
form = CustomInputForm()
|
||||||
|
form.form_values = json.dumps(
|
||||||
|
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "department"]}
|
||||||
|
)
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected == {"full_name": "Maria", "department": "Music"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_fields_list_ignores_undeclared_names():
|
||||||
|
"""Asked-but-undeclared template fields contribute no Nones."""
|
||||||
|
form = CustomInputForm()
|
||||||
|
form.form_values = json.dumps({"payload": {"room": "Chapel"}, "fields": ["full_name", "room"]})
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected == {"full_name": "", "room": "Chapel"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_raw_dict_ignores_fields_key():
|
||||||
|
"""A raw dict is payload, not envelope: 'fields' is just a value."""
|
||||||
|
form = CustomInputForm()
|
||||||
|
form.form_values = json.dumps({"fields": ["x"], "full_name": "Maria"})
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected["fields"] == ["x"]
|
||||||
|
assert collected["full_name"] == "Maria"
|
||||||
|
assert collected["age"] == 18 # all defaults apply without envelope
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_hides_branch_default_until_trigger_matches():
|
||||||
|
"""Hidden branch fields contribute no defaults (frontend parity)."""
|
||||||
|
form = CustomInputForm()
|
||||||
|
form._inputs["notes"].info = "Anything [visible_if department=Youth]"
|
||||||
|
assert "notes" not in form.collect().data # department defaults to Music
|
||||||
|
form.form_values = json.dumps({"payload": {"department": "Youth"}})
|
||||||
|
assert form.collect().data["notes"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_branch_visibility_uses_submitted_triggers():
|
||||||
|
form = CustomInputForm()
|
||||||
|
form._inputs["notes"].info = "[visible_if age=41]"
|
||||||
|
form.form_values = json.dumps({"payload": {"age": 41}, "fields": ["age", "notes"]})
|
||||||
|
assert form.collect().data["notes"] == ""
|
||||||
|
form.form_values = json.dumps({"payload": {"age": 18}, "fields": ["age", "notes"]})
|
||||||
|
assert "notes" not in form.collect().data
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_dangling_marker_stays_visible():
|
||||||
|
form = CustomInputForm()
|
||||||
|
form._inputs["notes"].info = "[visible_if nope=x]"
|
||||||
|
assert "notes" in form.collect().data
|
||||||
|
|
||||||
|
|
||||||
|
def test_bauvorhaben_component_contract():
|
||||||
|
from kirche.bauvorhaben_form import CustomInputBauvorhaben
|
||||||
|
|
||||||
|
assert CustomInputBauvorhaben.display_name == "Bauvorhaben Form"
|
||||||
|
inputs = {i.name: i for i in CustomInputBauvorhaben.inputs}
|
||||||
|
assert sorted(n for n in inputs if n != "form_values") == sorted(
|
||||||
|
[
|
||||||
|
"gebaeude",
|
||||||
|
"bestandteil",
|
||||||
|
"glockenmassnahme",
|
||||||
|
"gussjahr",
|
||||||
|
"gebaeudeart",
|
||||||
|
"denkmalschutz",
|
||||||
|
"denkmalnummer",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert inputs["form_values"].show is False
|
||||||
|
outputs = {o.name: o for o in CustomInputBauvorhaben.outputs}
|
||||||
|
assert set(outputs) == {"form_data"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bauvorhaben_collect_only_own_fields():
|
||||||
|
"""Standalone component: no intake leftovers, hidden branches dropped."""
|
||||||
|
from kirche.bauvorhaben_form import CustomInputBauvorhaben
|
||||||
|
|
||||||
|
form = CustomInputBauvorhaben()
|
||||||
|
default = form.collect().data
|
||||||
|
assert "full_name" not in default and "age" not in default
|
||||||
|
assert default["gebaeude"] == "" # class default (template ships " " sentinel)
|
||||||
|
assert "bestandteil" not in default # hidden (gebaeude default "").
|
||||||
|
assert "gebaeudeart" not in default
|
||||||
|
|
||||||
|
form.form_values = json.dumps(
|
||||||
|
{
|
||||||
|
"payload": {"gebaeude": "Kirche", "bestandteil": ["Glocke"]},
|
||||||
|
"fields": [
|
||||||
|
"gebaeude",
|
||||||
|
"bestandteil",
|
||||||
|
"glockenmassnahme",
|
||||||
|
"gussjahr",
|
||||||
|
"gebaeudeart",
|
||||||
|
"denkmalschutz",
|
||||||
|
"denkmalnummer",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
collected = form.collect().data
|
||||||
|
assert collected["bestandteil"] == ["Glocke"]
|
||||||
|
assert collected["glockenmassnahme"] == "Reinigung" # visible branch default
|
||||||
|
assert "gussjahr" not in collected # hidden branch
|
||||||
|
assert "gebaeudeart" not in collected
|
||||||
|
assert "full_name" not in collected
|
||||||
|
|
||||||
|
|
||||||
def test_intake_fixture_shape():
|
def test_intake_fixture_shape():
|
||||||
|
|
@ -72,6 +219,8 @@ def test_intake_fixture_shape():
|
||||||
assert field in template, field
|
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"]}
|
||||||
|
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
|
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
||||||
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
||||||
|
|
@ -99,7 +248,253 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
|
||||||
)
|
)
|
||||||
assert run.status_code == 200, run.text
|
assert run.status_code == 200, run.text
|
||||||
assert run.json()["status"] == "completed"
|
assert run.json()["status"] == "completed"
|
||||||
result = json.loads(run.json()["output"]["result"])
|
# collect() returns Data: the terminal ChatOutput renders it as a
|
||||||
|
# fenced ```json block, so unwrap before parsing.
|
||||||
|
result = _parse_chat_json(run.json()["output"]["result"])
|
||||||
assert result["full_name"] == "Maria Muster"
|
assert result["full_name"] == "Maria Muster"
|
||||||
assert result["age"] == 41
|
assert result["age"] == 41
|
||||||
assert result["department"] == "Music" # default preserved
|
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():
|
||||||
|
"""Node template fields == code-declared inputs (no unasked defaults).
|
||||||
|
|
||||||
|
The envelope tolerates drift at runtime, but authoring must stay
|
||||||
|
honest: what Langflow shows in the code editor must be what the form
|
||||||
|
asks. Building blocks: lfx's own create_class on the embedded code.
|
||||||
|
"""
|
||||||
|
from lfx.custom import validate as validate_mod
|
||||||
|
|
||||||
|
cases = {
|
||||||
|
"intake_form.json": ("CustomInputForm-intake", "CustomInputForm"),
|
||||||
|
"bauvorhaben.json": ("CustomInputBauvorhaben-bauvorhaben", "CustomInputBauvorhaben"),
|
||||||
|
"mitarbeiter_einstellen.json": (
|
||||||
|
"CustomInputMitarbeiter-mitarbeiter",
|
||||||
|
"CustomInputMitarbeiter",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for filename, (node_id, class_name) in cases.items():
|
||||||
|
doc = json.loads((FIXTURES / filename).read_text(encoding="utf-8"))
|
||||||
|
node = next(n for n in doc["data"]["nodes"] if n["id"] == node_id)
|
||||||
|
assert node["data"]["type"] == class_name, filename
|
||||||
|
template = node["data"]["node"]["template"]
|
||||||
|
template_names = {
|
||||||
|
field["name"] for field in template.values() if isinstance(field, dict)
|
||||||
|
} - {"code"}
|
||||||
|
code = template["code"]["value"]
|
||||||
|
cls = validate_mod.create_class(code, class_name)
|
||||||
|
code_names = {inp.name for inp in cls.inputs}
|
||||||
|
assert code_names == template_names, filename
|
||||||
|
|
||||||
|
|
||||||
|
def test_bauvorhaben_fixture_shape():
|
||||||
|
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
||||||
|
assert doc["name"] == "bauvorhaben"
|
||||||
|
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||||
|
form = nodes["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():
|
||||||
|
from app.flows import input_schema as parser
|
||||||
|
|
||||||
|
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
||||||
|
out = parser.extract_form_schema(doc)
|
||||||
|
schema = out["json_schema"]
|
||||||
|
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
|
||||||
|
assert schema["required"] == ["gebaeude"]
|
||||||
|
assert len(schema["allOf"]) == 5
|
||||||
|
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
|
||||||
|
assert set(by_field) == {
|
||||||
|
"bestandteil",
|
||||||
|
"glockenmassnahme",
|
||||||
|
"gussjahr",
|
||||||
|
"gebaeudeart",
|
||||||
|
"denkmalnummer",
|
||||||
|
}
|
||||||
|
assert by_field["bestandteil"]["if"]["properties"] == {"gebaeude": {"const": "Kirche"}}
|
||||||
|
assert by_field["glockenmassnahme"]["if"]["properties"] == {
|
||||||
|
"bestandteil": {"contains": {"const": "Glocke"}}
|
||||||
|
}
|
||||||
|
assert by_field["gussjahr"]["if"]["properties"] == {"glockenmassnahme": {"const": "Neuguss"}}
|
||||||
|
assert by_field["gebaeudeart"]["if"]["properties"] == {"gebaeude": {"const": "Anderes Gebäude"}}
|
||||||
|
assert by_field["denkmalnummer"]["if"]["properties"] == {"denkmalschutz": {"const": True}}
|
||||||
|
assert by_field["bestandteil"]["then"]["required"] == ["bestandteil"]
|
||||||
|
assert by_field["denkmalnummer"]["then"]["required"] == ["denkmalnummer"]
|
||||||
|
assert "required" not in by_field["gussjahr"]["then"]
|
||||||
|
# Markers stripped from descriptions.
|
||||||
|
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
|
||||||
|
"bestandteil"
|
||||||
|
].get("description", "")
|
||||||
|
assert out["ui_schema"]["ui:order"] == [
|
||||||
|
"gebaeude",
|
||||||
|
"bestandteil",
|
||||||
|
"glockenmassnahme",
|
||||||
|
"gussjahr",
|
||||||
|
"gebaeudeart",
|
||||||
|
"denkmalschutz",
|
||||||
|
"denkmalnummer",
|
||||||
|
"*",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
from kirche.mitarbeiter_form import CustomInputMitarbeiter
|
||||||
|
|
||||||
|
assert CustomInputMitarbeiter.display_name == "Mitarbeiter Einstellen Form"
|
||||||
|
inputs = {i.name: i for i in CustomInputMitarbeiter.inputs}
|
||||||
|
assert sorted(n for n in inputs if n != "form_values") == sorted(
|
||||||
|
["rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
|
||||||
|
)
|
||||||
|
assert inputs["form_values"].show is False
|
||||||
|
outputs = {o.name: o for o in CustomInputMitarbeiter.outputs}
|
||||||
|
assert set(outputs) == {"form_data"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitarbeiter_collect_branch_default():
|
||||||
|
"""Einstufung default applies only when Pfarrer:in is selected."""
|
||||||
|
from kirche.mitarbeiter_form import CustomInputMitarbeiter
|
||||||
|
|
||||||
|
form = CustomInputMitarbeiter()
|
||||||
|
assert "einstufung" not in form.collect().data # rolle defaults to Assistenz
|
||||||
|
form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}})
|
||||||
|
assert form.collect().data["einstufung"] == "Stufe 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitarbeiter_fixture_shape():
|
||||||
|
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
|
||||||
|
assert doc["name"] == "mitarbeiter_einstellen"
|
||||||
|
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||||
|
form = nodes["CustomInputMitarbeiter-mitarbeiter"]
|
||||||
|
assert form["data"]["type"] == "CustomInputMitarbeiter"
|
||||||
|
assert form["data"]["node"]["display_name"] == "Mitarbeiter Einstellen Form"
|
||||||
|
template = form["data"]["node"]["template"]
|
||||||
|
assert set(template) >= {
|
||||||
|
"form_values",
|
||||||
|
"rolle",
|
||||||
|
"einstufung",
|
||||||
|
"mitarbeiter_name",
|
||||||
|
"geburtsdatum",
|
||||||
|
"code",
|
||||||
|
}
|
||||||
|
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
|
||||||
|
assert outputs["form_data"]["types"] == ["JSON"]
|
||||||
|
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
||||||
|
assert ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter") in edges
|
||||||
|
assert ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt") in edges
|
||||||
|
assert ("Gehaltsrechner-gehalt", "ChatOutput-collected") in edges
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mitarbeiter_deploy_and_run_salary(client, lfx_server, login):
|
||||||
|
await login()
|
||||||
|
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
|
||||||
|
created = await client.post("/api/flows", json={"name": "mitarbeiter-e2e"})
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
flow_id = created.json()["id"]
|
||||||
|
|
||||||
|
version = await client.post(
|
||||||
|
f"/api/flows/{flow_id}/versions",
|
||||||
|
json={"langflow_json": doc, "author": "test"},
|
||||||
|
)
|
||||||
|
assert version.status_code == 201, version.text
|
||||||
|
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
||||||
|
assert deployed.status_code == 200, deployed.text
|
||||||
|
|
||||||
|
run = await client.post(
|
||||||
|
f"/api/flows/{flow_id}/run",
|
||||||
|
json={
|
||||||
|
"input": {
|
||||||
|
"rolle": "Pfarrer:in",
|
||||||
|
"einstufung": "Stufe 2",
|
||||||
|
"mitarbeiter_name": "Maria Muster",
|
||||||
|
"geburtsdatum": "1980-01-01",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert run.status_code == 200, run.text
|
||||||
|
assert run.json()["status"] == "completed"
|
||||||
|
result = run.json()["output"]["result"]
|
||||||
|
assert "65000" in result
|
||||||
|
assert "Maria Muster" in result
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@ def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
|
||||||
return {"data": {"nodes": [node], "edges": []}}
|
return {"data": {"nodes": [node], "edges": []}}
|
||||||
|
|
||||||
|
|
||||||
|
def _dropdown(name, options, **kw):
|
||||||
|
return _field(name, "DropdownInput", options=options, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_schema(doc):
|
||||||
|
schema = parser.extract_form_schema(doc)["json_schema"]
|
||||||
|
assert "allOf" in schema
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def test_type_mapping_table():
|
def test_type_mapping_table():
|
||||||
doc = _doc(
|
doc = _doc(
|
||||||
_field("a", "StrInput", value="x"),
|
_field("a", "StrInput", value="x"),
|
||||||
|
|
@ -192,3 +202,209 @@ async def test_input_schema_endpoint(client, lfx_server, login):
|
||||||
assert body["fallback"] is None
|
assert body["fallback"] is None
|
||||||
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
||||||
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
|
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_rename_keys_kept_verbatim():
|
||||||
|
doc = _doc(
|
||||||
|
_field("name", "StrInput", display_name="Name", required=True),
|
||||||
|
_field("favorite room", "StrInput"),
|
||||||
|
)
|
||||||
|
schema = parser.extract_form_schema(doc)
|
||||||
|
assert set(schema["json_schema"]["properties"]) == {"name", "favorite room"}
|
||||||
|
assert schema["json_schema"]["required"] == ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_name_skipped_and_collision_suffixed():
|
||||||
|
a = _field("a", "StrInput")
|
||||||
|
clash = _field("other_key", "StrInput")
|
||||||
|
clash["name"] = "a" # Langflow rename collision: second field gets a suffix
|
||||||
|
empty = _field("zzz", "StrInput")
|
||||||
|
empty["name"] = " "
|
||||||
|
node = {
|
||||||
|
"data": {
|
||||||
|
"type": "CustomInputForm",
|
||||||
|
"node": {
|
||||||
|
"display_name": "CustomInput Form",
|
||||||
|
"template": {"a": a, "other_key": clash, "zzz": empty},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc = {"data": {"nodes": [node], "edges": []}}
|
||||||
|
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
|
||||||
|
assert set(props) == {"a", "a_2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_if_single_branch():
|
||||||
|
doc = _doc(
|
||||||
|
_dropdown("gebaeude", ["Kirche", "Halle"], required=True),
|
||||||
|
_field(
|
||||||
|
"bestandteil",
|
||||||
|
"MultiselectInput",
|
||||||
|
options=["Glocke", "Orgel"],
|
||||||
|
info="Bitte wählen [visible_if gebaeude=Kirche]",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out = parser.extract_form_schema(doc)
|
||||||
|
schema = out["json_schema"]
|
||||||
|
# Conditional field lives only in its branch, not at root.
|
||||||
|
assert set(schema["properties"]) == {"gebaeude"}
|
||||||
|
assert schema["required"] == ["gebaeude"]
|
||||||
|
assert len(schema["allOf"]) == 1
|
||||||
|
rule = schema["allOf"][0]
|
||||||
|
assert rule["if"] == {
|
||||||
|
"properties": {"gebaeude": {"const": "Kirche"}},
|
||||||
|
"required": ["gebaeude"],
|
||||||
|
}
|
||||||
|
assert rule["then"]["properties"]["bestandteil"]["type"] == "array"
|
||||||
|
assert rule["then"]["required"] == ["bestandteil"]
|
||||||
|
# Marker stripped from the shown description.
|
||||||
|
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
|
||||||
|
assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_if_nested_three_levels():
|
||||||
|
doc = _doc(
|
||||||
|
_dropdown("a", ["x", "y"]),
|
||||||
|
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
|
||||||
|
_field("c", "IntInput", info="[visible_if b=p]"),
|
||||||
|
_field("d", "StrInput", info="[visible_if c=3]"),
|
||||||
|
)
|
||||||
|
schema = _branch_schema(doc)
|
||||||
|
assert set(schema["properties"]) == {"a"}
|
||||||
|
assert len(schema["allOf"]) == 3
|
||||||
|
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
|
||||||
|
assert by_field["b"]["if"] == {
|
||||||
|
"properties": {"a": {"const": "x"}},
|
||||||
|
"required": ["a"],
|
||||||
|
}
|
||||||
|
assert by_field["c"]["if"]["properties"] == {"b": {"const": "p"}}
|
||||||
|
assert by_field["d"]["if"]["properties"] == {"c": {"const": 3}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_if_and_quoted_bool_multiselect():
|
||||||
|
doc = _doc(
|
||||||
|
_dropdown("a", ["x", "y"]),
|
||||||
|
_field("flag", "BoolInput"),
|
||||||
|
_field(
|
||||||
|
"m",
|
||||||
|
"MultiselectInput",
|
||||||
|
options=["Glocke", "Orgel"],
|
||||||
|
info='[visible_if a="x" & flag=true]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
schema = _branch_schema(doc)
|
||||||
|
rule = schema["allOf"][0]
|
||||||
|
assert rule["if"] == {
|
||||||
|
"properties": {"a": {"const": "x"}, "flag": {"const": True}},
|
||||||
|
"required": ["a", "flag"],
|
||||||
|
}
|
||||||
|
assert rule["then"]["properties"]["m"]["type"] == "array"
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_if_multiselect_trigger_uses_contains():
|
||||||
|
doc = _doc(
|
||||||
|
_field("tags", "MultiselectInput", options=["Glocke", "Orgel"]),
|
||||||
|
_field("detail", "StrInput", info="[visible_if tags=Glocke]"),
|
||||||
|
)
|
||||||
|
schema = _branch_schema(doc)
|
||||||
|
assert schema["allOf"][0]["if"]["properties"] == {"tags": {"contains": {"const": "Glocke"}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_if_dangling_unsupported_cycle_fail_open():
|
||||||
|
doc = _doc(
|
||||||
|
_dropdown("a", ["x", "y"]),
|
||||||
|
_field("ok", "StrInput", info="[visible_if a=x]"),
|
||||||
|
_field("dangling", "StrInput", info="[visible_if nope=x]"),
|
||||||
|
_field("slider_dep", "StrInput", info="[visible_if slid=x]"),
|
||||||
|
_field(
|
||||||
|
"slid",
|
||||||
|
"SliderInput",
|
||||||
|
value=3,
|
||||||
|
range_spec={"min": 1, "max": 5, "step": 1},
|
||||||
|
),
|
||||||
|
_field("c1", "StrInput", info="[visible_if c2=x]"),
|
||||||
|
_field("c2", "StrInput", info="[visible_if c1=x]"),
|
||||||
|
_field("selfish", "StrInput", info="[visible_if selfish=x]"),
|
||||||
|
)
|
||||||
|
schema = parser.extract_form_schema(doc)["json_schema"]
|
||||||
|
# Only the valid marker produces a branch; the rest stay at root.
|
||||||
|
assert len(schema["allOf"]) == 1
|
||||||
|
assert set(schema["properties"]) >= {
|
||||||
|
"a",
|
||||||
|
"dangling",
|
||||||
|
"slider_dep",
|
||||||
|
"slid",
|
||||||
|
"c1",
|
||||||
|
"c2",
|
||||||
|
"selfish",
|
||||||
|
}
|
||||||
|
assert "ok" not in schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_markers_no_allof():
|
||||||
|
doc = _doc(_dropdown("a", ["x", "y"]))
|
||||||
|
schema = parser.extract_form_schema(doc)["json_schema"]
|
||||||
|
assert "allOf" not in schema
|
||||||
|
assert set(schema["properties"]) == {"a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_component_form_display_match():
|
||||||
|
"""User-built * Form components parse without a kirche base class."""
|
||||||
|
doc = _doc(
|
||||||
|
_field("mitarbeiter_name", "StrInput", info="Vor- und Nachname"),
|
||||||
|
_dropdown("rolle", ["Assistenz", "Pfarrer:in"]),
|
||||||
|
node_type="CustomComponent",
|
||||||
|
display="Mitarbeiter Einstellen Form",
|
||||||
|
)
|
||||||
|
schema = parser.extract_form_schema(doc)
|
||||||
|
assert "fallback" not in schema
|
||||||
|
props = schema["json_schema"]["properties"]
|
||||||
|
assert set(props) == {"mitarbeiter_name", "rolle"}
|
||||||
|
assert props["rolle"]["enum"] == ["Assistenz", "Pfarrer:in"]
|
||||||
|
assert props["mitarbeiter_name"]["description"] == "Vor- und Nachname"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_component_non_form_falls_back():
|
||||||
|
doc = _doc(
|
||||||
|
_field("payload", "StrInput"),
|
||||||
|
node_type="CustomComponent",
|
||||||
|
display="Rule Check",
|
||||||
|
)
|
||||||
|
assert parser.extract_form_schema(doc)["fallback"] == "textarea"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_mitarbeiter_file_parses():
|
||||||
|
path = Path(__file__).parent.parent / "flows" / "mitarbeiter_einstellen.json"
|
||||||
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
schema = parser.extract_form_schema(doc)
|
||||||
|
assert "fallback" not in schema
|
||||||
|
props = schema["json_schema"]["properties"]
|
||||||
|
assert set(props) == {"rolle", "mitarbeiter_name", "geburtsdatum"}
|
||||||
|
assert schema["json_schema"]["required"] == ["rolle", "mitarbeiter_name"]
|
||||||
|
# Einstufung is branched (visible only for Pfarrer:in).
|
||||||
|
assert len(schema["json_schema"]["allOf"]) == 1
|
||||||
|
rule = schema["json_schema"]["allOf"][0]
|
||||||
|
assert rule["if"]["properties"] == {"rolle": {"const": "Pfarrer:in"}}
|
||||||
|
assert set(rule["then"]["properties"]) == {"einstufung"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_field_names_covers_branches():
|
||||||
|
doc = _doc(
|
||||||
|
_dropdown("a", ["x", "y"]),
|
||||||
|
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
|
||||||
|
_field("c", "IntInput", info="[visible_if b=p]"),
|
||||||
|
)
|
||||||
|
cached = parser.extract_form_schema(doc)
|
||||||
|
assert parser.schema_field_names(cached) == ["a", "b", "c"]
|
||||||
|
plain = parser.extract_form_schema(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert parser.schema_field_names(plain) == []
|
||||||
|
assert parser.schema_field_names({}) == []
|
||||||
|
assert parser.schema_field_names({"json_schema": None}) == []
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.flows import export
|
||||||
|
|
||||||
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_flows_pass_lfx_validate():
|
def test_canonical_flows_pass_lfx_validate():
|
||||||
files = sorted(FLOW_DIR.glob("*.json"))
|
files = export.iter_flow_files(FLOW_DIR)
|
||||||
assert files, "no canonical flows found"
|
assert files, "no canonical flows found"
|
||||||
process = subprocess.run(
|
process = subprocess.run(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,14 @@ async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
|
||||||
assert versions[0].deployed_at is not None
|
assert versions[0].deployed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_skips_ignored_files(session, lfx_server, tmp_path):
|
||||||
|
_seed_dir(tmp_path)
|
||||||
|
(tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8")
|
||||||
|
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||||
|
assert report.added == [] and report.errors == {}
|
||||||
|
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
|
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
|
||||||
_seed_dir(tmp_path)
|
_seed_dir(tmp_path)
|
||||||
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||||
|
|
|
||||||
101
docs/new_workflow_module.md
Normal file
101
docs/new_workflow_module.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# New workflow module: form flow end to end
|
||||||
|
|
||||||
|
How to add a form-based workflow (web form → run → result), learned from
|
||||||
|
building intake, Bauvorhaben, and Mitarbeiter-Einstellen. Three layers:
|
||||||
|
component (Langflow palette), flow (canvas), backend parsing (automatic).
|
||||||
|
|
||||||
|
## 1. Form component (`backend/components/kirche/<name>_form.py`)
|
||||||
|
|
||||||
|
Copy `mitarbeiter_form.py` (small) or `bauvorhaben_form.py` (branching).
|
||||||
|
Rename the class with a **`CustomInput*` prefix** (e.g.
|
||||||
|
`CustomInputMitarbeiter`) — the backend parser matches that prefix; a
|
||||||
|
plain `CustomComponent` name additionally needs a display name ending
|
||||||
|
in `"Form"`.
|
||||||
|
|
||||||
|
Rules, all load-bearing:
|
||||||
|
|
||||||
|
- **Self-contained**: stdlib + `lfx` only. NEVER import from sibling
|
||||||
|
form modules — LFX executes the embedded module source without our
|
||||||
|
package, and Langflow round-trips the code. Duplicated helpers are
|
||||||
|
structural, not laziness.
|
||||||
|
- **Inputs**: one `lfx.io` entry per question (`name` = stable JSON key,
|
||||||
|
`display_name` = label, `info` = help, `options`/`value`/`required`
|
||||||
|
as needed) plus the hidden `form_values` plumbing input (reserved
|
||||||
|
name — never use it for a field).
|
||||||
|
- **Branching**: `[visible_if feld=Wert]` markers in `info`
|
||||||
|
(`&` for AND, quotes for spaces). Any depth; trigger types:
|
||||||
|
Dropdown/Text `const`, Bool, Multiselect `contains`, Int/Float exact.
|
||||||
|
Stale markers, cycles, and unknown triggers fail open (always visible).
|
||||||
|
- **Required sentinels**: required fields with empty values carry
|
||||||
|
`" "` (`[" "]` for Multiselect — a plain string crashes LFX at build,
|
||||||
|
`[]` fails validation). The parser treats them as "no default".
|
||||||
|
- **Outputs**: exactly `Output(name="form_data", method="collect")`
|
||||||
|
returning `Data`. Do not touch `collect()` — it is fully dynamic
|
||||||
|
(asked + visible defaults, submitted values win).
|
||||||
|
- Export in `backend/components/kirche/__init__.py`.
|
||||||
|
|
||||||
|
Then: `docker compose restart langflow` (components load at startup),
|
||||||
|
check the palette via `GET /api/v1/all` (or sidebar search, hard-refresh
|
||||||
|
the browser tab if the entry is missing — the API is source of truth).
|
||||||
|
|
||||||
|
## 2. Flow JSON (`backend/flows/<name>.json`)
|
||||||
|
|
||||||
|
Never hand-edit: add `build_<name>()` in
|
||||||
|
`backend/scripts/make_flow_fixtures.py` and run it (from `backend/`).
|
||||||
|
|
||||||
|
- Form node via `kirche_node(node_id, component_cls=..., code_override=...)`.
|
||||||
|
- Logic nodes via `custom_node(CODE, ...)` (keep return annotations like
|
||||||
|
`-> Message`: edge handles derive output types from them).
|
||||||
|
- Always wire `ChatInput → form_values` (run envelope), then
|
||||||
|
`form_data → … → ChatOutput`.
|
||||||
|
- Reorder template keys to question order (lfx emits alphabetical; RJSF
|
||||||
|
renders insertion order, and `ui:order` follows it).
|
||||||
|
- The script writes fixtures + canonical files; `git checkout` the churn
|
||||||
|
on unrelated flows, keep canonical files CRLF like the repo.
|
||||||
|
- Custom logic code (e.g. salary math) lives as a `*_CODE` constant in
|
||||||
|
the script — one owner for template and code.
|
||||||
|
|
||||||
|
## 3. Backend parsing (automatic, know the contract)
|
||||||
|
|
||||||
|
`app/flows/input_schema.py` turns the node template into
|
||||||
|
`{json_schema, ui_schema}` per version: `name` → key (verbatim; empty
|
||||||
|
skipped, collisions suffixed), `display_name`/`info`/`value`/`options`
|
||||||
|
→ title/description/default/enum, `show: false` skipped, unknown types
|
||||||
|
degrade to text. Markers become `allOf`/`if`/`then` branches;
|
||||||
|
branch-`required` applies only while visible.
|
||||||
|
|
||||||
|
Runs carry an envelope (`payload`, `fields` = asked names from the
|
||||||
|
cached schema, run context). `collect()` emits defaults only for asked
|
||||||
|
AND visible fields; submitted values always win; hidden values are kept
|
||||||
|
but never inventoried. Raw dicts and the Langflow playground use all
|
||||||
|
visible declared defaults.
|
||||||
|
|
||||||
|
## 4. Langflow round-trip (where flows go to die, if careless)
|
||||||
|
|
||||||
|
- Node **type names must be distinct per component** — Langflow
|
||||||
|
normalizes same-type nodes to the installed class on import and
|
||||||
|
silently discards variant templates.
|
||||||
|
- Upload via `POST /api/v1/flows/upload/`, then fetch the flow back and
|
||||||
|
assert node type, template fields, and code are intact.
|
||||||
|
- Restart re-seeds `backend/flows/` into Langflow; the sync button pulls
|
||||||
|
Langflow → files **matched by flow name, overwriting silently**.
|
||||||
|
Commit before syncing, review the `pulled` report, never rename or
|
||||||
|
repurpose flows casually.
|
||||||
|
- Experiments that must stay out: list their stem in
|
||||||
|
`backend/flows/.flowsignore` (skipped by sync, deploy, validation,
|
||||||
|
frontend, and future pulls).
|
||||||
|
|
||||||
|
## 5. Verify before reporting done
|
||||||
|
|
||||||
|
- `uv run pytest` (full suite incl. real-LFX deploy+run e2e) green.
|
||||||
|
- `uv run ruff check .` clean, `uv run ruff format --check .` clean.
|
||||||
|
- `uv run lfx validate --level 4 tests/fixtures/flows/` all pass.
|
||||||
|
- Live: `GET /api/flows/{id}/input-schema` shows `fallback: null`
|
||||||
|
with expected properties (cached schemas refresh only on new
|
||||||
|
versions — unchanged files keep their cached schema).
|
||||||
|
- Browser click-through in Workflows, including a branched path and an
|
||||||
|
empty submit (branch-required errors).
|
||||||
|
|
||||||
|
New behavior ships with tests; schema changes ship as Alembic revisions
|
||||||
|
(never edit applied migrations); commit on `feat/<short>`, never
|
||||||
|
directly on `main`.
|
||||||
|
|
@ -202,10 +202,10 @@ export const api = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name, email, reason }),
|
body: JSON.stringify({ name, email, reason }),
|
||||||
}),
|
}),
|
||||||
listAccessRequests: (status?: string) =>
|
listAccessRequests: (status?: string) => {
|
||||||
req<AccessRequest[]>(
|
const qs = new URLSearchParams(status ? { status } : {}).toString();
|
||||||
`/api/access/requests${status ? `?status=${status}` : ""}`,
|
return req<AccessRequest[]>(`/api/access/requests${qs ? `?${qs}` : ""}`);
|
||||||
),
|
},
|
||||||
decideAccess: (id: string, approve: boolean) =>
|
decideAccess: (id: string, approve: boolean) =>
|
||||||
req<{ request: AccessRequest; email_sent: boolean }>(
|
req<{ request: AccessRequest; email_sent: boolean }>(
|
||||||
`/api/access/requests/${id}/${approve ? "approve" : "reject"}`,
|
`/api/access/requests/${id}/${approve ? "approve" : "reject"}`,
|
||||||
|
|
|
||||||
|
|
@ -179,40 +179,67 @@ export default function AdminTab() {
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{flows.map((f) =>
|
{flows.map((f) => {
|
||||||
(versions[f.id] ?? []).map((v, i) => (
|
const list = versions[f.id] ?? [];
|
||||||
<Table.Tr key={v.id}>
|
const latest = list[list.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
return (
|
||||||
|
<Table.Tr key={f.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{i === 0 && (
|
|
||||||
<>
|
|
||||||
<strong>{f.name}</strong>{" "}
|
<strong>{f.name}</strong>{" "}
|
||||||
<Text size="xs" c="dimmed" component="span">
|
<Text size="xs" c="dimmed" component="span">
|
||||||
{f.source_path ? `(${f.source_path})` : "(API)"}
|
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</Table.Td>
|
||||||
|
<Table.Td>–</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusBadge status="pending" />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>–</Table.Td>
|
||||||
|
<Table.Td />
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const live = [...list]
|
||||||
|
.reverse()
|
||||||
|
.find((v) => v.deployed_at && v.id !== latest.id);
|
||||||
|
return (
|
||||||
|
<Table.Tr key={latest.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<strong>{f.name}</strong>{" "}
|
||||||
|
<Text size="xs" c="dimmed" component="span">
|
||||||
|
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
v{latest.version_no}
|
||||||
|
{live && (
|
||||||
|
<Text size="xs" c="dimmed" component="span">
|
||||||
|
{" "}
|
||||||
|
(live: v{live.version_no})
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>v{v.version_no}</Table.Td>
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{v.deployed_at ? (
|
{latest.deployed_at ? (
|
||||||
<StatusBadge status="deployed" />
|
<StatusBadge status="deployed" />
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge status="pending" />
|
<StatusBadge status="pending" />
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{v.author || "–"}</Table.Td>
|
<Table.Td>{latest.author || "–"}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => void onDeploy(f.id, v.version_no)}
|
onClick={() => void onDeploy(f.id, latest.version_no)}
|
||||||
>
|
>
|
||||||
Deployen
|
Deployen
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)),
|
);
|
||||||
)}
|
})}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
11
frontend/src/components/ErrorAlert.tsx
Normal file
11
frontend/src/components/ErrorAlert.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Alert } from "@mantine/core";
|
||||||
|
import { IconAlertCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export function ErrorAlert({ message }: { message: string | null }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return (
|
||||||
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/RunsTable.tsx
Normal file
30
frontend/src/components/RunsTable.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Code, Table } from "@mantine/core";
|
||||||
|
import StatusBadge from "./StatusBadge";
|
||||||
|
import type { Run } from "../api";
|
||||||
|
|
||||||
|
export function RunsTable({ runs }: { runs: Run[] }) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Ergebnis</Table.Th>
|
||||||
|
<Table.Th>Erstellt</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<Table.Tr key={r.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{new Date(r.created_at).toLocaleString("de-DE")}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,19 +3,19 @@ import Form from "@rjsf/mantine";
|
||||||
import validator from "@rjsf/validator-ajv8";
|
import validator from "@rjsf/validator-ajv8";
|
||||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Code,
|
Code,
|
||||||
Select,
|
Select,
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconAlertCircle, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||||
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
|
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
|
||||||
import StatusBadge from "../components/StatusBadge";
|
import { RunsTable } from "../components/RunsTable";
|
||||||
|
import { ErrorAlert } from "../components/ErrorAlert";
|
||||||
|
import { formatApiError } from "../utils";
|
||||||
|
|
||||||
export default function WorkflowsPage() {
|
export default function WorkflowsPage() {
|
||||||
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||||
|
|
@ -29,7 +29,7 @@ export default function WorkflowsPage() {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const fail = useCallback((e: unknown) => {
|
const fail = useCallback((e: unknown) => {
|
||||||
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
setError(formatApiError(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -85,9 +85,9 @@ export default function WorkflowsPage() {
|
||||||
Workflows
|
Workflows
|
||||||
</Title>
|
</Title>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
<div style={{ marginBottom: 12 }}>
|
||||||
{error}
|
<ErrorAlert message={error} />
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Flow"
|
label="Flow"
|
||||||
|
|
@ -154,28 +154,7 @@ export default function WorkflowsPage() {
|
||||||
{runs.length === 0 ? (
|
{runs.length === 0 ? (
|
||||||
<Text c="dimmed">Noch keine Läufe.</Text>
|
<Text c="dimmed">Noch keine Läufe.</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table striped highlightOnHover>
|
<RunsTable runs={runs} />
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th>Mandant</Table.Th>
|
|
||||||
<Table.Th>Ergebnis</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{runs.map((r) => (
|
|
||||||
<Table.Tr key={r.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<StatusBadge status={r.status} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{r.tenant_id}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
9
frontend/src/utils.ts
Normal file
9
frontend/src/utils.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/* Shared frontend helpers: error formatting, config (was scattered). */
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
export function formatApiError(e: unknown): string {
|
||||||
|
if (e instanceof ApiError) return `${e.status}: ${e.message}`;
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LANGFLOW_URL_FALLBACK = "http://localhost:7860";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue