diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0e593..33b5049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed - Declutter: shared `BaseApiClient` (LFX/Langflow) with tenacity transport retry, single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`, atomicwrites file writes, `secure`-package headers. -- Auth: stateless JWT sessions (HS256, 30d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`. +- Auth: stateless JWT sessions (HS256, 7d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`. - Forms: thin variant components over shared `components/kirche/_form_core.py` (codegen-inlined by `kirche_node()` for LFX; all 5 flows regenerated + level-4 validated), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI). - Store/sync: single `next_version_no` query per version, validation only at the `create_version` choke point, mtime-cached `.flowsignore`. -- Fix batch: typer CLIs exit non-zero via `typer.Exit` (`check_prod` gate works again); JWT expiry 30d→7d with startup warning and new expiry/deleted-user tests; deleted dead `resolve_tenant`, test alias helpers now delegate to `conftest` (flows/approvals/sync/deploy suites migrated to shared fixtures); `AdminTab` reads via react-query; Mantine aligned to 9.6.3 (consistent lockfile, `npm ci` green). +- Fix batch: typer CLIs exit non-zero via `typer.Exit` (`check_prod` gate works again); JWT expiry 7d with startup warning and new expiry/deleted-user tests; deleted dead `resolve_tenant`, test alias helpers now delegate to `conftest` (flows/approvals/sync/deploy suites migrated to shared fixtures); `AdminTab` reads via react-query; Mantine aligned to 9.6.3 (consistent lockfile, `npm ci` green). +- Removed `backend/flows/test_flow.json` (legacy Message-shape demo, superseded by `intake_form.json`; Langflow copy deleted first, sync cascades runs by design); `FlowVersion.input_schema` is now NOT NULL (backfill migration); `smoke_e2e.py` reuses `tests/_harness.py` and runs green; `FlowHelp` docs moved to `docs/flow-authoring.md`; shared `StatusBadge` status union, single `RunsTable`, `photos.css` class via one constant. - Tests (full real LFX kept): shared `conftest` helpers + `tests/_harness.py` adopted by fixtures and smoke script, parametrized matrices (export stems, check_prod env, deploy failures, sync listing failures, API auth probes, input_schema types), relaxed brittle asserts (ui:order sets, per-flag cookie asserts, SSE any-match). - Frontend: react-query reads + notifications, `useForm`+zod static forms, shared `DecisionButtons`/`ErrorAlert`/`JsonView`/`FormattedDate`/`RunsTable`/`Landing` blocks, new aggregate `GET /api/runs` kills the Tasks N+1, `Promise.all` version fetches, `sharp` icons, selector-based screenshots, dead photo removed. ### Added diff --git a/README.md b/README.md index 8a7edcf..17203a3 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,12 @@ needs judgment. Every step is documented and traceable. can automatically start a follow-up ("continuation") flow. - **Access management** — public landing page (German) with an access-request form (request + approval). Approved users get a passwordless magic-link - invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants - come from the session. First admin via `POST /api/auth/admin-bootstrap` - (only on an empty user table, email must be in `ADMIN_EMAILS`). + invite by email (SMTP via `.env`); sessions are stateless JWTs (HS256, + 7-day expiry) in HttpOnly cookies — no server-side revocation, logout + clears the cookie, deleted users fail closed. Tenants come from the + session. `JWT_SECRET` (long random value) signs the cookies. First admin + via `POST /api/auth/admin-bootstrap` (only on an empty user table, email + must be in `ADMIN_EMAILS`). - **Dynamic forms** — flows declare form fields as typed inputs on a `CustomInput*` custom component (reusable library in `backend/components/`, mounted into Langflow via @@ -125,6 +128,8 @@ Frontend (node lives in the `frontend` container): running via compose; this is for a second instance or flags). - `docker compose exec frontend npm run build` — typecheck + build. - Without a docker socket: VS Code "Attach to Running Container" → frontend. +- Local `npm install`: use `--legacy-peer-deps` (Mantine 9 + React 19 + peer range); `npm ci` inside the image works off the committed lockfile. Flow workflow: design in Langflow → press "Aus Langflow synchronisieren" in Verwaltung (manual full pass: Langflow → `backend/flows/.json` → @@ -160,7 +165,9 @@ proxy — nothing here substitutes for it): critical unauthenticated RCEs — treat its UI as privileged tooling, never share flows publicly ("Shareable Playground" = RCE risk). - Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate - after any incident or staff change; re-run `check_prod.py`. + after any incident or staff change; re-run `check_prod.py`. `JWT_SECRET` + signs session cookies — rotating it logs everyone out (same on every + deploy that drops session state: users re-login via magic link). - Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION` (`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then `lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 371fe7b..a998c11 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -254,7 +254,7 @@ def _jwt_secret() -> str: def mint_session_token(user: User) -> str: - """Issue a stateless session JWT for a user (30-day expiry).""" + """Issue a stateless session JWT for a user (7-day expiry).""" now = utcnow() return jwt.encode( { diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index df840dd..5f05bd0 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -27,7 +27,6 @@ from app.middleware import limiter, run_limit from app.models import User from app.settings import settings -from . import input_schema as form_schema from . import runner, store from .deploy import import_from_langflow from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable @@ -363,7 +362,7 @@ def get_input_schema( version = store.get_latest_deployed_version(session, flow_id) except (store.NotFoundError, store.InvalidError) as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc - cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json) + cached = version.input_schema return InputSchemaRead( flow_id=flow_id, version_no=version.version_no, diff --git a/backend/app/flows/runner.py b/backend/app/flows/runner.py index d54574e..24cb910 100644 --- a/backend/app/flows/runner.py +++ b/backend/app/flows/runner.py @@ -70,7 +70,7 @@ async def execute_run( run = store.create_run( session, version, input=payload, tenant_id=tenant_id, session_id=session_id ) - cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json) + cached = version.input_schema envelope = build_envelope( payload=payload, run_id=run.id, @@ -119,7 +119,7 @@ async def stream_run( run = store.create_run( session, version, input=payload, tenant_id=tenant_id, session_id=session_id ) - cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json) + cached = version.input_schema envelope = build_envelope( payload=payload, run_id=run.id, diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py index 1d263d1..b37f433 100644 --- a/backend/app/flows/schemas.py +++ b/backend/app/flows/schemas.py @@ -37,7 +37,7 @@ class FlowVersionRead(BaseModel): deployed_at: datetime | None author: str created_at: datetime - input_schema: dict | None = None + input_schema: dict class InputSchemaRead(BaseModel): diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py index 1e7d00a..a29e259 100644 --- a/backend/app/models/flow.py +++ b/backend/app/models/flow.py @@ -47,8 +47,8 @@ class FlowVersion(SQLModel, table=True): author: str = Field(default="", max_length=200) created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) # Cached form schema for this exact JSON (see app/flows/input_schema.py). - # Computed at version creation; nullable for pre-Phase-3 rows. - input_schema: dict | None = Field(default=None, sa_column=_json_column()) + # Computed at version creation; backfilled NOT NULL (e8f3a1c2b4d6). + input_schema: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) class FlowRun(SQLModel, table=True): diff --git a/backend/flows/test_flow.json b/backend/flows/test_flow.json deleted file mode 100644 index d11759b..0000000 --- a/backend/flows/test_flow.json +++ /dev/null @@ -1,747 +0,0 @@ -{ - "data": { - "edges": [ - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomInputForm", - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "name": "form_data", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-HkK3i", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__ext:kirche:CustomInputForm@extra-BhL6Q{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-HkK3i{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}", - "selected": false, - "source": "ext:kirche:CustomInputForm@extra-BhL6Q", - "sourceHandle": "{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}", - "target": "ChatOutput-HkK3i", - "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}" - } - ], - "nodes": [ - { - "data": { - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "node": { - "base_classes": [ - "Message" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Collects structured form values into a single JSON message.", - "display_name": "CustomInput Form", - "documentation": "", - "edited": false, - "field_order": [ - "form_values", - "full_name", - "notes", - "password", - "age", - "amount", - "newsletter", - "department", - "topics", - "satisfaction", - "attachment" - ], - "frozen": false, - "icon": "ClipboardList", - "legacy": false, - "lf_version": "1.12.0", - "metadata": { - "code_hash": "6d186fa88d37", - "dependencies": { - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - } - ], - "total_dependencies": 1 - }, - "module": "custom_components.custominput_form" - }, - "minimized": false, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Form Data", - "group_outputs": false, - "loop_types": null, - "method": "collect", - "name": "form_data", - "options": null, - "required_inputs": null, - "selected": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "age": { - "_input_type": "IntInput", - "advanced": false, - "api_editable": false, - "display_name": "Age", - "dynamic": false, - "info": "Your age in years", - "list": false, - "list_add_label": "Add More", - "name": "age", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "int", - "value": 18 - }, - "amount": { - "_input_type": "FloatInput", - "advanced": false, - "api_editable": false, - "display_name": "Amount", - "dynamic": false, - "info": "Amount in euros", - "list": false, - "list_add_label": "Add More", - "name": "amount", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "float", - "value": 0 - }, - "attachment": { - "_input_type": "FileInput", - "advanced": false, - "api_editable": false, - "display_name": "Attachment", - "dynamic": false, - "fileTypes": [ - "pdf", - "png", - "jpg" - ], - "file_path": "", - "info": "String reference only for now (no upload yet)", - "list": false, - "list_add_label": "Add More", - "name": "attachment", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "temp_file": false, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "file", - "value": "" - }, - "code": { - "advanced": true, - "api_editable": false, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "code", - "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.message import Message\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except json.JSONDecodeError:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\n" - }, - "department": { - "_input_type": "DropdownInput", - "advanced": false, - "api_editable": false, - "combobox": false, - "dialog_inputs": {}, - "display_name": "Department", - "dynamic": false, - "external_options": {}, - "info": "Which team is this for", - "name": "department", - "options": [ - "Music", - "Youth", - "Seniors", - "Admin" - ], - "options_metadata": [], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "str", - "value": "Youth" - }, - "form_values": { - "_input_type": "MessageTextInput", - "advanced": false, - "api_editable": false, - "display_name": "Form values (JSON envelope)", - "dynamic": false, - "info": "Submitted values as JSON; merged over the defaults", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "form_values", - "override_skip": false, - "placeholder": "", - "required": false, - "show": false, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "full_name": { - "_input_type": "StrInput", - "advanced": false, - "api_editable": false, - "display_name": "Full name", - "dynamic": false, - "info": "Your first and last name", - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "full_name", - "override_skip": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "Hi there" - }, - "newsletter": { - "_input_type": "BoolInput", - "advanced": false, - "api_editable": false, - "display_name": "Newsletter", - "dynamic": false, - "info": "Send me the parish newsletter", - "list": false, - "list_add_label": "Add More", - "name": "newsletter", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - }, - "notes": { - "_input_type": "MultilineInput", - "advanced": false, - "ai_enabled": false, - "api_editable": false, - "copy_field": false, - "display_name": "Notes", - "dynamic": false, - "info": "Anything else we should know", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "multiline": true, - "name": "notes", - "override_skip": false, - "password": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "Blabla" - }, - "password": { - "_input_type": "SecretStrInput", - "advanced": false, - "api_editable": false, - "display_name": "Password", - "dynamic": false, - "info": "Optional access phrase", - "input_types": [], - "load_from_db": false, - "name": "password", - "override_skip": false, - "password": true, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "value": "hidden" - }, - "satisfaction": { - "_input_type": "SliderInput", - "advanced": false, - "api_editable": false, - "display_name": "Satisfaction", - "dynamic": false, - "info": "How satisfied are you (1-5)", - "max_label": "", - "max_label_icon": "", - "min_label": "", - "min_label_icon": "", - "name": "satisfaction", - "override_skip": false, - "placeholder": "", - "range_spec": { - "max": 5, - "min": 1, - "step": 1, - "step_type": "float" - }, - "required": false, - "show": true, - "slider_buttons": false, - "slider_buttons_options": [], - "slider_input": false, - "title_case": false, - "tool_mode": false, - "track_in_telemetry": false, - "type": "slider", - "value": 3 - }, - "topics": { - "_input_type": "MultiselectInput", - "advanced": false, - "api_editable": false, - "combobox": false, - "display_name": "Topics", - "dynamic": false, - "info": "What are you interested in", - "list": true, - "list_add_label": "Add More", - "name": "topics", - "options": [ - "Worship", - "Outreach", - "Courses" - ], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": [ - "Outreach", - "Courses" - ] - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "CustomInputForm" - }, - "dragging": false, - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "measured": { - "height": 933, - "width": 320 - }, - "position": { - "x": -2869.215421770588, - "y": -259.29504008742293 - }, - "selected": true, - "type": "genericNode" - }, - { - "data": { - "id": "ChatOutput-HkK3i", - "node": { - "base_classes": [ - "Message" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Display a chat message in the Playground.", - "display_name": "Chat Output", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "edited": false, - "field_order": [ - "input_value", - "should_store_message", - "sender", - "sender_name", - "session_id", - "context_id", - "data_template", - "clean_data" - ], - "frozen": false, - "icon": "MessagesSquare", - "legacy": false, - "lf_version": "1.12.0", - "metadata": { - "code_hash": "84009527d08c", - "dependencies": { - "dependencies": [ - { - "name": "orjson", - "version": "3.11.9" - }, - { - "name": "fastapi", - "version": "0.139.2" - }, - { - "name": "lfx", - "version": null - } - ], - "total_dependencies": 3 - }, - "module": "lfx.components.input_output.chat_output.ChatOutput" - }, - "minimized": true, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Output Message", - "group_outputs": false, - "method": "message_response", - "name": "message", - "selected": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "clean_data": { - "_input_type": "BoolInput", - "advanced": true, - "api_editable": false, - "display_name": "Basic Clean Data", - "dynamic": false, - "info": "Whether to clean data before converting to string.", - "list": false, - "list_add_label": "Add More", - "name": "clean_data", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": true - }, - "code": { - "advanced": true, - "api_editable": false, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "code", - "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" - }, - "context_id": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Context ID", - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "context_id", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "data_template": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Data Template", - "dynamic": false, - "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "data_template", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "{text}" - }, - "input_value": { - "_input_type": "HandleInput", - "advanced": false, - "api_editable": false, - "display_name": "Inputs", - "dynamic": false, - "info": "Message to be passed as output.", - "input_types": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "list": false, - "list_add_label": "Add More", - "name": "input_value", - "override_skip": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "other", - "value": "" - }, - "sender": { - "_input_type": "DropdownInput", - "advanced": true, - "api_editable": false, - "combobox": false, - "dialog_inputs": {}, - "display_name": "Sender Type", - "dynamic": false, - "external_options": {}, - "info": "Type of sender.", - "name": "sender", - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "str", - "value": "Machine" - }, - "sender_name": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Sender Name", - "dynamic": false, - "info": "Name of the sender.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "sender_name", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "AI" - }, - "session_id": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Session ID", - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "session_id", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "should_store_message": { - "_input_type": "BoolInput", - "advanced": true, - "api_editable": false, - "display_name": "Store Messages", - "dynamic": false, - "info": "Store the message in the history.", - "list": false, - "list_add_label": "Add More", - "name": "should_store_message", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": true - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "ChatOutput" - }, - "id": "ChatOutput-HkK3i", - "measured": { - "height": 168, - "width": 320 - }, - "position": { - "x": -2181.727435009515, - "y": 144.80068578973714 - }, - "selected": false, - "type": "genericNode" - } - ], - "viewport": { - "x": 1755.3131372405762, - "y": 167.2405536194492, - "zoom": 0.5755627009646302 - } - }, - "description": "The Pinnacle of Prompt Generation.", - "endpoint_name": null, - "id": "fd440bee-f669-44a2-aa12-6af3bc299731", - "is_component": false, - "name": "test_flow", - "tags": [], - "webhook": false -} diff --git a/backend/migrations/versions/e8f3a1c2b4d6_input_schema_not_null.py b/backend/migrations/versions/e8f3a1c2b4d6_input_schema_not_null.py new file mode 100644 index 0000000..d0ccf88 --- /dev/null +++ b/backend/migrations/versions/e8f3a1c2b4d6_input_schema_not_null.py @@ -0,0 +1,65 @@ +"""Backfill + require FlowVersion.input_schema. + +Revision ID: e8f3a1c2b4d6 +Revises: c4d8f2a1e6b9 +Create Date: 2026-09-30 00:00:00.000000 + +Pre-Phase-3 rows have input_schema NULL. Backfill them from the stored +langflow_json (same parser the write choke point uses); unparseable rows +get the textarea fallback so reads never crash. Then enforce NOT NULL. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e8f3a1c2b4d6" +down_revision: str | Sequence[str] | None = "c4d8f2a1e6b9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_FALLBACK = { + "json_schema": {"type": "object", "properties": {}}, + "ui_schema": {}, + "fallback": "textarea", +} + + +def _derive(langflow_json) -> dict: + import json as stdlib_json + + try: + from app.flows import input_schema as parser + + if isinstance(langflow_json, str): + # SQLite stores JSON columns as TEXT; Postgres returns dict. + langflow_json = stdlib_json.loads(langflow_json) + return parser.extract_form_schema(langflow_json) + except Exception: # noqa: BLE001 -- backfill must never fail the migration + return dict(_FALLBACK) + + +def upgrade() -> None: + """Upgrade schema.""" + import json as stdlib_json + + connection = op.get_bind() + rows = connection.execute( + sa.text("SELECT id, langflow_json FROM flow_versions WHERE input_schema IS NULL") + ).all() + for row_id, langflow_json in rows: + schema = _derive(langflow_json) + connection.execute( + sa.text("UPDATE flow_versions SET input_schema = :schema WHERE id = :id"), + {"schema": stdlib_json.dumps(schema), "id": str(row_id)}, + ) + with op.batch_alter_table("flow_versions") as batch: + batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=False) + + +def downgrade() -> None: + """Downgrade schema (data kept, nullability restored).""" + with op.batch_alter_table("flow_versions") as batch: + batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=True) diff --git a/backend/scripts/smoke_e2e.py b/backend/scripts/smoke_e2e.py index c00092b..b1220d8 100644 --- a/backend/scripts/smoke_e2e.py +++ b/backend/scripts/smoke_e2e.py @@ -11,48 +11,36 @@ from __future__ import annotations import json import os import shutil -import socket import subprocess import sys import tempfile -import time -import urllib.request from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tests._harness import ( + free_port, + migrate_throwaway, + plant_session_jwt, + spawn_lfx, + wait_healthy, +) + BACKEND = Path(__file__).resolve().parent.parent # Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH. VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin") FIXTURES = BACKEND / "tests" / "fixtures" / "flows" - -def _free_port() -> int: - # Ephemeral ports: the dev API/LFX may already occupy the defaults - # (e.g. when smoke runs inside the api container itself). - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -LFX_PORT = _free_port() -API_PORT = _free_port() +# Ephemeral ports: the dev API/LFX may already occupy the defaults +# (e.g. when smoke runs inside the api container itself). +LFX_PORT = free_port() +API_PORT = free_port() LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs" # Stateless admin session JWT, minted in main() after planting the user. _SMOKE_JWT = "" -def wait_healthy(url: str, timeout: int = 180) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=3) as r: - if r.status == 200: - return - except OSError: - time.sleep(1) - raise RuntimeError(f"never healthy: {url}") - - def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]: import httpx @@ -75,35 +63,19 @@ def check(name: str, cond: bool, detail: str = "") -> None: def main() -> None: tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-")) - flow_dir = Path("/tmp/smoke-e2e-flows") - flow_dir.mkdir(exist_ok=True) + flow_dir = Path(tempfile.mkdtemp(prefix="kirche-smoke-flows-")) (flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text()) # Migrate a throwaway SQLite schema to head before starting the API. - from alembic import command as alembic_command - from alembic.config import Config - db_url = f"sqlite:///{tmp / 'smoke.db'}" - cfg = Config() - cfg.set_main_option("script_location", str(BACKEND / "migrations")) - cfg.set_main_option("sqlalchemy.url", db_url) - # migrations/env.py takes the URL from settings (not the ini value): - # point this process at the throwaway DB before migrating. - from app.settings import settings as app_settings - - app_settings.database_url = db_url - alembic_command.upgrade(cfg, "head") + migrate_throwaway(BACKEND, db_url) # Plant an admin user + mint its session JWT: management + runs # require login, and there is no SMTP in smoke to deliver a magic link. # Sessions are stateless (see app/auth/service.py) — the cookie value # below is the JWT sent as `kirche_session`. global _SMOKE_JWT - import os - from sqlmodel import Session, create_engine - from app.auth import service as auth_service - from app.models import User from app.settings import settings as app_settings if not app_settings.jwt_secret: @@ -112,52 +84,34 @@ def main() -> None: ) engine = create_engine(db_url) with Session(engine) as s: - user = User(name="smoke", email="smoke@example.com", role="admin") - s.add(user) - s.commit() - s.refresh(user) - _SMOKE_JWT = auth_service.mint_session_token(user) + _, _SMOKE_JWT = plant_session_jwt(s, email="smoke@example.com", name="smoke") + # Smoke predates the shared harness key; keep its long serve key. print("smoke admin JWT minted") engine.dispose() - env = { - **os.environ, - # lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set - # it. Clear it so the serve key below is the one we send. - "LANGFLOW_API_KEY": "", - "LFX_API_KEY": LFX_KEY, + env_extra = { "DATABASE_URL": db_url, "FLOWS_WATCH_ENABLED": "false", "LFX_URL": f"http://127.0.0.1:{LFX_PORT}", "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", "CALLBACK_TOKEN": "smoke-callback-token", } - lfx = subprocess.Popen( - [ - str(VENV_BIN / "lfx"), - "serve", - "--host", - "127.0.0.1", - "--port", - str(LFX_PORT), - "--flow-dir", - str(flow_dir), - str(flow_dir), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=env, - ) + lfx = spawn_lfx(flow_dir, LFX_PORT, env_extra=env_extra, api_key=LFX_KEY) uvicorn = subprocess.Popen( [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)], cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - env=env, + env={ + **os.environ, + "LANGFLOW_API_KEY": "", + "LFX_API_KEY": LFX_KEY, + **env_extra, + }, ) try: - wait_healthy(f"http://127.0.0.1:{LFX_PORT}/health") - wait_healthy(f"http://127.0.0.1:{API_PORT}/health") + wait_healthy(f"http://127.0.0.1:{LFX_PORT}", deadline_s=180) + wait_healthy(f"http://127.0.0.1:{API_PORT}", deadline_s=180) s, flows = api("GET", "/api/flows") check("list flows", s == 200 and isinstance(flows, list)) diff --git a/backend/tests/_harness.py b/backend/tests/_harness.py index 1774c5e..19b2458 100644 --- a/backend/tests/_harness.py +++ b/backend/tests/_harness.py @@ -1,7 +1,7 @@ """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. +Single place for logic shared between tests/conftest.py and +scripts/smoke_e2e.py. Full real-LFX runs are kept. """ from __future__ import annotations @@ -15,6 +15,10 @@ import time import urllib.request from pathlib import Path +# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the +# repo .env value must be cleared wherever a test key is used. +TEST_LFX_KEY = "test-lfx-key" + def free_port() -> int: sock = socket.socket() @@ -36,10 +40,14 @@ def wait_healthy(url: str, deadline_s: float = 240.0) -> None: raise RuntimeError(f"server did not become healthy: {url}") -def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen: +def spawn_lfx( + flow_dir: Path, port: int, env_extra: dict | None = None, api_key: str = TEST_LFX_KEY +) -> subprocess.Popen: bindir = Path(sys.executable).parent lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx" - env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key"} + env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": api_key} + if env_extra: + env.update(env_extra) return subprocess.Popen( [ lfx_bin, @@ -59,20 +67,21 @@ def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen: ) -def migrate_throwaway(backend_dir: Path) -> None: +def migrate_throwaway(backend_dir: Path, db_url: str | None = None) -> None: from alembic import command as alembic_command from alembic.config import Config cfg = Config() cfg.set_main_option("script_location", str(backend_dir / "migrations")) + if db_url is not None: + cfg.set_main_option("sqlalchemy.url", db_url) + # migrations/env.py takes the URL from settings (not the ini value). + from app.settings import settings as app_settings + + app_settings.database_url = db_url alembic_command.upgrade(cfg, "head") -# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the -# repo .env value must be cleared wherever a test key is used. -TEST_LFX_KEY = "test-lfx-key" - - def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def] """Real uvicorn server in a daemon thread. Returns (server, thread).""" import threading @@ -91,7 +100,7 @@ def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[ return server, thread -def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object: +def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> tuple: """Idempotent test user + session JWT (shared with smoke pattern).""" from sqlmodel import select diff --git a/backend/tests/test_form_components.py b/backend/tests/test_form_components.py index cb59ce4..e8f2379 100644 --- a/backend/tests/test_form_components.py +++ b/backend/tests/test_form_components.py @@ -4,6 +4,7 @@ import json import sys from pathlib import Path +import pytest from lfx.custom import Component REPO_ROOT = Path(__file__).resolve().parent.parent.parent @@ -208,28 +209,212 @@ def test_bauvorhaben_collect_only_own_fields(): assert "full_name" not in collected -def test_intake_fixture_shape(): - doc = json.loads((FIXTURES / "intake_form.json").read_text()) +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) + + +@pytest.mark.parametrize( + ( + "fixture_file", + "doc_name", + "node_id", + "expected_type", + "expected_display", + "display_mode", + "expected_fields", + "expected_edges", + ), + [ + ( + "intake_form.json", + None, + "CustomInputForm-intake", + "CustomInputForm", + "CustomInput", + "startswith", + { + "full_name", + "notes", + "password", + "age", + "amount", + "newsletter", + "department", + "topics", + "satisfaction", + "attachment", + }, + [ + ("ChatInput-entry", "CustomInputForm-intake"), + ("CustomInputForm-intake", "ChatOutput-collected"), + ], + ), + ( + "bauvorhaben.json", + "bauvorhaben", + "CustomInputBauvorhaben-bauvorhaben", + "CustomInputBauvorhaben", + "Bauvorhaben Form", + "exact", + { + "form_values", + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + "code", + }, + [ + ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben"), + ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected"), + ], + ), + ( + "mitarbeiter_einstellen.json", + "mitarbeiter_einstellen", + "CustomInputMitarbeiter-mitarbeiter", + "CustomInputMitarbeiter", + "Mitarbeiter Einstellen Form", + "exact", + { + "form_values", + "rolle", + "einstufung", + "mitarbeiter_name", + "geburtsdatum", + "code", + }, + [ + ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter"), + ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt"), + ("Gehaltsrechner-gehalt", "ChatOutput-collected"), + ], + ), + ], + ids=["intake", "bauvorhaben", "mitarbeiter"], +) +def test_fixture_shape( + fixture_file, + doc_name, + node_id, + expected_type, + expected_display, + display_mode, + expected_fields, + expected_edges, +): + doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8")) + if doc_name is not None: + assert doc["name"] == doc_name nodes = {n["id"]: n for n in doc["data"]["nodes"]} - form = nodes["CustomInputForm-intake"] - assert form["data"]["type"] == "CustomInputForm" - assert form["data"]["node"]["display_name"].startswith("CustomInput") + form = nodes[node_id] + assert form["data"]["type"] == expected_type + display = form["data"]["node"]["display_name"] + if display_mode == "startswith": + assert display.startswith(expected_display) + else: + assert display == expected_display template = form["data"]["node"]["template"] - for field in FORM_FIELDS: - assert field in template, field + assert set(template) >= expected_fields assert template["form_values"]["show"] is False 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"]} - assert ("ChatInput-entry", "CustomInputForm-intake") in edges - assert ("CustomInputForm-intake", "ChatOutput-collected") in edges + for edge in expected_edges: + assert edge in edges -async def test_intake_deploy_and_run_with_values(client, lfx_server, login): +async def _check_intake_result(client, flow_id, version, run): + # collect() returns Data: the terminal ChatOutput renders it as a + # fenced ```json block, so unwrap before parsing. + result = _parse_chat_json(run["output"]["result"]) + assert result["full_name"] == "Maria Muster" + assert result["age"] == 41 + assert result["department"] == "Music" # default preserved + + +async def _check_bauvorhaben_result(client, flow_id, version, run): + # Branch rules are cached per version and served via input-schema. + cached = version["input_schema"] + assert len(cached["json_schema"]["allOf"]) == 5 + schema = await client.get(f"/api/flows/{flow_id}/input-schema") + assert schema.status_code == 200, schema.text + assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"} + result = _parse_chat_json(run["output"]["result"]) + assert result["gebaeude"] == "Kirche" + assert result["bestandteil"] == ["Glocke", "Orgel"] + assert result["gussjahr"] == 1890 + assert result["denkmalnummer"] == "D-123" + # Only asked fields: unasked code defaults (age, amount, ...) stay out. + assert set(result) == { + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "denkmalschutz", + "denkmalnummer", + } + + +async def _check_mitarbeiter_result(client, flow_id, version, run): + result = run["output"]["result"] + assert "65000" in result + assert "Maria Muster" in result + + +@pytest.mark.parametrize( + ("fixture_file", "flow_name", "run_input", "check_result"), + [ + ( + "intake_form.json", + "intake-e2e", + {"full_name": "Maria Muster", "age": 41}, + _check_intake_result, + ), + ( + "bauvorhaben.json", + "bauvorhaben-e2e", + { + "gebaeude": "Kirche", + "bestandteil": ["Glocke", "Orgel"], + "glockenmassnahme": "Neuguss", + "gussjahr": 1890, + "denkmalschutz": True, + "denkmalnummer": "D-123", + }, + _check_bauvorhaben_result, + ), + ( + "mitarbeiter_einstellen.json", + "mitarbeiter-e2e", + { + "rolle": "Pfarrer:in", + "einstufung": "Stufe 2", + "mitarbeiter_name": "Maria Muster", + "geburtsdatum": "1980-01-01", + }, + _check_mitarbeiter_result, + ), + ], + ids=["intake", "bauvorhaben", "mitarbeiter"], +) +async def test_deploy_and_run( + client, lfx_server, login, fixture_file, flow_name, run_input, check_result +): await login() - doc = json.loads((FIXTURES / "intake_form.json").read_text()) - created = await client.post("/api/flows", json={"name": "intake-e2e"}) + doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8")) + created = await client.post("/api/flows", json={"name": flow_name}) assert created.status_code == 201, created.text flow_id = created.json()["id"] @@ -242,29 +427,10 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login): deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") assert deployed.status_code == 200, deployed.text - run = await client.post( - f"/api/flows/{flow_id}/run", - json={"input": {"full_name": "Maria Muster", "age": 41}}, - ) + run = await client.post(f"/api/flows/{flow_id}/run", json={"input": run_input}) assert run.status_code == 200, run.text assert run.json()["status"] == "completed" - # 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["age"] == 41 - assert result["department"] == "Music" # default preserved - - -def _parse_chat_json(text: str) -> dict: - """Parse terminal ChatOutput text: raw JSON or a ```json fenced block.""" - text = text.strip() - if text.startswith("```"): - lines = text.splitlines() - assert lines[0].startswith("```"), text[:60] - assert lines[-1].startswith("```"), text[-60:] - text = "\n".join(lines[1:-1]) - return json.loads(text) + await check_result(client, flow_id, version.json(), run.json()) def test_embedded_code_matches_template_fields(): @@ -341,32 +507,6 @@ def test_kirche_node_inlines_core(): assert "from kirche" not in composed and "def parse_envelope" in composed -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 @@ -411,59 +551,6 @@ def test_bauvorhaben_schema_branches(): } -async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login): - await login() - doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8")) - created = await client.post("/api/flows", json={"name": "bauvorhaben-e2e"}) - assert created.status_code == 201, created.text - flow_id = created.json()["id"] - - version = await client.post( - f"/api/flows/{flow_id}/versions", - json={"langflow_json": doc, "author": "test"}, - ) - assert version.status_code == 201, version.text - # Branch rules are cached per version and served via input-schema. - cached = version.json()["input_schema"] - assert len(cached["json_schema"]["allOf"]) == 5 - - deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") - assert deployed.status_code == 200, deployed.text - schema = await client.get(f"/api/flows/{flow_id}/input-schema") - assert schema.status_code == 200, schema.text - assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"} - - run = await client.post( - f"/api/flows/{flow_id}/run", - json={ - "input": { - "gebaeude": "Kirche", - "bestandteil": ["Glocke", "Orgel"], - "glockenmassnahme": "Neuguss", - "gussjahr": 1890, - "denkmalschutz": True, - "denkmalnummer": "D-123", - } - }, - ) - assert run.status_code == 200, run.text - assert run.json()["status"] == "completed" - result = _parse_chat_json(run.json()["output"]["result"]) - assert result["gebaeude"] == "Kirche" - assert result["bestandteil"] == ["Glocke", "Orgel"] - assert result["gussjahr"] == 1890 - assert result["denkmalnummer"] == "D-123" - # Only asked fields: unasked code defaults (age, amount, ...) stay out. - assert set(result) == { - "gebaeude", - "bestandteil", - "glockenmassnahme", - "gussjahr", - "denkmalschutz", - "denkmalnummer", - } - - def test_mitarbeiter_component_contract(): from kirche.mitarbeiter_form import CustomInputMitarbeiter @@ -485,60 +572,3 @@ def test_mitarbeiter_collect_branch_default(): assert "einstufung" not in form.collect().data # rolle defaults to Assistenz 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 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index f86ad00..959ffaf 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,5 +1,7 @@ """Model-level tests: defaults, constraints, JSON roundtrip, linkage.""" +import uuid + import pytest from sqlalchemy.exc import IntegrityError from sqlmodel import select @@ -86,3 +88,48 @@ def test_run_and_task_linkage_with_tenant_default(session): tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all() assert len(tasks) == 1 assert tasks[0].payload == {} + + +def test_create_version_populates_input_schema(session): + from app.flows import store + from app.flows.schemas import FlowVersionCreate + + d = FlowDefinition(name="cached") + session.add(d) + session.commit() + doc = { + "name": "cached", + "data": { + "nodes": [ + { + "id": "n1", + "data": { + "type": "ChatInput", + "node": {"display_name": "Chat", "template": {}}, + }, + } + ], + "edges": [], + }, + } + version = store.create_version(session, d.id, FlowVersionCreate(langflow_json=doc)) + assert version.input_schema["fallback"] == "textarea" + + +def test_input_schema_null_rejected(session): + from sqlalchemy import text + from sqlalchemy.exc import IntegrityError + + d = FlowDefinition(name="nonnull") + session.add(d) + session.commit() + with pytest.raises(IntegrityError): + session.execute( + text( + "INSERT INTO flow_versions (id, flow_id, version_no, langflow_json," + " author, input_schema) VALUES (:id, :flow_id, 1, :doc, :author, NULL)" + ), + {"id": str(uuid.uuid4()), "flow_id": str(d.id), "doc": "{}", "author": "t"}, + ) + session.commit() + session.rollback() diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index 3d833af..ef8bb01 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -136,73 +136,78 @@ async def test_sync_endpoint_returns_report( assert response.json()["skipped"] == ["simple_rule"] -async def test_sync_endpoint_pulls_from_langflow_then_syncs( - client, lfx_server, tmp_path, monkeypatch, login, override_langflow +@pytest.mark.parametrize( + ("seed", "flow_name", "flow_id", "extra_flows", "extra_docs", "expected_file"), + [ + ( + None, + "Gehaltserhöhung", + "22222222-3333-4444-8555-666666666666", + [], + {}, + "Gehaltserhöhung.json", + ), + ( + # Seeded from simple_rule.json: updates in place instead of + # duplicating as simple-rule.json (slug drift). + "simple_rule.json", + "simple_rule", + "44444444-5555-4666-8777-888888888888", + [], + {}, + "simple_rule.json", + ), + ( + None, + "Gut", + "33333333-4444-4555-8666-777777777777", + [{"id": "kaputt-id", "name": "Kaputt"}], + {}, + "Gut.json", + ), + ], + ids=["pull-new", "pull-reuse", "pull-error-continues"], +) +async def test_sync_endpoint_pull_matrix( + client, + lfx_server, + tmp_path, + monkeypatch, + login, + override_langflow, + seed, + flow_name, + flow_id, + extra_flows, + extra_docs, + expected_file, ): await login() monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - langflow_id = "22222222-3333-4444-8555-666666666666" + if seed is not None: + _seed_dir(tmp_path, seed) override_langflow( - flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}], - docs={langflow_id: langflow_export_doc("Gehaltserhöhung", langflow_id)}, + flows=[{"id": flow_id, "name": flow_name}, *extra_flows], + docs={flow_id: langflow_export_doc(flow_name, flow_id), **extra_docs}, ) response = await client.post("/api/flows/sync") assert response.status_code == 200, response.text body = response.json() - assert body["pulled"] == ["Gehaltserhöhung"] - assert body["added"] == ["Gehaltserhöhung"] - assert body["errors"] == {} - assert (tmp_path / "Gehaltserhöhung.json").is_file() + assert body["pulled"] == [flow_name] + assert body["added"] == [flow_name] + assert (tmp_path / expected_file).is_file() + if flow_name == "Gut": + assert "kaputt-id" in body["errors"] + else: + assert body["errors"] == {} + assert not (tmp_path / "simple-rule.json").exists() # Second pass: same content pulls again but syncs nothing new. response = await client.post("/api/flows/sync") assert response.status_code == 200, response.text body = response.json() - assert body["pulled"] == ["Gehaltserhöhung"] - assert body["skipped"] == ["Gehaltserhöhung"] - - -async def test_sync_endpoint_pull_reuses_same_named_file( - client, lfx_server, tmp_path, monkeypatch, login, override_langflow -): - """A pulled flow seeded from simple_rule.json updates it in place - instead of duplicating it as simple-rule.json (slug drift).""" - await login() - monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - _seed_dir(tmp_path) # fixture shape, name "simple_rule" - langflow_id = "44444444-5555-4666-8777-888888888888" - override_langflow( - flows=[{"id": langflow_id, "name": "simple_rule"}], - docs={langflow_id: langflow_export_doc("simple_rule", langflow_id)}, - ) - response = await client.post("/api/flows/sync") - assert response.status_code == 200, response.text - body = response.json() - assert body["pulled"] == ["simple_rule"] - assert body["added"] == ["simple_rule"] - assert body["errors"] == {} - assert not (tmp_path / "simple-rule.json").exists() - - response = await client.post("/api/flows/sync") - assert response.json()["skipped"] == ["simple_rule"] - - -async def test_sync_endpoint_pull_error_continues( - client, lfx_server, tmp_path, monkeypatch, login, override_langflow -): - await login() - monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - good_id = "33333333-4444-4555-8666-777777777777" - override_langflow( - flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}], - docs={good_id: langflow_export_doc("Gut", good_id)}, - ) - response = await client.post("/api/flows/sync") - assert response.status_code == 200, response.text - body = response.json() - assert body["pulled"] == ["Gut"] - assert body["added"] == ["Gut"] - assert "kaputt-id" in body["errors"] + assert body["pulled"] == [flow_name] + assert body["skipped"] == [flow_name] @pytest.mark.parametrize( diff --git a/docs/flow-authoring.md b/docs/flow-authoring.md new file mode 100644 index 0000000..fd9848b --- /dev/null +++ b/docs/flow-authoring.md @@ -0,0 +1,33 @@ +# Flow authoring (Langflow → Webapp) + +So kommt ein neuer Flow in die Webapp: + +1. **In Langflow entwerfen und speichern** (bestehende Flows sind dort + bereits geladen). Name vergeben, speichern. +2. **Sync-Button in der Verwaltung klicken** ("Aus Langflow + synchronisieren"): holt alle Flows aus Langflow nach + `backend/flows/*.json` und deployed sie danach automatisch (neue + Version nur bei geändertem Inhalt, sonst übersprungen). +3. **Prüfen:** Die Versions-Tabelle zeigt Version, Autor und Status. + Danach erscheint der Flow automatisch auf der Seite Workflows mit + seinem Formular. Flows ohne Formular-Knoten zeigen ein Freitext-Feld. + +## Regeln + +- Die Datei heißt wie der Flow in Langflow (z. B. `intake_form.json`). + Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt + bestehen und muss bei Bedarf gelöscht werden. +- Eine gelöschte Datei löscht den Flow inklusive seiner Läufe + (Datei bleibt in Git erhalten). +- Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON). + Der automatische Beobachter im Hintergrund syncet nur Dateien, + Langflow holt nur dieser Button. + +## Formular-Felder + +Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: +jeder Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename += Label). Die Verbindung ChatInput nach `form_values` bitte behalten — +darüber kommen die ausgefüllten Werte an. Unbekannte Feldtypen fallen +automatisch auf ein Textfeld zurück. Details: +`backend/components/kirche/README.md`. diff --git a/frontend/scripts/fetch-photos.py b/frontend/scripts/fetch-photos.py deleted file mode 100644 index b358b59..0000000 --- a/frontend/scripts/fetch-photos.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Fetch landing-page photos (one-time + re-runnable). - -Downloads royalty-free originals (see SOURCES.md next to the photos), -resizes to max 1600px wide and stores them as WebP q80 in -frontend/public/photos/ (self-hosted: no hotlinking, no tracking). - -Run in the api container (has network + Pillow): - docker compose exec api /opt/venv/bin/python \\ - /srv/kirche-einfach/frontend/scripts/fetch-photos.py -""" - -from __future__ import annotations - -import sys -import urllib.request -from io import BytesIO -from pathlib import Path - -try: - from PIL import Image -except ImportError: - sys.exit("Pillow required (present in the api image)") - -ROOT = Path(__file__).resolve().parent.parent.parent -OUT = ROOT / "frontend" / "public" / "photos" - - -def pexels(photo_id: int) -> str: - return ( - f"https://images.pexels.com/photos/{photo_id}/" - f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600" - ) - - -PHOTOS = { - # target file -> (direct file URL, source page for SOURCES.md) - "hero-church.webp": ( - pexels(31630127), - "https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/", - ), - "church-pews.webp": ( - pexels(7566207), - "https://www.pexels.com/photo/church-pews-7566207/", - ), - "paperwork-desk.webp": ( - pexels(18687845), - "https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/", - ), - "pilot-meeting.webp": ( - pexels(5668858), - "https://www.pexels.com/photo/two-people-shaking-hands-5668858/", - ), -} - - -def fetch(url: str) -> bytes: - req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"}) - with urllib.request.urlopen(req, timeout=120) as response: - return response.read() - - -def main() -> None: - OUT.mkdir(parents=True, exist_ok=True) - for filename, (url, _page) in PHOTOS.items(): - raw = fetch(url) - with Image.open(BytesIO(raw)) as img: - img = img.convert("RGB") - if img.width > 1600: - img = img.resize( - (1600, round(img.height * 1600 / img.width)), Image.LANCZOS - ) - target = OUT / Path(filename).with_suffix(".webp").name - img.save(target, "WEBP", quality=80, method=6) - print(f"wrote {target} ({target.stat().st_size // 1024} KB)") - - -if __name__ == "__main__": - main() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index eb0159d..aee7dc2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,19 @@ /* Typed client for the kirche-einfach API (served via vite /api proxy). */ +/** All statuses the backend can report (single color language in StatusBadge). */ +export type Status = + | "completed" + | "approved" + | "genehmigt" + | "deployed" + | "running" + | "open" + | "pending" + | "pending_approval" + | "failed" + | "rejected" + | "abgelehnt"; + export interface FlowDefinition { id: string; name: string; @@ -33,7 +47,7 @@ export interface Run { id: string; version_id: string; tenant_id: string; - status: string; + status: Status; input: unknown; output: RunOutput | null; session_id: string | null; @@ -50,7 +64,7 @@ export interface ApprovalTask { payload: unknown; continuation_flow_id: string | null; approver: string | null; - status: string; + status: Status; decision_note: string; created_at: string; decided_at: string | null; @@ -88,7 +102,7 @@ export interface AccessRequest { name: string; email: string; reason: string; - status: string; + status: Status; created_at: string; decided_at: string | null; decided_by: string | null; diff --git a/frontend/src/components/FlowHelp.tsx b/frontend/src/components/FlowHelp.tsx index 1b82010..5866554 100644 --- a/frontend/src/components/FlowHelp.tsx +++ b/frontend/src/components/FlowHelp.tsx @@ -1,12 +1,5 @@ import { useState } from "react"; -import { - Anchor, - Button, - Card, - List, - Text, - Title, -} from "@mantine/core"; +import { Anchor, Button, Card, List, Text, Title } from "@mantine/core"; import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react"; import { LANGFLOW_URL } from "../api"; @@ -23,70 +16,46 @@ export default function FlowHelp() { {open ? "Anleitung verbergen" : "Anleitung: Neuer Flow in Langflow"} {open && ( -
- - So kommt ein neuer Flow in die Webapp - - - - - 1. In Langflow entwerfen und speichern:{" "} - - - Langflow öffnen - - . Die bestehenden Flows sind dort bereits geladen. Name vergeben, speichern. - - - - 2. Sync-Button unten klicken:{" "} - - holt alle Flows aus Langflow nach{" "} +
+ + So kommt ein neuer Flow in die Webapp + + + + + 1. In Langflow entwerfen und speichern:{" "} + + + Langflow öffnen + + . Die bestehenden Flows sind dort bereits geladen. + + + + 2. Sync-Button unten klicken:{" "} + + holt alle Flows nach{" "} + + backend/flows/*.json + {" "} + und deployed sie automatisch. + + + + 3. Prüfen:{" "} + + danach erscheint der Flow auf der Seite Workflows. Flows ohne + Formular-Knoten zeigen ein Freitext-Feld. + + + + Regeln, Formular-Felder und Sync-Sicherheit: siehe{" "} - backend/flows/*.json + docs/flow-authoring.md {" "} - und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt, - sonst übersprungen). - - - - 3. Prüfen:{" "} - - Tabelle unten zeigt Version, Autor und Status. Danach erscheint der Flow - automatisch auf der Seite Workflows mit seinem Formular. Flows ohne - Formular-Knoten zeigen ein Freitext-Feld. - - - - Regeln - - - - Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json). - Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt - bestehen und muss bei Bedarf gelöscht werden. - - - Eine gelöschte Datei löscht den Flow inklusive seiner Läufe - (Datei bleibt in Git erhalten). - - - Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON). - Der automatische Beobachter im Hintergrund syncet nur Dateien, - Langflow holt nur dieser Button. - - - - Formular-Felder - - - Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: jeder - Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename = Label). - Die Verbindung ChatInput nach form_values bitte behalten — darüber kommen - die ausgefüllten Werte an. Unbekannte Feldtypen fallen automatisch auf ein - Textfeld zurück. - -
+ im Repo. + +
)} ); diff --git a/frontend/src/components/RunsTable.tsx b/frontend/src/components/RunsTable.tsx index c17e490..3c42eeb 100644 --- a/frontend/src/components/RunsTable.tsx +++ b/frontend/src/components/RunsTable.tsx @@ -4,12 +4,19 @@ import { FormattedDate } from "./FormattedDate"; import { JsonView } from "./JsonView"; import type { Run } from "../api"; -export function RunsTable({ runs }: { runs: Run[] }) { +export function RunsTable({ + runs, + flowName, +}: { + runs: Run[]; + flowName?: (run: Run) => string; +}) { return ( Status + {flowName && Flow} Ergebnis Erstellt @@ -20,6 +27,7 @@ export function RunsTable({ runs }: { runs: Run[] }) { + {flowName && {flowName(r)}} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 645c325..9b5e15f 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,8 +1,9 @@ import { Badge } from "@mantine/core"; +import type { Status } from "../api"; /* Single color language for every status in the app. */ -const COLORS: Record = { +const COLORS: Record = { completed: "green", approved: "green", genehmigt: "green", @@ -16,6 +17,6 @@ const COLORS: Record = { abgelehnt: "red", }; -export default function StatusBadge({ status }: { status: string }) { - return {status}; +export default function StatusBadge({ status }: { status: Status }) { + return {status}; } diff --git a/frontend/src/pages/StartPage.tsx b/frontend/src/pages/StartPage.tsx index b9d50c9..701b78b 100644 --- a/frontend/src/pages/StartPage.tsx +++ b/frontend/src/pages/StartPage.tsx @@ -39,6 +39,7 @@ import { import { useAuth } from "../auth"; import { FeatureCard, + PHOTO_CLASS, PhotoImage, SectionCard, SectionTitle, @@ -404,7 +405,7 @@ export default function StartPage() { src="/screenshots/workflows-light.png" alt="Portal von Kirche Einfach mit Vorgangsformular" radius="md" - className="photo" + className={PHOTO_CLASS} loading="lazy" bd="1px solid var(--mantine-color-default-border)" /> diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx index 66f883a..1ed0acc 100644 --- a/frontend/src/pages/TasksPage.tsx +++ b/frontend/src/pages/TasksPage.tsx @@ -1,13 +1,11 @@ -import { Table, Text, Title } from "@mantine/core"; -import dayjs from "dayjs"; +import { Text, Title } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import { api } from "../api"; import { formatApiError } from "../utils"; import ApprovalsTab from "../components/ApprovalsTab"; import { ErrorAlert } from "../components/ErrorAlert"; -import { JsonView } from "../components/JsonView"; -import StatusBadge from "../components/StatusBadge"; -import { useEffect, useState } from "react"; +import { RunsTable } from "../components/RunsTable"; export default function TasksPage() { const [error, setError] = useState(""); @@ -19,6 +17,7 @@ export default function TasksPage() { useEffect(() => { if (runsQuery.error) setError(formatApiError(runsQuery.error)); }, [runsQuery.error]); + const byId = new Map(runs.map(({ flow_name, run }) => [run.id, flow_name])); return (
@@ -34,34 +33,10 @@ export default function TasksPage() { Noch keine Läufe. ) : ( -
- - - Status - Flow - Ergebnis - Erstellt - - - - {runs.map(({ flow_name, run }) => ( - - - - - {flow_name} - - - - - {dayjs(run.created_at).format("DD.MM.YYYY HH:mm")} - - - ))} - -
+ run)} + flowName={(r) => byId.get(r.id) ?? "–"} + /> )} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index caceac5..d18c753 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,9 +1,7 @@ -/* Shared frontend helpers: error formatting, config (was scattered). */ +/* Shared frontend helpers: error formatting (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";