feat: flow engine data layer with tests

- SQLModel tables: flow_definitions/versions/runs, approval_tasks
- Alembic setup with autogenerate (sqlmodel types fixed via script.py.mako)
- Settings extended (shared root .env), db engine/session helpers
- pytest suite: conftest with isolated test DB, 5 model tests green
This commit is contained in:
Jonas Miederer 2026-09-06 22:28:53 +00:00
parent 2ba062c4c5
commit e178ae406d
12 changed files with 686 additions and 3 deletions

36
backend/app/db.py Normal file
View file

@ -0,0 +1,36 @@
"""Database engine, sessions, and FastAPI dependency."""
from collections.abc import Iterator
from sqlalchemy import Engine
from sqlmodel import Session, SQLModel, create_engine
from .settings import settings
_engine: Engine | None = None
def get_engine(url: str | None = None) -> Engine:
"""Process-wide engine singleton (override URL in tests via settings)."""
global _engine
if _engine is None:
_engine = create_engine(url or settings.database_url)
return _engine
def reset_engine() -> None:
"""Drop the cached engine (used by the test suite between DB targets)."""
global _engine
if _engine is not None:
_engine.dispose()
_engine = None
def get_session() -> Iterator[Session]:
with Session(get_engine()) as session:
yield session
def create_all(url: str | None = None) -> None:
"""Create tables directly (dev convenience; migrations own prod schema)."""
SQLModel.metadata.create_all(get_engine(url))

View file

@ -0,0 +1,3 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"]

View file

@ -0,0 +1,90 @@
"""Flow engine domain models (SQLModel tables)."""
from datetime import datetime, timezone
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _ts_column() -> Column:
return Column(DateTime(timezone=True))
def _json_column() -> Column:
# Generic JSON: JSONB on Postgres, JSON/TEXT elsewhere.
return Column(JSON)
class FlowDefinition(SQLModel, table=True):
"""A named decision flow (e.g. 'Bauvorhaben pruefen'). Versions live separately."""
__tablename__ = "flow_definitions"
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(index=True, unique=True, max_length=200)
description: str = Field(default="", max_length=2000)
status: str = Field(default="active", max_length=20) # active | archived
# 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"
)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class FlowVersion(SQLModel, table=True):
"""An immutable version of a flow: the exact Langflow JSON that runs."""
__tablename__ = "flow_versions"
__table_args__ = (UniqueConstraint("flow_id", "version_no"),)
id: UUID = Field(default_factory=uuid4, primary_key=True)
flow_id: UUID = Field(foreign_key="flow_definitions.id", index=True)
version_no: int = Field(index=True)
langflow_json: dict = Field(default_factory=dict, sa_column=_json_column())
# Flow id under which LFX serves this version (set on deploy).
lfx_flow_id: str | None = Field(default=None, max_length=200)
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
author: str = Field(default="", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class FlowRun(SQLModel, table=True):
"""One execution of a flow version."""
__tablename__ = "flow_runs"
id: UUID = Field(default_factory=uuid4, primary_key=True)
version_id: UUID = Field(foreign_key="flow_versions.id", index=True)
tenant_id: str = Field(default="default", index=True, max_length=200)
# running | completed | pending_approval | failed
status: str = Field(default="running", index=True, max_length=30)
input: dict | None = Field(default=None, sa_column=_json_column())
output: dict | None = Field(default=None, sa_column=_json_column())
session_id: str | None = Field(default=None, max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
finished_at: datetime | None = Field(default=None, sa_column=_ts_column())
class ApprovalTask(SQLModel, table=True):
"""Human task created by a flow that reached a terminal approval state."""
__tablename__ = "approval_tasks"
id: UUID = Field(default_factory=uuid4, primary_key=True)
run_id: UUID = Field(foreign_key="flow_runs.id", index=True)
version_id: UUID = Field(foreign_key="flow_versions.id", index=True)
tenant_id: str = Field(default="default", index=True, max_length=200)
title: str = Field(default="", max_length=500)
payload: dict = Field(default_factory=dict, sa_column=_json_column())
continuation_flow_id: UUID | None = Field(default=None)
approver: str | None = Field(default=None, max_length=200)
# open | approved | rejected
status: str = Field(default="open", index=True, max_length=20)
decision_note: str = Field(default="", max_length=2000)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())

View file

@ -1,11 +1,27 @@
from pydantic_settings import BaseSettings
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
# Repo-root .env (shared with the compose stack). Real environment variables
# always take precedence over .env values.
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=REPO_ROOT / ".env", extra="ignore")
version: str = "0.1.0"
debug: bool = False
model_config = {"env_prefix": "KIRCHE_"}
database_url: str = "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach"
test_database_url: str = (
"postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach_test"
)
langflow_url: str = "http://localhost:7860"
langflow_api_key: str = ""
lfx_url: str = "http://localhost:6004"
llm_api_key: str = ""
settings = Settings()