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:
parent
2ba062c4c5
commit
e178ae406d
12 changed files with 686 additions and 3 deletions
36
backend/app/db.py
Normal file
36
backend/app/db.py
Normal 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))
|
||||
3
backend/app/models/__init__.py
Normal file
3
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||
|
||||
__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"]
|
||||
90
backend/app/models/flow.py
Normal file
90
backend/app/models/flow.py
Normal 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())
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue