chore: enforce ruff lint and format standards across backend

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

View file

@ -19,4 +19,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: cd backend && uv sync - 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 - 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/`): Backend (run from `backend/`):
- `uv sync` — install dependencies - `uv sync` — install dependencies
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed) - `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 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 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) - `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 human release responsibility, honest pilot framing instead of
invented testimonials; CTA now "Pilotgespräch vereinbaren"). invented testimonials; CTA now "Pilotgespräch vereinbaren").
- New photo `pilot-meeting.webp` (consultation scene, self-hosted). - 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 ## [0.1.0] - 2026-09-13
### Added ### Added

View file

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

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from sqlmodel import Session from sqlmodel import Session
@ -21,7 +22,7 @@ def get_db() -> Iterator[Session]:
def get_optional_user( def get_optional_user(
request: Request, session: Session = Depends(get_db) request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None: ) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE) session_id = request.cookies.get(SESSION_COOKIE)
if not session_id: if not session_id:
@ -32,24 +33,20 @@ def get_optional_user(
return service.get_user(session, record.user_id) return service.get_user(session, record.user_id)
def get_current_user( def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:
request: Request, session: Session = Depends(get_db)
) -> User:
user = get_optional_user(request, session) user = get_optional_user(request, session)
if user is None: if user is None:
raise HTTPException(status_code=401, detail="not authenticated") raise HTTPException(status_code=401, detail="not authenticated")
return user 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": if user.role != "admin":
raise HTTPException(status_code=403, detail="admin only") raise HTTPException(status_code=403, detail="admin only")
return user return user
def resolve_tenant( def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str:
user: User | None, explicit: str | None, default: str = "default"
) -> str:
"""Tenant comes from the session; the body/query param is a deprecated """Tenant comes from the session; the body/query param is a deprecated
fallback for unauthenticated callers.""" fallback for unauthenticated callers."""
if user is not None: 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'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" ' f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,' 'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
'Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;' "Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
'font-weight:600;color:#ffffff;text-decoration:none;' "font-weight:600;color:#ffffff;text-decoration:none;"
f'border-radius:8px;">{button_label}</a></td></tr></table>' f'border-radius:8px;">{button_label}</a></td></tr></table>'
) )
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
@ -106,14 +106,10 @@ def _branded_mail(
message["From"] = settings.mail_from or settings.contact_to or to_email message["From"] = settings.mail_from or settings.contact_to or to_email
if settings.contact_to: if settings.contact_to:
message["Reply-To"] = settings.contact_to message["Reply-To"] = settings.contact_to
message.set_content( message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n")
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 # Email-safe HTML: tables + inline CSS; some clients strip <style>, so
# the palette is inlined via the f-string constants above. # the palette is inlined via the f-string constants above.
message.add_alternative( message.add_alternative(_page(headline, intro, button_label, button_url, note), subtype="html")
_page(headline, intro, button_label, button_url, note), subtype="html"
)
return message return message
@ -149,7 +145,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
) )
try: try:
_send(message) _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) logger.warning("magic-link delivery failed for %s", to_email)
return False return False
logger.info("magic link mailed to %s", to_email) 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, to_email=settings.contact_to,
headline="Neue Zugangsanfrage", headline="Neue Zugangsanfrage",
intro=( intro=(
f"{name} ({email}) hat Zugang angefordert und wartet auf " f"{name} ({email}) hat Zugang angefordert und wartet auf Freigabe in der Verwaltung."
"Freigabe in der Verwaltung."
), ),
button_label="In der Verwaltung prüfen", button_label="In der Verwaltung prüfen",
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin", 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: try:
_send(message) _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) logger.warning("access-request notification failed for %s", request_id)
return False return False
return True return True

View file

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

View file

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

View file

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

View file

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

View file

@ -51,9 +51,7 @@ class LfxRunResult:
class LfxClient: class LfxClient:
"""Thin async wrapper around the LFX serve HTTP API.""" """Thin async wrapper around the LFX serve HTTP API."""
def __init__( def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
self, base_url: str, timeout: float = 120.0, api_key: str | None = None
) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.timeout = timeout self.timeout = timeout
self.api_key = api_key self.api_key = api_key

View file

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

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -27,9 +27,10 @@ class InvalidError(Exception):
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition: def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
if data.continuation_flow_id is not None and session.get( if (
FlowDefinition, data.continuation_flow_id data.continuation_flow_id is not None
) is None: and session.get(FlowDefinition, data.continuation_flow_id) is None
):
raise InvalidError("continuation_flow_id unknown") raise InvalidError("continuation_flow_id unknown")
definition = FlowDefinition( definition = FlowDefinition(
name=data.name, 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()) return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
def get_definition_by_source_path( def get_definition_by_source_path(session: Session, source_path: str) -> FlowDefinition | None:
session: Session, source_path: str
) -> FlowDefinition | None:
"""Find the definition synced from a given backend/flows filename.""" """Find the definition synced from a given backend/flows filename."""
return session.exec( return session.exec(
select(FlowDefinition).where(FlowDefinition.source_path == source_path) select(FlowDefinition).where(FlowDefinition.source_path == source_path)
).first() ).first()
def set_definition_name( def set_definition_name(session: Session, definition: FlowDefinition, name: str) -> FlowDefinition:
session: Session, definition: FlowDefinition, name: str
) -> FlowDefinition:
"""Rename a definition and record its source file. Name clashes 409.""" """Rename a definition and record its source file. Name clashes 409."""
definition.name = name definition.name = name
session.add(definition) session.add(definition)
@ -88,32 +85,22 @@ def next_version_no(session: Session, flow_id: UUID) -> int:
return (max_no or 0) + 1 return (max_no or 0) + 1
def delete_definition_cascade( def delete_definition_cascade(session: Session, definition: FlowDefinition) -> dict[str, int]:
session: Session, definition: FlowDefinition
) -> dict[str, int]:
"""Remove a definition and its whole subtree (tasks, runs, versions). """Remove a definition and its whole subtree (tasks, runs, versions).
Used when the canonical file disappears: the directory is the source of Used when the canonical file disappears: the directory is the source of
truth, so the derived registry follows. Commits once. truth, so the derived registry follows. Commits once.
""" """
version_ids = list( version_ids = list(
session.exec( session.exec(select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)).all()
select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)
).all()
) )
task_count = 0 task_count = 0
run_count = 0 run_count = 0
if version_ids: if version_ids:
tasks = list( tasks = list(
session.exec( session.exec(select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))).all()
select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))
).all()
)
runs = list(
session.exec(
select(FlowRun).where(FlowRun.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) task_count, run_count = len(tasks), len(runs)
for row in (*tasks, *runs): for row in (*tasks, *runs):
session.delete(row) 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") raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
def create_version( def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
session: Session, flow_id: UUID, data: FlowVersionCreate
) -> FlowVersion:
get_definition(session, flow_id) get_definition(session, flow_id)
validate_flow_json(data.langflow_json) validate_flow_json(data.langflow_json)
max_no = session.exec( 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: def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
version.lfx_flow_id = lfx_flow_id version.lfx_flow_id = lfx_flow_id
version.deployed_at = datetime.now(timezone.utc) version.deployed_at = datetime.now(UTC)
session.add(version) session.add(version)
session.commit() session.commit()
session.refresh(version) session.refresh(version)
@ -234,12 +219,10 @@ def create_run(
return run return run
def set_run_result( def set_run_result(session: Session, run: FlowRun, status: str, output: dict | None) -> FlowRun:
session: Session, run: FlowRun, status: str, output: dict | None
) -> FlowRun:
run.status = status run.status = status
run.output = output run.output = output
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(UTC)
session.add(run) session.add(run)
session.commit() session.commit()
session.refresh(run) session.refresh(run)
@ -253,9 +236,7 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
return run return run
def list_runs( def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) -> list[FlowRun]:
session: Session, flow_id: UUID, tenant_id: str | None = None
) -> list[FlowRun]:
get_definition(session, flow_id) get_definition(session, flow_id)
query = ( query = (
select(FlowRun) select(FlowRun)
@ -272,9 +253,10 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
run = session.get(FlowRun, data.run_id) run = session.get(FlowRun, data.run_id)
if run is None: if run is None:
raise NotFoundError(f"run {data.run_id} not found") raise NotFoundError(f"run {data.run_id} not found")
if data.continuation_flow_id is not None and session.get( if (
FlowDefinition, data.continuation_flow_id data.continuation_flow_id is not None
) is None: and session.get(FlowDefinition, data.continuation_flow_id) is None
):
raise InvalidError("continuation_flow_id unknown") raise InvalidError("continuation_flow_id unknown")
existing = session.exec( existing = session.exec(
select(ApprovalTask).where( select(ApprovalTask).where(
@ -293,7 +275,7 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
) )
session.add(task) session.add(task)
run.status = "pending_approval" run.status = "pending_approval"
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(UTC)
session.add(run) session.add(run)
session.commit() session.commit()
session.refresh(task) session.refresh(task)
@ -330,7 +312,7 @@ def decide_task(
task.status = decision task.status = decision
task.approver = approver task.approver = approver
task.decision_note = note task.decision_note = note
task.decided_at = datetime.now(timezone.utc) task.decided_at = datetime.now(UTC)
session.add(task) session.add(task)
session.commit() session.commit()
session.refresh(task) 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 Same (definition, version) always maps to the same id, so re-syncs and
crash recovery overwrite instead of orphaning LFX registrations. crash recovery overwrite instead of orphaning LFX registrations.
""" """
return str( return str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}"))
uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}")
)
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None: def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
@ -72,9 +70,7 @@ async def sync_document(
with sync_lock: with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename) definition = store.get_definition_by_source_path(session, source_filename)
if definition is None: if definition is None:
definition = store.create_definition( definition = store.create_definition(session, FlowDefinitionCreate(name=name))
session, FlowDefinitionCreate(name=name)
)
definition.source_path = source_filename definition.source_path = source_filename
session.add(definition) session.add(definition)
session.commit() session.commit()
@ -86,10 +82,8 @@ async def sync_document(
store.set_definition_name(session, definition, name) store.set_definition_name(session, definition, name)
latest = _latest_version(session, definition.id) latest = _latest_version(session, definition.id)
if ( if latest is not None and export.content_hash(latest.langflow_json) == export.content_hash(
latest is not None doc
and export.content_hash(latest.langflow_json)
== export.content_hash(doc)
): ):
return definition, latest, "skipped" return definition, latest, "skipped"
@ -110,15 +104,9 @@ async def sync_document(
FlowVersionCreate(langflow_json=doc, author=author), FlowVersionCreate(langflow_json=doc, author=author),
) )
if version.version_no != version_no: # pragma: no cover - lock prevents this if version.version_no != version_no: # pragma: no cover - lock prevents this
raise store.ConflictError( raise store.ConflictError(f"concurrent version write for flow {definition.id}")
f"concurrent version write for flow {definition.id}"
)
version = store.mark_deployed(session, version, uploaded.id) version = store.mark_deployed(session, version, uploaded.id)
if ( if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
latest is not None
and latest.lfx_flow_id
and latest.lfx_flow_id != uploaded.id
):
try: try:
await lfx.delete_flow(latest.lfx_flow_id) await lfx.delete_flow(latest.lfx_flow_id)
except LfxError as exc: except LfxError as exc:
@ -175,10 +163,8 @@ async def sync_from_directory(
report.errors[filename] = "top-level JSON must be an object" report.errors[filename] = "top-level JSON must be an object"
continue continue
try: try:
_, _, action = await sync_document( _, _, action = await sync_document(session, lfx, doc, source_filename=filename)
session, lfx, doc, source_filename=filename except (store.InvalidError, store.ConflictError, store.NotFoundError, LfxError) as exc:
)
except Exception as exc: # noqa: BLE001 - reconciliation is total
report.errors[filename] = str(exc) report.errors[filename] = str(exc)
continue continue
getattr(report, action).append(path.stem) getattr(report, action).append(path.stem)
@ -193,7 +179,7 @@ async def sync_from_directory(
continue continue
try: try:
await delete_definition_tree(session, lfx, definition) 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) report.errors[stale] = str(exc)
continue continue
report.deleted.append(Path(stale).stem) report.deleted.append(Path(stale).stem)
@ -205,9 +191,7 @@ def default_session_factory() -> Callable[[], Session]:
def default_lfx_factory() -> Callable[[], LfxClient]: def default_lfx_factory() -> Callable[[], LfxClient]:
return lambda: LfxClient( return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
settings.lfx_url, api_key=settings.lfx_api_key or None
)
def run_sync_blocking( def run_sync_blocking(
@ -246,14 +230,12 @@ async def watch_forever(
logger.error("flows dir missing, retrying in 30s: %s", flows_dir) logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
try: try:
await asyncio.wait_for(stop.wait(), timeout=30) await asyncio.wait_for(stop.wait(), timeout=30)
except asyncio.TimeoutError: except TimeoutError:
pass pass
if stop.is_set(): if stop.is_set():
return return
logger.info("watching %s", flows_dir) logger.info("watching %s", flows_dir)
async for _ in awatch( async for _ in awatch(flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100):
flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100
):
if stop.is_set(): if stop.is_set():
break break
try: try:
@ -261,7 +243,7 @@ async def watch_forever(
except FileNotFoundError as exc: except FileNotFoundError as exc:
logger.error("flow sync failed: %s", exc) logger.error("flow sync failed: %s", exc)
continue continue
except Exception: # noqa: BLE001 - watcher must survive except Exception:
logger.exception("flow sync failed") logger.exception("flow sync failed")
continue continue
logger.info( logger.info(

View file

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

View file

@ -1,6 +1,6 @@
"""Flow engine domain models (SQLModel tables).""" """Flow engine domain models (SQLModel tables)."""
from datetime import datetime, timezone from datetime import UTC, datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
def utcnow() -> datetime: def utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(UTC)
def _ts_column() -> Column: 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. # (e.g. "simple_rule.json"). None for definitions created via the API.
source_path: str | None = Field(default=None, max_length=255) source_path: str | None = Field(default=None, max_length=255)
# Flow to run after a human approval on this flow's runs (nullable). # Flow to run after a human approval on this flow's runs (nullable).
continuation_flow_id: UUID | None = Field( continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
default=None, foreign_key="flow_definitions.id"
)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())

View file

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

View file

@ -47,9 +47,7 @@ class Settings(BaseSettings):
@property @property
def admin_email_list(self) -> list[str]: def admin_email_list(self) -> list[str]:
return [ return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
e.strip().lower() for e in self.admin_emails.split(",") if e.strip()
]
settings = Settings() settings = Settings()

View file

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

View file

@ -18,6 +18,7 @@ upload comes later.
from __future__ import annotations from __future__ import annotations
import json import json
from typing import ClassVar
from lfx.custom import Component from lfx.custom import Component
from lfx.field_typing.range_spec import RangeSpec 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." description = "Collects structured form values into a single JSON message."
icon = "ClipboardList" icon = "ClipboardList"
inputs = [ inputs: ClassVar[list] = [
# Plumbing, not a form field: the API posts submitted web-form # Plumbing, not a form field: the API posts submitted web-form
# values as the run envelope (same {"payload": {...}} shape the # values as the run envelope (same {"payload": {...}} shape the
# other kirche components use); they arrive here through a ChatInput # other kirche components use); they arrive here through a ChatInput
@ -129,7 +130,7 @@ class CustomInputForm(Component):
file_types=["pdf", "png", "jpg"], file_types=["pdf", "png", "jpg"],
), ),
] ]
outputs = [ outputs: ClassVar[list] = [
Output(display_name="Form Data", name="form_data", method="collect"), 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": {...}}. # Accepts the raw dict or the run envelope {"payload": {...}}.
try: try:
override = json.loads(_text(self.form_values) or "{}") override = json.loads(_text(self.form_values) or "{}")
except Exception: except json.JSONDecodeError:
override = {} override = {}
if isinstance(override, dict): if isinstance(override, dict):
if isinstance(override.get("payload"), dict): if isinstance(override.get("payload"), dict):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@ -58,9 +59,7 @@ def main() -> None:
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-")) tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
flow_dir = Path("/tmp/smoke-e2e-flows") flow_dir = Path("/tmp/smoke-e2e-flows")
flow_dir.mkdir(exist_ok=True) flow_dir.mkdir(exist_ok=True)
(flow_dir / "seed.json").write_text( (flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
(FIXTURES / "simple_rule.json").read_text()
)
# Migrate a throwaway SQLite schema to head before starting the API. # Migrate a throwaway SQLite schema to head before starting the API.
from alembic import command as alembic_command from alembic import command as alembic_command
from alembic.config import Config from alembic.config import Config
@ -83,14 +82,26 @@ def main() -> None:
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
} }
lfx = subprocess.Popen( 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)], str(VENV_BIN / "lfx"),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, "serve",
"--host",
"127.0.0.1",
"--port",
str(LFX_PORT),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
) )
uvicorn = subprocess.Popen( uvicorn = subprocess.Popen(
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
"--port", str(API_PORT)], cwd=str(BACKEND),
cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env, env=env,
) )
try: try:
@ -106,7 +117,8 @@ def main() -> None:
fx = json.loads((FIXTURES / "simple_rule.json").read_text()) fx = json.loads((FIXTURES / "simple_rule.json").read_text())
s, ver = api( s, ver = api(
"POST", f"/api/flows/{fid}/versions", "POST",
f"/api/flows/{fid}/versions",
{"langflow_json": fx, "author": "smoke"}, {"langflow_json": fx, "author": "smoke"},
) )
check("create version", s == 201, f"v{ver.get('version_no')}") 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]) check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
s, run = api( s, run = api(
"POST", f"/api/flows/{fid}/run", "POST",
f"/api/flows/{fid}/run",
{"input": {"betrag": 120000, "schwelle": 200000}}, {"input": {"betrag": 120000, "schwelle": 200000}},
) )
check("run standard", s == 200 and run.get("status") == "completed", check(
json.dumps(run.get("output"))[:120]) "run standard",
s == 200 and run.get("status") == "completed",
json.dumps(run.get("output"))[:120],
)
s, run2 = api( s, run2 = api(
"POST", f"/api/flows/{fid}/run", "POST",
f"/api/flows/{fid}/run",
{"input": {"betrag": 999000, "schwelle": 200000}}, {"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") s, tasks = api("GET", "/api/approvals")
check("approvals empty (no callback flow)", s == 200 and tasks == []) 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.db import get_engine, reset_engine
from app.flows import export, sync 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.lfx_client import LfxClient
from app.flows.schemas import SyncReport from app.flows.schemas import SyncReport
from app.settings import settings 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 name = doc.get("name") or flow_id
path = export.write_flow_file(flows_dir, doc, name=name) path = export.write_flow_file(flows_dir, doc, name=name)
pulled.append(path.stem) 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) errors[flow_id] = str(exc)
return {"pulled": sorted(pulled), "errors": errors} return {"pulled": sorted(pulled), "errors": errors}
@ -73,12 +73,11 @@ async def main_async(args) -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pull", action="store_true", parser.add_argument(
help="export all Langflow flows to backend/flows first") "--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("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None, parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
help="flows directory (default: settings.flows_dir)")
args = parser.parse_args() args = parser.parse_args()
if not args.pull and not args.sync: if not args.pull and not args.sync:
args.sync = True args.sync = True

View file

@ -107,9 +107,7 @@ def lfx_server(tmp_path_factory):
sock.close() sock.close()
flow_dir = tmp_path_factory.mktemp("lfx-flows") flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = ( seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json"
).read_text()
(flow_dir / "seed.json").write_text(seed) (flow_dir / "seed.json").write_text(seed)
env = { 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: if continuation_flow_id is not None:
body["continuation_flow_id"] = continuation_flow_id body["continuation_flow_id"] = continuation_flow_id
run = ( run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
await client.post(f"/api/flows/{flow['id']}/run", json=body)
).json()
assert run["status"] == "pending_approval" assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json() tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"]) 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]}") cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json") await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
_, task = await _handoff_task( _, task = await _handoff_task(client, live_server, continuation_flow_id=cont["id"])
client, live_server, continuation_flow_id=cont["id"]
)
assert task["continuation_flow_id"] == cont["id"] assert task["continuation_flow_id"] == cont["id"]
response = await client.post( 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"}) response = await client.post(url, json={"decision": "reject"})
assert response.status_code == 409 assert response.status_code == 409
response = await client.post( response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"}
)
assert response.status_code == 404 assert response.status_code == 404
_, task2 = await _handoff_task(client, live_server) _, 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"])), flow_store.get_version(session, UUID(version["id"])),
input={}, input={},
) )
task = flow_store.create_approval_task( task = flow_store.create_approval_task(session, ApprovalCreate(run_id=run.id, title="t"))
session, ApprovalCreate(run_id=run.id, title="t")
)
# plant unknown continuation directly (store allows it pre-validation) # plant unknown continuation directly (store allows it pre-validation)
task.continuation_flow_id = uuid4() task.continuation_flow_id = uuid4()
session.add(task) session.add(task)
session.commit() session.commit()
response = await client.post( response = await client.post(f"/api/approvals/{task.id}/decision", json={"decision": "approve"})
f"/api/approvals/{task.id}/decision", json={"decision": "approve"}
)
assert response.status_code == 422 assert response.status_code == 422
@ -168,9 +158,7 @@ async def test_tenant_isolation(client, lfx_server):
) )
assert response.status_code == 200 assert response.status_code == 200
response = await client.get( response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"}
)
runs_a = response.json() runs_a = response.json()
assert len(runs_a) == 1 assert len(runs_a) == 1
assert runs_a[0]["tenant_id"] == "pfarramt-a" 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") deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
assert deployed.status_code == 200 assert deployed.status_code == 200
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9") monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
response = await client.post( response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
assert response.status_code == 503 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( await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
deployed["lfx_flow_id"] deployed["lfx_flow_id"]
) )
response = await client.post( response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
assert response.status_code == 502 assert response.status_code == 502
@ -247,9 +231,7 @@ async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
"edges": [], "edges": [],
}, },
} }
response = await client.post( response = await client.post(f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus})
f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus}
)
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy") response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
assert response.status_code == 422 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): 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") admin = await _login(client, session, ADMIN, role="admin")
assert 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) user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user) token = service.issue_magic_token(session, user)
record = session.exec( record = session.exec(
select(MagicLinkToken).where( select(MagicLinkToken).where(MagicLinkToken.token_hash == service.hash_token(token))
MagicLinkToken.token_hash == service.hash_token(token)
)
).one() ).one()
record.expires_at = service.utcnow() - timedelta(seconds=1) record.expires_at = service.utcnow() - timedelta(seconds=1)
session.add(record) 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: async with AsyncClient(transport=transport, base_url="http://test") as anon:
assert (await anon.get("/api/access/requests")).status_code == 401 assert (await anon.get("/api/access/requests")).status_code == 401
assert ( assert (
await anon.post( await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
f"/api/access/requests/{created.json()['id']}/approve"
)
).status_code == 401 ).status_code == 401
assert (await anon.get("/api/auth/me")).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 client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin") await _login(client, session, ADMIN, role="admin")
assert ( assert (await client.post(f"/api/access/requests/{uuid4()}/approve")).status_code == 404
await client.post(f"/api/access/requests/{uuid4()}/approve")
).status_code == 404
created = await client.post( created = await client.post(
"/api/access/requests", "/api/access/requests",
@ -246,9 +242,7 @@ def test_magic_link_delivery_failure_returns_false(monkeypatch):
monkeypatch.setattr(settings, "smtp_host", "dummy") monkeypatch.setattr(settings, "smtp_host", "dummy")
_broken_smtp(monkeypatch) _broken_smtp(monkeypatch)
assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False
assert ( assert mail.send_access_notification("req-1", "A", "a@example.com") is False
mail.send_access_notification("req-1", "A", "a@example.com") is False
)
async def test_approve_survives_delivery_failure(client, session, monkeypatch): 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": ""}, json={"name": "M", "email": "m@example.com", "reason": ""},
) )
assert created.status_code == 201, created.text assert created.status_code == 201, created.text
approved = await client.post( approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
f"/api/access/requests/{created.json()['id']}/approve"
)
assert approved.status_code == 200, approved.text assert approved.status_code == 200, approved.text
body = approved.json() body = approved.json()
assert body["request"]["status"] == "approved" 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): async def test_magic_request_for_known_user(client, session):
_get_or_make_user(session, MEMBER) _get_or_make_user(session, MEMBER)
response = await client.post( response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
"/api/auth/magic/request", json={"email": MEMBER}
)
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["message"] == "login link sent" 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 assert "token" not in response.text
tokens = session.exec( tokens = session.exec(
select(MagicLinkToken).where( select(MagicLinkToken).where(
MagicLinkToken.user_id MagicLinkToken.user_id == service.get_user_by_email(session, MEMBER).id
== service.get_user_by_email(session, MEMBER).id
) )
).all() ).all()
assert len(tokens) == 1 assert len(tokens) == 1
async def test_magic_request_unknown_email_403(client): async def test_magic_request_unknown_email_403(client):
response = await client.post( response = await client.post("/api/auth/magic/request", json={"email": "stranger@example.com"})
"/api/auth/magic/request", json={"email": "stranger@example.com"}
)
assert response.status_code == 403 assert response.status_code == 403
async def test_magic_request_admin_email_creates_admin(client, session): async def test_magic_request_admin_email_creates_admin(client, session):
response = await client.post( response = await client.post("/api/auth/magic/request", json={"email": ADMIN})
"/api/auth/magic/request", json={"email": ADMIN}
)
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
user = service.get_user_by_email(session, ADMIN) user = service.get_user_by_email(session, ADMIN)
assert user is not None and user.role == "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): async def test_magic_request_rate_limit(client, session):
_get_or_make_user(session, MEMBER) _get_or_make_user(session, MEMBER)
for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR): for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
response = await client.post( response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
"/api/auth/magic/request", json={"email": MEMBER}
)
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
limited = await client.post( limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
"/api/auth/magic/request", json={"email": MEMBER}
)
assert limited.status_code == 429 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", button_url="http://localhost:3000/login?token=GEHEIM",
note="Gültig 15 Minuten.", note="Gültig 15 Minuten.",
) )
html_part = next( html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
p for p in message.walk() if p.get_content_type() == "text/html"
)
html = html_part.get_content() html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form. # Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html 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. # Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()] parts = [p.get_content_type() for p in message.walk()]
assert "text/plain" in parts and "text/html" in parts assert "text/plain" in parts and "text/html" in parts
plain = next( plain = next(p.get_content() for p in message.walk() if p.get_content_type() == "text/plain")
p.get_content() for p in message.walk() if p.get_content_type() == "text/plain"
)
assert "http://localhost:3000/login?token=GEHEIM" in 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( async def test_import_creates_definition_version_and_deploys(client, lfx_server, override_langflow):
client, lfx_server, override_langflow
):
override_langflow(_export_doc()) override_langflow(_export_doc())
response = await client.post( response = await client.post(
"/api/flows/import", "/api/flows/import",
@ -37,31 +35,21 @@ async def test_import_creates_definition_version_and_deploys(
assert body["definition"]["name"] == "importiert" assert body["definition"]["name"] == "importiert"
assert body["version"]["version_no"] == 1 assert body["version"]["version_no"] == 1
assert body["version"]["author"] == "admin" assert body["version"]["author"] == "admin"
assert body["version"]["lfx_flow_id"] == lfx_id_for( assert body["version"]["lfx_flow_id"] == lfx_id_for(UUID(body["definition"]["id"]), 1)
UUID(body["definition"]["id"]), 1
)
assert body["version"]["deployed_at"] is not None assert body["version"]["deployed_at"] is not None
assert body["version"]["langflow_json"]["data"]["nodes"] assert body["version"]["langflow_json"]["data"]["nodes"]
# Imported flow actually runs on LFX. # Imported flow actually runs on LFX.
definition_id = body["definition"]["id"] definition_id = body["definition"]["id"]
response = await client.post( response = await client.post(f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}})
f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}}
)
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert response.json()["status"] == "completed" assert response.json()["status"] == "completed"
async def test_import_same_content_is_idempotent( async def test_import_same_content_is_idempotent(client, lfx_server, override_langflow):
client, lfx_server, override_langflow
):
override_langflow(_export_doc()) override_langflow(_export_doc())
first = await client.post( first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} 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 first.status_code == 201 assert first.status_code == 201
assert second.status_code == 201 assert second.status_code == 201
assert first.json()["definition"]["id"] == second.json()["definition"]["id"] 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 assert second.json()["version"]["version_no"] == 1
async def test_import_changed_content_bumps_version( async def test_import_changed_content_bumps_version(client, lfx_server, override_langflow):
client, lfx_server, override_langflow
):
override_langflow(_export_doc()) override_langflow(_export_doc())
first = await client.post( first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
assert first.status_code == 201 assert first.status_code == 201
changed = _export_doc() changed = _export_doc()
changed["description"] = "neue Beschreibung" changed["description"] = "neue Beschreibung"
override_langflow(changed) override_langflow(changed)
second = await client.post( second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
assert second.status_code == 201 assert second.status_code == 201
assert second.json()["version"]["version_no"] == 2 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)) monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(_export_doc()) override_langflow(_export_doc())
response = await client.post( response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
written = tmp_path / "importiert.json" written = tmp_path / "importiert.json"
assert written.is_file() 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): async def test_import_langflow_not_found(client, override_langflow):
override_langflow(status=404) override_langflow(status=404)
response = await client.post( response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
assert response.status_code == 404 assert response.status_code == 404
async def test_import_langflow_unreachable(client, override_langflow): async def test_import_langflow_unreachable(client, override_langflow):
override_langflow(status=0) override_langflow(status=0)
response = await client.post( response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
)
assert response.status_code == 503 assert response.status_code == 503

View file

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

View file

@ -9,7 +9,7 @@ from lfx.custom import Component
REPO_ROOT = Path(__file__).resolve().parent.parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "components")) 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" 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(): def test_display_name_match_and_fallback():
doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X") doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X")
assert set( assert set(parser.extract_form_schema(doc)["json_schema"]["properties"]) == {"a"}
parser.extract_form_schema(doc)["json_schema"]["properties"]
) == {"a"}
plain = { plain = {
"data": { "data": {
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}], "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): async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
(tmp_path / "intake_form.json").write_text( (tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
(FIXTURES / "intake_form.json").read_text()
)
report = await sync.sync_from_directory(session, _lfx(), tmp_path) report = await sync.sync_from_directory(session, _lfx(), tmp_path)
assert not report.errors, report.errors assert not report.errors, report.errors
from app.flows import store 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()) doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "schema-e2e"}) created = await client.post("/api/flows", json={"name": "schema-e2e"})
flow_id = created.json()["id"] flow_id = created.json()["id"]
version = await client.post( version = await client.post(f"/api/flows/{flow_id}/versions", json={"langflow_json": doc})
f"/api/flows/{flow_id}/versions", json={"langflow_json": doc}
)
assert version.status_code == 201, version.text assert version.status_code == 201, version.text
assert version.json()["input_schema"]["json_schema"]["properties"]["age"][ assert version.json()["input_schema"]["json_schema"]["properties"]["age"]["type"] == "integer"
"type"
] == "integer"
# Created but never deployed → 404. # Created but never deployed → 404.
assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 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 @respx.mock
async def test_export_flow_sends_api_key_and_returns_doc(): async def test_export_flow_sends_api_key_and_returns_doc():
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}} doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
route = respx.get(f"{BASE}/api/v1/flows/abc").mock( route = respx.get(f"{BASE}/api/v1/flows/abc").mock(return_value=httpx.Response(200, json=doc))
return_value=httpx.Response(200, json=doc)
)
result = await _client().export_flow("abc") result = await _client().export_flow("abc")
assert result == doc assert result == doc
assert route.called assert route.called

View file

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

View file

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