chore: enforce ruff lint and format standards across backend

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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