Merge branch 'chore/ruff-hygiene'
This commit is contained in:
commit
cfcf0e8bd6
39 changed files with 459 additions and 559 deletions
|
|
@ -19,4 +19,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cd backend && uv sync
|
||||
- run: cd backend && uv run ruff check .
|
||||
- run: cd backend && uv run ruff format . --check --diff
|
||||
- run: cd backend && uv run pytest
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/
|
|||
Backend (run from `backend/`):
|
||||
- `uv sync` — install dependencies
|
||||
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed)
|
||||
- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`)
|
||||
- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`)
|
||||
- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored)
|
||||
- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX
|
||||
- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
human release responsibility, honest pilot framing instead of
|
||||
invented testimonials; CTA now "Pilotgespräch vereinbaren").
|
||||
- New photo `pilot-meeting.webp` (consultation scene, self-hosted).
|
||||
- Backend lint + format standards via ruff (pinned dev dependency,
|
||||
enforced in CI): `uv run ruff check .`, `uv run ruff format`.
|
||||
|
||||
## [0.1.0] - 2026-09-13
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Access + auth HTTP API (magic-link login, session cookie)."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
|
|
@ -68,11 +69,9 @@ def _set_session_cookie(response: Response, session_id: str) -> None:
|
|||
|
||||
|
||||
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
|
||||
def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)):
|
||||
def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
request = service.create_access_request(
|
||||
session, data.name, data.email, data.reason
|
||||
)
|
||||
request = service.create_access_request(session, data.name, data.email, data.reason)
|
||||
except service.RateLimitedError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
mail.send_access_notification(str(request.id), request.name, request.email)
|
||||
|
|
@ -81,9 +80,9 @@ def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)
|
|||
|
||||
@access_router.get("/requests", response_model=list[AccessRequestRead])
|
||||
def list_requests(
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
status: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
return [_read_request(r) for r in service.list_access_requests(session, status)]
|
||||
|
||||
|
|
@ -91,13 +90,11 @@ def list_requests(
|
|||
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
|
||||
def approve_request(
|
||||
request_id: UUID,
|
||||
session: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
request, _user, token = service.approve_access_request(
|
||||
session, request_id, admin.email
|
||||
)
|
||||
request, _user, token = service.approve_access_request(session, request_id, admin.email)
|
||||
except service.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except service.ConflictError as exc:
|
||||
|
|
@ -110,8 +107,8 @@ def approve_request(
|
|||
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
|
||||
def reject_request(
|
||||
request_id: UUID,
|
||||
session: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
request = service.reject_access_request(session, request_id, admin.email)
|
||||
|
|
@ -125,7 +122,7 @@ def reject_request(
|
|||
@auth_router.post("/magic/request", response_model=MagicRequestRead)
|
||||
def request_magic(
|
||||
data: MagicRequestRequest,
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
"""Email-first login: allowed emails get a magic link mailed.
|
||||
|
||||
|
|
@ -146,7 +143,7 @@ def request_magic(
|
|||
def consume_magic(
|
||||
data: MagicConsumeRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
try:
|
||||
user_session = service.consume_magic_token(session, data.token)
|
||||
|
|
@ -164,7 +161,7 @@ def consume_magic(
|
|||
|
||||
|
||||
@auth_router.get("/me", response_model=MeRead)
|
||||
def read_me(user: User = Depends(get_current_user)):
|
||||
def read_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
return _read_me(user)
|
||||
|
||||
|
||||
|
|
@ -172,7 +169,7 @@ def read_me(user: User = Depends(get_current_user)):
|
|||
def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
# Revoke the server-side row when the cookie maps to a live session;
|
||||
# always clear the client cookie.
|
||||
|
|
@ -184,7 +181,7 @@ def logout(
|
|||
|
||||
|
||||
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
|
||||
def admin_bootstrap(data: AdminBootstrapRequest, session: Session = Depends(get_db)):
|
||||
def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
_user, token = service.bootstrap_admin(session, data.email)
|
||||
except service.ConflictError as exc:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlmodel import Session
|
||||
|
|
@ -21,7 +22,7 @@ def get_db() -> Iterator[Session]:
|
|||
|
||||
|
||||
def get_optional_user(
|
||||
request: Request, session: Session = Depends(get_db)
|
||||
request: Request, session: Annotated[Session, Depends(get_db)]
|
||||
) -> User | None:
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if not session_id:
|
||||
|
|
@ -32,24 +33,20 @@ def get_optional_user(
|
|||
return service.get_user(session, record.user_id)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, session: Session = Depends(get_db)
|
||||
) -> User:
|
||||
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:
|
||||
user = get_optional_user(request, session)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin only")
|
||||
return user
|
||||
|
||||
|
||||
def resolve_tenant(
|
||||
user: User | None, explicit: str | None, default: str = "default"
|
||||
) -> str:
|
||||
def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str:
|
||||
"""Tenant comes from the session; the body/query param is a deprecated
|
||||
fallback for unauthenticated callers."""
|
||||
if user is not None:
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ def _page(title: str, intro: str, button_label: str, button_url: str | None, not
|
|||
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
|
||||
f'<a href="{button_url}" '
|
||||
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
|
||||
'Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;'
|
||||
'font-weight:600;color:#ffffff;text-decoration:none;'
|
||||
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
|
||||
"font-weight:600;color:#ffffff;text-decoration:none;"
|
||||
f'border-radius:8px;">{button_label}</a></td></tr></table>'
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
|
|
@ -106,14 +106,10 @@ def _branded_mail(
|
|||
message["From"] = settings.mail_from or settings.contact_to or to_email
|
||||
if settings.contact_to:
|
||||
message["Reply-To"] = settings.contact_to
|
||||
message.set_content(
|
||||
f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n"
|
||||
)
|
||||
message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n")
|
||||
# Email-safe HTML: tables + inline CSS; some clients strip <style>, so
|
||||
# the palette is inlined via the f-string constants above.
|
||||
message.add_alternative(
|
||||
_page(headline, intro, button_label, button_url, note), subtype="html"
|
||||
)
|
||||
message.add_alternative(_page(headline, intro, button_label, button_url, note), subtype="html")
|
||||
return message
|
||||
|
||||
|
||||
|
|
@ -149,7 +145,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
|
|||
)
|
||||
try:
|
||||
_send(message)
|
||||
except Exception: # noqa: BLE001 - delivery is best effort, see docstring
|
||||
except (OSError, smtplib.SMTPException):
|
||||
logger.warning("magic-link delivery failed for %s", to_email)
|
||||
return False
|
||||
logger.info("magic link mailed to %s", to_email)
|
||||
|
|
@ -166,8 +162,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
|||
to_email=settings.contact_to,
|
||||
headline="Neue Zugangsanfrage",
|
||||
intro=(
|
||||
f"{name} ({email}) hat Zugang angefordert und wartet auf "
|
||||
"Freigabe in der Verwaltung."
|
||||
f"{name} ({email}) hat Zugang angefordert und wartet auf Freigabe in der Verwaltung."
|
||||
),
|
||||
button_label="In der Verwaltung prüfen",
|
||||
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
|
||||
|
|
@ -175,7 +170,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
|||
)
|
||||
try:
|
||||
_send(message)
|
||||
except Exception: # noqa: BLE001 - best effort, request is already stored
|
||||
except (OSError, smtplib.SMTPException):
|
||||
logger.warning("access-request notification failed for %s", request_id)
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Session, func, select
|
||||
|
|
@ -61,7 +61,7 @@ class RateLimitedError(AuthError):
|
|||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
|
|
@ -85,9 +85,7 @@ def get_user(session: Session, user_id: UUID) -> User | None:
|
|||
|
||||
|
||||
def get_user_by_email(session: Session, email: str) -> User | None:
|
||||
return session.exec(
|
||||
select(User).where(User.email == normalize_email(email))
|
||||
).first()
|
||||
return session.exec(select(User).where(User.email == normalize_email(email))).first()
|
||||
|
||||
|
||||
def user_count(session: Session) -> int:
|
||||
|
|
@ -223,9 +221,7 @@ def approve_access_request(
|
|||
return request, user, token
|
||||
|
||||
|
||||
def reject_access_request(
|
||||
session: Session, request_id: UUID, decided_by: str
|
||||
) -> AccessRequest:
|
||||
def reject_access_request(session: Session, request_id: UUID, decided_by: str) -> AccessRequest:
|
||||
request = get_access_request(session, request_id)
|
||||
if request.status != "pending":
|
||||
raise ConflictError(f"access request {request_id} already {request.status}")
|
||||
|
|
@ -242,16 +238,14 @@ def reject_access_request(
|
|||
def _aware(value: datetime) -> datetime:
|
||||
# SQLite returns naive datetimes; stored values are UTC.
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
def consume_magic_token(session: Session, token: str) -> UserSession:
|
||||
"""Single-use consume → session row. used_at is set before creation."""
|
||||
record = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.token_hash == hash_token(token.strip())
|
||||
)
|
||||
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
|
||||
).first()
|
||||
if record is None:
|
||||
raise InvalidTokenError("unknown token")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
|
@ -12,15 +13,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_optional_user, resolve_tenant
|
||||
from app.db import get_engine, get_session
|
||||
from app.models import User
|
||||
from app.settings import settings
|
||||
from app.auth.deps import get_optional_user, resolve_tenant
|
||||
|
||||
from . import runner, store
|
||||
from . import input_schema as form_schema
|
||||
from . import runner, store
|
||||
from .deploy import import_from_langflow
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
from .langflow_client import (
|
||||
LangflowClient,
|
||||
LangflowError,
|
||||
|
|
@ -33,7 +33,6 @@ from .lfx_client import (
|
|||
LfxError,
|
||||
LfxInvalidFlow,
|
||||
LfxNotFound,
|
||||
LfxRunFailed,
|
||||
LfxUnavailable,
|
||||
)
|
||||
from .schemas import (
|
||||
|
|
@ -52,6 +51,7 @@ from .schemas import (
|
|||
RunRequest,
|
||||
SyncReport,
|
||||
)
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
|
||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||
|
|
@ -66,9 +66,7 @@ def get_lfx() -> LfxClient:
|
|||
|
||||
|
||||
def get_langflow() -> LangflowClient:
|
||||
return LangflowClient(
|
||||
settings.langflow_url, api_key=settings.langflow_api_key or ""
|
||||
)
|
||||
return LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key or "")
|
||||
|
||||
|
||||
def _read_definition(d) -> FlowDefinitionRead:
|
||||
|
|
@ -129,7 +127,7 @@ def _read_task(t) -> ApprovalRead:
|
|||
|
||||
|
||||
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
||||
def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
|
||||
def create_flow(data: FlowDefinitionCreate, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
return _read_definition(store.create_definition(session, data))
|
||||
except store.ConflictError as exc:
|
||||
|
|
@ -139,14 +137,14 @@ def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
|
|||
|
||||
|
||||
@flows_router.get("", response_model=list[FlowDefinitionRead])
|
||||
def list_flows(session: Session = Depends(get_db)):
|
||||
def list_flows(session: Annotated[Session, Depends(get_db)]):
|
||||
return [_read_definition(d) for d in store.list_definitions(session)]
|
||||
|
||||
|
||||
@flows_router.post("/sync", response_model=SyncReport)
|
||||
async def sync_flows(
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
):
|
||||
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
||||
try:
|
||||
|
|
@ -156,18 +154,16 @@ async def sync_flows(
|
|||
|
||||
|
||||
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
||||
def get_flow(flow_id: UUID, session: Session = Depends(get_db)):
|
||||
def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
return _read_definition(store.get_definition(session, flow_id))
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@flows_router.post(
|
||||
"/{flow_id}/versions", response_model=FlowVersionRead, status_code=201
|
||||
)
|
||||
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
|
||||
def create_version(
|
||||
flow_id: UUID, data: FlowVersionCreate, session: Session = Depends(get_db)
|
||||
flow_id: UUID, data: FlowVersionCreate, session: Annotated[Session, Depends(get_db)]
|
||||
):
|
||||
try:
|
||||
return _read_version(store.create_version(session, flow_id, data))
|
||||
|
|
@ -178,21 +174,19 @@ def create_version(
|
|||
|
||||
|
||||
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
|
||||
def list_versions(flow_id: UUID, session: Session = Depends(get_db)):
|
||||
def list_versions(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
return [_read_version(v) for v in store.list_versions(session, flow_id)]
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@flows_router.post(
|
||||
"/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead
|
||||
)
|
||||
@flows_router.post("/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead)
|
||||
async def deploy_version(
|
||||
flow_id: UUID,
|
||||
version_no: int,
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
):
|
||||
try:
|
||||
version = store.get_version_by_no(session, flow_id, version_no)
|
||||
|
|
@ -227,10 +221,10 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
|
|||
async def run_flow(
|
||||
flow_id: UUID,
|
||||
data: RunRequest,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
user: Annotated[User | None, Depends(get_optional_user)],
|
||||
stream: bool = Query(default=False),
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
):
|
||||
# Tenant comes from the session; the body param stays as a deprecated
|
||||
# fallback for unauthenticated callers.
|
||||
|
|
@ -265,9 +259,7 @@ async def run_flow(
|
|||
|
||||
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
|
||||
if not version.lfx_flow_id:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"version {version.id} is not deployed to LFX"
|
||||
)
|
||||
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
|
||||
run = store.create_run(
|
||||
session,
|
||||
version,
|
||||
|
|
@ -288,14 +280,16 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
|||
async def event_gen() -> AsyncIterator[str]:
|
||||
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with (
|
||||
httpx.AsyncClient(
|
||||
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
||||
) as client:
|
||||
async with client.stream(
|
||||
) as client,
|
||||
client.stream(
|
||||
"POST",
|
||||
f"/flows/{lfx_flow_id}/stream",
|
||||
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")
|
||||
response.raise_for_status()
|
||||
|
|
@ -307,11 +301,7 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
|||
with Session(get_engine()) as fresh:
|
||||
try:
|
||||
fresh_run = store.get_run(fresh, run_id)
|
||||
status = (
|
||||
"pending_approval"
|
||||
if runner._task_exists(fresh, run_id)
|
||||
else "completed"
|
||||
)
|
||||
status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed"
|
||||
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
|
||||
except store.NotFoundError:
|
||||
status = "failed"
|
||||
|
|
@ -323,9 +313,9 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
|||
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
|
||||
def list_runs(
|
||||
flow_id: UUID,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User | None, Depends(get_optional_user)],
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
):
|
||||
# Authenticated callers are scoped to their session tenant unless they
|
||||
# pass an explicit tenant (deprecated fallback for unauthenticated use).
|
||||
|
|
@ -338,7 +328,7 @@ def list_runs(
|
|||
|
||||
|
||||
@flows_router.get("/langflow/available")
|
||||
async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
|
||||
async def list_langflow_flows(langflow: Annotated[LangflowClient, Depends(get_langflow)]):
|
||||
"""Browse flows on the Langflow authoring server (admin import source)."""
|
||||
try:
|
||||
return await langflow.list_flows()
|
||||
|
|
@ -351,9 +341,9 @@ async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
|
|||
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
||||
async def import_flow(
|
||||
data: FlowImportRequest,
|
||||
session: Session = Depends(get_db),
|
||||
langflow: LangflowClient = Depends(get_langflow),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
):
|
||||
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
|
||||
try:
|
||||
|
|
@ -384,24 +374,20 @@ async def import_flow(
|
|||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except LfxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return FlowImportRead(
|
||||
definition=_read_definition(definition), version=_read_version(version)
|
||||
)
|
||||
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
|
||||
|
||||
|
||||
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
|
||||
def get_input_schema(
|
||||
flow_id: UUID,
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
"""Form schema of the latest deployed version (404 when none)."""
|
||||
try:
|
||||
version = store.get_latest_deployed_version(session, flow_id)
|
||||
except (store.NotFoundError, store.InvalidError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
cached = version.input_schema or form_schema.extract_form_schema(
|
||||
version.langflow_json
|
||||
)
|
||||
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
return InputSchemaRead(
|
||||
flow_id=flow_id,
|
||||
version_no=version.version_no,
|
||||
|
|
@ -414,9 +400,9 @@ def get_input_schema(
|
|||
@flows_router.get("/runs/{run_id}", response_model=RunRead)
|
||||
def get_run(
|
||||
run_id: UUID,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User | None, Depends(get_optional_user)],
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
):
|
||||
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
|
||||
if user is not None and tenant_id is None:
|
||||
|
|
@ -431,7 +417,7 @@ def get_run(
|
|||
|
||||
|
||||
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
||||
def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
|
||||
def create_approval(data: ApprovalCreate, session: Annotated[Session, Depends(get_db)]):
|
||||
"""Flow callback contract: report a terminal approval state for a run."""
|
||||
try:
|
||||
return _read_task(store.create_approval_task(session, data))
|
||||
|
|
@ -445,9 +431,9 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
|
|||
|
||||
@approvals_router.get("", response_model=list[ApprovalRead])
|
||||
def list_approvals(
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User | None, Depends(get_optional_user)],
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
):
|
||||
if user is not None and tenant_id is None:
|
||||
tenant_id = user.tenant_id
|
||||
|
|
@ -458,8 +444,8 @@ def list_approvals(
|
|||
async def decide_approval(
|
||||
task_id: UUID,
|
||||
data: DecisionRequest,
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
):
|
||||
"""Decide an open task. Approve starts the continuation flow (if any)."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from sqlmodel import Session
|
|||
from app.models import FlowDefinition, FlowVersion
|
||||
from app.settings import settings
|
||||
|
||||
from . import export, store, sync
|
||||
from . import export, sync
|
||||
from .langflow_client import LangflowClient
|
||||
from .lfx_client import LfxClient
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ def slugify(name: str) -> str:
|
|||
|
||||
def canonical_bytes(doc: dict) -> bytes:
|
||||
"""Deterministic serialization: stable bytes for stable git diffs."""
|
||||
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def content_hash(doc: dict) -> str:
|
||||
|
|
@ -38,9 +36,7 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
|
|||
return Path(flows_dir) / f"{stem}.json"
|
||||
|
||||
|
||||
def write_flow_file(
|
||||
flows_dir: str | Path, doc: dict, *, name: str | None = None
|
||||
) -> Path:
|
||||
def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path:
|
||||
"""Write a flow document to backend/flows atomically (tmp + rename).
|
||||
|
||||
The filename derives from `name` (or the document's own name). Returns
|
||||
|
|
@ -50,9 +46,7 @@ def write_flow_file(
|
|||
flows_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = slugify(name or doc.get("name") or "flow")
|
||||
target = flow_path(flows_dir, stem)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp"
|
||||
)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
tmp.write(canonical_bytes(doc))
|
||||
|
|
|
|||
|
|
@ -51,9 +51,7 @@ class LfxRunResult:
|
|||
class LfxClient:
|
||||
"""Thin async wrapper around the LFX serve HTTP API."""
|
||||
|
||||
def __init__(
|
||||
self, base_url: str, timeout: float = 120.0, api_key: str | None = None
|
||||
) -> None:
|
||||
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.api_key = api_key
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ def build_envelope(
|
|||
"payload": payload,
|
||||
"run_id": str(run_id),
|
||||
"callback_url": callback_base_url.rstrip("/"),
|
||||
"continuation_flow_id": (
|
||||
str(continuation_flow_id) if continuation_flow_id else None
|
||||
),
|
||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -36,9 +34,7 @@ def build_envelope(
|
|||
def _task_exists(session: Session, run_id: UUID) -> bool:
|
||||
return (
|
||||
session.exec(
|
||||
select(ApprovalTask).where(
|
||||
ApprovalTask.run_id == run_id, ApprovalTask.status == "open"
|
||||
)
|
||||
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
|
@ -80,9 +76,7 @@ async def execute_run(
|
|||
session_id=session_id or str(run.id),
|
||||
)
|
||||
except LfxRunFailed as exc:
|
||||
return store.set_run_result(
|
||||
session, run, "failed", {"error": str(exc)}
|
||||
)
|
||||
return store.set_run_result(session, run, "failed", {"error": str(exc)})
|
||||
output = {
|
||||
"result": result.result,
|
||||
"component": result.component,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -27,9 +27,10 @@ class InvalidError(Exception):
|
|||
|
||||
|
||||
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
|
||||
if data.continuation_flow_id is not None and session.get(
|
||||
FlowDefinition, data.continuation_flow_id
|
||||
) is None:
|
||||
if (
|
||||
data.continuation_flow_id is not None
|
||||
and session.get(FlowDefinition, data.continuation_flow_id) is None
|
||||
):
|
||||
raise InvalidError("continuation_flow_id unknown")
|
||||
definition = FlowDefinition(
|
||||
name=data.name,
|
||||
|
|
@ -57,18 +58,14 @@ def list_definitions(session: Session) -> list[FlowDefinition]:
|
|||
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
|
||||
|
||||
|
||||
def get_definition_by_source_path(
|
||||
session: Session, source_path: str
|
||||
) -> FlowDefinition | None:
|
||||
def get_definition_by_source_path(session: Session, source_path: str) -> FlowDefinition | None:
|
||||
"""Find the definition synced from a given backend/flows filename."""
|
||||
return session.exec(
|
||||
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
|
||||
).first()
|
||||
|
||||
|
||||
def set_definition_name(
|
||||
session: Session, definition: FlowDefinition, name: str
|
||||
) -> FlowDefinition:
|
||||
def set_definition_name(session: Session, definition: FlowDefinition, name: str) -> FlowDefinition:
|
||||
"""Rename a definition and record its source file. Name clashes 409."""
|
||||
definition.name = name
|
||||
session.add(definition)
|
||||
|
|
@ -88,32 +85,22 @@ def next_version_no(session: Session, flow_id: UUID) -> int:
|
|||
return (max_no or 0) + 1
|
||||
|
||||
|
||||
def delete_definition_cascade(
|
||||
session: Session, definition: FlowDefinition
|
||||
) -> dict[str, int]:
|
||||
def delete_definition_cascade(session: Session, definition: FlowDefinition) -> dict[str, int]:
|
||||
"""Remove a definition and its whole subtree (tasks, runs, versions).
|
||||
|
||||
Used when the canonical file disappears: the directory is the source of
|
||||
truth, so the derived registry follows. Commits once.
|
||||
"""
|
||||
version_ids = list(
|
||||
session.exec(
|
||||
select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)
|
||||
).all()
|
||||
session.exec(select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)).all()
|
||||
)
|
||||
task_count = 0
|
||||
run_count = 0
|
||||
if version_ids:
|
||||
tasks = list(
|
||||
session.exec(
|
||||
select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))
|
||||
).all()
|
||||
)
|
||||
runs = list(
|
||||
session.exec(
|
||||
select(FlowRun).where(FlowRun.version_id.in_(version_ids))
|
||||
).all()
|
||||
session.exec(select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))).all()
|
||||
)
|
||||
runs = list(session.exec(select(FlowRun).where(FlowRun.version_id.in_(version_ids))).all())
|
||||
task_count, run_count = len(tasks), len(runs)
|
||||
for row in (*tasks, *runs):
|
||||
session.delete(row)
|
||||
|
|
@ -142,9 +129,7 @@ def validate_flow_json(flow_json: dict) -> None:
|
|||
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
|
||||
|
||||
|
||||
def create_version(
|
||||
session: Session, flow_id: UUID, data: FlowVersionCreate
|
||||
) -> FlowVersion:
|
||||
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
|
||||
get_definition(session, flow_id)
|
||||
validate_flow_json(data.langflow_json)
|
||||
max_no = session.exec(
|
||||
|
|
@ -207,7 +192,7 @@ def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion:
|
|||
|
||||
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
|
||||
version.lfx_flow_id = lfx_flow_id
|
||||
version.deployed_at = datetime.now(timezone.utc)
|
||||
version.deployed_at = datetime.now(UTC)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
session.refresh(version)
|
||||
|
|
@ -234,12 +219,10 @@ def create_run(
|
|||
return run
|
||||
|
||||
|
||||
def set_run_result(
|
||||
session: Session, run: FlowRun, status: str, output: dict | None
|
||||
) -> FlowRun:
|
||||
def set_run_result(session: Session, run: FlowRun, status: str, output: dict | None) -> FlowRun:
|
||||
run.status = status
|
||||
run.output = output
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
session.refresh(run)
|
||||
|
|
@ -253,9 +236,7 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
|
|||
return run
|
||||
|
||||
|
||||
def list_runs(
|
||||
session: Session, flow_id: UUID, tenant_id: str | None = None
|
||||
) -> list[FlowRun]:
|
||||
def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) -> list[FlowRun]:
|
||||
get_definition(session, flow_id)
|
||||
query = (
|
||||
select(FlowRun)
|
||||
|
|
@ -272,9 +253,10 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
|
|||
run = session.get(FlowRun, data.run_id)
|
||||
if run is None:
|
||||
raise NotFoundError(f"run {data.run_id} not found")
|
||||
if data.continuation_flow_id is not None and session.get(
|
||||
FlowDefinition, data.continuation_flow_id
|
||||
) is None:
|
||||
if (
|
||||
data.continuation_flow_id is not None
|
||||
and session.get(FlowDefinition, data.continuation_flow_id) is None
|
||||
):
|
||||
raise InvalidError("continuation_flow_id unknown")
|
||||
existing = session.exec(
|
||||
select(ApprovalTask).where(
|
||||
|
|
@ -293,7 +275,7 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
|
|||
)
|
||||
session.add(task)
|
||||
run.status = "pending_approval"
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
|
|
@ -330,7 +312,7 @@ def decide_task(
|
|||
task.status = decision
|
||||
task.approver = approver
|
||||
task.decision_note = note
|
||||
task.decided_at = datetime.now(timezone.utc)
|
||||
task.decided_at = datetime.now(UTC)
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ def lfx_id_for(definition_id: UUID, version_no: int) -> str:
|
|||
Same (definition, version) always maps to the same id, so re-syncs and
|
||||
crash recovery overwrite instead of orphaning LFX registrations.
|
||||
"""
|
||||
return str(
|
||||
uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}")
|
||||
)
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}"))
|
||||
|
||||
|
||||
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
|
||||
|
|
@ -72,9 +70,7 @@ async def sync_document(
|
|||
with sync_lock:
|
||||
definition = store.get_definition_by_source_path(session, source_filename)
|
||||
if definition is None:
|
||||
definition = store.create_definition(
|
||||
session, FlowDefinitionCreate(name=name)
|
||||
)
|
||||
definition = store.create_definition(session, FlowDefinitionCreate(name=name))
|
||||
definition.source_path = source_filename
|
||||
session.add(definition)
|
||||
session.commit()
|
||||
|
|
@ -86,10 +82,8 @@ async def sync_document(
|
|||
store.set_definition_name(session, definition, name)
|
||||
|
||||
latest = _latest_version(session, definition.id)
|
||||
if (
|
||||
latest is not None
|
||||
and export.content_hash(latest.langflow_json)
|
||||
== export.content_hash(doc)
|
||||
if latest is not None and export.content_hash(latest.langflow_json) == export.content_hash(
|
||||
doc
|
||||
):
|
||||
return definition, latest, "skipped"
|
||||
|
||||
|
|
@ -110,15 +104,9 @@ async def sync_document(
|
|||
FlowVersionCreate(langflow_json=doc, author=author),
|
||||
)
|
||||
if version.version_no != version_no: # pragma: no cover - lock prevents this
|
||||
raise store.ConflictError(
|
||||
f"concurrent version write for flow {definition.id}"
|
||||
)
|
||||
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
|
||||
version = store.mark_deployed(session, version, uploaded.id)
|
||||
if (
|
||||
latest is not None
|
||||
and latest.lfx_flow_id
|
||||
and latest.lfx_flow_id != uploaded.id
|
||||
):
|
||||
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
|
||||
try:
|
||||
await lfx.delete_flow(latest.lfx_flow_id)
|
||||
except LfxError as exc:
|
||||
|
|
@ -175,10 +163,8 @@ async def sync_from_directory(
|
|||
report.errors[filename] = "top-level JSON must be an object"
|
||||
continue
|
||||
try:
|
||||
_, _, action = await sync_document(
|
||||
session, lfx, doc, source_filename=filename
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||
_, _, action = await sync_document(session, lfx, doc, source_filename=filename)
|
||||
except (store.InvalidError, store.ConflictError, store.NotFoundError, LfxError) as exc:
|
||||
report.errors[filename] = str(exc)
|
||||
continue
|
||||
getattr(report, action).append(path.stem)
|
||||
|
|
@ -193,7 +179,7 @@ async def sync_from_directory(
|
|||
continue
|
||||
try:
|
||||
await delete_definition_tree(session, lfx, definition)
|
||||
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||
except store.NotFoundError as exc:
|
||||
report.errors[stale] = str(exc)
|
||||
continue
|
||||
report.deleted.append(Path(stale).stem)
|
||||
|
|
@ -205,9 +191,7 @@ def default_session_factory() -> Callable[[], Session]:
|
|||
|
||||
|
||||
def default_lfx_factory() -> Callable[[], LfxClient]:
|
||||
return lambda: LfxClient(
|
||||
settings.lfx_url, api_key=settings.lfx_api_key or None
|
||||
)
|
||||
return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
|
||||
|
||||
def run_sync_blocking(
|
||||
|
|
@ -246,14 +230,12 @@ async def watch_forever(
|
|||
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
pass
|
||||
if stop.is_set():
|
||||
return
|
||||
logger.info("watching %s", flows_dir)
|
||||
async for _ in awatch(
|
||||
flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100
|
||||
):
|
||||
async for _ in awatch(flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100):
|
||||
if stop.is_set():
|
||||
break
|
||||
try:
|
||||
|
|
@ -261,7 +243,7 @@ async def watch_forever(
|
|||
except FileNotFoundError as exc:
|
||||
logger.error("flow sync failed: %s", exc)
|
||||
continue
|
||||
except Exception: # noqa: BLE001 - watcher must survive
|
||||
except Exception:
|
||||
logger.exception("flow sync failed")
|
||||
continue
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ from pathlib import Path
|
|||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .flows.api import approvals_router, flows_router
|
||||
from .auth.api import access_router, auth_router
|
||||
from .flows.api import approvals_router, flows_router
|
||||
from .flows.sync import run_sync_blocking, watch_forever
|
||||
from .settings import settings
|
||||
|
||||
|
|
@ -30,8 +30,8 @@ def _blocking_startup_sync() -> None:
|
|||
logger.warning("startup flow sync: %s: %s", filename, reason)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("startup flow sync skipped: %s", exc)
|
||||
except Exception: # noqa: BLE001 - API must start even if sync fails
|
||||
logger.exception("startup flow sync failed")
|
||||
except Exception:
|
||||
logger.exception("startup flow sync failed") # never fatal at boot
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -50,7 +50,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Flow engine domain models (SQLModel tables)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
|
||||
|
|
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
|
|||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _ts_column() -> Column:
|
||||
|
|
@ -33,9 +33,7 @@ class FlowDefinition(SQLModel, table=True):
|
|||
# (e.g. "simple_rule.json"). None for definitions created via the API.
|
||||
source_path: str | None = Field(default=None, max_length=255)
|
||||
# Flow to run after a human approval on this flow's runs (nullable).
|
||||
continuation_flow_id: UUID | None = Field(
|
||||
default=None, foreign_key="flow_definitions.id"
|
||||
)
|
||||
continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Access-management domain models (SQLModel tables)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
|
|
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
|
|||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _ts_column() -> Column:
|
||||
|
|
|
|||
|
|
@ -47,9 +47,7 @@ class Settings(BaseSettings):
|
|||
|
||||
@property
|
||||
def admin_email_list(self) -> list[str]:
|
||||
return [
|
||||
e.strip().lower() for e in self.admin_emails.split(",") if e.strip()
|
||||
]
|
||||
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ upload comes later.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
from lfx.field_typing.range_spec import RangeSpec
|
||||
|
|
@ -51,7 +52,7 @@ class CustomInputForm(Component):
|
|||
description = "Collects structured form values into a single JSON message."
|
||||
icon = "ClipboardList"
|
||||
|
||||
inputs = [
|
||||
inputs: ClassVar[list] = [
|
||||
# Plumbing, not a form field: the API posts submitted web-form
|
||||
# values as the run envelope (same {"payload": {...}} shape the
|
||||
# other kirche components use); they arrive here through a ChatInput
|
||||
|
|
@ -129,7 +130,7 @@ class CustomInputForm(Component):
|
|||
file_types=["pdf", "png", "jpg"],
|
||||
),
|
||||
]
|
||||
outputs = [
|
||||
outputs: ClassVar[list] = [
|
||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||
]
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ class CustomInputForm(Component):
|
|||
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
||||
try:
|
||||
override = json.loads(_text(self.form_values) or "{}")
|
||||
except Exception:
|
||||
except json.JSONDecodeError:
|
||||
override = {}
|
||||
if isinstance(override, dict):
|
||||
if isinstance(override.get("payload"), dict):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
|
@ -16,10 +14,11 @@ if config.config_file_name is not None:
|
|||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from app.models import * # noqa: F401,F403 - register all tables
|
||||
from app.settings import settings
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.models import *
|
||||
from app.settings import settings
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
# The URL always comes from settings (repo-root .env or environment),
|
||||
|
|
@ -70,9 +69,7 @@ def run_migrations_online() -> None:
|
|||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
|
|
|||
|
|
@ -5,104 +5,129 @@ Revises:
|
|||
Create Date: 2026-09-06 22:27:07.129788
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '62ea9a5d8f9d'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "62ea9a5d8f9d"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('flow_definitions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"flow_definitions",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["continuation_flow_id"],
|
||||
["flow_definitions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True)
|
||||
op.create_table('flow_versions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('flow_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version_no', sa.Integer(), nullable=False),
|
||||
sa.Column('langflow_json', sa.JSON(), nullable=True),
|
||||
sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column('deployed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('flow_id', 'version_no')
|
||||
op.create_index(op.f("ix_flow_definitions_name"), "flow_definitions", ["name"], unique=True)
|
||||
op.create_table(
|
||||
"flow_versions",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("flow_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||
sa.Column("langflow_json", sa.JSON(), nullable=True),
|
||||
sa.Column("lfx_flow_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("author", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["flow_id"],
|
||||
["flow_definitions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("flow_id", "version_no"),
|
||||
)
|
||||
op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False)
|
||||
op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False)
|
||||
op.create_table('flow_runs',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
|
||||
sa.Column('input', sa.JSON(), nullable=True),
|
||||
sa.Column('output', sa.JSON(), nullable=True),
|
||||
sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(op.f("ix_flow_versions_flow_id"), "flow_versions", ["flow_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_flow_versions_version_no"), "flow_versions", ["version_no"], unique=False
|
||||
)
|
||||
op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False)
|
||||
op.create_table('approval_tasks',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('run_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
||||
sa.Column('payload', sa.JSON(), nullable=True),
|
||||
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ),
|
||||
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"flow_runs",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("version_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
|
||||
sa.Column("input", sa.JSON(), nullable=True),
|
||||
sa.Column("output", sa.JSON(), nullable=True),
|
||||
sa.Column("session_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["version_id"],
|
||||
["flow_versions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_flow_runs_status"), "flow_runs", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_flow_runs_tenant_id"), "flow_runs", ["tenant_id"], unique=False)
|
||||
op.create_index(op.f("ix_flow_runs_version_id"), "flow_runs", ["version_id"], unique=False)
|
||||
op.create_table(
|
||||
"approval_tasks",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("run_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("version_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("approver", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("decision_note", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["run_id"],
|
||||
["flow_runs.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["version_id"],
|
||||
["flow_versions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_approval_tasks_run_id"), "approval_tasks", ["run_id"], unique=False)
|
||||
op.create_index(op.f("ix_approval_tasks_status"), "approval_tasks", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_approval_tasks_tenant_id"), "approval_tasks", ["tenant_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_approval_tasks_version_id"), "approval_tasks", ["version_id"], unique=False
|
||||
)
|
||||
op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False)
|
||||
op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks')
|
||||
op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks')
|
||||
op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks')
|
||||
op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks')
|
||||
op.drop_table('approval_tasks')
|
||||
op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs')
|
||||
op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs')
|
||||
op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs')
|
||||
op.drop_table('flow_runs')
|
||||
op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions')
|
||||
op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions')
|
||||
op.drop_table('flow_versions')
|
||||
op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions')
|
||||
op.drop_table('flow_definitions')
|
||||
op.drop_index(op.f("ix_approval_tasks_version_id"), table_name="approval_tasks")
|
||||
op.drop_index(op.f("ix_approval_tasks_tenant_id"), table_name="approval_tasks")
|
||||
op.drop_index(op.f("ix_approval_tasks_status"), table_name="approval_tasks")
|
||||
op.drop_index(op.f("ix_approval_tasks_run_id"), table_name="approval_tasks")
|
||||
op.drop_table("approval_tasks")
|
||||
op.drop_index(op.f("ix_flow_runs_version_id"), table_name="flow_runs")
|
||||
op.drop_index(op.f("ix_flow_runs_tenant_id"), table_name="flow_runs")
|
||||
op.drop_index(op.f("ix_flow_runs_status"), table_name="flow_runs")
|
||||
op.drop_table("flow_runs")
|
||||
op.drop_index(op.f("ix_flow_versions_version_no"), table_name="flow_versions")
|
||||
op.drop_index(op.f("ix_flow_versions_flow_id"), table_name="flow_versions")
|
||||
op.drop_table("flow_versions")
|
||||
op.drop_index(op.f("ix_flow_definitions_name"), table_name="flow_definitions")
|
||||
op.drop_table("flow_definitions")
|
||||
# ### end Alembic commands ###
|
||||
|
|
|
|||
|
|
@ -5,78 +5,92 @@ Revises: d932b6993f9a
|
|||
Create Date: 2026-09-11 15:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3f1c9e2b4d7'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd932b6993f9a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "a3f1c9e2b4d7"
|
||||
down_revision: str | Sequence[str] | None = "d932b6993f9a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("role", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_table('access_requests',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
|
||||
sa.Column('reason', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('decided_by', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_table(
|
||||
"access_requests",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
|
||||
sa.Column("reason", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_by", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_access_requests_email'), 'access_requests', ['email'], unique=False)
|
||||
op.create_index(op.f('ix_access_requests_status'), 'access_requests', ['status'], unique=False)
|
||||
op.create_table('magic_link_tokens',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(op.f("ix_access_requests_email"), "access_requests", ["email"], unique=False)
|
||||
op.create_index(op.f("ix_access_requests_status"), "access_requests", ["status"], unique=False)
|
||||
op.create_table(
|
||||
"magic_link_tokens",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_magic_link_tokens_token_hash'), 'magic_link_tokens', ['token_hash'], unique=True)
|
||||
op.create_index(op.f('ix_magic_link_tokens_user_id'), 'magic_link_tokens', ['user_id'], unique=False)
|
||||
op.create_table('user_sessions',
|
||||
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(
|
||||
op.f("ix_magic_link_tokens_token_hash"), "magic_link_tokens", ["token_hash"], unique=True
|
||||
)
|
||||
op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_magic_link_tokens_user_id"), "magic_link_tokens", ["user_id"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"user_sessions",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions')
|
||||
op.drop_table('user_sessions')
|
||||
op.drop_index(op.f('ix_magic_link_tokens_user_id'), table_name='magic_link_tokens')
|
||||
op.drop_index(op.f('ix_magic_link_tokens_token_hash'), table_name='magic_link_tokens')
|
||||
op.drop_table('magic_link_tokens')
|
||||
op.drop_index(op.f('ix_access_requests_status'), table_name='access_requests')
|
||||
op.drop_index(op.f('ix_access_requests_email'), table_name='access_requests')
|
||||
op.drop_table('access_requests')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
|
||||
op.drop_table("user_sessions")
|
||||
op.drop_index(op.f("ix_magic_link_tokens_user_id"), table_name="magic_link_tokens")
|
||||
op.drop_index(op.f("ix_magic_link_tokens_token_hash"), table_name="magic_link_tokens")
|
||||
op.drop_table("magic_link_tokens")
|
||||
op.drop_index(op.f("ix_access_requests_status"), table_name="access_requests")
|
||||
op.drop_index(op.f("ix_access_requests_email"), table_name="access_requests")
|
||||
op.drop_table("access_requests")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_table("users")
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ Revises: a3f1c9e2b4d7
|
|||
Create Date: 2026-09-11 20:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b7e2d4a1c9f3'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a3f1c9e2b4d7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "b7e2d4a1c9f3"
|
||||
down_revision: str | Sequence[str] | None = "a3f1c9e2b4d7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ Revises: 62ea9a5d8f9d
|
|||
Create Date: 2026-09-07 08:40:48.986026
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd932b6993f9a'
|
||||
down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "d932b6993f9a"
|
||||
down_revision: str | Sequence[str] | None = "62ea9a5d8f9d"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -34,4 +34,15 @@ dev = [
|
|||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=6",
|
||||
"respx>=0.22",
|
||||
"ruff>=0.16,<0.17",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Default rule set minus EXE: Docker Desktop bind-mounts report every file as
|
||||
# executable (git index stays 100644), so EXE002 would fire on the dev mount
|
||||
# but never on Linux CI. Everything else is enforced as ruff's default.
|
||||
ignore = ["EXE002"]
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ def builtin_node(
|
|||
return node_dict
|
||||
|
||||
|
||||
def custom_node( code: str,
|
||||
def custom_node(
|
||||
code: str,
|
||||
node_id: str,
|
||||
values: dict | None = None,
|
||||
position: tuple[int, int] = (0, 0),
|
||||
|
|
@ -109,9 +110,7 @@ def kirche_node(
|
|||
|
||||
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
|
||||
inst = CustomInputForm()
|
||||
frontend_node = ComponentFrontendNode.from_inputs(
|
||||
**inst.get_template_config(inst)
|
||||
)
|
||||
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
|
||||
inst._map_parameters_on_frontend_node(frontend_node)
|
||||
node = frontend_node.to_dict(keep_name=False)
|
||||
node = inst._update_template(node)
|
||||
|
|
@ -207,9 +206,7 @@ def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
|
|||
# --------------------------------------------------------------------------
|
||||
# flow document
|
||||
# --------------------------------------------------------------------------
|
||||
def flow_doc(
|
||||
name: str, description: str, nodes: list[dict], edges: list[dict]
|
||||
) -> dict:
|
||||
def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict]) -> dict:
|
||||
return {
|
||||
"data": {
|
||||
"nodes": nodes,
|
||||
|
|
@ -233,7 +230,7 @@ def flow_doc(
|
|||
# --------------------------------------------------------------------------
|
||||
# custom component code
|
||||
# --------------------------------------------------------------------------
|
||||
RULE_CHECK_CODE = '''from lfx.custom import Component
|
||||
RULE_CHECK_CODE = """from lfx.custom import Component
|
||||
from lfx.io import MessageTextInput, Output
|
||||
from lfx.schema.message import Message
|
||||
import json
|
||||
|
|
@ -277,9 +274,9 @@ class RuleCheckComponent(Component):
|
|||
except (TypeError, ValueError):
|
||||
schwelle = 200000.0
|
||||
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
|
||||
'''
|
||||
"""
|
||||
|
||||
ENVELOPE_CODE = '''from lfx.custom import Component
|
||||
ENVELOPE_CODE = """from lfx.custom import Component
|
||||
from lfx.io import MessageTextInput, Output
|
||||
from lfx.schema.message import Message
|
||||
import json
|
||||
|
|
@ -336,9 +333,9 @@ class EnvelopeComponent(Component):
|
|||
def get_continuation(self) -> Message:
|
||||
value = self._env().get("continuation_flow_id")
|
||||
return Message(text=str(value) if value else "")
|
||||
'''
|
||||
"""
|
||||
|
||||
APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
|
||||
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
|
||||
from lfx.io import MessageTextInput, Output
|
||||
from lfx.schema.message import Message
|
||||
|
||||
|
|
@ -393,7 +390,7 @@ class ApprovalCallbackComponent(Component):
|
|||
except Exception as exc:
|
||||
return Message(text=f"CALLBACK_FAILED: {exc}")
|
||||
return Message(text="PENDING_HUMAN_APPROVAL")
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -456,9 +453,7 @@ def build_approval_handoff() -> dict:
|
|||
"ChatInput-entry",
|
||||
position=(0, 300),
|
||||
)
|
||||
envelope = custom_node(
|
||||
ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300)
|
||||
)
|
||||
envelope = custom_node(ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300))
|
||||
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
|
||||
router = builtin_node(
|
||||
"lfx.components.flow_controls.conditional_router",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import os
|
|||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -58,9 +59,7 @@ def main() -> None:
|
|||
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
|
||||
flow_dir = Path("/tmp/smoke-e2e-flows")
|
||||
flow_dir.mkdir(exist_ok=True)
|
||||
(flow_dir / "seed.json").write_text(
|
||||
(FIXTURES / "simple_rule.json").read_text()
|
||||
)
|
||||
(flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
|
||||
# Migrate a throwaway SQLite schema to head before starting the API.
|
||||
from alembic import command as alembic_command
|
||||
from alembic.config import Config
|
||||
|
|
@ -83,14 +82,26 @@ def main() -> None:
|
|||
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
|
||||
}
|
||||
lfx = subprocess.Popen(
|
||||
[str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1",
|
||||
"--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env,
|
||||
[
|
||||
str(VENV_BIN / "lfx"),
|
||||
"serve",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(LFX_PORT),
|
||||
"--flow-dir",
|
||||
str(flow_dir),
|
||||
str(flow_dir),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
uvicorn = subprocess.Popen(
|
||||
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1",
|
||||
"--port", str(API_PORT)],
|
||||
cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
|
||||
cwd=str(BACKEND),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
|
|
@ -106,7 +117,8 @@ def main() -> None:
|
|||
|
||||
fx = json.loads((FIXTURES / "simple_rule.json").read_text())
|
||||
s, ver = api(
|
||||
"POST", f"/api/flows/{fid}/versions",
|
||||
"POST",
|
||||
f"/api/flows/{fid}/versions",
|
||||
{"langflow_json": fx, "author": "smoke"},
|
||||
)
|
||||
check("create version", s == 201, f"v{ver.get('version_no')}")
|
||||
|
|
@ -115,17 +127,25 @@ def main() -> None:
|
|||
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
|
||||
|
||||
s, run = api(
|
||||
"POST", f"/api/flows/{fid}/run",
|
||||
"POST",
|
||||
f"/api/flows/{fid}/run",
|
||||
{"input": {"betrag": 120000, "schwelle": 200000}},
|
||||
)
|
||||
check("run standard", s == 200 and run.get("status") == "completed",
|
||||
json.dumps(run.get("output"))[:120])
|
||||
check(
|
||||
"run standard",
|
||||
s == 200 and run.get("status") == "completed",
|
||||
json.dumps(run.get("output"))[:120],
|
||||
)
|
||||
|
||||
s, run2 = api(
|
||||
"POST", f"/api/flows/{fid}/run",
|
||||
"POST",
|
||||
f"/api/flows/{fid}/run",
|
||||
{"input": {"betrag": 999000, "schwelle": 200000}},
|
||||
)
|
||||
check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""))
|
||||
check(
|
||||
"run sonderfall",
|
||||
s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""),
|
||||
)
|
||||
|
||||
s, tasks = api("GET", "/api/approvals")
|
||||
check("approvals empty (no callback flow)", s == 200 and tasks == [])
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from sqlmodel import Session
|
|||
|
||||
from app.db import get_engine, reset_engine
|
||||
from app.flows import export, sync
|
||||
from app.flows.langflow_client import LangflowClient
|
||||
from app.flows.langflow_client import LangflowClient, LangflowError
|
||||
from app.flows.lfx_client import LfxClient
|
||||
from app.flows.schemas import SyncReport
|
||||
from app.settings import settings
|
||||
|
|
@ -38,7 +38,7 @@ async def pull_from_langflow(flows_dir: Path) -> dict:
|
|||
name = doc.get("name") or flow_id
|
||||
path = export.write_flow_file(flows_dir, doc, name=name)
|
||||
pulled.append(path.stem)
|
||||
except Exception as exc: # noqa: BLE001 - pull-all is total
|
||||
except (LangflowError, OSError, ValueError) as exc:
|
||||
errors[flow_id] = str(exc)
|
||||
return {"pulled": sorted(pulled), "errors": errors}
|
||||
|
||||
|
|
@ -73,12 +73,11 @@ async def main_async(args) -> int:
|
|||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pull", action="store_true",
|
||||
help="export all Langflow flows to backend/flows first")
|
||||
parser.add_argument("--sync", action="store_true",
|
||||
help="reconcile backend/flows into DB + LFX")
|
||||
parser.add_argument("--dir", default=None,
|
||||
help="flows directory (default: settings.flows_dir)")
|
||||
parser.add_argument(
|
||||
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
|
||||
)
|
||||
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
|
||||
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
|
||||
args = parser.parse_args()
|
||||
if not args.pull and not args.sync:
|
||||
args.sync = True
|
||||
|
|
|
|||
|
|
@ -107,9 +107,7 @@ def lfx_server(tmp_path_factory):
|
|||
sock.close()
|
||||
|
||||
flow_dir = tmp_path_factory.mktemp("lfx-flows")
|
||||
seed = (
|
||||
Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json"
|
||||
).read_text()
|
||||
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
|
||||
(flow_dir / "seed.json").write_text(seed)
|
||||
|
||||
env = {
|
||||
|
|
|
|||
|
|
@ -40,9 +40,7 @@ async def _handoff_task(client, live_server, continuation_flow_id=None):
|
|||
}
|
||||
if continuation_flow_id is not None:
|
||||
body["continuation_flow_id"] = continuation_flow_id
|
||||
run = (
|
||||
await client.post(f"/api/flows/{flow['id']}/run", json=body)
|
||||
).json()
|
||||
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
|
||||
assert run["status"] == "pending_approval"
|
||||
tasks = (await client.get("/api/approvals")).json()
|
||||
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||
|
|
@ -67,9 +65,7 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
|
|||
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
||||
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
||||
|
||||
_, task = await _handoff_task(
|
||||
client, live_server, continuation_flow_id=cont["id"]
|
||||
)
|
||||
_, task = await _handoff_task(client, live_server, continuation_flow_id=cont["id"])
|
||||
assert task["continuation_flow_id"] == cont["id"]
|
||||
|
||||
response = await client.post(
|
||||
|
|
@ -109,9 +105,7 @@ async def test_decision_guards(client, lfx_server, live_server):
|
|||
response = await client.post(url, json={"decision": "reject"})
|
||||
assert response.status_code == 409
|
||||
|
||||
response = await client.post(
|
||||
f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"}
|
||||
)
|
||||
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
|
||||
assert response.status_code == 404
|
||||
|
||||
_, task2 = await _handoff_task(client, live_server)
|
||||
|
|
@ -143,17 +137,13 @@ async def test_decision_bad_continuation_reference(client, session):
|
|||
flow_store.get_version(session, UUID(version["id"])),
|
||||
input={},
|
||||
)
|
||||
task = flow_store.create_approval_task(
|
||||
session, ApprovalCreate(run_id=run.id, title="t")
|
||||
)
|
||||
task = flow_store.create_approval_task(session, ApprovalCreate(run_id=run.id, title="t"))
|
||||
# plant unknown continuation directly (store allows it pre-validation)
|
||||
task.continuation_flow_id = uuid4()
|
||||
session.add(task)
|
||||
session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/approvals/{task.id}/decision", json={"decision": "approve"}
|
||||
)
|
||||
response = await client.post(f"/api/approvals/{task.id}/decision", json={"decision": "approve"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
|
|
@ -168,9 +158,7 @@ async def test_tenant_isolation(client, lfx_server):
|
|||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"}
|
||||
)
|
||||
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
|
||||
runs_a = response.json()
|
||||
assert len(runs_a) == 1
|
||||
assert runs_a[0]["tenant_id"] == "pfarramt-a"
|
||||
|
|
@ -206,9 +194,7 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
|
|||
deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
||||
assert deployed.status_code == 200
|
||||
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
|
|
@ -221,9 +207,7 @@ async def test_lfx_drift_maps_to_502(client, lfx_server):
|
|||
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
|
||||
deployed["lfx_flow_id"]
|
||||
)
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 502
|
||||
|
||||
|
||||
|
|
@ -247,9 +231,7 @@ async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
|
|||
"edges": [],
|
||||
},
|
||||
}
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus})
|
||||
assert response.status_code == 201, response.text
|
||||
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
||||
assert response.status_code == 422
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ async def _login(client, session, email, role="member"):
|
|||
|
||||
|
||||
async def test_request_approve_consume_me_logout(client, session):
|
||||
assert (await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})).status_code == 201
|
||||
assert (
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
).status_code == 201
|
||||
admin = await _login(client, session, ADMIN, role="admin")
|
||||
assert admin["role"] == "admin"
|
||||
|
||||
|
|
@ -103,9 +105,7 @@ async def test_token_expiry_boundary(client, session):
|
|||
user = _get_or_make_user(session, MEMBER)
|
||||
token = service.issue_magic_token(session, user)
|
||||
record = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.token_hash == service.hash_token(token)
|
||||
)
|
||||
select(MagicLinkToken).where(MagicLinkToken.token_hash == service.hash_token(token))
|
||||
).one()
|
||||
record.expires_at = service.utcnow() - timedelta(seconds=1)
|
||||
session.add(record)
|
||||
|
|
@ -145,9 +145,7 @@ async def test_admin_only_endpoints(client, session):
|
|||
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
||||
assert (await anon.get("/api/access/requests")).status_code == 401
|
||||
assert (
|
||||
await anon.post(
|
||||
f"/api/access/requests/{created.json()['id']}/approve"
|
||||
)
|
||||
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
).status_code == 401
|
||||
assert (await anon.get("/api/auth/me")).status_code == 401
|
||||
|
||||
|
|
@ -156,9 +154,7 @@ async def test_approve_unknown_and_double_decide(client, session):
|
|||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
await _login(client, session, ADMIN, role="admin")
|
||||
|
||||
assert (
|
||||
await client.post(f"/api/access/requests/{uuid4()}/approve")
|
||||
).status_code == 404
|
||||
assert (await client.post(f"/api/access/requests/{uuid4()}/approve")).status_code == 404
|
||||
|
||||
created = await client.post(
|
||||
"/api/access/requests",
|
||||
|
|
@ -246,9 +242,7 @@ def test_magic_link_delivery_failure_returns_false(monkeypatch):
|
|||
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
||||
_broken_smtp(monkeypatch)
|
||||
assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False
|
||||
assert (
|
||||
mail.send_access_notification("req-1", "A", "a@example.com") is False
|
||||
)
|
||||
assert mail.send_access_notification("req-1", "A", "a@example.com") is False
|
||||
|
||||
|
||||
async def test_approve_survives_delivery_failure(client, session, monkeypatch):
|
||||
|
|
@ -261,9 +255,7 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch):
|
|||
json={"name": "M", "email": "m@example.com", "reason": ""},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
approved = await client.post(
|
||||
f"/api/access/requests/{created.json()['id']}/approve"
|
||||
)
|
||||
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
body = approved.json()
|
||||
assert body["request"]["status"] == "approved"
|
||||
|
|
@ -272,9 +264,7 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch):
|
|||
|
||||
async def test_magic_request_for_known_user(client, session):
|
||||
_get_or_make_user(session, MEMBER)
|
||||
response = await client.post(
|
||||
"/api/auth/magic/request", json={"email": MEMBER}
|
||||
)
|
||||
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["message"] == "login link sent"
|
||||
|
|
@ -282,24 +272,19 @@ async def test_magic_request_for_known_user(client, session):
|
|||
assert "token" not in response.text
|
||||
tokens = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.user_id
|
||||
== service.get_user_by_email(session, MEMBER).id
|
||||
MagicLinkToken.user_id == service.get_user_by_email(session, MEMBER).id
|
||||
)
|
||||
).all()
|
||||
assert len(tokens) == 1
|
||||
|
||||
|
||||
async def test_magic_request_unknown_email_403(client):
|
||||
response = await client.post(
|
||||
"/api/auth/magic/request", json={"email": "stranger@example.com"}
|
||||
)
|
||||
response = await client.post("/api/auth/magic/request", json={"email": "stranger@example.com"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_magic_request_admin_email_creates_admin(client, session):
|
||||
response = await client.post(
|
||||
"/api/auth/magic/request", json={"email": ADMIN}
|
||||
)
|
||||
response = await client.post("/api/auth/magic/request", json={"email": ADMIN})
|
||||
assert response.status_code == 200, response.text
|
||||
user = service.get_user_by_email(session, ADMIN)
|
||||
assert user is not None and user.role == "admin"
|
||||
|
|
@ -308,13 +293,9 @@ async def test_magic_request_admin_email_creates_admin(client, session):
|
|||
async def test_magic_request_rate_limit(client, session):
|
||||
_get_or_make_user(session, MEMBER)
|
||||
for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
|
||||
response = await client.post(
|
||||
"/api/auth/magic/request", json={"email": MEMBER}
|
||||
)
|
||||
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
||||
assert response.status_code == 200, response.text
|
||||
limited = await client.post(
|
||||
"/api/auth/magic/request", json={"email": MEMBER}
|
||||
)
|
||||
limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
|
|
@ -331,9 +312,7 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
|
|||
button_url="http://localhost:3000/login?token=GEHEIM",
|
||||
note="Gültig 15 Minuten.",
|
||||
)
|
||||
html_part = next(
|
||||
p for p in message.walk() if p.get_content_type() == "text/html"
|
||||
)
|
||||
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
|
||||
html = html_part.get_content()
|
||||
# Button = anchor with href around the label; token never in text-only form.
|
||||
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
|
||||
|
|
@ -343,7 +322,5 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
|
|||
# Multipart: plain-text fallback keeps the raw link.
|
||||
parts = [p.get_content_type() for p in message.walk()]
|
||||
assert "text/plain" in parts and "text/html" in parts
|
||||
plain = next(
|
||||
p.get_content() for p in message.walk() if p.get_content_type() == "text/plain"
|
||||
)
|
||||
plain = next(p.get_content() for p in message.walk() if p.get_content_type() == "text/plain")
|
||||
assert "http://localhost:3000/login?token=GEHEIM" in plain
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ def _export_doc(name="importiert"):
|
|||
}
|
||||
|
||||
|
||||
async def test_import_creates_definition_version_and_deploys(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
async def test_import_creates_definition_version_and_deploys(client, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
response = await client.post(
|
||||
"/api/flows/import",
|
||||
|
|
@ -37,31 +35,21 @@ async def test_import_creates_definition_version_and_deploys(
|
|||
assert body["definition"]["name"] == "importiert"
|
||||
assert body["version"]["version_no"] == 1
|
||||
assert body["version"]["author"] == "admin"
|
||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(
|
||||
UUID(body["definition"]["id"]), 1
|
||||
)
|
||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(UUID(body["definition"]["id"]), 1)
|
||||
assert body["version"]["deployed_at"] is not None
|
||||
assert body["version"]["langflow_json"]["data"]["nodes"]
|
||||
|
||||
# Imported flow actually runs on LFX.
|
||||
definition_id = body["definition"]["id"]
|
||||
response = await client.post(
|
||||
f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_import_same_content_is_idempotent(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
async def test_import_same_content_is_idempotent(client, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
first = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
|
||||
|
|
@ -69,20 +57,14 @@ async def test_import_same_content_is_idempotent(
|
|||
assert second.json()["version"]["version_no"] == 1
|
||||
|
||||
|
||||
async def test_import_changed_content_bumps_version(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
async def test_import_changed_content_bumps_version(client, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
first = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert first.status_code == 201
|
||||
changed = _export_doc()
|
||||
changed["description"] = "neue Beschreibung"
|
||||
override_langflow(changed)
|
||||
second = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert second.status_code == 201
|
||||
assert second.json()["version"]["version_no"] == 2
|
||||
|
||||
|
|
@ -94,9 +76,7 @@ async def test_import_writes_canonical_file(
|
|||
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
override_langflow(_export_doc())
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert response.status_code == 201, response.text
|
||||
written = tmp_path / "importiert.json"
|
||||
assert written.is_file()
|
||||
|
|
@ -127,17 +107,13 @@ async def test_import_without_deploy(client, override_langflow):
|
|||
|
||||
async def test_import_langflow_not_found(client, override_langflow):
|
||||
override_langflow(status=404)
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_import_langflow_unreachable(client, override_langflow):
|
||||
override_langflow(status=0)
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
|
||||
)
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ def load_fixture(name: str) -> dict:
|
|||
|
||||
|
||||
async def _create_flow(client, name="regeltest"):
|
||||
response = await client.post(
|
||||
"/api/flows", json={"name": name, "description": "test flow"}
|
||||
)
|
||||
response = await client.post("/api/flows", json={"name": name, "description": "test flow"})
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
|
@ -113,9 +111,7 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
|||
async def test_run_undeployed_version_fails(client):
|
||||
flow = await _create_flow(client, "undeployed")
|
||||
await _create_version(client, flow["id"])
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from lfx.custom import Component
|
|||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
|
||||
from kirche.form_inputs import CustomInputForm # noqa: E402
|
||||
from kirche.form_inputs import CustomInputForm
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
|
|
|||
|
|
@ -117,9 +117,7 @@ def test_required_advanced_hidden_code():
|
|||
|
||||
def test_display_name_match_and_fallback():
|
||||
doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X")
|
||||
assert set(
|
||||
parser.extract_form_schema(doc)["json_schema"]["properties"]
|
||||
) == {"a"}
|
||||
assert set(parser.extract_form_schema(doc)["json_schema"]["properties"]) == {"a"}
|
||||
plain = {
|
||||
"data": {
|
||||
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
||||
|
|
@ -158,9 +156,7 @@ def _lfx() -> LfxClient:
|
|||
|
||||
|
||||
async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
|
||||
(tmp_path / "intake_form.json").write_text(
|
||||
(FIXTURES / "intake_form.json").read_text()
|
||||
)
|
||||
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert not report.errors, report.errors
|
||||
from app.flows import store
|
||||
|
|
@ -180,13 +176,9 @@ async def test_input_schema_endpoint(client, lfx_server):
|
|||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
created = await client.post("/api/flows", json={"name": "schema-e2e"})
|
||||
flow_id = created.json()["id"]
|
||||
version = await client.post(
|
||||
f"/api/flows/{flow_id}/versions", json={"langflow_json": doc}
|
||||
)
|
||||
version = await client.post(f"/api/flows/{flow_id}/versions", json={"langflow_json": doc})
|
||||
assert version.status_code == 201, version.text
|
||||
assert version.json()["input_schema"]["json_schema"]["properties"]["age"][
|
||||
"type"
|
||||
] == "integer"
|
||||
assert version.json()["input_schema"]["json_schema"]["properties"]["age"]["type"] == "integer"
|
||||
# Created but never deployed → 404.
|
||||
assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 404
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ def _client():
|
|||
@respx.mock
|
||||
async def test_export_flow_sends_api_key_and_returns_doc():
|
||||
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
|
||||
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(
|
||||
return_value=httpx.Response(200, json=doc)
|
||||
)
|
||||
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(return_value=httpx.Response(200, json=doc))
|
||||
result = await _client().export_flow("abc")
|
||||
assert result == doc
|
||||
assert route.called
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ def test_canonical_flows_pass_lfx_validate():
|
|||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
assert process.returncode == 0, (
|
||||
f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
|
||||
)
|
||||
assert process.returncode == 0, f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import json
|
|||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue