Merge branch 'chore/ruff-hygiene'

This commit is contained in:
Jonas Miederer 2026-09-14 08:10:23 +02:00
commit cfcf0e8bd6
39 changed files with 459 additions and 559 deletions

View file

@ -19,4 +19,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: cd backend && uv sync
- run: cd backend && uv run ruff check .
- run: cd backend && uv run ruff format . --check --diff
- run: cd backend && uv run pytest

View file

@ -7,6 +7,8 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/
Backend (run from `backend/`):
- `uv sync` — install dependencies
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed)
- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`)
- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`)
- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored)
- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX
- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)

View file

@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
human release responsibility, honest pilot framing instead of
invented testimonials; CTA now "Pilotgespräch vereinbaren").
- New photo `pilot-meeting.webp` (consultation scene, self-hosted).
- Backend lint + format standards via ruff (pinned dev dependency,
enforced in CI): `uv run ruff check .`, `uv run ruff format`.
## [0.1.0] - 2026-09-13
### Added

View file

@ -1,5 +1,6 @@
"""Access + auth HTTP API (magic-link login, session cookie)."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@ -68,11 +69,9 @@ def _set_session_cookie(response: Response, session_id: str) -> None:
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)):
def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]):
try:
request = service.create_access_request(
session, data.name, data.email, data.reason
)
request = service.create_access_request(session, data.name, data.email, data.reason)
except service.RateLimitedError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
mail.send_access_notification(str(request.id), request.name, request.email)
@ -81,9 +80,9 @@ def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)
@access_router.get("/requests", response_model=list[AccessRequestRead])
def list_requests(
session: Annotated[Session, Depends(get_db)],
admin: Annotated[User, Depends(require_admin)],
status: str | None = Query(default=None),
session: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return [_read_request(r) for r in service.list_access_requests(session, status)]
@ -91,13 +90,11 @@ def list_requests(
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
def approve_request(
request_id: UUID,
session: Session = Depends(get_db),
admin: User = Depends(require_admin),
session: Annotated[Session, Depends(get_db)],
admin: Annotated[User, Depends(require_admin)],
):
try:
request, _user, token = service.approve_access_request(
session, request_id, admin.email
)
request, _user, token = service.approve_access_request(session, request_id, admin.email)
except service.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except service.ConflictError as exc:
@ -110,8 +107,8 @@ def approve_request(
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
def reject_request(
request_id: UUID,
session: Session = Depends(get_db),
admin: User = Depends(require_admin),
session: Annotated[Session, Depends(get_db)],
admin: Annotated[User, Depends(require_admin)],
):
try:
request = service.reject_access_request(session, request_id, admin.email)
@ -125,7 +122,7 @@ def reject_request(
@auth_router.post("/magic/request", response_model=MagicRequestRead)
def request_magic(
data: MagicRequestRequest,
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
):
"""Email-first login: allowed emails get a magic link mailed.
@ -146,7 +143,7 @@ def request_magic(
def consume_magic(
data: MagicConsumeRequest,
response: Response,
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
):
try:
user_session = service.consume_magic_token(session, data.token)
@ -164,7 +161,7 @@ def consume_magic(
@auth_router.get("/me", response_model=MeRead)
def read_me(user: User = Depends(get_current_user)):
def read_me(user: Annotated[User, Depends(get_current_user)]):
return _read_me(user)
@ -172,7 +169,7 @@ def read_me(user: User = Depends(get_current_user)):
def logout(
request: Request,
response: Response,
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
):
# Revoke the server-side row when the cookie maps to a live session;
# always clear the client cookie.
@ -184,7 +181,7 @@ def logout(
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
def admin_bootstrap(data: AdminBootstrapRequest, session: Session = Depends(get_db)):
def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]):
try:
_user, token = service.bootstrap_admin(session, data.email)
except service.ConflictError as exc:

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session
@ -21,7 +22,7 @@ def get_db() -> Iterator[Session]:
def get_optional_user(
request: Request, session: Session = Depends(get_db)
request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE)
if not session_id:
@ -32,24 +33,20 @@ def get_optional_user(
return service.get_user(session, record.user_id)
def get_current_user(
request: Request, session: Session = Depends(get_db)
) -> User:
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:
user = get_optional_user(request, session)
if user is None:
raise HTTPException(status_code=401, detail="not authenticated")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin only")
return user
def resolve_tenant(
user: User | None, explicit: str | None, default: str = "default"
) -> str:
def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str:
"""Tenant comes from the session; the body/query param is a deprecated
fallback for unauthenticated callers."""
if user is not None:

View file

@ -42,8 +42,8 @@ def _page(title: str, intro: str, button_label: str, button_url: str | None, not
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
'Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;'
'font-weight:600;color:#ffffff;text-decoration:none;'
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
"font-weight:600;color:#ffffff;text-decoration:none;"
f'border-radius:8px;">{button_label}</a></td></tr></table>'
)
return f"""<!DOCTYPE html>
@ -106,14 +106,10 @@ def _branded_mail(
message["From"] = settings.mail_from or settings.contact_to or to_email
if settings.contact_to:
message["Reply-To"] = settings.contact_to
message.set_content(
f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n"
)
message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n")
# Email-safe HTML: tables + inline CSS; some clients strip <style>, so
# the palette is inlined via the f-string constants above.
message.add_alternative(
_page(headline, intro, button_label, button_url, note), subtype="html"
)
message.add_alternative(_page(headline, intro, button_label, button_url, note), subtype="html")
return message
@ -149,7 +145,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
)
try:
_send(message)
except Exception: # noqa: BLE001 - delivery is best effort, see docstring
except (OSError, smtplib.SMTPException):
logger.warning("magic-link delivery failed for %s", to_email)
return False
logger.info("magic link mailed to %s", to_email)
@ -166,8 +162,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
to_email=settings.contact_to,
headline="Neue Zugangsanfrage",
intro=(
f"{name} ({email}) hat Zugang angefordert und wartet auf "
"Freigabe in der Verwaltung."
f"{name} ({email}) hat Zugang angefordert und wartet auf Freigabe in der Verwaltung."
),
button_label="In der Verwaltung prüfen",
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
@ -175,7 +170,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
)
try:
_send(message)
except Exception: # noqa: BLE001 - best effort, request is already stored
except (OSError, smtplib.SMTPException):
logger.warning("access-request notification failed for %s", request_id)
return False
return True

View file

@ -9,7 +9,7 @@ from __future__ import annotations
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from uuid import UUID
from sqlmodel import Session, func, select
@ -61,7 +61,7 @@ class RateLimitedError(AuthError):
def utcnow() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
def normalize_email(email: str) -> str:
@ -85,9 +85,7 @@ def get_user(session: Session, user_id: UUID) -> User | None:
def get_user_by_email(session: Session, email: str) -> User | None:
return session.exec(
select(User).where(User.email == normalize_email(email))
).first()
return session.exec(select(User).where(User.email == normalize_email(email))).first()
def user_count(session: Session) -> int:
@ -223,9 +221,7 @@ def approve_access_request(
return request, user, token
def reject_access_request(
session: Session, request_id: UUID, decided_by: str
) -> AccessRequest:
def reject_access_request(session: Session, request_id: UUID, decided_by: str) -> AccessRequest:
request = get_access_request(session, request_id)
if request.status != "pending":
raise ConflictError(f"access request {request_id} already {request.status}")
@ -242,16 +238,14 @@ def reject_access_request(
def _aware(value: datetime) -> datetime:
# SQLite returns naive datetimes; stored values are UTC.
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.replace(tzinfo=UTC)
return value
def consume_magic_token(session: Session, token: str) -> UserSession:
"""Single-use consume → session row. used_at is set before creation."""
record = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(token.strip())
)
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
).first()
if record is None:
raise InvalidTokenError("unknown token")

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import json
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Annotated
from uuid import UUID
import httpx
@ -12,15 +13,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_optional_user, resolve_tenant
from app.db import get_engine, get_session
from app.models import User
from app.settings import settings
from app.auth.deps import get_optional_user, resolve_tenant
from . import runner, store
from . import input_schema as form_schema
from . import runner, store
from .deploy import import_from_langflow
from .sync import lfx_id_for, sync_from_directory
from .langflow_client import (
LangflowClient,
LangflowError,
@ -33,7 +33,6 @@ from .lfx_client import (
LfxError,
LfxInvalidFlow,
LfxNotFound,
LfxRunFailed,
LfxUnavailable,
)
from .schemas import (
@ -52,6 +51,7 @@ from .schemas import (
RunRequest,
SyncReport,
)
from .sync import lfx_id_for, sync_from_directory
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
@ -66,9 +66,7 @@ def get_lfx() -> LfxClient:
def get_langflow() -> LangflowClient:
return LangflowClient(
settings.langflow_url, api_key=settings.langflow_api_key or ""
)
return LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key or "")
def _read_definition(d) -> FlowDefinitionRead:
@ -129,7 +127,7 @@ def _read_task(t) -> ApprovalRead:
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
def create_flow(data: FlowDefinitionCreate, session: Annotated[Session, Depends(get_db)]):
try:
return _read_definition(store.create_definition(session, data))
except store.ConflictError as exc:
@ -139,14 +137,14 @@ def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
@flows_router.get("", response_model=list[FlowDefinitionRead])
def list_flows(session: Session = Depends(get_db)):
def list_flows(session: Annotated[Session, Depends(get_db)]):
return [_read_definition(d) for d in store.list_definitions(session)]
@flows_router.post("/sync", response_model=SyncReport)
async def sync_flows(
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
):
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
try:
@ -156,18 +154,16 @@ async def sync_flows(
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
def get_flow(flow_id: UUID, session: Session = Depends(get_db)):
def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
try:
return _read_definition(store.get_definition(session, flow_id))
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.post(
"/{flow_id}/versions", response_model=FlowVersionRead, status_code=201
)
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
def create_version(
flow_id: UUID, data: FlowVersionCreate, session: Session = Depends(get_db)
flow_id: UUID, data: FlowVersionCreate, session: Annotated[Session, Depends(get_db)]
):
try:
return _read_version(store.create_version(session, flow_id, data))
@ -178,21 +174,19 @@ def create_version(
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
def list_versions(flow_id: UUID, session: Session = Depends(get_db)):
def list_versions(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
try:
return [_read_version(v) for v in store.list_versions(session, flow_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.post(
"/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead
)
@flows_router.post("/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead)
async def deploy_version(
flow_id: UUID,
version_no: int,
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
):
try:
version = store.get_version_by_no(session, flow_id, version_no)
@ -227,10 +221,10 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
async def run_flow(
flow_id: UUID,
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User | None, Depends(get_optional_user)],
stream: bool = Query(default=False),
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
user: User | None = Depends(get_optional_user),
):
# Tenant comes from the session; the body param stays as a deprecated
# fallback for unauthenticated callers.
@ -265,9 +259,7 @@ async def run_flow(
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
if not version.lfx_flow_id:
raise HTTPException(
status_code=422, detail=f"version {version.id} is not deployed to LFX"
)
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
run = store.create_run(
session,
version,
@ -288,30 +280,28 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
async def event_gen() -> AsyncIterator[str]:
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
try:
async with httpx.AsyncClient(
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
) as client:
async with client.stream(
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
) 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"
# 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"
)
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"
@ -323,9 +313,9 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
user: Annotated[User | None, Depends(get_optional_user)],
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
# Authenticated callers are scoped to their session tenant unless they
# pass an explicit tenant (deprecated fallback for unauthenticated use).
@ -338,7 +328,7 @@ def list_runs(
@flows_router.get("/langflow/available")
async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
async def list_langflow_flows(langflow: Annotated[LangflowClient, Depends(get_langflow)]):
"""Browse flows on the Langflow authoring server (admin import source)."""
try:
return await langflow.list_flows()
@ -351,9 +341,9 @@ async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
async def import_flow(
data: FlowImportRequest,
session: Session = Depends(get_db),
langflow: LangflowClient = Depends(get_langflow),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
langflow: Annotated[LangflowClient, Depends(get_langflow)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
):
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
try:
@ -384,24 +374,20 @@ async def import_flow(
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return FlowImportRead(
definition=_read_definition(definition), version=_read_version(version)
)
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
def get_input_schema(
flow_id: UUID,
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
):
"""Form schema of the latest deployed version (404 when none)."""
try:
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 or form_schema.extract_form_schema(version.langflow_json)
return InputSchemaRead(
flow_id=flow_id,
version_no=version.version_no,
@ -414,9 +400,9 @@ def get_input_schema(
@flows_router.get("/runs/{run_id}", response_model=RunRead)
def get_run(
run_id: UUID,
session: Annotated[Session, Depends(get_db)],
user: Annotated[User | None, Depends(get_optional_user)],
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
if user is not None and tenant_id is None:
@ -431,7 +417,7 @@ def get_run(
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
def create_approval(data: ApprovalCreate, session: Annotated[Session, Depends(get_db)]):
"""Flow callback contract: report a terminal approval state for a run."""
try:
return _read_task(store.create_approval_task(session, data))
@ -445,9 +431,9 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
@approvals_router.get("", response_model=list[ApprovalRead])
def list_approvals(
session: Annotated[Session, Depends(get_db)],
user: Annotated[User | None, Depends(get_optional_user)],
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
@ -458,8 +444,8 @@ def list_approvals(
async def decide_approval(
task_id: UUID,
data: DecisionRequest,
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
):
"""Decide an open task. Approve starts the continuation flow (if any)."""
try:

View file

@ -7,7 +7,7 @@ from sqlmodel import Session
from app.models import FlowDefinition, FlowVersion
from app.settings import settings
from . import export, store, sync
from . import export, sync
from .langflow_client import LangflowClient
from .lfx_client import LfxClient

View file

@ -24,9 +24,7 @@ def slugify(name: str) -> str:
def canonical_bytes(doc: dict) -> bytes:
"""Deterministic serialization: stable bytes for stable git diffs."""
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode(
"utf-8"
)
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
def content_hash(doc: dict) -> str:
@ -38,9 +36,7 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
return Path(flows_dir) / f"{stem}.json"
def write_flow_file(
flows_dir: str | Path, doc: dict, *, name: str | None = None
) -> Path:
def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path:
"""Write a flow document to backend/flows atomically (tmp + rename).
The filename derives from `name` (or the document's own name). Returns
@ -50,9 +46,7 @@ def write_flow_file(
flows_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(name or doc.get("name") or "flow")
target = flow_path(flows_dir, stem)
fd, tmp_name = tempfile.mkstemp(
dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp"
)
fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(canonical_bytes(doc))

View file

@ -51,9 +51,7 @@ class LfxRunResult:
class LfxClient:
"""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("/")
self.timeout = timeout
self.api_key = api_key

View file

@ -26,9 +26,7 @@ def build_envelope(
"payload": payload,
"run_id": str(run_id),
"callback_url": callback_base_url.rstrip("/"),
"continuation_flow_id": (
str(continuation_flow_id) if continuation_flow_id else None
),
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
}
)
@ -36,9 +34,7 @@ def build_envelope(
def _task_exists(session: Session, run_id: UUID) -> bool:
return (
session.exec(
select(ApprovalTask).where(
ApprovalTask.run_id == run_id, ApprovalTask.status == "open"
)
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
).first()
is not None
)
@ -80,9 +76,7 @@ async def execute_run(
session_id=session_id or str(run.id),
)
except LfxRunFailed as exc:
return store.set_run_result(
session, run, "failed", {"error": str(exc)}
)
return store.set_run_result(session, run, "failed", {"error": str(exc)})
output = {
"result": result.result,
"component": result.component,

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy.exc import IntegrityError
@ -27,9 +27,10 @@ class InvalidError(Exception):
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
if data.continuation_flow_id is not None and session.get(
FlowDefinition, data.continuation_flow_id
) is None:
if (
data.continuation_flow_id is not None
and session.get(FlowDefinition, data.continuation_flow_id) is None
):
raise InvalidError("continuation_flow_id unknown")
definition = FlowDefinition(
name=data.name,
@ -57,18 +58,14 @@ def list_definitions(session: Session) -> list[FlowDefinition]:
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
def get_definition_by_source_path(
session: Session, source_path: str
) -> FlowDefinition | None:
def get_definition_by_source_path(session: Session, source_path: str) -> FlowDefinition | None:
"""Find the definition synced from a given backend/flows filename."""
return session.exec(
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
).first()
def set_definition_name(
session: Session, definition: FlowDefinition, name: str
) -> FlowDefinition:
def set_definition_name(session: Session, definition: FlowDefinition, name: str) -> FlowDefinition:
"""Rename a definition and record its source file. Name clashes 409."""
definition.name = name
session.add(definition)
@ -88,32 +85,22 @@ def next_version_no(session: Session, flow_id: UUID) -> int:
return (max_no or 0) + 1
def delete_definition_cascade(
session: Session, definition: FlowDefinition
) -> dict[str, int]:
def delete_definition_cascade(session: Session, definition: FlowDefinition) -> dict[str, int]:
"""Remove a definition and its whole subtree (tasks, runs, versions).
Used when the canonical file disappears: the directory is the source of
truth, so the derived registry follows. Commits once.
"""
version_ids = list(
session.exec(
select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)
).all()
session.exec(select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)).all()
)
task_count = 0
run_count = 0
if version_ids:
tasks = list(
session.exec(
select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))
).all()
)
runs = list(
session.exec(
select(FlowRun).where(FlowRun.version_id.in_(version_ids))
).all()
session.exec(select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))).all()
)
runs = list(session.exec(select(FlowRun).where(FlowRun.version_id.in_(version_ids))).all())
task_count, run_count = len(tasks), len(runs)
for row in (*tasks, *runs):
session.delete(row)
@ -142,9 +129,7 @@ def validate_flow_json(flow_json: dict) -> None:
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
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)
validate_flow_json(data.langflow_json)
max_no = session.exec(
@ -207,7 +192,7 @@ def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion:
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
version.lfx_flow_id = lfx_flow_id
version.deployed_at = datetime.now(timezone.utc)
version.deployed_at = datetime.now(UTC)
session.add(version)
session.commit()
session.refresh(version)
@ -234,12 +219,10 @@ def create_run(
return run
def set_run_result(
session: Session, run: FlowRun, status: str, output: dict | None
) -> FlowRun:
def set_run_result(session: Session, run: FlowRun, status: str, output: dict | None) -> FlowRun:
run.status = status
run.output = output
run.finished_at = datetime.now(timezone.utc)
run.finished_at = datetime.now(UTC)
session.add(run)
session.commit()
session.refresh(run)
@ -253,9 +236,7 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
return run
def list_runs(
session: Session, flow_id: UUID, tenant_id: str | None = None
) -> list[FlowRun]:
def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) -> list[FlowRun]:
get_definition(session, flow_id)
query = (
select(FlowRun)
@ -272,9 +253,10 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
run = session.get(FlowRun, data.run_id)
if run is None:
raise NotFoundError(f"run {data.run_id} not found")
if data.continuation_flow_id is not None and session.get(
FlowDefinition, data.continuation_flow_id
) is None:
if (
data.continuation_flow_id is not None
and session.get(FlowDefinition, data.continuation_flow_id) is None
):
raise InvalidError("continuation_flow_id unknown")
existing = session.exec(
select(ApprovalTask).where(
@ -293,7 +275,7 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
)
session.add(task)
run.status = "pending_approval"
run.finished_at = datetime.now(timezone.utc)
run.finished_at = datetime.now(UTC)
session.add(run)
session.commit()
session.refresh(task)
@ -330,7 +312,7 @@ def decide_task(
task.status = decision
task.approver = approver
task.decision_note = note
task.decided_at = datetime.now(timezone.utc)
task.decided_at = datetime.now(UTC)
session.add(task)
session.commit()
session.refresh(task)

View file

@ -41,9 +41,7 @@ def lfx_id_for(definition_id: UUID, version_no: int) -> str:
Same (definition, version) always maps to the same id, so re-syncs and
crash recovery overwrite instead of orphaning LFX registrations.
"""
return str(
uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}")
)
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}"))
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
@ -72,9 +70,7 @@ async def sync_document(
with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename)
if definition is None:
definition = store.create_definition(
session, FlowDefinitionCreate(name=name)
)
definition = store.create_definition(session, FlowDefinitionCreate(name=name))
definition.source_path = source_filename
session.add(definition)
session.commit()
@ -86,10 +82,8 @@ async def sync_document(
store.set_definition_name(session, definition, name)
latest = _latest_version(session, definition.id)
if (
latest is not None
and export.content_hash(latest.langflow_json)
== export.content_hash(doc)
if latest is not None and export.content_hash(latest.langflow_json) == export.content_hash(
doc
):
return definition, latest, "skipped"
@ -110,15 +104,9 @@ async def sync_document(
FlowVersionCreate(langflow_json=doc, author=author),
)
if version.version_no != version_no: # pragma: no cover - lock prevents this
raise store.ConflictError(
f"concurrent version write for flow {definition.id}"
)
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
version = store.mark_deployed(session, version, uploaded.id)
if (
latest is not None
and latest.lfx_flow_id
and latest.lfx_flow_id != uploaded.id
):
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
try:
await lfx.delete_flow(latest.lfx_flow_id)
except LfxError as exc:
@ -175,10 +163,8 @@ async def sync_from_directory(
report.errors[filename] = "top-level JSON must be an object"
continue
try:
_, _, action = await sync_document(
session, lfx, doc, source_filename=filename
)
except Exception as exc: # noqa: BLE001 - reconciliation is total
_, _, action = await sync_document(session, lfx, doc, source_filename=filename)
except (store.InvalidError, store.ConflictError, store.NotFoundError, LfxError) as exc:
report.errors[filename] = str(exc)
continue
getattr(report, action).append(path.stem)
@ -193,7 +179,7 @@ async def sync_from_directory(
continue
try:
await delete_definition_tree(session, lfx, definition)
except Exception as exc: # noqa: BLE001 - reconciliation is total
except store.NotFoundError as exc:
report.errors[stale] = str(exc)
continue
report.deleted.append(Path(stale).stem)
@ -205,9 +191,7 @@ def default_session_factory() -> Callable[[], Session]:
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)
def run_sync_blocking(
@ -246,14 +230,12 @@ async def watch_forever(
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
try:
await asyncio.wait_for(stop.wait(), timeout=30)
except asyncio.TimeoutError:
except TimeoutError:
pass
if stop.is_set():
return
logger.info("watching %s", flows_dir)
async for _ in awatch(
flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100
):
async for _ in awatch(flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100):
if stop.is_set():
break
try:
@ -261,7 +243,7 @@ async def watch_forever(
except FileNotFoundError as exc:
logger.error("flow sync failed: %s", exc)
continue
except Exception: # noqa: BLE001 - watcher must survive
except Exception:
logger.exception("flow sync failed")
continue
logger.info(

View file

@ -6,8 +6,8 @@ from pathlib import Path
from fastapi import FastAPI
from .flows.api import approvals_router, flows_router
from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router
from .flows.sync import run_sync_blocking, watch_forever
from .settings import settings
@ -30,8 +30,8 @@ def _blocking_startup_sync() -> None:
logger.warning("startup flow sync: %s: %s", filename, reason)
except FileNotFoundError as exc:
logger.error("startup flow sync skipped: %s", exc)
except Exception: # noqa: BLE001 - API must start even if sync fails
logger.exception("startup flow sync failed")
except Exception:
logger.exception("startup flow sync failed") # never fatal at boot
@asynccontextmanager
@ -50,7 +50,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
except asyncio.CancelledError:
pass

View file

@ -1,6 +1,6 @@
"""Flow engine domain models (SQLModel tables)."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
def _ts_column() -> Column:
@ -33,9 +33,7 @@ class FlowDefinition(SQLModel, table=True):
# (e.g. "simple_rule.json"). None for definitions created via the API.
source_path: str | None = Field(default=None, max_length=255)
# 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())

View file

@ -1,6 +1,6 @@
"""Access-management domain models (SQLModel tables)."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
def _ts_column() -> Column:

View file

@ -47,9 +47,7 @@ class Settings(BaseSettings):
@property
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()]
settings = Settings()

View file

@ -15,12 +15,12 @@ derives a JSON Schema + uiSchema from the node's template
from lfx.io import DropdownInput
DropdownInput(
name="room", # JSON-Schema property key
display_name="Room", # form label (title)
name="room", # JSON-Schema property key
display_name="Room", # form label (title)
info="Where should it take place", # help text (description)
options=["Chapel", "Hall"], # dropdown choices (enum)
value="Chapel", # prefill (default)
required=True, # marks the field required
value="Chapel", # prefill (default)
required=True, # marks the field required
)
```
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,

View file

@ -18,6 +18,7 @@ upload comes later.
from __future__ import annotations
import json
from typing import ClassVar
from lfx.custom import Component
from lfx.field_typing.range_spec import RangeSpec
@ -51,7 +52,7 @@ class CustomInputForm(Component):
description = "Collects structured form values into a single JSON message."
icon = "ClipboardList"
inputs = [
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
@ -129,7 +130,7 @@ class CustomInputForm(Component):
file_types=["pdf", "png", "jpg"],
),
]
outputs = [
outputs: ClassVar[list] = [
Output(display_name="Form Data", name="form_data", method="collect"),
]
@ -150,7 +151,7 @@ class CustomInputForm(Component):
# Accepts the raw dict or the run envelope {"payload": {...}}.
try:
override = json.loads(_text(self.form_values) or "{}")
except Exception:
except json.JSONDecodeError:
override = {}
if isinstance(override, dict):
if isinstance(override.get("payload"), dict):

View file

@ -1,9 +1,7 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@ -16,10 +14,11 @@ if config.config_file_name is not None:
# add your model's MetaData object here
# for 'autogenerate' support
from app.models import * # noqa: F401,F403 - register all tables
from app.settings import settings
from sqlmodel import SQLModel
from app.models import *
from app.settings import settings
target_metadata = SQLModel.metadata
# The URL always comes from settings (repo-root .env or environment),
@ -70,9 +69,7 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

View file

@ -1,108 +1,133 @@
"""flow engine tables
Revision ID: 62ea9a5d8f9d
Revises:
Revises:
Create Date: 2026-09-06 22:27:07.129788
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '62ea9a5d8f9d'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "62ea9a5d8f9d"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('flow_definitions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"flow_definitions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["continuation_flow_id"],
["flow_definitions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True)
op.create_table('flow_versions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('flow_id', sa.Uuid(), nullable=False),
sa.Column('version_no', sa.Integer(), nullable=False),
sa.Column('langflow_json', sa.JSON(), nullable=True),
sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column('deployed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('flow_id', 'version_no')
op.create_index(op.f("ix_flow_definitions_name"), "flow_definitions", ["name"], unique=True)
op.create_table(
"flow_versions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("flow_id", sa.Uuid(), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False),
sa.Column("langflow_json", sa.JSON(), nullable=True),
sa.Column("lfx_flow_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("author", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["flow_id"],
["flow_definitions.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("flow_id", "version_no"),
)
op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False)
op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False)
op.create_table('flow_runs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('version_id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
sa.Column('input', sa.JSON(), nullable=True),
sa.Column('output', sa.JSON(), nullable=True),
sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_index(op.f("ix_flow_versions_flow_id"), "flow_versions", ["flow_id"], unique=False)
op.create_index(
op.f("ix_flow_versions_version_no"), "flow_versions", ["version_no"], unique=False
)
op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False)
op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False)
op.create_table('approval_tasks',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('run_id', sa.Uuid(), nullable=False),
sa.Column('version_id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
sa.Column('payload', sa.JSON(), nullable=True),
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ),
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"flow_runs",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("version_id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
sa.Column("input", sa.JSON(), nullable=True),
sa.Column("output", sa.JSON(), nullable=True),
sa.Column("session_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["version_id"],
["flow_versions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_flow_runs_status"), "flow_runs", ["status"], unique=False)
op.create_index(op.f("ix_flow_runs_tenant_id"), "flow_runs", ["tenant_id"], unique=False)
op.create_index(op.f("ix_flow_runs_version_id"), "flow_runs", ["version_id"], unique=False)
op.create_table(
"approval_tasks",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("run_id", sa.Uuid(), nullable=False),
sa.Column("version_id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
sa.Column("approver", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("decision_note", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["run_id"],
["flow_runs.id"],
),
sa.ForeignKeyConstraint(
["version_id"],
["flow_versions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_approval_tasks_run_id"), "approval_tasks", ["run_id"], unique=False)
op.create_index(op.f("ix_approval_tasks_status"), "approval_tasks", ["status"], unique=False)
op.create_index(
op.f("ix_approval_tasks_tenant_id"), "approval_tasks", ["tenant_id"], unique=False
)
op.create_index(
op.f("ix_approval_tasks_version_id"), "approval_tasks", ["version_id"], unique=False
)
op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False)
op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False)
op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False)
op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks')
op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks')
op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks')
op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks')
op.drop_table('approval_tasks')
op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs')
op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs')
op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs')
op.drop_table('flow_runs')
op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions')
op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions')
op.drop_table('flow_versions')
op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions')
op.drop_table('flow_definitions')
op.drop_index(op.f("ix_approval_tasks_version_id"), table_name="approval_tasks")
op.drop_index(op.f("ix_approval_tasks_tenant_id"), table_name="approval_tasks")
op.drop_index(op.f("ix_approval_tasks_status"), table_name="approval_tasks")
op.drop_index(op.f("ix_approval_tasks_run_id"), table_name="approval_tasks")
op.drop_table("approval_tasks")
op.drop_index(op.f("ix_flow_runs_version_id"), table_name="flow_runs")
op.drop_index(op.f("ix_flow_runs_tenant_id"), table_name="flow_runs")
op.drop_index(op.f("ix_flow_runs_status"), table_name="flow_runs")
op.drop_table("flow_runs")
op.drop_index(op.f("ix_flow_versions_version_no"), table_name="flow_versions")
op.drop_index(op.f("ix_flow_versions_flow_id"), table_name="flow_versions")
op.drop_table("flow_versions")
op.drop_index(op.f("ix_flow_definitions_name"), table_name="flow_definitions")
op.drop_table("flow_definitions")
# ### end Alembic commands ###

View file

@ -5,78 +5,92 @@ Revises: d932b6993f9a
Create Date: 2026-09-11 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a3f1c9e2b4d7'
down_revision: Union[str, Sequence[str], None] = 'd932b6993f9a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "a3f1c9e2b4d7"
down_revision: str | Sequence[str] | None = "d932b6993f9a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("role", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('access_requests',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column('reason', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('decided_by', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
sa.PrimaryKeyConstraint('id')
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_table(
"access_requests",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column("reason", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_by", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_access_requests_email'), 'access_requests', ['email'], unique=False)
op.create_index(op.f('ix_access_requests_status'), 'access_requests', ['status'], unique=False)
op.create_table('magic_link_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_index(op.f("ix_access_requests_email"), "access_requests", ["email"], unique=False)
op.create_index(op.f("ix_access_requests_status"), "access_requests", ["status"], unique=False)
op.create_table(
"magic_link_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_magic_link_tokens_token_hash'), 'magic_link_tokens', ['token_hash'], unique=True)
op.create_index(op.f('ix_magic_link_tokens_user_id'), 'magic_link_tokens', ['user_id'], unique=False)
op.create_table('user_sessions',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_magic_link_tokens_token_hash"), "magic_link_tokens", ["token_hash"], unique=True
)
op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False)
op.create_index(
op.f("ix_magic_link_tokens_user_id"), "magic_link_tokens", ["user_id"], unique=False
)
op.create_table(
"user_sessions",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions')
op.drop_table('user_sessions')
op.drop_index(op.f('ix_magic_link_tokens_user_id'), table_name='magic_link_tokens')
op.drop_index(op.f('ix_magic_link_tokens_token_hash'), table_name='magic_link_tokens')
op.drop_table('magic_link_tokens')
op.drop_index(op.f('ix_access_requests_status'), table_name='access_requests')
op.drop_index(op.f('ix_access_requests_email'), table_name='access_requests')
op.drop_table('access_requests')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
op.drop_table("user_sessions")
op.drop_index(op.f("ix_magic_link_tokens_user_id"), table_name="magic_link_tokens")
op.drop_index(op.f("ix_magic_link_tokens_token_hash"), table_name="magic_link_tokens")
op.drop_table("magic_link_tokens")
op.drop_index(op.f("ix_access_requests_status"), table_name="access_requests")
op.drop_index(op.f("ix_access_requests_email"), table_name="access_requests")
op.drop_table("access_requests")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

View file

@ -5,18 +5,18 @@ Revises: a3f1c9e2b4d7
Create Date: 2026-09-11 20:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b7e2d4a1c9f3'
down_revision: Union[str, Sequence[str], None] = 'a3f1c9e2b4d7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "b7e2d4a1c9f3"
down_revision: str | Sequence[str] | None = "a3f1c9e2b4d7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View file

@ -5,18 +5,18 @@ Revises: 62ea9a5d8f9d
Create Date: 2026-09-07 08:40:48.986026
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'd932b6993f9a'
down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "d932b6993f9a"
down_revision: str | Sequence[str] | None = "62ea9a5d8f9d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View file

@ -34,4 +34,15 @@ dev = [
"pytest-asyncio>=0.24",
"pytest-cov>=6",
"respx>=0.22",
"ruff>=0.16,<0.17",
]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
# Default rule set minus EXE: Docker Desktop bind-mounts report every file as
# executable (git index stays 100644), so EXE002 would fire on the dev mount
# but never on Linux CI. Everything else is enforced as ruff's default.
ignore = ["EXE002"]

View file

@ -68,7 +68,8 @@ def builtin_node(
return node_dict
def custom_node( code: str,
def custom_node(
code: str,
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
@ -109,9 +110,7 @@ def kirche_node(
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
inst = CustomInputForm()
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)
node = frontend_node.to_dict(keep_name=False)
node = inst._update_template(node)
@ -207,9 +206,7 @@ def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
# --------------------------------------------------------------------------
# flow document
# --------------------------------------------------------------------------
def flow_doc(
name: str, description: str, nodes: list[dict], edges: list[dict]
) -> dict:
def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict]) -> dict:
return {
"data": {
"nodes": nodes,
@ -233,7 +230,7 @@ def flow_doc(
# --------------------------------------------------------------------------
# custom component code
# --------------------------------------------------------------------------
RULE_CHECK_CODE = '''from lfx.custom import Component
RULE_CHECK_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
@ -277,9 +274,9 @@ class RuleCheckComponent(Component):
except (TypeError, ValueError):
schwelle = 200000.0
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
'''
"""
ENVELOPE_CODE = '''from lfx.custom import Component
ENVELOPE_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
@ -336,9 +333,9 @@ class EnvelopeComponent(Component):
def get_continuation(self) -> Message:
value = self._env().get("continuation_flow_id")
return Message(text=str(value) if value else "")
'''
"""
APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
@ -393,7 +390,7 @@ class ApprovalCallbackComponent(Component):
except Exception as exc:
return Message(text=f"CALLBACK_FAILED: {exc}")
return Message(text="PENDING_HUMAN_APPROVAL")
'''
"""
# --------------------------------------------------------------------------
@ -456,9 +453,7 @@ def build_approval_handoff() -> dict:
"ChatInput-entry",
position=(0, 300),
)
envelope = custom_node(
ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300)
)
envelope = custom_node(ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300))
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
router = builtin_node(
"lfx.components.flow_controls.conditional_router",

View file

@ -13,6 +13,7 @@ import os
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
@ -58,9 +59,7 @@ 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 / "seed.json").write_text(
(FIXTURES / "simple_rule.json").read_text()
)
(flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
# Migrate a throwaway SQLite schema to head before starting the API.
from alembic import command as alembic_command
from alembic.config import Config
@ -83,14 +82,26 @@ def main() -> None:
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
}
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,
[
str(VENV_BIN / "lfx"),
"serve",
"--host",
"127.0.0.1",
"--port",
str(LFX_PORT),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
uvicorn = subprocess.Popen(
[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,
[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,
)
try:
@ -106,7 +117,8 @@ def main() -> None:
fx = json.loads((FIXTURES / "simple_rule.json").read_text())
s, ver = api(
"POST", f"/api/flows/{fid}/versions",
"POST",
f"/api/flows/{fid}/versions",
{"langflow_json": fx, "author": "smoke"},
)
check("create version", s == 201, f"v{ver.get('version_no')}")
@ -115,17 +127,25 @@ def main() -> None:
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
s, run = api(
"POST", f"/api/flows/{fid}/run",
"POST",
f"/api/flows/{fid}/run",
{"input": {"betrag": 120000, "schwelle": 200000}},
)
check("run standard", s == 200 and run.get("status") == "completed",
json.dumps(run.get("output"))[:120])
check(
"run standard",
s == 200 and run.get("status") == "completed",
json.dumps(run.get("output"))[:120],
)
s, run2 = api(
"POST", f"/api/flows/{fid}/run",
"POST",
f"/api/flows/{fid}/run",
{"input": {"betrag": 999000, "schwelle": 200000}},
)
check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""))
check(
"run sonderfall",
s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""),
)
s, tasks = api("GET", "/api/approvals")
check("approvals empty (no callback flow)", s == 200 and tasks == [])

View file

@ -19,7 +19,7 @@ from sqlmodel import Session
from app.db import get_engine, reset_engine
from app.flows import export, sync
from app.flows.langflow_client import LangflowClient
from app.flows.langflow_client import LangflowClient, LangflowError
from app.flows.lfx_client import LfxClient
from app.flows.schemas import SyncReport
from app.settings import settings
@ -38,7 +38,7 @@ async def pull_from_langflow(flows_dir: Path) -> dict:
name = doc.get("name") or flow_id
path = export.write_flow_file(flows_dir, doc, name=name)
pulled.append(path.stem)
except Exception as exc: # noqa: BLE001 - pull-all is total
except (LangflowError, OSError, ValueError) as exc:
errors[flow_id] = str(exc)
return {"pulled": sorted(pulled), "errors": errors}
@ -73,12 +73,11 @@ async def main_async(args) -> int:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pull", action="store_true",
help="export all Langflow flows to backend/flows first")
parser.add_argument("--sync", action="store_true",
help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None,
help="flows directory (default: settings.flows_dir)")
parser.add_argument(
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
)
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
args = parser.parse_args()
if not args.pull and not args.sync:
args.sync = True

View file

@ -107,9 +107,7 @@ def lfx_server(tmp_path_factory):
sock.close()
flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = (
Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json"
).read_text()
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
(flow_dir / "seed.json").write_text(seed)
env = {

View file

@ -40,9 +40,7 @@ async def _handoff_task(client, live_server, continuation_flow_id=None):
}
if continuation_flow_id is not None:
body["continuation_flow_id"] = continuation_flow_id
run = (
await client.post(f"/api/flows/{flow['id']}/run", json=body)
).json()
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
@ -67,9 +65,7 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
_, task = await _handoff_task(
client, live_server, continuation_flow_id=cont["id"]
)
_, task = await _handoff_task(client, live_server, continuation_flow_id=cont["id"])
assert task["continuation_flow_id"] == cont["id"]
response = await client.post(
@ -109,9 +105,7 @@ async def test_decision_guards(client, lfx_server, live_server):
response = await client.post(url, json={"decision": "reject"})
assert response.status_code == 409
response = await client.post(
f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"}
)
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
assert response.status_code == 404
_, task2 = await _handoff_task(client, live_server)
@ -143,17 +137,13 @@ async def test_decision_bad_continuation_reference(client, session):
flow_store.get_version(session, UUID(version["id"])),
input={},
)
task = flow_store.create_approval_task(
session, ApprovalCreate(run_id=run.id, title="t")
)
task = flow_store.create_approval_task(session, ApprovalCreate(run_id=run.id, title="t"))
# plant unknown continuation directly (store allows it pre-validation)
task.continuation_flow_id = uuid4()
session.add(task)
session.commit()
response = await client.post(
f"/api/approvals/{task.id}/decision", json={"decision": "approve"}
)
response = await client.post(f"/api/approvals/{task.id}/decision", json={"decision": "approve"})
assert response.status_code == 422
@ -168,9 +158,7 @@ async def test_tenant_isolation(client, lfx_server):
)
assert response.status_code == 200
response = await client.get(
f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"}
)
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
runs_a = response.json()
assert len(runs_a) == 1
assert runs_a[0]["tenant_id"] == "pfarramt-a"
@ -206,9 +194,7 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
assert deployed.status_code == 200
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 503
@ -221,9 +207,7 @@ async def test_lfx_drift_maps_to_502(client, lfx_server):
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
deployed["lfx_flow_id"]
)
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 502
@ -247,9 +231,7 @@ async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
"edges": [],
},
}
response = await client.post(
f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus}
)
response = await client.post(f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus})
assert response.status_code == 201, response.text
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
assert response.status_code == 422

View file

@ -50,7 +50,9 @@ async def _login(client, session, email, role="member"):
async def test_request_approve_consume_me_logout(client, session):
assert (await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})).status_code == 201
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 201
admin = await _login(client, session, ADMIN, role="admin")
assert admin["role"] == "admin"
@ -103,9 +105,7 @@ async def test_token_expiry_boundary(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
record = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == service.hash_token(token)
)
select(MagicLinkToken).where(MagicLinkToken.token_hash == service.hash_token(token))
).one()
record.expires_at = service.utcnow() - timedelta(seconds=1)
session.add(record)
@ -145,9 +145,7 @@ async def test_admin_only_endpoints(client, session):
async with AsyncClient(transport=transport, base_url="http://test") as anon:
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(
f"/api/access/requests/{created.json()['id']}/approve"
)
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
).status_code == 401
assert (await anon.get("/api/auth/me")).status_code == 401
@ -156,9 +154,7 @@ async def test_approve_unknown_and_double_decide(client, session):
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin")
assert (
await client.post(f"/api/access/requests/{uuid4()}/approve")
).status_code == 404
assert (await client.post(f"/api/access/requests/{uuid4()}/approve")).status_code == 404
created = await client.post(
"/api/access/requests",
@ -246,9 +242,7 @@ def test_magic_link_delivery_failure_returns_false(monkeypatch):
monkeypatch.setattr(settings, "smtp_host", "dummy")
_broken_smtp(monkeypatch)
assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False
assert (
mail.send_access_notification("req-1", "A", "a@example.com") is False
)
assert mail.send_access_notification("req-1", "A", "a@example.com") is False
async def test_approve_survives_delivery_failure(client, session, monkeypatch):
@ -261,9 +255,7 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch):
json={"name": "M", "email": "m@example.com", "reason": ""},
)
assert created.status_code == 201, created.text
approved = await client.post(
f"/api/access/requests/{created.json()['id']}/approve"
)
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["request"]["status"] == "approved"
@ -272,9 +264,7 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch):
async def test_magic_request_for_known_user(client, session):
_get_or_make_user(session, MEMBER)
response = await client.post(
"/api/auth/magic/request", json={"email": MEMBER}
)
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert response.status_code == 200, response.text
body = response.json()
assert body["message"] == "login link sent"
@ -282,24 +272,19 @@ async def test_magic_request_for_known_user(client, session):
assert "token" not in response.text
tokens = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.user_id
== service.get_user_by_email(session, MEMBER).id
MagicLinkToken.user_id == service.get_user_by_email(session, MEMBER).id
)
).all()
assert len(tokens) == 1
async def test_magic_request_unknown_email_403(client):
response = await client.post(
"/api/auth/magic/request", json={"email": "stranger@example.com"}
)
response = await client.post("/api/auth/magic/request", json={"email": "stranger@example.com"})
assert response.status_code == 403
async def test_magic_request_admin_email_creates_admin(client, session):
response = await client.post(
"/api/auth/magic/request", json={"email": ADMIN}
)
response = await client.post("/api/auth/magic/request", json={"email": ADMIN})
assert response.status_code == 200, response.text
user = service.get_user_by_email(session, ADMIN)
assert user is not None and user.role == "admin"
@ -308,13 +293,9 @@ async def test_magic_request_admin_email_creates_admin(client, session):
async def test_magic_request_rate_limit(client, session):
_get_or_make_user(session, MEMBER)
for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
response = await client.post(
"/api/auth/magic/request", json={"email": MEMBER}
)
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert response.status_code == 200, response.text
limited = await client.post(
"/api/auth/magic/request", json={"email": MEMBER}
)
limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert limited.status_code == 429
@ -331,9 +312,7 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
button_url="http://localhost:3000/login?token=GEHEIM",
note="Gültig 15 Minuten.",
)
html_part = next(
p for p in message.walk() if p.get_content_type() == "text/html"
)
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
@ -343,7 +322,5 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]
assert "text/plain" in parts and "text/html" in parts
plain = next(
p.get_content() for p in message.walk() if p.get_content_type() == "text/plain"
)
plain = next(p.get_content() for p in message.walk() if p.get_content_type() == "text/plain")
assert "http://localhost:3000/login?token=GEHEIM" in plain

View file

@ -24,9 +24,7 @@ def _export_doc(name="importiert"):
}
async def test_import_creates_definition_version_and_deploys(
client, lfx_server, override_langflow
):
async def test_import_creates_definition_version_and_deploys(client, lfx_server, override_langflow):
override_langflow(_export_doc())
response = await client.post(
"/api/flows/import",
@ -37,31 +35,21 @@ async def test_import_creates_definition_version_and_deploys(
assert body["definition"]["name"] == "importiert"
assert body["version"]["version_no"] == 1
assert body["version"]["author"] == "admin"
assert body["version"]["lfx_flow_id"] == lfx_id_for(
UUID(body["definition"]["id"]), 1
)
assert body["version"]["lfx_flow_id"] == lfx_id_for(UUID(body["definition"]["id"]), 1)
assert body["version"]["deployed_at"] is not None
assert body["version"]["langflow_json"]["data"]["nodes"]
# Imported flow actually runs on LFX.
definition_id = body["definition"]["id"]
response = await client.post(
f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}}
)
response = await client.post(f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}})
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
async def test_import_same_content_is_idempotent(
client, lfx_server, override_langflow
):
async def test_import_same_content_is_idempotent(client, lfx_server, override_langflow):
override_langflow(_export_doc())
first = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
second = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
@ -69,20 +57,14 @@ async def test_import_same_content_is_idempotent(
assert second.json()["version"]["version_no"] == 1
async def test_import_changed_content_bumps_version(
client, lfx_server, override_langflow
):
async def test_import_changed_content_bumps_version(client, lfx_server, override_langflow):
override_langflow(_export_doc())
first = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
changed = _export_doc()
changed["description"] = "neue Beschreibung"
override_langflow(changed)
second = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert second.status_code == 201
assert second.json()["version"]["version_no"] == 2
@ -94,9 +76,7 @@ async def test_import_writes_canonical_file(
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(_export_doc())
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 201, response.text
written = tmp_path / "importiert.json"
assert written.is_file()
@ -127,17 +107,13 @@ async def test_import_without_deploy(client, override_langflow):
async def test_import_langflow_not_found(client, override_langflow):
override_langflow(status=404)
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 404
async def test_import_langflow_unreachable(client, override_langflow):
override_langflow(status=0)
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
)
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
assert response.status_code == 503

View file

@ -14,9 +14,7 @@ def load_fixture(name: str) -> dict:
async def _create_flow(client, name="regeltest"):
response = await client.post(
"/api/flows", json={"name": name, "description": "test flow"}
)
response = await client.post("/api/flows", json={"name": name, "description": "test flow"})
assert response.status_code == 201, response.text
return response.json()
@ -113,9 +111,7 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
async def test_run_undeployed_version_fails(client):
flow = await _create_flow(client, "undeployed")
await _create_version(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 422

View file

@ -9,7 +9,7 @@ from lfx.custom import Component
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
from kirche.form_inputs import CustomInputForm # noqa: E402
from kirche.form_inputs import CustomInputForm
FIXTURES = Path(__file__).parent / "fixtures" / "flows"

View file

@ -117,9 +117,7 @@ def test_required_advanced_hidden_code():
def test_display_name_match_and_fallback():
doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X")
assert set(
parser.extract_form_schema(doc)["json_schema"]["properties"]
) == {"a"}
assert set(parser.extract_form_schema(doc)["json_schema"]["properties"]) == {"a"}
plain = {
"data": {
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
@ -158,9 +156,7 @@ def _lfx() -> LfxClient:
async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
(tmp_path / "intake_form.json").write_text(
(FIXTURES / "intake_form.json").read_text()
)
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
assert not report.errors, report.errors
from app.flows import store
@ -180,13 +176,9 @@ async def test_input_schema_endpoint(client, lfx_server):
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "schema-e2e"})
flow_id = created.json()["id"]
version = await client.post(
f"/api/flows/{flow_id}/versions", json={"langflow_json": doc}
)
version = await client.post(f"/api/flows/{flow_id}/versions", json={"langflow_json": doc})
assert version.status_code == 201, version.text
assert version.json()["input_schema"]["json_schema"]["properties"]["age"][
"type"
] == "integer"
assert version.json()["input_schema"]["json_schema"]["properties"]["age"]["type"] == "integer"
# Created but never deployed → 404.
assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 404

View file

@ -20,9 +20,7 @@ def _client():
@respx.mock
async def test_export_flow_sends_api_key_and_returns_doc():
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(
return_value=httpx.Response(200, json=doc)
)
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(return_value=httpx.Response(200, json=doc))
result = await _client().export_flow("abc")
assert result == doc
assert route.called

View file

@ -28,7 +28,6 @@ def test_canonical_flows_pass_lfx_validate():
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert process.returncode == 0, (
f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
)
assert process.returncode == 0, f"lfx validate failed:\n{process.stdout}\n{process.stderr}"

View file

@ -5,7 +5,6 @@ import json
import shutil
import time
from pathlib import Path
from uuid import UUID
import pytest