Compare commits
No commits in common. "b1c8d6c66e9d2437b06a93711064838168e0f1d0" and "af993bd43f3755ac1b62fb257844183b52049034" have entirely different histories.
b1c8d6c66e
...
af993bd43f
|
|
@ -11,23 +11,14 @@
|
||||||
},
|
},
|
||||||
"mounts": [
|
"mounts": [
|
||||||
"source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly",
|
"source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly",
|
||||||
"source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume",
|
"source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind"
|
||||||
"source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume"
|
|
||||||
],
|
],
|
||||||
"features": {
|
"features": {
|
||||||
// moby:false — python:3.12-slim is Debian trixie now, whose repos have
|
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
|
||||||
// no moby-cli (feature default). false installs the upstream Docker CE
|
|
||||||
// CLI instead, which supports trixie.
|
|
||||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
|
||||||
"moby": false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"overrideCommand": false,
|
"overrideCommand": false,
|
||||||
// Absolute paths: lifecycle commands run in the image WORKDIR
|
"postCreateCommand": "sudo -E bash .devcontainer/setup.sh",
|
||||||
// (/srv/kirche-einfach/backend), not the workspace folder — relative
|
"postStartCommand": "bash .devcontainer/postStart.sh",
|
||||||
// .devcontainer/... paths fail with "No such file or directory" (exit 127).
|
|
||||||
"postCreateCommand": "sudo -E bash /srv/kirche-einfach/.devcontainer/setup.sh",
|
|
||||||
"postStartCommand": "bash /srv/kirche-einfach/.devcontainer/postStart.sh",
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
@ -38,7 +29,7 @@
|
||||||
"esbenp.prettier-vscode"
|
"esbenp.prettier-vscode"
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"python.defaultInterpreterPath": "/opt/venv/bin/python"
|
"python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,73 +2,31 @@
|
||||||
# Dev-only setup (runs via postCreateCommand in the attached `api` container).
|
# Dev-only setup (runs via postCreateCommand in the attached `api` container).
|
||||||
# Production needs none of this: images carry their dependencies and the
|
# Production needs none of this: images carry their dependencies and the
|
||||||
# api entrypoint migrates on every start.
|
# api entrypoint migrates on every start.
|
||||||
set -eo pipefail
|
set -e
|
||||||
|
|
||||||
REPO=/srv/kirche-einfach
|
REPO=/srv/kirche-einfach
|
||||||
|
|
||||||
# Run a command as vscode with the current environment intact.
|
|
||||||
# (`su vscode -c` resets the environment, which used to drop
|
|
||||||
# UV_PROJECT_ENVIRONMENT/UV_PYTHON_INSTALL_DIR plus every compose-provided
|
|
||||||
# variable — uv then created a project-local backend/.venv in the bind-mount
|
|
||||||
# instead of using /opt/venv, and sync_flows.py ran without its settings.)
|
|
||||||
# -H repoints HOME to /home/vscode: without it HOME stays /root (from the
|
|
||||||
# outer sudo) and uv/pip caches fail with Permission denied.
|
|
||||||
as_vscode() {
|
|
||||||
sudo -H -u vscode -E bash -c "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Venv lives outside the bind-mount (see backend/Dockerfile).
|
|
||||||
export UV_PYTHON_INSTALL_DIR=/opt/python UV_PROJECT_ENVIRONMENT=/opt/venv
|
|
||||||
|
|
||||||
echo "=== Setting up kirche-einfach ==="
|
echo "=== Setting up kirche-einfach ==="
|
||||||
|
|
||||||
# Writable dirs first: this script runs via sudo as root, but the editor and
|
# Backend deps into the venv volume (setup runs via sudo; handed to vscode below).
|
||||||
# most commands run as vscode. (A root-created backend/data/ or venv used to
|
|
||||||
# break the DB and the interpreter for vscode.)
|
|
||||||
mkdir -p "$REPO/backend/data" \
|
|
||||||
"$REPO/frontend/node_modules" \
|
|
||||||
/opt/python /opt/venv \
|
|
||||||
/home/vscode/.cache \
|
|
||||||
/home/vscode/.local/share/uv \
|
|
||||||
/home/vscode/.local/share/opencode \
|
|
||||||
/home/vscode/.config/opencode
|
|
||||||
chown -R vscode:vscode "$REPO/backend/data" \
|
|
||||||
"$REPO/frontend/node_modules" \
|
|
||||||
/opt/python /opt/venv \
|
|
||||||
/home/vscode/.cache \
|
|
||||||
/home/vscode/.local/share/uv \
|
|
||||||
/home/vscode/.local/share/opencode \
|
|
||||||
/home/vscode/.config/opencode 2>/dev/null || true
|
|
||||||
|
|
||||||
# Backend deps into /opt/venv, as vscode (a root-owned venv is unusable for
|
|
||||||
# the editor; uv resolves at setup time, no lockfile is committed).
|
|
||||||
echo "--- Installing Python dependencies ---"
|
echo "--- Installing Python dependencies ---"
|
||||||
cd "$REPO/backend"
|
cd "$REPO/backend"
|
||||||
as_vscode "uv sync"
|
uv sync
|
||||||
|
|
||||||
# opencode CLI (dev tooling; intentionally not baked into the image).
|
# opencode CLI (dev tooling; intentionally not baked into the image).
|
||||||
if [ ! -x /home/vscode/.opencode/bin/opencode ]; then
|
if [ ! -x /home/vscode/.opencode/bin/opencode ]; then
|
||||||
echo "--- Installing opencode ---"
|
echo "--- Installing opencode ---"
|
||||||
as_vscode "curl -fsSL https://opencode.ai/install | bash"
|
su vscode -c "curl -fsSL https://opencode.ai/install | bash"
|
||||||
ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode
|
ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Compose services (langflow, lfx) must be up; fail loudly otherwise.
|
# Compose services (langflow, lfx) must be up; fail loudly otherwise.
|
||||||
# (api/frontend are starting alongside this script.)
|
# (api/frontend are starting alongside this script.)
|
||||||
# Fresh clones have no `.env` (gitignored) and compose `env_file` is
|
|
||||||
# optional, so provision one from the example instead of failing setup.
|
|
||||||
# Fill in real secrets afterwards; empty SMTP_* means the API logs mail.
|
|
||||||
if [ ! -f "$REPO/.env" ]; then
|
|
||||||
echo "WARN: $REPO/.env missing — copying .env.example (fill in secrets)."
|
|
||||||
cp "$REPO/.env.example" "$REPO/.env"
|
|
||||||
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
# Dev needs the Vite HMR server; the compose default is fail-closed preview.
|
|
||||||
if ! grep -q "^FRONTEND_MODE=" "$REPO/.env" 2>/dev/null; then
|
|
||||||
echo "FRONTEND_MODE=dev" >> "$REPO/.env"
|
|
||||||
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "--- Waiting for compose services ---"
|
echo "--- Waiting for compose services ---"
|
||||||
|
if [ ! -f "$REPO/.env" ]; then
|
||||||
|
echo "ERROR: $REPO/.env is missing. Copy .env.example to .env and fill in secrets." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
python3 - <<'EOF'
|
python3 - <<'EOF'
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -78,13 +36,13 @@ targets = [("langflow", 7860), ("lfx", 6004)]
|
||||||
deadline = time.time() + 180
|
deadline = time.time() + 180
|
||||||
pending = dict(targets)
|
pending = dict(targets)
|
||||||
while pending and time.time() < deadline:
|
while pending and time.time() < deadline:
|
||||||
for host, port in list(pending.items()):
|
for host, port in list(pending):
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
s.settimeout(2)
|
s.settimeout(2)
|
||||||
try:
|
try:
|
||||||
s.connect((host, port))
|
s.connect((host, port))
|
||||||
print(f"OK {host}:{port}", flush=True)
|
print(f"OK {host}:{port}", flush=True)
|
||||||
del pending[host]
|
del pending[(host, port)]
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -99,18 +57,13 @@ EOF
|
||||||
# entrypoint on every start).
|
# entrypoint on every start).
|
||||||
echo "--- Syncing canonical flows ---"
|
echo "--- Syncing canonical flows ---"
|
||||||
cd "$REPO/backend"
|
cd "$REPO/backend"
|
||||||
as_vscode "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)"
|
uv run python scripts/sync_flows.py --sync || echo "WARN: flow sync failed (check backend/flows and LFX)"
|
||||||
|
|
||||||
echo "--- Installing Git Credential Manager ---"
|
|
||||||
# GCM (Linux) depends on libicu (.NET runtime) — runtime libs only.
|
|
||||||
apt-get install -y -qq libicu76 2>/dev/null || true
|
|
||||||
GCM_DEB_URL="https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.9.1/gcm-linux-x64-2.9.1.deb"
|
|
||||||
GCM_DEB="/tmp/gcm-linux-x64-2.9.1.deb"
|
|
||||||
curl -fsSL "$GCM_DEB_URL" -o "$GCM_DEB"
|
|
||||||
dpkg -i "$GCM_DEB" 2>/dev/null || apt-get install -f -y
|
|
||||||
as_vscode "git config --global credential.helper manager"
|
|
||||||
rm -f "$GCM_DEB"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Setup complete ==="
|
echo "=== Setup complete ==="
|
||||||
echo "Forgejo username: $FORGEJO_USERNAME"
|
echo "Forgejo username: $FORGEJO_USERNAME"
|
||||||
|
|
||||||
|
# Hand dirs back to vscode (this script runs via sudo; uv creates caches as root)
|
||||||
|
chown -R vscode:vscode "$REPO/backend/.venv" \
|
||||||
|
"$REPO/frontend/node_modules" \
|
||||||
|
/home/vscode/.cache 2>/dev/null || true
|
||||||
|
|
|
||||||
39
.env.example
|
|
@ -1,21 +1,12 @@
|
||||||
# Copy to `.env` and fill in values. `.env` is gitignored.
|
# Copy to `.env` and fill in values. `.env` is gitignored.
|
||||||
# Generate secrets with: openssl rand -hex 32
|
# It holds only ubiquitous variables (identical value in dev and prod).
|
||||||
# Validate before deploying: uv run python scripts/check_prod.py --env ../.env
|
# Dev-only variables travel via devcontainer.json `containerEnv`
|
||||||
# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas).
|
# (e.g. APP_ENV=dev); paths resolve from the repo root in code.
|
||||||
# Prod overrides via this .env file (e.g. APP_ENV=prod).
|
|
||||||
# Paths resolve from the repo root in code.
|
|
||||||
FORGEJO_TOKEN=
|
FORGEJO_TOKEN=
|
||||||
|
|
||||||
# --- langflow (authoring UI) + lfx (flow runtime): pinned in lockstep ---
|
# --- langflow (authoring UI) ---
|
||||||
# LFX contract: major.minor of Langflow and LFX must match (patches are
|
|
||||||
# independent). Bump deliberately on both sides together (compose image pin
|
|
||||||
# below + Dockerfile.lfx ARG + backend dev dep `lfx~=`), then re-check
|
|
||||||
# backend/flows/*.json with `lfx upgrade --upgrade-flow=safe`.
|
|
||||||
LANGFLOW_VERSION=1.12.0
|
|
||||||
LANGFLOW_URL=http://langflow:7860
|
LANGFLOW_URL=http://langflow:7860
|
||||||
LANGFLOW_SUPERUSER=langflow
|
LANGFLOW_SUPERUSER=langflow
|
||||||
# Long random values (check_prod.py enforces length). Never leave the
|
|
||||||
# placeholders: the authoring UI is internet-facing.
|
|
||||||
LANGFLOW_SUPERUSER_PASSWORD=
|
LANGFLOW_SUPERUSER_PASSWORD=
|
||||||
LANGFLOW_SECRET_KEY=
|
LANGFLOW_SECRET_KEY=
|
||||||
# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope.
|
# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope.
|
||||||
|
|
@ -28,27 +19,9 @@ LFX_API_KEY=lfx-
|
||||||
|
|
||||||
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
|
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
|
||||||
CALLBACK_BASE_URL=http://api:8000
|
CALLBACK_BASE_URL=http://api:8000
|
||||||
# Shared secret for flow callbacks (POST /api/approvals carries it as
|
|
||||||
# X-Callback-Token). Long random value; empty disables callbacks.
|
|
||||||
CALLBACK_TOKEN=
|
|
||||||
|
|
||||||
# --- frontend mode: unset/preview serves the production bundle ---
|
# --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
|
||||||
# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it.
|
FRONTEND_MODE=dev
|
||||||
# Never set `dev` in prod: the Vite dev server must not face the internet.
|
|
||||||
# FRONTEND_MODE=dev
|
|
||||||
|
|
||||||
# --- access management (request + approval, invite via email) ---
|
|
||||||
# Public frontend URL (magic links point here).
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
# Comma-separated emails that become admins (role assignment + bootstrap).
|
|
||||||
ADMIN_EMAILS=
|
|
||||||
# Reply-To on outgoing mail; also receives new access-request notifications.
|
|
||||||
CONTACT_TO=
|
|
||||||
MAIL_FROM=
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
|
|
||||||
# --- LLM (optional; tests mock the provider without it) ---
|
# --- LLM (optional; tests mock the provider without it) ---
|
||||||
# LLM_API_KEY=
|
# LLM_API_KEY=
|
||||||
|
|
@ -19,6 +19,4 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: cd backend && uv sync
|
- run: cd backend && uv sync
|
||||||
- run: cd backend && uv run ruff check .
|
|
||||||
- run: cd backend && uv run ruff format . --check --diff
|
|
||||||
- run: cd backend && uv run pytest
|
- run: cd backend && uv run pytest
|
||||||
|
|
|
||||||
4
.gitignore
vendored
|
|
@ -26,10 +26,6 @@ backend/data/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# Devcontainer
|
|
||||||
.devcontainer/devcontainer-lock.json
|
|
||||||
.devcontainer/opencode-data/
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
29
AGENTS.md
|
|
@ -7,8 +7,6 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/
|
||||||
Backend (run from `backend/`):
|
Backend (run from `backend/`):
|
||||||
- `uv sync` — install dependencies
|
- `uv sync` — install dependencies
|
||||||
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed)
|
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed)
|
||||||
- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`)
|
|
||||||
- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`)
|
|
||||||
- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored)
|
- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored)
|
||||||
- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX
|
- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX
|
||||||
- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)
|
- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)
|
||||||
|
|
@ -34,11 +32,6 @@ profiles, or override files. Containers: `api` (FastAPI :8000, attached),
|
||||||
- Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via
|
- Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via
|
||||||
`git pull` + restart, image rebuilds only for dependency changes
|
`git pull` + restart, image rebuilds only for dependency changes
|
||||||
(`docker compose up -d --build`).
|
(`docker compose up -d --build`).
|
||||||
- Git in the container uses HTTPS with GCM (`credential.helper=manager`,
|
|
||||||
installed by `.devcontainer/setup.sh` together with `libicu76` runtime).
|
|
||||||
Remote URL has no embedded token. First push/pull triggers a device-code
|
|
||||||
prompt (open URL on host). Rotate GitLab tokens in GitLab UI before
|
|
||||||
switching credentials.
|
|
||||||
|
|
||||||
## Definition of done
|
## Definition of done
|
||||||
|
|
||||||
|
|
@ -69,25 +62,3 @@ report = await sync_from_directory(session, lfx, settings.flows_dir)
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
When committing: `git add -A && git commit -m "<message>"` (conventional commits: `feat:`, `fix:`, `chore:`, `docs:`). When pushing: `git push` (branch is tracking `origin/main`).
|
When committing: `git add -A && git commit -m "<message>"` (conventional commits: `feat:`, `fix:`, `chore:`, `docs:`). When pushing: `git push` (branch is tracking `origin/main`).
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
- `VERSION` (repo root) is canonical — currently `0.1.0`.
|
|
||||||
- Backend: `backend/pyproject.toml` version; Frontend: `frontend/package.json` version — bump both together with VERSION on every release.
|
|
||||||
- SemVer: feat → minor (X.Y+1.0), fix → patch (X.Y.Z+1), breaking → major (X+1.0.0). Tag `vX.Y.Z` at release merge.
|
|
||||||
- Changelog: every user-visible change gets an `[Unreleased]` entry (Keep a Changelog categories) before commit; at release move it into `[X.Y.Z] - YYYY-MM-DD`.
|
|
||||||
|
|
||||||
## Git workflow
|
|
||||||
|
|
||||||
- New work branches from `main`: `feat/<short>` for features, `fix/<short>` for fixes.
|
|
||||||
- Push branch, open MR, merge `--no-ff` on green CI, delete branch.
|
|
||||||
- Never push directly to `main` for features or fixes.
|
|
||||||
|
|
||||||
## Web-dev digest
|
|
||||||
|
|
||||||
- Small batches: PR diff ≤ ~200 lines ideal; trunk-based with short-lived branches (above).
|
|
||||||
- Tests green before merge (definition of done); `npm run build` green for frontend.
|
|
||||||
- Docs in same PR as feature (README, AGENTS, CHANGELOG).
|
|
||||||
- Deprecate in minor, remove in next major; document Removed/Deprecated in CHANGELOG.
|
|
||||||
- Lockfiles (uv.lock, package-lock.json, compose pins) — don't drift without reason.
|
|
||||||
- Future implementation ideas → `ROADMAP.md` (authoritative); `CHANGELOG.md` is historical only.
|
|
||||||
|
|
|
||||||
49
CHANGELOG.md
|
|
@ -1,49 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
### Security
|
|
||||||
- Production ops: `scripts/check_prod.py` validates `.env` secrets and
|
|
||||||
modes before deploy (with tests); README production checklist
|
|
||||||
(proxy gates for Langflow, secret rotation, upgrade runbook).
|
|
||||||
### Security
|
|
||||||
- API authorization: flow management is admin-only, runs and run/approval
|
|
||||||
reads require login with strict session-tenant scoping, approval
|
|
||||||
decisions record the session user, and flow callbacks authenticate with
|
|
||||||
a shared `X-Callback-Token` (`CALLBACK_TOKEN`). Request-controlled
|
|
||||||
`tenant_id`, `callback_base_url` and `continuation_flow_id` were
|
|
||||||
removed (SSRF / cross-tenant / flow-chaining hardening);
|
|
||||||
continuations now resolve from the admin-authored definition.
|
|
||||||
- Frontend: `/app/*` routes require login; approver is the session user.
|
|
||||||
- Edge hardening: interactive API docs only with debug, security headers
|
|
||||||
(nosniff/frame/Referrer-Policy), 2 MiB body cap, per-IP run throttle,
|
|
||||||
generic upstream-error responses, magic links never logged.
|
|
||||||
- Containers run rootless (api/vite as uid 1000, lfx as `lfx` user);
|
|
||||||
frontend fails closed to preview mode unless `FRONTEND_MODE=dev`.
|
|
||||||
### Added
|
|
||||||
- Landing page: status-quo problem block, 5-step process with quote,
|
|
||||||
portal overview with admin areas, AI platform parts, exception tiers,
|
|
||||||
quantified benefits, integrations, use-case examples, pilot-program
|
|
||||||
section, expanded FAQ (8 items incl. KI data usage, price model,
|
|
||||||
responsibility).
|
|
||||||
### Changed
|
|
||||||
- Landing page copy overhauled after 3 reviewer rounds (church-admin
|
|
||||||
compliance wording: "entwickelt nach DSG-EKD/DSGVO" + AVV/TOMs,
|
|
||||||
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
|
|
||||||
- Repo scaffold: docker-compose dev/prod, version-pinned images, devcontainer.
|
|
||||||
- Magic-link auth, access requests, RBAC, frontend access-management UI.
|
|
||||||
- File-based flow source of truth + sync engine + LFX runtime.
|
|
||||||
- RJSF dynamic forms, router, Mantine dark-mode design system.
|
|
||||||
- Conversion landing page, automated screenshots, bell-tower favicon.
|
|
||||||
- Email-first login, branded multipart HTML magic-link mails.
|
|
||||||
- lfx level-4 validation on all canonical flows + regression test.
|
|
||||||
|
|
@ -8,21 +8,12 @@ ENV PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
# lfx = flow runtime; requests = convenience for custom-component code.
|
# lfx = flow runtime; requests = convenience for custom-component code.
|
||||||
# Pinned to the Langflow minor (contract: major.minor must match Langflow;
|
RUN pip install lfx requests
|
||||||
# patches independent). Bump together with LANGFLOW_VERSION (.env.example).
|
|
||||||
ARG LFX_VERSION=1.12.0
|
|
||||||
RUN pip install "lfx~=${LFX_VERSION}" requests
|
|
||||||
|
|
||||||
# Non-root runtime: uploads persist into the mounted volume.
|
RUN mkdir -p /flows
|
||||||
RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows
|
|
||||||
VOLUME /flows
|
VOLUME /flows
|
||||||
EXPOSE 6004
|
EXPOSE 6004
|
||||||
USER lfx
|
|
||||||
|
|
||||||
# Serve the /flows dir (uploaded flows persist there via --flow-dir).
|
# Serve the /flows dir (uploaded flows persist there via --flow-dir).
|
||||||
# No positional path: with one, lfx>=1.12.1 exits when the dir holds no
|
# Flags verified against `lfx serve --help` (lfx 1.12.0).
|
||||||
# .json yet ("No .json files found"), restart-looping the container on
|
CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"]
|
||||||
# first boot. --flow-dir alone starts clean (health 200); the API sync
|
|
||||||
# deploys backend/flows/*.json right after boot.
|
|
||||||
# Flags verified against `lfx serve --help` (lfx 1.12.x).
|
|
||||||
CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows"]
|
|
||||||
|
|
|
||||||
49
README.md
|
|
@ -22,28 +22,8 @@ needs judgment. Every step is documented and traceable.
|
||||||
deploy to the lightweight LFX runtime and can run synchronously or streamed.
|
deploy to the lightweight LFX runtime and can run synchronously or streamed.
|
||||||
- **Approvals** — a flow can pause and ask a human for a decision. Approving
|
- **Approvals** — a flow can pause and ask a human for a decision. Approving
|
||||||
can automatically start a follow-up ("continuation") flow.
|
can automatically start a follow-up ("continuation") flow.
|
||||||
- **Access management** — public landing page (German) with an access-request
|
- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben*
|
||||||
form (request + approval). Approved users get a passwordless magic-link
|
(approvals), *Administration* (import, sync, deploy).
|
||||||
invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants
|
|
||||||
come from the session. First admin via `POST /api/auth/admin-bootstrap`
|
|
||||||
(only on an empty user table, email must be in `ADMIN_EMAILS`).
|
|
||||||
- **Dynamic forms** — flows declare form fields as typed inputs on a
|
|
||||||
`CustomInput*` custom component (reusable library in
|
|
||||||
`backend/components/`, mounted into Langflow via
|
|
||||||
`LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a
|
|
||||||
JSON Schema + RJSF uiSchema from the deployed version; the webapp renders
|
|
||||||
the matching form automatically. Flows without such a node fall back to a
|
|
||||||
single textarea. See
|
|
||||||
`backend/components/kirche/README.md` for authoring new form fields.
|
|
||||||
- **Web UI** — landing page (*Start*), access request (*Zugang*), and the
|
|
||||||
logged-in webapp: *Workflows* (choose + run with dynamic form),
|
|
||||||
*Aufgaben* (approvals), *Verwaltung* (admin: access requests, import,
|
|
||||||
sync, deploy).
|
|
||||||
|
|
||||||
## Future
|
|
||||||
|
|
||||||
Ideas and planned features live in [ROADMAP.md](ROADMAP.md)
|
|
||||||
(the single authoritative place for future implementation ideas).
|
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
|
|
@ -126,32 +106,15 @@ Flow workflow: design in Langflow → export into `backend/flows/<name>.json`
|
||||||
(`--pull`, or UI download) → commit → the watcher deploys it automatically.
|
(`--pull`, or UI download) → commit → the watcher deploys it automatically.
|
||||||
Deleting a file removes the flow including its runs (git keeps the JSON).
|
Deleting a file removes the flow including its runs (git keeps the JSON).
|
||||||
|
|
||||||
## Production (self-hosted — TLS/reverse proxy required, not included)
|
## Production (self-hosted, internal use — no TLS/reverse proxy included)
|
||||||
|
|
||||||
1. Check out the repo on the host, create `.env` from `.env.example` with
|
1. Check out the repo on the host, create `.env` from `.env.example` with
|
||||||
real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`.
|
real secrets and `FRONTEND_MODE=preview`.
|
||||||
2. Validate it: `uv run python scripts/check_prod.py --env ../.env`
|
2. `docker compose up -d --build`.
|
||||||
(run from `backend/`; blocks on weak secrets or dev modes).
|
3. Code updates via `git pull` + `docker compose restart api frontend`
|
||||||
3. `docker compose up -d --build`.
|
|
||||||
4. Code updates via `git pull` + `docker compose restart api frontend`
|
|
||||||
(the checkout is bind-mounted); image rebuilds only for dependency
|
(the checkout is bind-mounted); image rebuilds only for dependency
|
||||||
changes (`docker compose up -d --build`).
|
changes (`docker compose up -d --build`).
|
||||||
|
|
||||||
Internet exposure checklist (both forwarded ports sit behind your HTTPS
|
|
||||||
proxy — nothing here substitutes for it):
|
|
||||||
|
|
||||||
- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or
|
|
||||||
OIDC) in front of Langflow `:7860`. Langflow has a history of
|
|
||||||
critical unauthenticated RCEs — treat its UI as privileged tooling,
|
|
||||||
never share flows publicly ("Shareable Playground" = RCE risk).
|
|
||||||
- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate
|
|
||||||
after any incident or staff change; re-run `check_prod.py`.
|
|
||||||
- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION`
|
|
||||||
(`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then
|
|
||||||
`lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on
|
|
||||||
`backend/flows/*.json`. Check Langflow security advisories monthly.
|
|
||||||
- Backups: `backend/data/` (SQLite) and the `langflow-data` volume.
|
|
||||||
|
|
||||||
Container updates: registry images (`langflowai/langflow:latest`)
|
Container updates: registry images (`langflowai/langflow:latest`)
|
||||||
auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built
|
auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built
|
||||||
images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`.
|
images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`.
|
||||||
|
|
|
||||||
20
ROADMAP.md
|
|
@ -1,20 +0,0 @@
|
||||||
# Roadmap
|
|
||||||
|
|
||||||
Future implementation ideas for kirche-einfach.
|
|
||||||
Ideas live here until they ship (then a `feat:` commit + `[Unreleased]`
|
|
||||||
entry in CHANGELOG.md). Keep entries short, ordered by priority.
|
|
||||||
|
|
||||||
This file is the **authoritative place** for future ideas.
|
|
||||||
`CHANGELOG.md` is historical (what shipped); `AGENTS.md` is rules/architecture.
|
|
||||||
|
|
||||||
## Planned
|
|
||||||
|
|
||||||
(none yet — add ideas here as they emerge)
|
|
||||||
|
|
||||||
## Considered / not started
|
|
||||||
|
|
||||||
- **Member form-filling page** — members fill out forms (attendance, sign-up) via the same input schema used by admins to *run* flows.
|
|
||||||
- **Approval decision flow** — complete approve/reject-with-reason + member notification for access requests.
|
|
||||||
- **E2E tests** — Playwright/Cypress smoke tests across key user journeys.
|
|
||||||
- **CI pipeline** — complete Forgejo workflows (backend pytest, frontend build, compose validation, lint).
|
|
||||||
- **Audit log** — track who submitted/approved what (church compliance).
|
|
||||||
1
VERSION
|
|
@ -1 +0,0 @@
|
||||||
0.1.0
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Keep the build context lean: only pyproject/alembic/entrypoint plus
|
|
||||||
# app/migrations/scripts are COPYed. Local venvs, databases and caches must
|
|
||||||
# never enter the context (slow builds, root-owned file leaks).
|
|
||||||
.venv/
|
|
||||||
data/
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
.pytest_cache/
|
|
||||||
uv.lock
|
|
||||||
|
|
@ -4,28 +4,17 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1
|
||||||
# Venv + managed Python live outside the repo bind-mount: a venv under
|
|
||||||
# backend/ used to be shadowed by a named volume (empty on first start)
|
|
||||||
# and backed by a root-owned interpreter vscode could not execute.
|
|
||||||
# /opt is image-local, world-readable, identical in dev and prod.
|
|
||||||
UV_PYTHON_INSTALL_DIR=/opt/python \
|
|
||||||
UV_PROJECT_ENVIRONMENT=/opt/venv
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
# Foundational, inert in prod: non-root user for the editor, sudo for setup,
|
# Foundational, inert in prod: non-root user for the editor, sudo for setup,
|
||||||
# openssh-client for the devcontainer post-start hook, curl for the opencode
|
# openssh-client for the devcontainer post-start hook.
|
||||||
# installer in setup.sh, git for the VS Code source-control view, gosu to
|
|
||||||
# drop privileges before serving (the server never runs as root).
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \
|
&& apt-get install -y --no-install-recommends sudo openssh-client \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& useradd -m -s /bin/bash vscode \
|
&& useradd -m -s /bin/bash vscode \
|
||||||
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \
|
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode
|
||||||
&& mkdir -p /opt/python /opt/venv \
|
|
||||||
&& chmod 755 /opt/python /opt/venv
|
|
||||||
|
|
||||||
WORKDIR /srv/kirche-einfach/backend
|
WORKDIR /srv/kirche-einfach/backend
|
||||||
COPY pyproject.toml alembic.ini entrypoint.sh ./
|
COPY pyproject.toml alembic.ini entrypoint.sh ./
|
||||||
|
|
@ -34,12 +23,9 @@ COPY migrations ./migrations
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
# No lockfile is committed; dependencies resolve at build time.
|
# No lockfile is committed; dependencies resolve at build time.
|
||||||
# Full groups (incl. dev): pytest and friends must work in dev with zero steps.
|
# Full groups (incl. dev): pytest and friends must work in dev with zero steps.
|
||||||
# a+rX: the image builds as root, but the attached devcontainer runs as vscode.
|
RUN uv sync && chmod +x entrypoint.sh
|
||||||
RUN uv sync && chmod +x entrypoint.sh && chmod -R a+rX /opt/python /opt/venv
|
|
||||||
|
|
||||||
ENV FLOWS_WATCH_ENABLED=true
|
ENV FLOWS_WATCH_ENABLED=true
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Explicit interpreter: the repo bind-mount shadows the image at runtime and
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
# git tracks entrypoint.sh as 100644, so the exec bit cannot be relied on.
|
|
||||||
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Magic-link authentication + access-request approval (SMTP invite)."""
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
"""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
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.models import User
|
|
||||||
|
|
||||||
from . import mail, service
|
|
||||||
from .deps import (
|
|
||||||
SESSION_COOKIE,
|
|
||||||
SESSION_MAX_AGE,
|
|
||||||
get_current_user,
|
|
||||||
get_db,
|
|
||||||
require_admin,
|
|
||||||
)
|
|
||||||
from .schemas import (
|
|
||||||
AccessDecisionRead,
|
|
||||||
AccessRequestCreate,
|
|
||||||
AccessRequestRead,
|
|
||||||
AdminBootstrapRequest,
|
|
||||||
MagicConsumeRequest,
|
|
||||||
MagicRequestRead,
|
|
||||||
MagicRequestRequest,
|
|
||||||
MeRead,
|
|
||||||
MessageRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
access_router = APIRouter(prefix="/api/access", tags=["access"])
|
|
||||||
auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
||||||
|
|
||||||
|
|
||||||
def _read_request(r) -> AccessRequestRead:
|
|
||||||
return AccessRequestRead(
|
|
||||||
id=r.id,
|
|
||||||
name=r.name,
|
|
||||||
email=r.email,
|
|
||||||
reason=r.reason,
|
|
||||||
status=r.status,
|
|
||||||
created_at=r.created_at,
|
|
||||||
decided_at=r.decided_at,
|
|
||||||
decided_by=r.decided_by,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_me(user: User) -> MeRead:
|
|
||||||
return MeRead(
|
|
||||||
id=user.id,
|
|
||||||
email=user.email,
|
|
||||||
name=user.name,
|
|
||||||
role=user.role,
|
|
||||||
tenant_id=user.tenant_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, session_id: str) -> None:
|
|
||||||
# localhost is a secure context, so Secure is safe for local dev too.
|
|
||||||
response.set_cookie(
|
|
||||||
SESSION_COOKIE,
|
|
||||||
session_id,
|
|
||||||
max_age=SESSION_MAX_AGE,
|
|
||||||
path="/",
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="lax",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
|
|
||||||
def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]):
|
|
||||||
try:
|
|
||||||
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)
|
|
||||||
return _read_request(request)
|
|
||||||
|
|
||||||
|
|
||||||
@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),
|
|
||||||
):
|
|
||||||
return [_read_request(r) for r in service.list_access_requests(session, status)]
|
|
||||||
|
|
||||||
|
|
||||||
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
|
|
||||||
def approve_request(
|
|
||||||
request_id: UUID,
|
|
||||||
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)
|
|
||||||
except service.NotFoundError as exc:
|
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
||||||
except service.ConflictError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
# The token travels by mail only — never in an API response.
|
|
||||||
email_sent = mail.send_magic_link(request.email, mail.magic_link(token))
|
|
||||||
return AccessDecisionRead(request=_read_request(request), email_sent=email_sent)
|
|
||||||
|
|
||||||
|
|
||||||
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
|
|
||||||
def reject_request(
|
|
||||||
request_id: UUID,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
request = service.reject_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:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
return _read_request(request)
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/magic/request", response_model=MagicRequestRead)
|
|
||||||
def request_magic(
|
|
||||||
data: MagicRequestRequest,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Email-first login: allowed emails get a magic link mailed.
|
|
||||||
|
|
||||||
403 means "not allowed" — the frontend forwards those to /access.
|
|
||||||
The token travels by mail only, never in the response.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, token = service.request_magic_link(session, data.email)
|
|
||||||
except service.ForbiddenError as exc:
|
|
||||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
||||||
except service.RateLimitedError as exc:
|
|
||||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
||||||
email_sent = mail.send_magic_link(user.email, mail.magic_link(token))
|
|
||||||
return MagicRequestRead(message="login link sent", email_sent=email_sent)
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/magic/consume", response_model=MeRead)
|
|
||||||
def consume_magic(
|
|
||||||
data: MagicConsumeRequest,
|
|
||||||
response: Response,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
user_session = service.consume_magic_token(session, data.token)
|
|
||||||
except (
|
|
||||||
service.InvalidTokenError,
|
|
||||||
service.ExpiredTokenError,
|
|
||||||
service.UsedTokenError,
|
|
||||||
) as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
user = service.get_user(session, user_session.user_id)
|
|
||||||
if user is None: # pragma: no cover - consume already validated the user
|
|
||||||
raise HTTPException(status_code=400, detail="unknown token")
|
|
||||||
_set_session_cookie(response, user_session.id)
|
|
||||||
return _read_me(user)
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.get("/me", response_model=MeRead)
|
|
||||||
def read_me(user: Annotated[User, Depends(get_current_user)]):
|
|
||||||
return _read_me(user)
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/logout", response_model=MessageRead)
|
|
||||||
def logout(
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
):
|
|
||||||
# Revoke the server-side row when the cookie maps to a live session;
|
|
||||||
# always clear the client cookie.
|
|
||||||
session_id = request.cookies.get(SESSION_COOKIE)
|
|
||||||
if session_id:
|
|
||||||
service.revoke_session(session, session_id)
|
|
||||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
|
||||||
return MessageRead(message="logged out")
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
|
|
||||||
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:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except service.ForbiddenError as exc:
|
|
||||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
||||||
mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token))
|
|
||||||
return MessageRead(message="admin created, magic link sent")
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"""Session-cookie dependencies. Handlers stay thin; DB via get_session."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.db import get_session
|
|
||||||
from app.models import User
|
|
||||||
|
|
||||||
from . import service
|
|
||||||
|
|
||||||
SESSION_COOKIE = "kirche_session"
|
|
||||||
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Iterator[Session]:
|
|
||||||
yield from get_session()
|
|
||||||
|
|
||||||
|
|
||||||
def get_optional_user(
|
|
||||||
request: Request, session: Annotated[Session, Depends(get_db)]
|
|
||||||
) -> User | None:
|
|
||||||
session_id = request.cookies.get(SESSION_COOKIE)
|
|
||||||
if not session_id:
|
|
||||||
return None
|
|
||||||
record = service.get_valid_session(session, session_id)
|
|
||||||
if record is None:
|
|
||||||
return None
|
|
||||||
return service.get_user(session, record.user_id)
|
|
||||||
|
|
||||||
|
|
||||||
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: 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:
|
|
||||||
"""Tenant comes from the session; the body/query param is a deprecated
|
|
||||||
fallback for unauthenticated callers."""
|
|
||||||
if user is not None:
|
|
||||||
return user.tenant_id
|
|
||||||
return explicit or default
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.
|
|
||||||
|
|
||||||
Mail design follows the web branding (see frontend/src/theme.ts):
|
|
||||||
kirchenblau header, serif headline, white card on paper background.
|
|
||||||
Email-safe by construction: table layout, inline styles only, system
|
|
||||||
fonts, no external images (many clients block them / track opens).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import smtplib
|
|
||||||
from email.message import EmailMessage
|
|
||||||
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Brand palette (mirrors frontend/src/theme.ts kirchenblau).
|
|
||||||
BLUE = "#1d4a79"
|
|
||||||
BLUE_LIGHT = "#eef4fb"
|
|
||||||
INK = "#162f4c"
|
|
||||||
MUTED = "#5b6b7c"
|
|
||||||
|
|
||||||
BLUE = "#1d4a79"
|
|
||||||
BLUE_LIGHT = "#eef4fb"
|
|
||||||
INK = "#162f4c"
|
|
||||||
MUTED = "#5b6b7c"
|
|
||||||
|
|
||||||
|
|
||||||
def magic_link(token: str) -> str:
|
|
||||||
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
|
||||||
|
|
||||||
|
|
||||||
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
|
|
||||||
"""Branded HTML mail body (table layout, inline styles, no images)."""
|
|
||||||
button = ""
|
|
||||||
if button_url:
|
|
||||||
button = (
|
|
||||||
'<table role="presentation" cellpadding="0" cellspacing="0" '
|
|
||||||
'style="margin:24px auto 8px auto;"><tr>'
|
|
||||||
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;"
|
|
||||||
f'border-radius:8px;">{button_label}</a></td></tr></table>'
|
|
||||||
)
|
|
||||||
return f"""<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
||||||
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
|
|
||||||
<tr><td align="center">
|
|
||||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
|
||||||
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
|
|
||||||
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="{BLUE}" style="padding:20px 28px;">
|
|
||||||
<span style="color:#ffffff;font-size:18px;font-weight:700;
|
|
||||||
letter-spacing:0.02em;">Kirche Einfach</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
|
|
||||||
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
|
|
||||||
color:{INK};">{title}</h1>
|
|
||||||
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
|
|
||||||
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
|
|
||||||
color:{MUTED};">{intro}</p>
|
|
||||||
{button}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
|
||||||
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
|
|
||||||
{note}
|
|
||||||
</p>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _send(message: EmailMessage) -> None:
|
|
||||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
|
||||||
smtp.starttls()
|
|
||||||
if settings.smtp_username:
|
|
||||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
|
||||||
smtp.send_message(message)
|
|
||||||
|
|
||||||
|
|
||||||
def _branded_mail(
|
|
||||||
subject: str,
|
|
||||||
to_email: str,
|
|
||||||
headline: str,
|
|
||||||
intro: str,
|
|
||||||
button_label: str,
|
|
||||||
button_url: str,
|
|
||||||
note: str,
|
|
||||||
) -> EmailMessage:
|
|
||||||
"""Text + branded HTML alternative; the link is a real button."""
|
|
||||||
message = EmailMessage()
|
|
||||||
message["Subject"] = subject
|
|
||||||
message["To"] = to_email
|
|
||||||
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")
|
|
||||||
# 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")
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def send_magic_link(to_email: str, link: str) -> bool:
|
|
||||||
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
|
|
||||||
|
|
||||||
Never raises: callers invoke it after committing (approve/bootstrap),
|
|
||||||
so a delivery failure must not turn into a 500 — the token stays valid
|
|
||||||
and the admin sees ``email_sent: false``.
|
|
||||||
|
|
||||||
The plaintext link only ever appears here: service/api logging must
|
|
||||||
never include tokens or session ids (see test_auth no-leak test).
|
|
||||||
"""
|
|
||||||
if not settings.smtp_host:
|
|
||||||
# Never log the link itself: without SMTP this fallback fires in
|
|
||||||
# every environment, and logs are rarely as protected as mail.
|
|
||||||
logger.info("SMTP not configured; dropping magic link for %s", to_email)
|
|
||||||
return False
|
|
||||||
message = _branded_mail(
|
|
||||||
subject="Dein Login-Link für Kirche Einfach",
|
|
||||||
to_email=to_email,
|
|
||||||
headline="Anmeldung bei Kirche Einfach",
|
|
||||||
intro=(
|
|
||||||
"Klicke auf den Button, um dich anzumelden. Der Link ist "
|
|
||||||
"15 Minuten gültig und funktioniert nur einmal."
|
|
||||||
),
|
|
||||||
button_label="Jetzt anmelden",
|
|
||||||
button_url=link,
|
|
||||||
note=(
|
|
||||||
"Falls du den Button nicht nutzen kannst, kopiere den Link "
|
|
||||||
"aus der Textversion dieser E-Mail in deinen Browser. "
|
|
||||||
"Diese E-Mail wurde angefordert — wenn du das warst, ist "
|
|
||||||
"alles gut; sonst kannst du sie ignorieren."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
_send(message)
|
|
||||||
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)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
|
||||||
"""Notify CONTACT_TO about a new access request (best effort)."""
|
|
||||||
if not settings.smtp_host or not settings.contact_to:
|
|
||||||
logger.info("new access request %s from %s <%s>", request_id, name, email)
|
|
||||||
return False
|
|
||||||
message = _branded_mail(
|
|
||||||
subject=f"Neue Zugangsanfrage: {name}",
|
|
||||||
to_email=settings.contact_to,
|
|
||||||
headline="Neue Zugangsanfrage",
|
|
||||||
intro=(
|
|
||||||
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",
|
|
||||||
note="Diese Benachrichtigung geht an das Kontakt-Postfach.",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
_send(message)
|
|
||||||
except (OSError, smtplib.SMTPException):
|
|
||||||
logger.warning("access-request notification failed for %s", request_id)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
"""Pydantic contracts for the access/auth API."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class AccessRequestCreate(BaseModel):
|
|
||||||
name: str = Field(min_length=1, max_length=200)
|
|
||||||
email: str = Field(min_length=3, max_length=320)
|
|
||||||
reason: str = Field(default="", max_length=2000)
|
|
||||||
|
|
||||||
|
|
||||||
class AccessRequestRead(BaseModel):
|
|
||||||
id: UUID
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
reason: str
|
|
||||||
status: str
|
|
||||||
created_at: datetime
|
|
||||||
decided_at: datetime | None
|
|
||||||
decided_by: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class AccessDecisionRead(BaseModel):
|
|
||||||
request: AccessRequestRead
|
|
||||||
# False when SMTP is unset (dev): the magic link is logged, not mailed.
|
|
||||||
email_sent: bool
|
|
||||||
|
|
||||||
|
|
||||||
class MagicConsumeRequest(BaseModel):
|
|
||||||
token: str = Field(min_length=1, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
class MagicRequestRequest(BaseModel):
|
|
||||||
email: str = Field(min_length=3, max_length=320)
|
|
||||||
|
|
||||||
|
|
||||||
class MagicRequestRead(BaseModel):
|
|
||||||
message: str
|
|
||||||
email_sent: bool
|
|
||||||
|
|
||||||
|
|
||||||
class MeRead(BaseModel):
|
|
||||||
id: UUID
|
|
||||||
email: str
|
|
||||||
name: str
|
|
||||||
role: str
|
|
||||||
tenant_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class AdminBootstrapRequest(BaseModel):
|
|
||||||
email: str = Field(min_length=3, max_length=320)
|
|
||||||
|
|
||||||
|
|
||||||
class MessageRead(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
"""Access requests, magic-link tokens, and sessions (DB via function args).
|
|
||||||
|
|
||||||
Keep API handlers thin: all persistence lives here. Tokens and session ids
|
|
||||||
are bearer secrets — log user/request ids only, never secret values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlmodel import Session, func, select
|
|
||||||
|
|
||||||
from app.models import AccessRequest, MagicLinkToken, User, UserSession
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAGIC_LINK_TTL = timedelta(minutes=15)
|
|
||||||
SESSION_TTL = timedelta(days=30)
|
|
||||||
|
|
||||||
# Public request endpoint: at most this many requests per email per hour.
|
|
||||||
REQUESTS_PER_EMAIL_PER_HOUR = 5
|
|
||||||
# Same for magic-link requests (email bombing / token spam protection).
|
|
||||||
MAGIC_LINKS_PER_EMAIL_PER_HOUR = 5
|
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ConflictError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ForbiddenError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidTokenError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ExpiredTokenError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UsedTokenError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitedError(AuthError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_email(email: str) -> str:
|
|
||||||
return email.strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def hash_token(token: str) -> str:
|
|
||||||
return hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def is_admin_email(email: str) -> bool:
|
|
||||||
return normalize_email(email) in settings.admin_email_list
|
|
||||||
|
|
||||||
|
|
||||||
def role_for(email: str) -> str:
|
|
||||||
return "admin" if is_admin_email(email) else "member"
|
|
||||||
|
|
||||||
|
|
||||||
def get_user(session: Session, user_id: UUID) -> User | None:
|
|
||||||
return session.get(User, user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_email(session: Session, email: str) -> User | None:
|
|
||||||
return session.exec(select(User).where(User.email == normalize_email(email))).first()
|
|
||||||
|
|
||||||
|
|
||||||
def user_count(session: Session) -> int:
|
|
||||||
return session.exec(select(func.count()).select_from(User)).one()
|
|
||||||
|
|
||||||
|
|
||||||
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
|
|
||||||
since = utcnow() - timedelta(hours=hours)
|
|
||||||
return session.exec(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(AccessRequest)
|
|
||||||
.where(
|
|
||||||
AccessRequest.email == normalize_email(email),
|
|
||||||
AccessRequest.created_at >= since,
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_request(
|
|
||||||
session: Session, name: str, email: str, reason: str = ""
|
|
||||||
) -> AccessRequest:
|
|
||||||
email = normalize_email(email)
|
|
||||||
if recent_request_count(session, email) >= REQUESTS_PER_EMAIL_PER_HOUR:
|
|
||||||
raise RateLimitedError("too many requests from this email, try again later")
|
|
||||||
request = AccessRequest(name=name.strip(), email=email, reason=reason.strip())
|
|
||||||
session.add(request)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(request)
|
|
||||||
logger.info("access request %s from %s", request.id, email)
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
def get_access_request(session: Session, request_id: UUID) -> AccessRequest:
|
|
||||||
request = session.get(AccessRequest, request_id)
|
|
||||||
if request is None:
|
|
||||||
raise NotFoundError(f"access request {request_id} not found")
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
def list_access_requests(session: Session, status: str | None = None) -> list[AccessRequest]:
|
|
||||||
query = select(AccessRequest).order_by(AccessRequest.created_at.desc())
|
|
||||||
if status is not None:
|
|
||||||
query = query.where(AccessRequest.status == status)
|
|
||||||
return list(session.exec(query).all())
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_user(session: Session, name: str, email: str) -> User:
|
|
||||||
user = get_user_by_email(session, email)
|
|
||||||
if user is None:
|
|
||||||
user = User(name=name.strip(), email=email, role=role_for(email))
|
|
||||||
session.add(user)
|
|
||||||
session.flush()
|
|
||||||
logger.info("user %s created for %s", user.id, email)
|
|
||||||
else:
|
|
||||||
if not user.name and name.strip():
|
|
||||||
user.name = name.strip()
|
|
||||||
new_role = role_for(email)
|
|
||||||
if user.role != new_role:
|
|
||||||
user.role = new_role
|
|
||||||
session.add(user)
|
|
||||||
session.flush()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def issue_magic_token(session: Session, user: User) -> str:
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
session.add(
|
|
||||||
MagicLinkToken(
|
|
||||||
user_id=user.id,
|
|
||||||
token_hash=hash_token(token),
|
|
||||||
expires_at=utcnow() + MAGIC_LINK_TTL,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
logger.info("magic token issued for user %s", user.id)
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
|
|
||||||
since = utcnow() - timedelta(hours=hours)
|
|
||||||
return session.exec(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(MagicLinkToken)
|
|
||||||
.where(
|
|
||||||
MagicLinkToken.user_id == user.id,
|
|
||||||
MagicLinkToken.created_at >= since,
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
|
|
||||||
|
|
||||||
def request_magic_link(session: Session, email: str) -> tuple[User, str]:
|
|
||||||
"""Self-service login link for an allowed email.
|
|
||||||
|
|
||||||
Allowed = approved before (has a User row) or listed in ADMIN_EMAILS
|
|
||||||
(admin user is created on first request). Anything else raises
|
|
||||||
ForbiddenError — the frontend forwards those to the access form.
|
|
||||||
Note: 403-vs-200 distinguishes registered emails by design here.
|
|
||||||
"""
|
|
||||||
email = normalize_email(email)
|
|
||||||
user = get_user_by_email(session, email)
|
|
||||||
if user is None:
|
|
||||||
if not is_admin_email(email):
|
|
||||||
raise ForbiddenError("email is not allowed to log in")
|
|
||||||
user = _get_or_create_user(session, "", email)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
if recent_token_count(session, user) >= MAGIC_LINKS_PER_EMAIL_PER_HOUR:
|
|
||||||
raise RateLimitedError("too many login links, try again later")
|
|
||||||
return user, issue_magic_token(session, user)
|
|
||||||
|
|
||||||
|
|
||||||
def approve_access_request(
|
|
||||||
session: Session, request_id: UUID, decided_by: str
|
|
||||||
) -> tuple[AccessRequest, User, str]:
|
|
||||||
"""Approve → upsert User + fresh magic-link token (plaintext, mail it)."""
|
|
||||||
request = get_access_request(session, request_id)
|
|
||||||
if request.status != "pending":
|
|
||||||
raise ConflictError(f"access request {request_id} already {request.status}")
|
|
||||||
user = _get_or_create_user(session, request.name, request.email)
|
|
||||||
request.status = "approved"
|
|
||||||
request.decided_at = utcnow()
|
|
||||||
request.decided_by = normalize_email(decided_by)
|
|
||||||
session.add(request)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(request)
|
|
||||||
token = issue_magic_token(session, user)
|
|
||||||
logger.info(
|
|
||||||
"access request %s approved by %s; user %s",
|
|
||||||
request.id,
|
|
||||||
request.decided_by,
|
|
||||||
user.id,
|
|
||||||
)
|
|
||||||
return request, user, token
|
|
||||||
|
|
||||||
|
|
||||||
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}")
|
|
||||||
request.status = "rejected"
|
|
||||||
request.decided_at = utcnow()
|
|
||||||
request.decided_by = normalize_email(decided_by)
|
|
||||||
session.add(request)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(request)
|
|
||||||
logger.info("access request %s rejected by %s", request.id, request.decided_by)
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
def _aware(value: datetime) -> datetime:
|
|
||||||
# SQLite returns naive datetimes; stored values are UTC.
|
|
||||||
if value.tzinfo is None:
|
|
||||||
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()))
|
|
||||||
).first()
|
|
||||||
if record is None:
|
|
||||||
raise InvalidTokenError("unknown token")
|
|
||||||
if record.used_at is not None:
|
|
||||||
raise UsedTokenError("token already used")
|
|
||||||
if _aware(record.expires_at) <= utcnow():
|
|
||||||
raise ExpiredTokenError("token expired")
|
|
||||||
user = session.get(User, record.user_id)
|
|
||||||
if user is None:
|
|
||||||
raise InvalidTokenError("unknown token")
|
|
||||||
# Mark used first: a reused token can never mint a second session.
|
|
||||||
record.used_at = utcnow()
|
|
||||||
session.add(record)
|
|
||||||
user_session = UserSession(
|
|
||||||
id=secrets.token_urlsafe(32),
|
|
||||||
user_id=user.id,
|
|
||||||
expires_at=utcnow() + SESSION_TTL,
|
|
||||||
)
|
|
||||||
session.add(user_session)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user_session)
|
|
||||||
logger.info("magic token consumed for user %s", user.id)
|
|
||||||
return user_session
|
|
||||||
|
|
||||||
|
|
||||||
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
|
|
||||||
record = session.get(UserSession, session_id)
|
|
||||||
if record is None or record.revoked_at is not None:
|
|
||||||
return None
|
|
||||||
if _aware(record.expires_at) <= utcnow():
|
|
||||||
return None
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
def revoke_session(session: Session, session_id: str) -> None:
|
|
||||||
record = session.get(UserSession, session_id)
|
|
||||||
if record is not None and record.revoked_at is None:
|
|
||||||
record.revoked_at = utcnow()
|
|
||||||
session.add(record)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
|
|
||||||
"""First-admin setup: only when the user table is empty AND the email
|
|
||||||
is in ADMIN_EMAILS. Issues a magic-link token like approve does."""
|
|
||||||
email = normalize_email(email)
|
|
||||||
if user_count(session) > 0:
|
|
||||||
raise ConflictError("users already exist, bootstrap is closed")
|
|
||||||
if not is_admin_email(email):
|
|
||||||
raise ForbiddenError("email is not an admin email")
|
|
||||||
user = User(name="", email=email, role="admin")
|
|
||||||
session.add(user)
|
|
||||||
session.flush()
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
token = issue_magic_token(session, user)
|
|
||||||
logger.info("admin bootstrapped for user %s", user.id)
|
|
||||||
return user, token
|
|
||||||
|
|
@ -1,35 +1,23 @@
|
||||||
"""Flows + approvals HTTP API.
|
"""Flows + approvals HTTP API."""
|
||||||
|
|
||||||
Auth model: flow *management* (create/version/deploy/sync/import) is
|
|
||||||
admin-only. Browsing (list/get/input-schema) is public so the website
|
|
||||||
renders. Runs, run reads and approval reads require login and are scoped
|
|
||||||
to the session tenant. Flow *callbacks* (LFX has no session) carry a
|
|
||||||
shared X-Callback-Token instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user, require_admin
|
|
||||||
from app.db import get_engine, get_session
|
from app.db import get_engine, get_session
|
||||||
from app.models import User
|
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
from . import input_schema as form_schema
|
|
||||||
from . import runner, store
|
from . import runner, store
|
||||||
from .deploy import import_from_langflow
|
from .deploy import import_from_langflow
|
||||||
|
from .sync import lfx_id_for, sync_from_directory
|
||||||
from .langflow_client import (
|
from .langflow_client import (
|
||||||
LangflowClient,
|
LangflowClient,
|
||||||
LangflowError,
|
LangflowError,
|
||||||
|
|
@ -42,6 +30,7 @@ from .lfx_client import (
|
||||||
LfxError,
|
LfxError,
|
||||||
LfxInvalidFlow,
|
LfxInvalidFlow,
|
||||||
LfxNotFound,
|
LfxNotFound,
|
||||||
|
LfxRunFailed,
|
||||||
LfxUnavailable,
|
LfxUnavailable,
|
||||||
)
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
|
|
@ -55,18 +44,14 @@ from .schemas import (
|
||||||
FlowImportRequest,
|
FlowImportRequest,
|
||||||
FlowVersionCreate,
|
FlowVersionCreate,
|
||||||
FlowVersionRead,
|
FlowVersionRead,
|
||||||
InputSchemaRead,
|
|
||||||
RunRead,
|
RunRead,
|
||||||
RunRequest,
|
RunRequest,
|
||||||
SyncReport,
|
SyncReport,
|
||||||
)
|
)
|
||||||
from .sync import lfx_id_for, sync_from_directory
|
|
||||||
|
|
||||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Iterator[Session]:
|
def get_db() -> Iterator[Session]:
|
||||||
yield from get_session()
|
yield from get_session()
|
||||||
|
|
@ -77,7 +62,9 @@ def get_lfx() -> LfxClient:
|
||||||
|
|
||||||
|
|
||||||
def get_langflow() -> LangflowClient:
|
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:
|
def _read_definition(d) -> FlowDefinitionRead:
|
||||||
|
|
@ -102,7 +89,6 @@ def _read_version(v) -> FlowVersionRead:
|
||||||
deployed_at=v.deployed_at,
|
deployed_at=v.deployed_at,
|
||||||
author=v.author,
|
author=v.author,
|
||||||
created_at=v.created_at,
|
created_at=v.created_at,
|
||||||
input_schema=v.input_schema,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -138,11 +124,7 @@ def _read_task(t) -> ApprovalRead:
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
||||||
def create_flow(
|
def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
|
||||||
data: FlowDefinitionCreate,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
return _read_definition(store.create_definition(session, data))
|
return _read_definition(store.create_definition(session, data))
|
||||||
except store.ConflictError as exc:
|
except store.ConflictError as exc:
|
||||||
|
|
@ -152,15 +134,14 @@ def create_flow(
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("", response_model=list[FlowDefinitionRead])
|
@flows_router.get("", response_model=list[FlowDefinitionRead])
|
||||||
def list_flows(session: Annotated[Session, Depends(get_db)]):
|
def list_flows(session: Session = Depends(get_db)):
|
||||||
return [_read_definition(d) for d in store.list_definitions(session)]
|
return [_read_definition(d) for d in store.list_definitions(session)]
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("/sync", response_model=SyncReport)
|
@flows_router.post("/sync", response_model=SyncReport)
|
||||||
async def sync_flows(
|
async def sync_flows(
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Session = Depends(get_db),
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: LfxClient = Depends(get_lfx),
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
):
|
||||||
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -170,19 +151,18 @@ async def sync_flows(
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
||||||
def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
|
def get_flow(flow_id: UUID, session: Session = Depends(get_db)):
|
||||||
try:
|
try:
|
||||||
return _read_definition(store.get_definition(session, flow_id))
|
return _read_definition(store.get_definition(session, flow_id))
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
|
@flows_router.post(
|
||||||
|
"/{flow_id}/versions", response_model=FlowVersionRead, status_code=201
|
||||||
|
)
|
||||||
def create_version(
|
def create_version(
|
||||||
flow_id: UUID,
|
flow_id: UUID, data: FlowVersionCreate, session: Session = Depends(get_db)
|
||||||
data: FlowVersionCreate,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return _read_version(store.create_version(session, flow_id, data))
|
return _read_version(store.create_version(session, flow_id, data))
|
||||||
|
|
@ -193,24 +173,21 @@ def create_version(
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
|
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
|
||||||
def list_versions(
|
def list_versions(flow_id: UUID, session: Session = Depends(get_db)):
|
||||||
flow_id: UUID,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
return [_read_version(v) for v in store.list_versions(session, flow_id)]
|
return [_read_version(v) for v in store.list_versions(session, flow_id)]
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("/{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(
|
async def deploy_version(
|
||||||
flow_id: UUID,
|
flow_id: UUID,
|
||||||
version_no: int,
|
version_no: int,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Session = Depends(get_db),
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: LfxClient = Depends(get_lfx),
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
version = store.get_version_by_no(session, flow_id, version_no)
|
version = store.get_version_by_no(session, flow_id, version_no)
|
||||||
|
|
@ -229,11 +206,9 @@ async def deploy_version(
|
||||||
except LfxConflict as exc:
|
except LfxConflict as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
except LfxUnavailable as exc:
|
except LfxUnavailable as exc:
|
||||||
logger.warning("lfx unavailable: %s", exc)
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
logger.warning("lfx error: %s", exc)
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
|
||||||
return _read_version(store.mark_deployed(session, version, uploaded.id))
|
return _read_version(store.mark_deployed(session, version, uploaded.id))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -247,64 +222,55 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
|
||||||
async def run_flow(
|
async def run_flow(
|
||||||
flow_id: UUID,
|
flow_id: UUID,
|
||||||
data: RunRequest,
|
data: RunRequest,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
|
||||||
stream: bool = Query(default=False),
|
stream: bool = Query(default=False),
|
||||||
|
session: Session = Depends(get_db),
|
||||||
|
lfx: LfxClient = Depends(get_lfx),
|
||||||
):
|
):
|
||||||
# Tenant, callback URL and continuation always come from the server:
|
|
||||||
# the session, the settings, and the admin-authored definition.
|
|
||||||
tenant_id = user.tenant_id
|
|
||||||
try:
|
try:
|
||||||
definition = store.get_definition(session, flow_id)
|
|
||||||
version = _resolve_version(session, flow_id, data.version_no)
|
version = _resolve_version(session, flow_id, data.version_no)
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except store.InvalidError as exc:
|
except store.InvalidError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
if stream:
|
if stream:
|
||||||
return await _run_stream(session, lfx, version, data, user, definition)
|
return await _run_stream(session, lfx, version, data)
|
||||||
try:
|
try:
|
||||||
run = await runner.execute_run(
|
run = await runner.execute_run(
|
||||||
session,
|
session,
|
||||||
lfx,
|
lfx,
|
||||||
version,
|
version,
|
||||||
payload=data.input,
|
payload=data.input,
|
||||||
tenant_id=tenant_id,
|
tenant_id=data.tenant_id,
|
||||||
session_id=data.session_id,
|
session_id=data.session_id,
|
||||||
callback_base_url=settings.callback_base_url,
|
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||||
continuation_flow_id=definition.continuation_flow_id,
|
continuation_flow_id=data.continuation_flow_id,
|
||||||
callback_token=settings.callback_token,
|
|
||||||
)
|
)
|
||||||
except LfxNotFound as exc:
|
except LfxNotFound as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||||
except LfxUnavailable as exc:
|
except LfxUnavailable as exc:
|
||||||
logger.warning("lfx unavailable: %s", exc)
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
logger.warning("lfx error: %s", exc)
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
|
||||||
return _read_run(run)
|
return _read_run(run)
|
||||||
|
|
||||||
|
|
||||||
async def _run_stream(
|
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
|
||||||
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
|
|
||||||
):
|
|
||||||
if not version.lfx_flow_id:
|
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(
|
run = store.create_run(
|
||||||
session,
|
session,
|
||||||
version,
|
version,
|
||||||
input=data.input,
|
input=data.input,
|
||||||
tenant_id=user.tenant_id,
|
tenant_id=data.tenant_id,
|
||||||
session_id=data.session_id,
|
session_id=data.session_id,
|
||||||
)
|
)
|
||||||
envelope = runner.build_envelope(
|
envelope = runner.build_envelope(
|
||||||
payload=data.input,
|
payload=data.input,
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
callback_base_url=settings.callback_base_url,
|
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||||
continuation_flow_id=definition.continuation_flow_id,
|
continuation_flow_id=data.continuation_flow_id,
|
||||||
callback_token=settings.callback_token,
|
|
||||||
)
|
)
|
||||||
run_id = run.id
|
run_id = run.id
|
||||||
lfx_flow_id = version.lfx_flow_id
|
lfx_flow_id = version.lfx_flow_id
|
||||||
|
|
@ -313,16 +279,14 @@ async def _run_stream(
|
||||||
async def event_gen() -> AsyncIterator[str]:
|
async def event_gen() -> AsyncIterator[str]:
|
||||||
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
||||||
try:
|
try:
|
||||||
async with (
|
async with httpx.AsyncClient(
|
||||||
httpx.AsyncClient(
|
|
||||||
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
||||||
) as client,
|
) as client:
|
||||||
client.stream(
|
async with client.stream(
|
||||||
"POST",
|
"POST",
|
||||||
f"/flows/{lfx_flow_id}/stream",
|
f"/flows/{lfx_flow_id}/stream",
|
||||||
json={"input_value": envelope, "session_id": session_id},
|
json={"input_value": envelope, "session_id": session_id},
|
||||||
) as response,
|
) as response:
|
||||||
):
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
|
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -334,7 +298,11 @@ async def _run_stream(
|
||||||
with Session(get_engine()) as fresh:
|
with Session(get_engine()) as fresh:
|
||||||
try:
|
try:
|
||||||
fresh_run = store.get_run(fresh, run_id)
|
fresh_run = store.get_run(fresh, run_id)
|
||||||
status = "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})
|
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
|
||||||
except store.NotFoundError:
|
except store.NotFoundError:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
|
@ -346,39 +314,32 @@ async def _run_stream(
|
||||||
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
|
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
|
||||||
def list_runs(
|
def list_runs(
|
||||||
flow_id: UUID,
|
flow_id: UUID,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
tenant_id: str | None = Query(default=None),
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
session: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Strict tenant scoping: the session decides, query params cannot widen.
|
|
||||||
try:
|
try:
|
||||||
return [_read_run(r) for r in store.list_runs(session, flow_id, user.tenant_id)]
|
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/langflow/available")
|
@flows_router.get("/langflow/available")
|
||||||
async def list_langflow_flows(
|
async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
|
||||||
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
|
||||||
"""Browse flows on the Langflow authoring server (admin import source)."""
|
"""Browse flows on the Langflow authoring server (admin import source)."""
|
||||||
try:
|
try:
|
||||||
return await langflow.list_flows()
|
return await langflow.list_flows()
|
||||||
except LangflowUnavailable as exc:
|
except LangflowUnavailable as exc:
|
||||||
logger.warning("langflow unavailable: %s", exc)
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
|
||||||
except LangflowError as exc:
|
except LangflowError as exc:
|
||||||
logger.warning("langflow error: %s", exc)
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
||||||
async def import_flow(
|
async def import_flow(
|
||||||
data: FlowImportRequest,
|
data: FlowImportRequest,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Session = Depends(get_db),
|
||||||
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
langflow: LangflowClient = Depends(get_langflow),
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: LfxClient = Depends(get_lfx),
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
|
||||||
):
|
):
|
||||||
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
|
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -394,11 +355,9 @@ async def import_flow(
|
||||||
except LangflowNotFound as exc:
|
except LangflowNotFound as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except LangflowUnavailable as exc:
|
except LangflowUnavailable as exc:
|
||||||
logger.warning("langflow unavailable: %s", exc)
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
|
||||||
except LangflowError as exc:
|
except LangflowError as exc:
|
||||||
logger.warning("langflow error: %s", exc)
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
|
||||||
except store.ConflictError as exc:
|
except store.ConflictError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
except store.InvalidError as exc:
|
except store.InvalidError as exc:
|
||||||
|
|
@ -408,70 +367,33 @@ async def import_flow(
|
||||||
except LfxConflict as exc:
|
except LfxConflict as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
except LfxUnavailable as exc:
|
except LfxUnavailable as exc:
|
||||||
logger.warning("lfx unavailable: %s", exc)
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
logger.warning("lfx error: %s", exc)
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
return FlowImportRead(
|
||||||
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
|
definition=_read_definition(definition), version=_read_version(version)
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
|
|
||||||
def get_input_schema(
|
|
||||||
flow_id: UUID,
|
|
||||||
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)
|
|
||||||
return InputSchemaRead(
|
|
||||||
flow_id=flow_id,
|
|
||||||
version_no=version.version_no,
|
|
||||||
json_schema=cached.get("json_schema", {}),
|
|
||||||
ui_schema=cached.get("ui_schema", {}),
|
|
||||||
fallback=cached.get("fallback"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/runs/{run_id}", response_model=RunRead)
|
@flows_router.get("/runs/{run_id}", response_model=RunRead)
|
||||||
def get_run(
|
def get_run(
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
tenant_id: str | None = Query(default=None),
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
session: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Read a run. Other tenants' runs 404 (strict session scoping)."""
|
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
|
||||||
try:
|
try:
|
||||||
run = store.get_run(session, run_id)
|
run = store.get_run(session, run_id)
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
if run.tenant_id != user.tenant_id:
|
if tenant_id is not None and run.tenant_id != tenant_id:
|
||||||
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
||||||
return _read_run(run)
|
return _read_run(run)
|
||||||
|
|
||||||
|
|
||||||
CALLBACK_TOKEN_HEADER = "X-Callback-Token"
|
|
||||||
|
|
||||||
|
|
||||||
def _check_callback_token(token: str | None) -> None:
|
|
||||||
"""LFX-served flows prove they carry our run envelope (no session there)."""
|
|
||||||
expected = settings.callback_token
|
|
||||||
if not expected:
|
|
||||||
raise HTTPException(status_code=503, detail="flow callbacks are disabled")
|
|
||||||
if not token or not secrets.compare_digest(token, expected):
|
|
||||||
raise HTTPException(status_code=401, detail="invalid callback token")
|
|
||||||
|
|
||||||
|
|
||||||
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
||||||
def create_approval(
|
def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
|
||||||
data: ApprovalCreate,
|
|
||||||
session: Annotated[Session, Depends(get_db)],
|
|
||||||
callback_token: Annotated[str | None, Header(alias=CALLBACK_TOKEN_HEADER)] = None,
|
|
||||||
):
|
|
||||||
"""Flow callback contract: report a terminal approval state for a run."""
|
"""Flow callback contract: report a terminal approval state for a run."""
|
||||||
_check_callback_token(callback_token)
|
|
||||||
try:
|
try:
|
||||||
return _read_task(store.create_approval_task(session, data))
|
return _read_task(store.create_approval_task(session, data))
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
|
|
@ -484,25 +406,20 @@ def create_approval(
|
||||||
|
|
||||||
@approvals_router.get("", response_model=list[ApprovalRead])
|
@approvals_router.get("", response_model=list[ApprovalRead])
|
||||||
def list_approvals(
|
def list_approvals(
|
||||||
session: Annotated[Session, Depends(get_db)],
|
tenant_id: str | None = Query(default=None),
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
session: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
return [_read_task(t) for t in store.list_approval_tasks(session, user.tenant_id)]
|
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
|
||||||
|
|
||||||
|
|
||||||
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
|
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
|
||||||
async def decide_approval(
|
async def decide_approval(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
data: DecisionRequest,
|
data: DecisionRequest,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Session = Depends(get_db),
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: LfxClient = Depends(get_lfx),
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
|
||||||
):
|
):
|
||||||
"""Decide an open task. Approve starts the continuation flow (if any).
|
"""Decide an open task. Approve starts the continuation flow (if any)."""
|
||||||
|
|
||||||
Identity comes from the session: the approver is the logged-in user,
|
|
||||||
and the continuation is the task's own (never client-chosen).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
task = store.get_task(session, task_id)
|
task = store.get_task(session, task_id)
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
|
|
@ -522,7 +439,7 @@ async def decide_approval(
|
||||||
session,
|
session,
|
||||||
task,
|
task,
|
||||||
decision="approved" if data.decision == "approve" else "rejected",
|
decision="approved" if data.decision == "approve" else "rejected",
|
||||||
approver=user.email,
|
approver=data.approver,
|
||||||
note=data.note,
|
note=data.note,
|
||||||
)
|
)
|
||||||
except store.ConflictError as exc:
|
except store.ConflictError as exc:
|
||||||
|
|
@ -544,8 +461,7 @@ async def decide_approval(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
tenant_id=task.tenant_id,
|
tenant_id=task.tenant_id,
|
||||||
session_id=f"continuation-of-{task.run_id}",
|
session_id=f"continuation-of-{task.run_id}",
|
||||||
callback_base_url=settings.callback_base_url,
|
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||||
callback_token=settings.callback_token,
|
|
||||||
)
|
)
|
||||||
except LfxNotFound as exc:
|
except LfxNotFound as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlmodel import Session
|
||||||
from app.models import FlowDefinition, FlowVersion
|
from app.models import FlowDefinition, FlowVersion
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
from . import export, sync
|
from . import export, store, sync
|
||||||
from .langflow_client import LangflowClient
|
from .langflow_client import LangflowClient
|
||||||
from .lfx_client import LfxClient
|
from .lfx_client import LfxClient
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ def slugify(name: str) -> str:
|
||||||
|
|
||||||
def canonical_bytes(doc: dict) -> bytes:
|
def canonical_bytes(doc: dict) -> bytes:
|
||||||
"""Deterministic serialization: stable bytes for stable git diffs."""
|
"""Deterministic serialization: stable bytes for stable git diffs."""
|
||||||
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def content_hash(doc: dict) -> str:
|
def content_hash(doc: dict) -> str:
|
||||||
|
|
@ -36,7 +38,9 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
|
||||||
return Path(flows_dir) / f"{stem}.json"
|
return Path(flows_dir) / f"{stem}.json"
|
||||||
|
|
||||||
|
|
||||||
def write_flow_file(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).
|
"""Write a flow document to backend/flows atomically (tmp + rename).
|
||||||
|
|
||||||
The filename derives from `name` (or the document's own name). Returns
|
The filename derives from `name` (or the document's own name). Returns
|
||||||
|
|
@ -46,7 +50,9 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
|
||||||
flows_dir.mkdir(parents=True, exist_ok=True)
|
flows_dir.mkdir(parents=True, exist_ok=True)
|
||||||
stem = slugify(name or doc.get("name") or "flow")
|
stem = slugify(name or doc.get("name") or "flow")
|
||||||
target = flow_path(flows_dir, stem)
|
target = flow_path(flows_dir, stem)
|
||||||
fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp")
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "wb") as tmp:
|
with os.fdopen(fd, "wb") as tmp:
|
||||||
tmp.write(canonical_bytes(doc))
|
tmp.write(canonical_bytes(doc))
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"""Derive a web-form schema from a Langflow export.
|
|
||||||
|
|
||||||
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
|
||||||
``display_name`` starting with ``CustomInput`` — see
|
|
||||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
|
||||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
|
||||||
|
|
||||||
* ``name`` → property key, ``display_name`` → ``title``,
|
|
||||||
``info`` → ``description``, ``value`` → ``default``,
|
|
||||||
``options`` → ``enum``, ``required`` → required array
|
|
||||||
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
|
||||||
form fields, and are skipped
|
|
||||||
* ``advanced`` fields render last via ``ui:order``
|
|
||||||
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
|
||||||
so the parser never crashes on new fields)
|
|
||||||
|
|
||||||
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
|
||||||
renders one free-text box instead of a generated form.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
|
||||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
|
||||||
if not isinstance(langflow_json, dict):
|
|
||||||
return []
|
|
||||||
data = langflow_json.get("data") or {}
|
|
||||||
nodes = data.get("nodes") or []
|
|
||||||
found = []
|
|
||||||
for node in nodes:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
continue
|
|
||||||
node_data = node.get("data") or {}
|
|
||||||
node_type = str(node_data.get("type") or "")
|
|
||||||
inner = node_data.get("node") or {}
|
|
||||||
display = str(inner.get("display_name") or "")
|
|
||||||
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
|
||||||
found.append(inner)
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
|
||||||
"""(JSON-Schema property, uiSchema entry) for one template field."""
|
|
||||||
input_type = str(field.get("_input_type") or "")
|
|
||||||
prop: dict = {}
|
|
||||||
ui: dict = {}
|
|
||||||
title = field.get("display_name") or name
|
|
||||||
info = field.get("info") or ""
|
|
||||||
if title:
|
|
||||||
prop["title"] = title
|
|
||||||
if info:
|
|
||||||
prop["description"] = info
|
|
||||||
|
|
||||||
options = field.get("options") or []
|
|
||||||
if input_type in ("StrInput", "MessageTextInput"):
|
|
||||||
prop["type"] = "string"
|
|
||||||
elif input_type == "MultilineInput":
|
|
||||||
prop["type"] = "string"
|
|
||||||
ui["ui:widget"] = "textarea"
|
|
||||||
elif input_type == "SecretStrInput":
|
|
||||||
prop["type"] = "string"
|
|
||||||
ui["ui:widget"] = "password"
|
|
||||||
elif input_type == "IntInput":
|
|
||||||
prop["type"] = "integer"
|
|
||||||
elif input_type == "FloatInput":
|
|
||||||
prop["type"] = "number"
|
|
||||||
elif input_type == "BoolInput":
|
|
||||||
prop["type"] = "boolean"
|
|
||||||
elif input_type == "DropdownInput":
|
|
||||||
prop["type"] = "string"
|
|
||||||
if options:
|
|
||||||
prop["enum"] = list(options)
|
|
||||||
elif input_type == "MultiselectInput":
|
|
||||||
prop["type"] = "array"
|
|
||||||
items: dict = {"type": "string"}
|
|
||||||
if options:
|
|
||||||
items["enum"] = list(options)
|
|
||||||
prop["items"] = items
|
|
||||||
prop["uniqueItems"] = True
|
|
||||||
elif input_type == "SliderInput":
|
|
||||||
prop["type"] = "number"
|
|
||||||
spec = field.get("range_spec") or {}
|
|
||||||
if spec.get("min") is not None:
|
|
||||||
prop["minimum"] = spec["min"]
|
|
||||||
if spec.get("max") is not None:
|
|
||||||
prop["maximum"] = spec["max"]
|
|
||||||
if spec.get("step"):
|
|
||||||
prop["multipleOf"] = spec["step"]
|
|
||||||
ui["ui:widget"] = "range"
|
|
||||||
elif input_type == "FileInput":
|
|
||||||
# Stub for now: string reference only, real upload comes later.
|
|
||||||
prop["type"] = "string"
|
|
||||||
else:
|
|
||||||
prop["type"] = "string"
|
|
||||||
|
|
||||||
value = field.get("value")
|
|
||||||
if value is not None:
|
|
||||||
# Whitespace-only string values are the flow generator's sentinel
|
|
||||||
# for "required, filled at runtime" (see make_flow_fixtures.py) —
|
|
||||||
# not a real default for the web form.
|
|
||||||
if isinstance(value, str) and not value.strip():
|
|
||||||
return prop, ui
|
|
||||||
# A default outside its enum would fail validation on touch.
|
|
||||||
enum = prop.get("enum")
|
|
||||||
if not (enum is not None and value not in enum):
|
|
||||||
# bool is an int subclass; keep integer defaults clean.
|
|
||||||
if prop.get("type") == "integer" and isinstance(value, bool):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
prop["default"] = value
|
|
||||||
return prop, ui
|
|
||||||
|
|
||||||
|
|
||||||
def extract_form_schema(langflow_json: dict) -> dict:
|
|
||||||
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
|
||||||
templates = _form_templates(langflow_json)
|
|
||||||
if not templates:
|
|
||||||
return {
|
|
||||||
"json_schema": {"type": "object", "properties": {}},
|
|
||||||
"ui_schema": {},
|
|
||||||
"fallback": "textarea",
|
|
||||||
}
|
|
||||||
template = templates[0].get("template") or {}
|
|
||||||
properties: dict = {}
|
|
||||||
required: list[str] = []
|
|
||||||
ui_schema: dict = {}
|
|
||||||
normal: list[str] = []
|
|
||||||
advanced: list[str] = []
|
|
||||||
for key, field in template.items():
|
|
||||||
if key == "code" or not isinstance(field, dict):
|
|
||||||
continue
|
|
||||||
if field.get("show") is False:
|
|
||||||
continue
|
|
||||||
name = field.get("name") or key
|
|
||||||
prop, ui = _field_schema(name, field)
|
|
||||||
properties[name] = prop
|
|
||||||
if field.get("required"):
|
|
||||||
required.append(name)
|
|
||||||
if ui:
|
|
||||||
ui_schema[name] = ui
|
|
||||||
(advanced if field.get("advanced") else normal).append(name)
|
|
||||||
if advanced:
|
|
||||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
|
||||||
return {
|
|
||||||
"json_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": properties,
|
|
||||||
"required": required,
|
|
||||||
},
|
|
||||||
"ui_schema": ui_schema,
|
|
||||||
}
|
|
||||||
|
|
@ -51,7 +51,9 @@ class LfxRunResult:
|
||||||
class LfxClient:
|
class LfxClient:
|
||||||
"""Thin async wrapper around the LFX serve HTTP API."""
|
"""Thin async wrapper around the LFX serve HTTP API."""
|
||||||
|
|
||||||
def __init__(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.base_url = base_url.rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,15 @@ def build_envelope(
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
callback_base_url: str,
|
callback_base_url: str,
|
||||||
continuation_flow_id: UUID | None,
|
continuation_flow_id: UUID | None,
|
||||||
callback_token: str,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
"run_id": str(run_id),
|
"run_id": str(run_id),
|
||||||
"callback_url": callback_base_url.rstrip("/"),
|
"callback_url": callback_base_url.rstrip("/"),
|
||||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
"continuation_flow_id": (
|
||||||
"callback_token": callback_token,
|
str(continuation_flow_id) if continuation_flow_id else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -36,7 +36,9 @@ def build_envelope(
|
||||||
def _task_exists(session: Session, run_id: UUID) -> bool:
|
def _task_exists(session: Session, run_id: UUID) -> bool:
|
||||||
return (
|
return (
|
||||||
session.exec(
|
session.exec(
|
||||||
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
|
select(ApprovalTask).where(
|
||||||
|
ApprovalTask.run_id == run_id, ApprovalTask.status == "open"
|
||||||
|
)
|
||||||
).first()
|
).first()
|
||||||
is not None
|
is not None
|
||||||
)
|
)
|
||||||
|
|
@ -52,7 +54,6 @@ async def execute_run(
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
callback_base_url: str,
|
callback_base_url: str,
|
||||||
continuation_flow_id: UUID | None = None,
|
continuation_flow_id: UUID | None = None,
|
||||||
callback_token: str = "",
|
|
||||||
) -> FlowRun:
|
) -> FlowRun:
|
||||||
"""Run a deployed version to a terminal state (no interrupts).
|
"""Run a deployed version to a terminal state (no interrupts).
|
||||||
|
|
||||||
|
|
@ -71,7 +72,6 @@ async def execute_run(
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
callback_base_url=callback_base_url,
|
callback_base_url=callback_base_url,
|
||||||
continuation_flow_id=continuation_flow_id,
|
continuation_flow_id=continuation_flow_id,
|
||||||
callback_token=callback_token,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await lfx.run_flow(
|
result = await lfx.run_flow(
|
||||||
|
|
@ -80,7 +80,9 @@ async def execute_run(
|
||||||
session_id=session_id or str(run.id),
|
session_id=session_id or str(run.id),
|
||||||
)
|
)
|
||||||
except LfxRunFailed as exc:
|
except LfxRunFailed as exc:
|
||||||
return store.set_run_result(session, run, "failed", {"error": str(exc)})
|
return store.set_run_result(
|
||||||
|
session, run, "failed", {"error": str(exc)}
|
||||||
|
)
|
||||||
output = {
|
output = {
|
||||||
"result": result.result,
|
"result": result.result,
|
||||||
"component": result.component,
|
"component": result.component,
|
||||||
|
|
|
||||||
|
|
@ -37,31 +37,19 @@ class FlowVersionRead(BaseModel):
|
||||||
deployed_at: datetime | None
|
deployed_at: datetime | None
|
||||||
author: str
|
author: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
input_schema: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class InputSchemaRead(BaseModel):
|
|
||||||
"""Form schema of the latest deployed version (RJSF renders it)."""
|
|
||||||
|
|
||||||
flow_id: UUID
|
|
||||||
version_no: int
|
|
||||||
json_schema: dict
|
|
||||||
ui_schema: dict = Field(default_factory=dict)
|
|
||||||
# Set when the flow has no CustomInput node: render a free-text box.
|
|
||||||
fallback: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
"""Start a flow run. `input` is the domain payload handed to the flow.
|
"""Start a flow run. `input` is the domain payload handed to the flow."""
|
||||||
|
|
||||||
Tenant, callback URL and continuation come from the server (session /
|
|
||||||
settings / task), never from the caller — they must not be
|
|
||||||
client-controllable (SSRF / cross-flow chaining).
|
|
||||||
"""
|
|
||||||
|
|
||||||
input: dict = Field(default_factory=dict)
|
input: dict = Field(default_factory=dict)
|
||||||
version_no: int | None = None
|
version_no: int | None = None
|
||||||
|
tenant_id: str = Field(default="default", max_length=200)
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
|
# Base URL the flow uses for callbacks (e.g. approval tasks).
|
||||||
|
# Defaults to settings.callback_base_url; tests point it at a live server.
|
||||||
|
callback_base_url: str | None = None
|
||||||
|
continuation_flow_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class RunRead(BaseModel):
|
class RunRead(BaseModel):
|
||||||
|
|
@ -115,10 +103,10 @@ class FlowImportRead(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class DecisionRequest(BaseModel):
|
class DecisionRequest(BaseModel):
|
||||||
"""Decide a task. Identity comes from the session, not the body."""
|
|
||||||
|
|
||||||
decision: str = Field(pattern="^(approve|reject)$")
|
decision: str = Field(pattern="^(approve|reject)$")
|
||||||
|
approver: str = Field(default="", max_length=200)
|
||||||
note: str = Field(default="", max_length=2000)
|
note: str = Field(default="", max_length=2000)
|
||||||
|
callback_base_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class DecisionRead(BaseModel):
|
class DecisionRead(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -10,7 +10,6 @@ from sqlmodel import Session, func, select
|
||||||
|
|
||||||
from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||||
|
|
||||||
from . import input_schema as form_schema
|
|
||||||
from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate
|
from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,10 +26,9 @@ class InvalidError(Exception):
|
||||||
|
|
||||||
|
|
||||||
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
|
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
|
||||||
if (
|
if data.continuation_flow_id is not None and session.get(
|
||||||
data.continuation_flow_id is not None
|
FlowDefinition, data.continuation_flow_id
|
||||||
and session.get(FlowDefinition, data.continuation_flow_id) is None
|
) is None:
|
||||||
):
|
|
||||||
raise InvalidError("continuation_flow_id unknown")
|
raise InvalidError("continuation_flow_id unknown")
|
||||||
definition = FlowDefinition(
|
definition = FlowDefinition(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
|
|
@ -58,14 +56,18 @@ def list_definitions(session: Session) -> list[FlowDefinition]:
|
||||||
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
|
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
|
||||||
|
|
||||||
|
|
||||||
def get_definition_by_source_path(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."""
|
"""Find the definition synced from a given backend/flows filename."""
|
||||||
return session.exec(
|
return session.exec(
|
||||||
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
|
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|
||||||
def set_definition_name(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."""
|
"""Rename a definition and record its source file. Name clashes 409."""
|
||||||
definition.name = name
|
definition.name = name
|
||||||
session.add(definition)
|
session.add(definition)
|
||||||
|
|
@ -85,22 +87,32 @@ def next_version_no(session: Session, flow_id: UUID) -> int:
|
||||||
return (max_no or 0) + 1
|
return (max_no or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
def delete_definition_cascade(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).
|
"""Remove a definition and its whole subtree (tasks, runs, versions).
|
||||||
|
|
||||||
Used when the canonical file disappears: the directory is the source of
|
Used when the canonical file disappears: the directory is the source of
|
||||||
truth, so the derived registry follows. Commits once.
|
truth, so the derived registry follows. Commits once.
|
||||||
"""
|
"""
|
||||||
version_ids = list(
|
version_ids = list(
|
||||||
session.exec(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
|
task_count = 0
|
||||||
run_count = 0
|
run_count = 0
|
||||||
if version_ids:
|
if version_ids:
|
||||||
tasks = list(
|
tasks = list(
|
||||||
session.exec(select(ApprovalTask).where(ApprovalTask.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()
|
||||||
)
|
)
|
||||||
runs = list(session.exec(select(FlowRun).where(FlowRun.version_id.in_(version_ids))).all())
|
|
||||||
task_count, run_count = len(tasks), len(runs)
|
task_count, run_count = len(tasks), len(runs)
|
||||||
for row in (*tasks, *runs):
|
for row in (*tasks, *runs):
|
||||||
session.delete(row)
|
session.delete(row)
|
||||||
|
|
@ -129,7 +141,9 @@ def validate_flow_json(flow_json: dict) -> None:
|
||||||
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
|
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
|
||||||
|
|
||||||
|
|
||||||
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
|
def create_version(
|
||||||
|
session: Session, flow_id: UUID, data: FlowVersionCreate
|
||||||
|
) -> FlowVersion:
|
||||||
get_definition(session, flow_id)
|
get_definition(session, flow_id)
|
||||||
validate_flow_json(data.langflow_json)
|
validate_flow_json(data.langflow_json)
|
||||||
max_no = session.exec(
|
max_no = session.exec(
|
||||||
|
|
@ -140,9 +154,6 @@ def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) ->
|
||||||
version_no=(max_no or 0) + 1,
|
version_no=(max_no or 0) + 1,
|
||||||
langflow_json=data.langflow_json,
|
langflow_json=data.langflow_json,
|
||||||
author=data.author,
|
author=data.author,
|
||||||
# Single choke point: sync, import, and API creation all cache the
|
|
||||||
# form schema here, so reads never re-parse.
|
|
||||||
input_schema=form_schema.extract_form_schema(data.langflow_json),
|
|
||||||
)
|
)
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -192,7 +203,7 @@ def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion:
|
||||||
|
|
||||||
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
|
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
|
||||||
version.lfx_flow_id = lfx_flow_id
|
version.lfx_flow_id = lfx_flow_id
|
||||||
version.deployed_at = datetime.now(UTC)
|
version.deployed_at = datetime.now(timezone.utc)
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(version)
|
session.refresh(version)
|
||||||
|
|
@ -219,10 +230,12 @@ def create_run(
|
||||||
return 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.status = status
|
||||||
run.output = output
|
run.output = output
|
||||||
run.finished_at = datetime.now(UTC)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
session.add(run)
|
session.add(run)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(run)
|
session.refresh(run)
|
||||||
|
|
@ -236,7 +249,9 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
|
||||||
return run
|
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)
|
get_definition(session, flow_id)
|
||||||
query = (
|
query = (
|
||||||
select(FlowRun)
|
select(FlowRun)
|
||||||
|
|
@ -253,10 +268,9 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
|
||||||
run = session.get(FlowRun, data.run_id)
|
run = session.get(FlowRun, data.run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
raise NotFoundError(f"run {data.run_id} not found")
|
raise NotFoundError(f"run {data.run_id} not found")
|
||||||
if (
|
if data.continuation_flow_id is not None and session.get(
|
||||||
data.continuation_flow_id is not None
|
FlowDefinition, data.continuation_flow_id
|
||||||
and session.get(FlowDefinition, data.continuation_flow_id) is None
|
) is None:
|
||||||
):
|
|
||||||
raise InvalidError("continuation_flow_id unknown")
|
raise InvalidError("continuation_flow_id unknown")
|
||||||
existing = session.exec(
|
existing = session.exec(
|
||||||
select(ApprovalTask).where(
|
select(ApprovalTask).where(
|
||||||
|
|
@ -275,7 +289,7 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
|
||||||
)
|
)
|
||||||
session.add(task)
|
session.add(task)
|
||||||
run.status = "pending_approval"
|
run.status = "pending_approval"
|
||||||
run.finished_at = datetime.now(UTC)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
session.add(run)
|
session.add(run)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(task)
|
session.refresh(task)
|
||||||
|
|
@ -312,7 +326,7 @@ def decide_task(
|
||||||
task.status = decision
|
task.status = decision
|
||||||
task.approver = approver
|
task.approver = approver
|
||||||
task.decision_note = note
|
task.decision_note = note
|
||||||
task.decided_at = datetime.now(UTC)
|
task.decided_at = datetime.now(timezone.utc)
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(task)
|
session.refresh(task)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@ def lfx_id_for(definition_id: UUID, version_no: int) -> str:
|
||||||
Same (definition, version) always maps to the same id, so re-syncs and
|
Same (definition, version) always maps to the same id, so re-syncs and
|
||||||
crash recovery overwrite instead of orphaning LFX registrations.
|
crash recovery overwrite instead of orphaning LFX registrations.
|
||||||
"""
|
"""
|
||||||
return str(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:
|
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
|
||||||
|
|
@ -70,7 +72,9 @@ async def sync_document(
|
||||||
with sync_lock:
|
with sync_lock:
|
||||||
definition = store.get_definition_by_source_path(session, source_filename)
|
definition = store.get_definition_by_source_path(session, source_filename)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
definition = store.create_definition(session, FlowDefinitionCreate(name=name))
|
definition = store.create_definition(
|
||||||
|
session, FlowDefinitionCreate(name=name)
|
||||||
|
)
|
||||||
definition.source_path = source_filename
|
definition.source_path = source_filename
|
||||||
session.add(definition)
|
session.add(definition)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -82,8 +86,10 @@ async def sync_document(
|
||||||
store.set_definition_name(session, definition, name)
|
store.set_definition_name(session, definition, name)
|
||||||
|
|
||||||
latest = _latest_version(session, definition.id)
|
latest = _latest_version(session, definition.id)
|
||||||
if latest is not None and export.content_hash(latest.langflow_json) == export.content_hash(
|
if (
|
||||||
doc
|
latest is not None
|
||||||
|
and export.content_hash(latest.langflow_json)
|
||||||
|
== export.content_hash(doc)
|
||||||
):
|
):
|
||||||
return definition, latest, "skipped"
|
return definition, latest, "skipped"
|
||||||
|
|
||||||
|
|
@ -104,9 +110,15 @@ async def sync_document(
|
||||||
FlowVersionCreate(langflow_json=doc, author=author),
|
FlowVersionCreate(langflow_json=doc, author=author),
|
||||||
)
|
)
|
||||||
if version.version_no != version_no: # pragma: no cover - lock prevents this
|
if version.version_no != version_no: # pragma: no cover - lock prevents this
|
||||||
raise store.ConflictError(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)
|
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:
|
try:
|
||||||
await lfx.delete_flow(latest.lfx_flow_id)
|
await lfx.delete_flow(latest.lfx_flow_id)
|
||||||
except LfxError as exc:
|
except LfxError as exc:
|
||||||
|
|
@ -163,8 +175,10 @@ async def sync_from_directory(
|
||||||
report.errors[filename] = "top-level JSON must be an object"
|
report.errors[filename] = "top-level JSON must be an object"
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
_, _, action = await sync_document(session, lfx, doc, source_filename=filename)
|
_, _, action = await sync_document(
|
||||||
except (store.InvalidError, store.ConflictError, store.NotFoundError, LfxError) as exc:
|
session, lfx, doc, source_filename=filename
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||||
report.errors[filename] = str(exc)
|
report.errors[filename] = str(exc)
|
||||||
continue
|
continue
|
||||||
getattr(report, action).append(path.stem)
|
getattr(report, action).append(path.stem)
|
||||||
|
|
@ -179,7 +193,7 @@ async def sync_from_directory(
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await delete_definition_tree(session, lfx, definition)
|
await delete_definition_tree(session, lfx, definition)
|
||||||
except store.NotFoundError as exc:
|
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||||
report.errors[stale] = str(exc)
|
report.errors[stale] = str(exc)
|
||||||
continue
|
continue
|
||||||
report.deleted.append(Path(stale).stem)
|
report.deleted.append(Path(stale).stem)
|
||||||
|
|
@ -191,7 +205,9 @@ def default_session_factory() -> Callable[[], Session]:
|
||||||
|
|
||||||
|
|
||||||
def default_lfx_factory() -> Callable[[], LfxClient]:
|
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(
|
def run_sync_blocking(
|
||||||
|
|
@ -230,12 +246,14 @@ async def watch_forever(
|
||||||
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
|
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(stop.wait(), timeout=30)
|
await asyncio.wait_for(stop.wait(), timeout=30)
|
||||||
except TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
return
|
return
|
||||||
logger.info("watching %s", flows_dir)
|
logger.info("watching %s", flows_dir)
|
||||||
async for _ in awatch(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():
|
if stop.is_set():
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
|
|
@ -243,7 +261,7 @@ async def watch_forever(
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
logger.error("flow sync failed: %s", exc)
|
logger.error("flow sync failed: %s", exc)
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - watcher must survive
|
||||||
logger.exception("flow sync failed")
|
logger.exception("flow sync failed")
|
||||||
continue
|
continue
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,9 @@ from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
|
|
||||||
|
|
||||||
from .auth.api import access_router, auth_router
|
|
||||||
from .flows.api import approvals_router, flows_router
|
from .flows.api import approvals_router, flows_router
|
||||||
from .flows.sync import run_sync_blocking, watch_forever
|
from .flows.sync import run_sync_blocking, watch_forever
|
||||||
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
|
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -32,8 +29,8 @@ def _blocking_startup_sync() -> None:
|
||||||
logger.warning("startup flow sync: %s: %s", filename, reason)
|
logger.warning("startup flow sync: %s: %s", filename, reason)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
logger.error("startup flow sync skipped: %s", exc)
|
logger.error("startup flow sync skipped: %s", exc)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - API must start even if sync fails
|
||||||
logger.exception("startup flow sync failed") # never fatal at boot
|
logger.exception("startup flow sync failed")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -52,7 +49,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
await task
|
await task
|
||||||
except asyncio.CancelledError:
|
except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,25 +57,14 @@ app = FastAPI(
|
||||||
title="Kirche Einfach API",
|
title="Kirche Einfach API",
|
||||||
description="KI-gestützte Verwaltungsplattform für Kirche",
|
description="KI-gestützte Verwaltungsplattform für Kirche",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
# Interactive docs only in debug: /api/docs maps the whole surface.
|
docs_url="/api/docs",
|
||||||
docs_url="/api/docs" if settings.debug else None,
|
redoc_url="/api/redoc",
|
||||||
redoc_url="/api/redoc" if settings.debug else None,
|
openapi_url="/api/openapi.json",
|
||||||
openapi_url="/api/openapi.json" if settings.debug else None,
|
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# TLS terminates at the reverse proxy: uvicorn --proxy-headers (see
|
|
||||||
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
|
|
||||||
# IPs (throttle) and absolute URLs are correct. The API is not directly
|
|
||||||
# internet-reachable (proxy-only), so trusting them is safe.
|
|
||||||
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
|
|
||||||
app.add_middleware(RunThrottleMiddleware)
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
|
||||||
|
|
||||||
app.include_router(flows_router)
|
app.include_router(flows_router)
|
||||||
app.include_router(approvals_router)
|
app.include_router(approvals_router)
|
||||||
app.include_router(access_router)
|
|
||||||
app.include_router(auth_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
"""Edge hardening: run throttling, security headers.
|
|
||||||
|
|
||||||
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
|
|
||||||
main.py). All limits are settings-driven so tests can shrink them. State
|
|
||||||
is in-process memory: correct for the single-replica SQLite deployment;
|
|
||||||
revisit with Redis if the API ever scales horizontally.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from starlette.datastructures import MutableHeaders
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
||||||
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
|
|
||||||
# per client IP and minute.
|
|
||||||
RUN_PATH_SUFFIX = "/run"
|
|
||||||
THROTTLE_WINDOW_SECONDS = 60.0
|
|
||||||
|
|
||||||
_hits: dict[str, list[float]] = defaultdict(list)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_throttle() -> None:
|
|
||||||
"""Drop all counters (tests)."""
|
|
||||||
_hits.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
|
||||||
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
|
|
||||||
# scope["client"] from X-Forwarded-For.
|
|
||||||
if request.client is not None:
|
|
||||||
return request.client.host
|
|
||||||
forwarded = request.headers.get("x-forwarded-for", "")
|
|
||||||
return forwarded.split(",")[0].strip() or "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class RunThrottleMiddleware:
|
|
||||||
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
|
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
if (
|
|
||||||
scope["type"] != "http"
|
|
||||||
or scope.get("method") != "POST"
|
|
||||||
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
|
|
||||||
):
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
request = Request(scope)
|
|
||||||
now = time.monotonic()
|
|
||||||
window_start = now - THROTTLE_WINDOW_SECONDS
|
|
||||||
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
|
|
||||||
if len(bucket) >= settings.run_throttle_per_minute:
|
|
||||||
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
|
|
||||||
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
|
|
||||||
response.headers["Retry-After"] = str(retry_after)
|
|
||||||
await response(scope, receive, send)
|
|
||||||
return
|
|
||||||
bucket.append(now)
|
|
||||||
_hits[_client_ip(request)] = bucket
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
|
|
||||||
|
|
||||||
class SecurityHeadersMiddleware:
|
|
||||||
"""Baseline response headers (TLS/HSTS live on the proxy)."""
|
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def secured(message: Message) -> None:
|
|
||||||
if message["type"] == "http.response.start":
|
|
||||||
headers = MutableHeaders(raw=message.setdefault("headers", []))
|
|
||||||
headers["X-Content-Type-Options"] = "nosniff"
|
|
||||||
headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
||||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
||||||
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
|
||||||
await send(message)
|
|
||||||
|
|
||||||
await self.app(scope, receive, secured)
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||||
from .user import AccessRequest, MagicLinkToken, User, UserSession
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"]
|
||||||
"AccessRequest",
|
|
||||||
"ApprovalTask",
|
|
||||||
"FlowDefinition",
|
|
||||||
"FlowRun",
|
|
||||||
"FlowVersion",
|
|
||||||
"MagicLinkToken",
|
|
||||||
"User",
|
|
||||||
"UserSession",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Flow engine domain models (SQLModel tables)."""
|
"""Flow engine domain models (SQLModel tables)."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
|
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
return datetime.now(UTC)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _ts_column() -> Column:
|
def _ts_column() -> Column:
|
||||||
|
|
@ -33,7 +33,9 @@ class FlowDefinition(SQLModel, table=True):
|
||||||
# (e.g. "simple_rule.json"). None for definitions created via the API.
|
# (e.g. "simple_rule.json"). None for definitions created via the API.
|
||||||
source_path: str | None = Field(default=None, max_length=255)
|
source_path: str | None = Field(default=None, max_length=255)
|
||||||
# Flow to run after a human approval on this flow's runs (nullable).
|
# Flow to run after a human approval on this flow's runs (nullable).
|
||||||
continuation_flow_id: UUID | None = Field(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())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,9 +54,6 @@ class FlowVersion(SQLModel, table=True):
|
||||||
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
||||||
author: str = Field(default="", max_length=200)
|
author: str = Field(default="", max_length=200)
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||||
# Cached form schema for this exact JSON (see app/flows/input_schema.py).
|
|
||||||
# Computed at version creation; nullable for pre-Phase-3 rows.
|
|
||||||
input_schema: dict | None = Field(default=None, sa_column=_json_column())
|
|
||||||
|
|
||||||
|
|
||||||
class FlowRun(SQLModel, table=True):
|
class FlowRun(SQLModel, table=True):
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
"""Access-management domain models (SQLModel tables)."""
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime
|
|
||||||
from sqlmodel import Field, SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _ts_column() -> Column:
|
|
||||||
return Column(DateTime(timezone=True))
|
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
|
||||||
"""A person with API access. Role is member or admin."""
|
|
||||||
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
email: str = Field(index=True, unique=True, max_length=320)
|
|
||||||
name: str = Field(default="", max_length=200)
|
|
||||||
# member | admin
|
|
||||||
role: str = Field(default="member", max_length=20)
|
|
||||||
tenant_id: str = Field(default="default", max_length=200)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
|
||||||
|
|
||||||
|
|
||||||
class AccessRequest(SQLModel, table=True):
|
|
||||||
"""A public request for access; an admin approves or rejects it."""
|
|
||||||
|
|
||||||
__tablename__ = "access_requests"
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
name: str = Field(max_length=200)
|
|
||||||
email: str = Field(index=True, max_length=320)
|
|
||||||
reason: str = Field(default="", max_length=2000)
|
|
||||||
# pending | approved | rejected
|
|
||||||
status: str = Field(default="pending", index=True, max_length=20)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
|
||||||
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
|
||||||
decided_by: str | None = Field(default=None, max_length=320)
|
|
||||||
|
|
||||||
|
|
||||||
class MagicLinkToken(SQLModel, table=True):
|
|
||||||
"""A single-use 15-minute login token. Only the sha256 is stored."""
|
|
||||||
|
|
||||||
__tablename__ = "magic_link_tokens"
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
|
||||||
token_hash: str = Field(index=True, unique=True, max_length=64)
|
|
||||||
expires_at: datetime = Field(sa_column=_ts_column())
|
|
||||||
# Set before the session row is created, so a reused token can never
|
|
||||||
# mint a second session.
|
|
||||||
used_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
|
||||||
|
|
||||||
|
|
||||||
class UserSession(SQLModel, table=True):
|
|
||||||
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
|
|
||||||
|
|
||||||
__tablename__ = "user_sessions"
|
|
||||||
|
|
||||||
id: str = Field(primary_key=True, max_length=128)
|
|
||||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
|
||||||
expires_at: datetime = Field(sa_column=_ts_column())
|
|
||||||
revoked_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
|
||||||
|
|
@ -14,9 +14,7 @@ class Settings(BaseSettings):
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|
||||||
# App database (SQLite file). Override via DATABASE_URL for other engines.
|
# App database (SQLite file). Override via DATABASE_URL for other engines.
|
||||||
# Must be a URL (not a bare path): db.py and Alembic hand it to
|
database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db")
|
||||||
# create_engine, which rejects bare paths.
|
|
||||||
database_url: str = f"sqlite:///{REPO_ROOT / 'backend' / 'data' / 'kirche.db'}"
|
|
||||||
|
|
||||||
# Canonical flow definitions (source of truth): one Langflow export per file.
|
# Canonical flow definitions (source of truth): one Langflow export per file.
|
||||||
flows_dir: str = str(REPO_ROOT / "backend" / "flows")
|
flows_dir: str = str(REPO_ROOT / "backend" / "flows")
|
||||||
|
|
@ -31,31 +29,6 @@ class Settings(BaseSettings):
|
||||||
# Base URL flows use for callbacks into this API. Must be reachable
|
# Base URL flows use for callbacks into this API. Must be reachable
|
||||||
# FROM the LFX process (compose: http://api:8000).
|
# FROM the LFX process (compose: http://api:8000).
|
||||||
callback_base_url: str = "http://localhost:8000"
|
callback_base_url: str = "http://localhost:8000"
|
||||||
# Shared secret that authenticates flow callbacks (POST /api/approvals).
|
|
||||||
# The runner injects it into every run envelope; LFX-served flows send
|
|
||||||
# it back as X-Callback-Token. Empty disables callbacks (503) — set a
|
|
||||||
# long random value in prod (.env CALLBACK_TOKEN).
|
|
||||||
callback_token: str = ""
|
|
||||||
# Edge hardening (see app/middleware.py).
|
|
||||||
max_body_bytes: int = 2 * 1024 * 1024
|
|
||||||
run_throttle_per_minute: int = 60
|
|
||||||
|
|
||||||
# --- access management (magic-link auth, see app/auth) ---
|
|
||||||
# Public frontend URL (magic links point at /login?token= there).
|
|
||||||
frontend_url: str = "http://localhost:3000"
|
|
||||||
# Comma-separated emails that become admins (role assignment + bootstrap).
|
|
||||||
admin_emails: str = ""
|
|
||||||
# Reply-To on outgoing mail; also receives access-request notifications.
|
|
||||||
contact_to: str = ""
|
|
||||||
mail_from: str = ""
|
|
||||||
smtp_host: str = ""
|
|
||||||
smtp_port: int = 587
|
|
||||||
smtp_username: str = ""
|
|
||||||
smtp_password: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def admin_email_list(self) -> list[str]:
|
|
||||||
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# Authoring web forms (CustomInputForm)
|
|
||||||
|
|
||||||
Flows that should render a web form start with a `CustomInputForm` node
|
|
||||||
(this package — the `kirche` category in the Langflow palette). The API
|
|
||||||
derives a JSON Schema + uiSchema from the node's template
|
|
||||||
(`app/flows/input_schema.py`) and the webapp renders it with RJSF.
|
|
||||||
|
|
||||||
## Adding a new form field
|
|
||||||
|
|
||||||
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
|
||||||
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
|
||||||
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
|
|
||||||
one web-form field. Add an `lfx.io` input, e.g.
|
|
||||||
```python
|
|
||||||
from lfx.io import DropdownInput
|
|
||||||
|
|
||||||
DropdownInput(
|
|
||||||
name="room", # JSON-Schema property key
|
|
||||||
display_name="Room", # form label (title)
|
|
||||||
info="Where should it take place", # help text (description)
|
|
||||||
options=["Chapel", "Hall"], # dropdown choices (enum)
|
|
||||||
value="Chapel", # prefill (default)
|
|
||||||
required=True, # marks the field required
|
|
||||||
)
|
|
||||||
```
|
|
||||||
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
|
|
||||||
`MultilineInput` → textarea, `SecretStrInput` → password,
|
|
||||||
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
|
|
||||||
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
|
||||||
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
|
||||||
(real upload later).
|
|
||||||
3. Add the new field to `collect()` so it reaches the JSON output.
|
|
||||||
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
|
||||||
input, `show=False`). Submitted web-form values arrive there as
|
|
||||||
`{"payload": {...}}` and are merged over the defaults. Never make form
|
|
||||||
fields required *with* an incoming edge — that's the envelope's job.
|
|
||||||
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
|
||||||
The API watcher syncs + deploys it automatically; the form schema is
|
|
||||||
regenerated per version.
|
|
||||||
|
|
||||||
## Conventions the pipeline relies on
|
|
||||||
|
|
||||||
- `required=True` on a template field: `lfx validate --level 4` demands a
|
|
||||||
value or an edge, so the canonical-flow generator
|
|
||||||
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
|
||||||
sentinel value for such fields. The form parser treats whitespace-only
|
|
||||||
values as "no default" — don't ship real whitespace defaults.
|
|
||||||
- Field key = `name` (stable); label = `display_name`; help = `info`.
|
|
||||||
- Unknown future input types degrade to a plain text field in the web
|
|
||||||
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
|
||||||
|
|
||||||
## Regenerating the bundled fixtures
|
|
||||||
|
|
||||||
After editing the component source (`form_inputs.py`), refresh the
|
|
||||||
canonical + test-fixture exports:
|
|
||||||
|
|
||||||
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
|
||||||
docker compose exec -T api uv run pytest tests/test_lfx_validate.py
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
"""Kirche form-input components (Langflow category ``kirche``).
|
|
||||||
|
|
||||||
GUI-editable by design: plain :class:`lfx.custom.Component` subclasses with
|
|
||||||
``lfx.io`` inputs only (stdlib + ``lfx``), so both the Langflow authoring UI
|
|
||||||
and the LFX runtime can load them. The backend parses the exported node
|
|
||||||
template into a web form (``app/flows/input_schema.py``); the frontend
|
|
||||||
renders that schema with RJSF.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .form_inputs import CustomInputForm
|
|
||||||
|
|
||||||
__all__ = ["CustomInputForm"]
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
"""Structured form-input component for kirche-einfach flows.
|
|
||||||
|
|
||||||
A flow that starts with :class:`CustomInputForm` declares the fields of its
|
|
||||||
web form. The API reads the exported node template
|
|
||||||
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from
|
|
||||||
the ``lfx.io`` input definitions below — one JSON-Schema property per field,
|
|
||||||
keyed by ``name``. Field metadata maps as follows:
|
|
||||||
|
|
||||||
* ``display_name`` → ``title``, ``info`` → ``description``,
|
|
||||||
``value`` → ``default``, ``options`` → ``enum``
|
|
||||||
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
|
|
||||||
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
|
|
||||||
|
|
||||||
``FileInput`` is a stub for now: it carries a string reference only, real
|
|
||||||
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
|
|
||||||
from lfx.io import (
|
|
||||||
BoolInput,
|
|
||||||
DropdownInput,
|
|
||||||
FileInput,
|
|
||||||
FloatInput,
|
|
||||||
IntInput,
|
|
||||||
MessageTextInput,
|
|
||||||
MultilineInput,
|
|
||||||
MultiselectInput,
|
|
||||||
Output,
|
|
||||||
SecretStrInput,
|
|
||||||
SliderInput,
|
|
||||||
StrInput,
|
|
||||||
)
|
|
||||||
from lfx.schema.message import Message
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if hasattr(value, "text"):
|
|
||||||
return value.text or ""
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomInputForm(Component):
|
|
||||||
display_name = "CustomInput Form"
|
|
||||||
description = "Collects structured form values into a single JSON message."
|
|
||||||
icon = "ClipboardList"
|
|
||||||
|
|
||||||
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
|
|
||||||
# entry node and override the defaults below. Hidden in the GUI and
|
|
||||||
# skipped by the form parser (show=False).
|
|
||||||
MessageTextInput(
|
|
||||||
name="form_values",
|
|
||||||
display_name="Form values (JSON envelope)",
|
|
||||||
info="Submitted values as JSON; merged over the defaults",
|
|
||||||
value="",
|
|
||||||
show=False,
|
|
||||||
),
|
|
||||||
StrInput(
|
|
||||||
name="full_name",
|
|
||||||
display_name="Full name",
|
|
||||||
info="Your first and last name",
|
|
||||||
value="",
|
|
||||||
required=True,
|
|
||||||
),
|
|
||||||
MultilineInput(
|
|
||||||
name="notes",
|
|
||||||
display_name="Notes",
|
|
||||||
info="Anything else we should know",
|
|
||||||
value="",
|
|
||||||
),
|
|
||||||
SecretStrInput(
|
|
||||||
name="password",
|
|
||||||
display_name="Password",
|
|
||||||
info="Optional access phrase",
|
|
||||||
value="",
|
|
||||||
),
|
|
||||||
IntInput(
|
|
||||||
name="age",
|
|
||||||
display_name="Age",
|
|
||||||
info="Your age in years",
|
|
||||||
value=18,
|
|
||||||
),
|
|
||||||
FloatInput(
|
|
||||||
name="amount",
|
|
||||||
display_name="Amount",
|
|
||||||
info="Amount in euros",
|
|
||||||
value=0.0,
|
|
||||||
),
|
|
||||||
BoolInput(
|
|
||||||
name="newsletter",
|
|
||||||
display_name="Newsletter",
|
|
||||||
info="Send me the parish newsletter",
|
|
||||||
value=False,
|
|
||||||
),
|
|
||||||
DropdownInput(
|
|
||||||
name="department",
|
|
||||||
display_name="Department",
|
|
||||||
info="Which team is this for",
|
|
||||||
options=["Music", "Youth", "Seniors", "Admin"],
|
|
||||||
value="Music",
|
|
||||||
),
|
|
||||||
MultiselectInput(
|
|
||||||
name="topics",
|
|
||||||
display_name="Topics",
|
|
||||||
info="What are you interested in",
|
|
||||||
options=["Worship", "Outreach", "Courses"],
|
|
||||||
value=[],
|
|
||||||
),
|
|
||||||
SliderInput(
|
|
||||||
name="satisfaction",
|
|
||||||
display_name="Satisfaction",
|
|
||||||
info="How satisfied are you (1-5)",
|
|
||||||
value=3,
|
|
||||||
range_spec=RangeSpec(min=1, max=5, step=1),
|
|
||||||
),
|
|
||||||
FileInput(
|
|
||||||
name="attachment",
|
|
||||||
display_name="Attachment",
|
|
||||||
info="String reference only for now (no upload yet)",
|
|
||||||
file_types=["pdf", "png", "jpg"],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
outputs: ClassVar[list] = [
|
|
||||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def collect(self) -> Message:
|
|
||||||
data = {
|
|
||||||
"full_name": _text(self.full_name),
|
|
||||||
"notes": _text(self.notes),
|
|
||||||
"password": _text(self.password),
|
|
||||||
"age": self.age,
|
|
||||||
"amount": self.amount,
|
|
||||||
"newsletter": self.newsletter,
|
|
||||||
"department": _text(self.department),
|
|
||||||
"topics": self.topics,
|
|
||||||
"satisfaction": self.satisfaction,
|
|
||||||
"attachment": _text(self.attachment),
|
|
||||||
}
|
|
||||||
# Submitted values (via form_values) win over the field defaults.
|
|
||||||
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
|
||||||
try:
|
|
||||||
override = json.loads(_text(self.form_values) or "{}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
override = {}
|
|
||||||
if isinstance(override, dict):
|
|
||||||
if isinstance(override.get("payload"), dict):
|
|
||||||
override = override["payload"]
|
|
||||||
for key in data:
|
|
||||||
if key in override:
|
|
||||||
data[key] = override[key]
|
|
||||||
return Message(text=json.dumps(data, ensure_ascii=False))
|
|
||||||
|
|
@ -6,24 +6,10 @@ set -e
|
||||||
|
|
||||||
cd /srv/kirche-einfach/backend
|
cd /srv/kirche-einfach/backend
|
||||||
|
|
||||||
# Runtime SQLite file lives in the bind-mount: ensure the dir exists and is
|
.venv/bin/alembic upgrade head
|
||||||
# owned by vscode (the entrypoint runs as root, but the editor runs as
|
|
||||||
# vscode — a root-owned data/ dir made the DB unwritable).
|
|
||||||
mkdir -p data
|
|
||||||
if id vscode >/dev/null 2>&1; then
|
|
||||||
chown -R vscode:vscode data 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
/opt/venv/bin/alembic upgrade head
|
|
||||||
|
|
||||||
# Migrate ran as root: hand the (possibly just created) DB files back to
|
|
||||||
# vscode so editor-run commands stay writable.
|
|
||||||
if id vscode >/dev/null 2>&1; then
|
|
||||||
chown -R vscode:vscode data 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${APP_ENV:-prod}" = "dev" ]; then
|
if [ "${APP_ENV:-prod}" = "dev" ]; then
|
||||||
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers
|
exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
else
|
else
|
||||||
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers
|
exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -399,20 +399,6 @@
|
||||||
"allows_loop": false,
|
"allows_loop": false,
|
||||||
"group_outputs": false,
|
"group_outputs": false,
|
||||||
"tool_mode": true
|
"tool_mode": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"types": [
|
|
||||||
"Message"
|
|
||||||
],
|
|
||||||
"selected": "Message",
|
|
||||||
"name": "callback_token",
|
|
||||||
"display_name": "Callback Token",
|
|
||||||
"method": "get_callback_token",
|
|
||||||
"value": "__UNDEFINED__",
|
|
||||||
"cache": true,
|
|
||||||
"allows_loop": false,
|
|
||||||
"group_outputs": false,
|
|
||||||
"tool_mode": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
|
@ -423,7 +409,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.envelope",
|
"module": "custom_components.envelope",
|
||||||
"code_hash": "02e69613e210",
|
"code_hash": "2b0bd3c3d529",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 1,
|
"total_dependencies": 1,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -872,32 +858,6 @@
|
||||||
"node": {
|
"node": {
|
||||||
"template": {
|
"template": {
|
||||||
"_type": "Component",
|
"_type": "Component",
|
||||||
"callback_token": {
|
|
||||||
"tool_mode": false,
|
|
||||||
"trace_as_input": true,
|
|
||||||
"trace_as_metadata": true,
|
|
||||||
"load_from_db": false,
|
|
||||||
"list": false,
|
|
||||||
"list_add_label": "Add More",
|
|
||||||
"override_skip": false,
|
|
||||||
"required": false,
|
|
||||||
"placeholder": "",
|
|
||||||
"show": true,
|
|
||||||
"name": "callback_token",
|
|
||||||
"value": "",
|
|
||||||
"display_name": "Callback Token",
|
|
||||||
"advanced": false,
|
|
||||||
"api_editable": false,
|
|
||||||
"input_types": [
|
|
||||||
"Message"
|
|
||||||
],
|
|
||||||
"dynamic": false,
|
|
||||||
"info": "",
|
|
||||||
"title_case": false,
|
|
||||||
"track_in_telemetry": false,
|
|
||||||
"type": "str",
|
|
||||||
"_input_type": "MessageTextInput"
|
|
||||||
},
|
|
||||||
"callback_url": {
|
"callback_url": {
|
||||||
"tool_mode": false,
|
"tool_mode": false,
|
||||||
"trace_as_input": true,
|
"trace_as_input": true,
|
||||||
|
|
@ -931,7 +891,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -1080,7 +1040,6 @@
|
||||||
"trigger",
|
"trigger",
|
||||||
"run_id",
|
"run_id",
|
||||||
"callback_url",
|
"callback_url",
|
||||||
"callback_token",
|
|
||||||
"continuation_flow_id",
|
"continuation_flow_id",
|
||||||
"facts"
|
"facts"
|
||||||
],
|
],
|
||||||
|
|
@ -1089,7 +1048,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.approval_callback",
|
"module": "custom_components.approval_callback",
|
||||||
"code_hash": "28b2f5894566",
|
"code_hash": "a334376410f7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 2,
|
"total_dependencies": 2,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -1845,34 +1804,6 @@
|
||||||
"target": "CustomComponent-callback",
|
"target": "CustomComponent-callback",
|
||||||
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"animated": false,
|
|
||||||
"className": "",
|
|
||||||
"data": {
|
|
||||||
"sourceHandle": {
|
|
||||||
"dataType": "CustomComponent",
|
|
||||||
"id": "CustomComponent-envelope",
|
|
||||||
"name": "callback_token",
|
|
||||||
"output_types": [
|
|
||||||
"Message"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"targetHandle": {
|
|
||||||
"fieldName": "callback_token",
|
|
||||||
"id": "CustomComponent-callback",
|
|
||||||
"inputTypes": [
|
|
||||||
"Message"
|
|
||||||
],
|
|
||||||
"type": "str"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
|
|
||||||
"selected": false,
|
|
||||||
"source": "CustomComponent-envelope",
|
|
||||||
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
|
|
||||||
"target": "CustomComponent-callback",
|
|
||||||
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"animated": false,
|
"animated": false,
|
||||||
"className": "",
|
"className": "",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
|
|
@ -14,10 +16,9 @@ if config.config_file_name is not None:
|
||||||
|
|
||||||
# add your model's MetaData object here
|
# add your model's MetaData object here
|
||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
from sqlmodel import SQLModel
|
from app.models import * # noqa: F401,F403 - register all tables
|
||||||
|
|
||||||
from app.models import *
|
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
target_metadata = SQLModel.metadata
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
|
@ -69,7 +70,9 @@ def run_migrations_online() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
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():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
|
||||||
|
|
@ -5,129 +5,104 @@ Revises:
|
||||||
Create Date: 2026-09-06 22:27:07.129788
|
Create Date: 2026-09-06 22:27:07.129788
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from alembic import op
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlmodel
|
import sqlmodel
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "62ea9a5d8f9d"
|
revision: str = '62ea9a5d8f9d'
|
||||||
down_revision: str | Sequence[str] | None = None
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table(
|
op.create_table('flow_definitions',
|
||||||
"flow_definitions",
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
sa.Column("id", sa.Uuid(), nullable=False),
|
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), 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("description", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
|
||||||
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ),
|
||||||
sa.ForeignKeyConstraint(
|
sa.PrimaryKeyConstraint('id')
|
||||||
["continuation_flow_id"],
|
|
||||||
["flow_definitions.id"],
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_flow_definitions_name"), "flow_definitions", ["name"], unique=True)
|
op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True)
|
||||||
op.create_table(
|
op.create_table('flow_versions',
|
||||||
"flow_versions",
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
sa.Column("id", sa.Uuid(), nullable=False),
|
sa.Column('flow_id', sa.Uuid(), nullable=False),
|
||||||
sa.Column("flow_id", sa.Uuid(), nullable=False),
|
sa.Column('version_no', sa.Integer(), nullable=False),
|
||||||
sa.Column("version_no", sa.Integer(), nullable=False),
|
sa.Column('langflow_json', sa.JSON(), nullable=True),
|
||||||
sa.Column("langflow_json", sa.JSON(), nullable=True),
|
sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), 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("deployed_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||||
sa.Column("author", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ),
|
||||||
sa.ForeignKeyConstraint(
|
sa.PrimaryKeyConstraint('id'),
|
||||||
["flow_id"],
|
sa.UniqueConstraint('flow_id', 'version_no')
|
||||||
["flow_definitions.id"],
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("flow_id", "version_no"),
|
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_flow_versions_flow_id"), "flow_versions", ["flow_id"], unique=False)
|
op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False)
|
||||||
op.create_index(
|
op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False)
|
||||||
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_table(
|
op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False)
|
||||||
"flow_runs",
|
op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False)
|
||||||
sa.Column("id", sa.Uuid(), nullable=False),
|
op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False)
|
||||||
sa.Column("version_id", sa.Uuid(), nullable=False),
|
op.create_table('approval_tasks',
|
||||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
|
sa.Column('run_id', sa.Uuid(), nullable=False),
|
||||||
sa.Column("input", sa.JSON(), nullable=True),
|
sa.Column('version_id', sa.Uuid(), nullable=False),
|
||||||
sa.Column("output", sa.JSON(), nullable=True),
|
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||||
sa.Column("session_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column('payload', sa.JSON(), nullable=True),
|
||||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
|
||||||
sa.ForeignKeyConstraint(
|
sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||||
["version_id"],
|
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||||
["flow_versions.id"],
|
sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||||
),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ),
|
||||||
op.create_index(op.f("ix_flow_runs_status"), "flow_runs", ["status"], unique=False)
|
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
|
||||||
op.create_index(op.f("ix_flow_runs_tenant_id"), "flow_runs", ["tenant_id"], unique=False)
|
sa.PrimaryKeyConstraint('id')
|
||||||
op.create_index(op.f("ix_flow_runs_version_id"), "flow_runs", ["version_id"], unique=False)
|
|
||||||
op.create_table(
|
|
||||||
"approval_tasks",
|
|
||||||
sa.Column("id", sa.Uuid(), nullable=False),
|
|
||||||
sa.Column("run_id", sa.Uuid(), nullable=False),
|
|
||||||
sa.Column("version_id", sa.Uuid(), nullable=False),
|
|
||||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
|
||||||
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
|
||||||
sa.Column("payload", sa.JSON(), nullable=True),
|
|
||||||
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
|
|
||||||
sa.Column("approver", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
|
||||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
|
||||||
sa.Column("decision_note", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["run_id"],
|
|
||||||
["flow_runs.id"],
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["version_id"],
|
|
||||||
["flow_versions.id"],
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_approval_tasks_run_id"), "approval_tasks", ["run_id"], unique=False)
|
|
||||||
op.create_index(op.f("ix_approval_tasks_status"), "approval_tasks", ["status"], unique=False)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_approval_tasks_tenant_id"), "approval_tasks", ["tenant_id"], unique=False
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_approval_tasks_version_id"), "approval_tasks", ["version_id"], unique=False
|
|
||||||
)
|
)
|
||||||
|
op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_index(op.f("ix_approval_tasks_version_id"), table_name="approval_tasks")
|
op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks')
|
||||||
op.drop_index(op.f("ix_approval_tasks_tenant_id"), table_name="approval_tasks")
|
op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks')
|
||||||
op.drop_index(op.f("ix_approval_tasks_status"), table_name="approval_tasks")
|
op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks')
|
||||||
op.drop_index(op.f("ix_approval_tasks_run_id"), table_name="approval_tasks")
|
op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks')
|
||||||
op.drop_table("approval_tasks")
|
op.drop_table('approval_tasks')
|
||||||
op.drop_index(op.f("ix_flow_runs_version_id"), table_name="flow_runs")
|
op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs')
|
||||||
op.drop_index(op.f("ix_flow_runs_tenant_id"), table_name="flow_runs")
|
op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs')
|
||||||
op.drop_index(op.f("ix_flow_runs_status"), table_name="flow_runs")
|
op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs')
|
||||||
op.drop_table("flow_runs")
|
op.drop_table('flow_runs')
|
||||||
op.drop_index(op.f("ix_flow_versions_version_no"), table_name="flow_versions")
|
op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions')
|
||||||
op.drop_index(op.f("ix_flow_versions_flow_id"), table_name="flow_versions")
|
op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions')
|
||||||
op.drop_table("flow_versions")
|
op.drop_table('flow_versions')
|
||||||
op.drop_index(op.f("ix_flow_definitions_name"), table_name="flow_definitions")
|
op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions')
|
||||||
op.drop_table("flow_definitions")
|
op.drop_table('flow_definitions')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
"""auth tables (users, access requests, magic links, sessions)
|
|
||||||
|
|
||||||
Revision ID: a3f1c9e2b4d7
|
|
||||||
Revises: d932b6993f9a
|
|
||||||
Create Date: 2026-09-11 15:30:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import sqlmodel
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
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_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_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_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")
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"""cache form schema per flow version
|
|
||||||
|
|
||||||
Revision ID: b7e2d4a1c9f3
|
|
||||||
Revises: a3f1c9e2b4d7
|
|
||||||
Create Date: 2026-09-11 20:30:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
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: str | Sequence[str] | None = "a3f1c9e2b4d7"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
op.add_column(
|
|
||||||
"flow_versions",
|
|
||||||
sa.Column("input_schema", sa.JSON(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
op.drop_column("flow_versions", "input_schema")
|
|
||||||
|
|
@ -5,18 +5,18 @@ Revises: 62ea9a5d8f9d
|
||||||
Create Date: 2026-09-07 08:40:48.986026
|
Create Date: 2026-09-07 08:40:48.986026
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from alembic import op
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "d932b6993f9a"
|
revision: str = 'd932b6993f9a'
|
||||||
down_revision: str | Sequence[str] | None = "62ea9a5d8f9d"
|
down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d'
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -27,22 +27,9 @@ testpaths = ["tests"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
# Pinned to the Langflow minor (contract: major.minor must match).
|
"lfx",
|
||||||
# Bump together with LANGFLOW_VERSION (.env.example) + Dockerfile.lfx.
|
|
||||||
"lfx~=1.12.0",
|
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
"pytest-cov>=6",
|
"pytest-cov>=6",
|
||||||
"respx>=0.22",
|
"respx>=0.22",
|
||||||
"ruff>=0.16,<0.17",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py312"
|
|
||||||
line-length = 100
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
# Default rule set minus EXE: Docker Desktop bind-mounts report every file as
|
|
||||||
# executable (git index stays 100644), so EXE002 would fire on the dev mount
|
|
||||||
# but never on Linux CI. Everything else is enforced as ruff's default.
|
|
||||||
ignore = ["EXE002"]
|
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
"""Validate a prod `.env` file before (re)deploying.
|
|
||||||
|
|
||||||
Usage (from backend/ or repo root):
|
|
||||||
uv run python scripts/check_prod.py --env ../.env
|
|
||||||
uv run python scripts/check_prod.py # defaults to repo-root .env
|
|
||||||
|
|
||||||
Fails (exit 1) on missing/weak secrets and insecure modes; warns on
|
|
||||||
soft misconfigurations. Safe to run anywhere: reads one file, no imports
|
|
||||||
from app (stdlib only), prints no secret values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BACKEND = Path(__file__).resolve().parent.parent
|
|
||||||
REPO_ROOT = BACKEND.parent
|
|
||||||
|
|
||||||
MIN_TOKEN = 32
|
|
||||||
MIN_PASSWORD = 16
|
|
||||||
MIN_API_KEY = 16
|
|
||||||
|
|
||||||
|
|
||||||
def parse_env(path: Path) -> dict[str, str]:
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for line in path.read_text().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip().strip("'\"")
|
|
||||||
if key.startswith("export "):
|
|
||||||
key = key[len("export ") :].strip()
|
|
||||||
values[key] = value
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
|
|
||||||
"""(failures, warnings) for a parsed env file."""
|
|
||||||
failures: list[str] = []
|
|
||||||
warnings: list[str] = []
|
|
||||||
|
|
||||||
def secret(name: str, minimum: int, what: str) -> None:
|
|
||||||
value = values.get(name, "")
|
|
||||||
if not value:
|
|
||||||
failures.append(f"{name} is empty — generate one: openssl rand -hex 32")
|
|
||||||
elif len(value) < minimum:
|
|
||||||
failures.append(f"{name} too short ({len(value)} < {minimum} chars)")
|
|
||||||
|
|
||||||
def placeholder(name: str, prefixes: tuple[str, ...]) -> None:
|
|
||||||
value = values.get(name, "")
|
|
||||||
if value and any(value == p or value.startswith(p) for p in prefixes):
|
|
||||||
failures.append(f"{name} still holds an example placeholder")
|
|
||||||
|
|
||||||
# Shared + service API keys.
|
|
||||||
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
|
|
||||||
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
|
|
||||||
placeholder("LANGFLOW_API_KEY", ("sk-",))
|
|
||||||
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
|
|
||||||
placeholder("LFX_API_KEY", ("lfx-",))
|
|
||||||
# Langflow's own auth (internet-facing authoring UI).
|
|
||||||
secret("LANGFLOW_SUPERUSER_PASSWORD", MIN_PASSWORD, "langflow superuser password")
|
|
||||||
secret("LANGFLOW_SECRET_KEY", MIN_TOKEN, "langflow secret key")
|
|
||||||
|
|
||||||
# Insecure modes.
|
|
||||||
if values.get("FRONTEND_MODE", "preview") == "dev":
|
|
||||||
failures.append("FRONTEND_MODE=dev serves the Vite dev server — use preview in prod")
|
|
||||||
if values.get("APP_ENV", "prod") == "dev":
|
|
||||||
failures.append("APP_ENV=dev enables autoreload — unset it in prod")
|
|
||||||
if values.get("LANGFLOW_AUTO_LOGIN", "false").lower() == "true":
|
|
||||||
failures.append("LANGFLOW_AUTO_LOGIN=true allows passwordless superuser access")
|
|
||||||
|
|
||||||
# Soft misconfigurations.
|
|
||||||
if not values.get("ADMIN_EMAILS", ""):
|
|
||||||
warnings.append("ADMIN_EMAILS empty — nobody can bootstrap an admin account")
|
|
||||||
if values.get("SMTP_HOST", "") and not values.get("SMTP_PASSWORD", ""):
|
|
||||||
warnings.append("SMTP_HOST without SMTP_PASSWORD — magic-link mail will fail")
|
|
||||||
if not values.get("CONTACT_TO", ""):
|
|
||||||
warnings.append("CONTACT_TO empty — access-request notifications go nowhere")
|
|
||||||
frontend_url = values.get("FRONTEND_URL", "")
|
|
||||||
if frontend_url and not frontend_url.startswith("https://"):
|
|
||||||
warnings.append("FRONTEND_URL is not https — cookies and tokens travel in cleartext")
|
|
||||||
|
|
||||||
return failures, warnings
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
|
|
||||||
args = parser.parse_args()
|
|
||||||
path = Path(args.env)
|
|
||||||
if not path.is_file():
|
|
||||||
print(f"no env file at {path}")
|
|
||||||
return 1
|
|
||||||
failures, warnings = check(parse_env(path))
|
|
||||||
for message in warnings:
|
|
||||||
print(f"WARN: {message}")
|
|
||||||
for message in failures:
|
|
||||||
print(f"FAIL: {message}")
|
|
||||||
if failures:
|
|
||||||
print(f"{len(failures)} blocking issue(s) — fix before deploying")
|
|
||||||
return 1
|
|
||||||
print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else ""))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -87,77 +87,6 @@ def custom_node(
|
||||||
return node_dict
|
return node_dict
|
||||||
|
|
||||||
|
|
||||||
def kirche_node(
|
|
||||||
node_id: str,
|
|
||||||
values: dict | None = None,
|
|
||||||
position: tuple[int, int] = (0, 0),
|
|
||||||
) -> dict:
|
|
||||||
"""A node from our GUI-editable category lib (backend/components/kirche).
|
|
||||||
|
|
||||||
Built with lfx's own frontend-node serialization, exactly like
|
|
||||||
``Component.to_frontend_node()`` — minus its final ``Template.from_dict``
|
|
||||||
round-trip: lfx 1.12.x cannot re-instantiate ``MultiselectInput`` (it
|
|
||||||
emits ``list``, the constructor forbids extras; their own builtins fail
|
|
||||||
the same way). The template dict before that round-trip is complete and
|
|
||||||
faithful, so we use it directly and append the ``code`` field by hand
|
|
||||||
(mirroring the shape ``build_custom_component_template`` produces).
|
|
||||||
"""
|
|
||||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
|
||||||
from kirche.form_inputs import CustomInputForm
|
|
||||||
from lfx.template.frontend_node.custom_components import (
|
|
||||||
ComponentFrontendNode,
|
|
||||||
)
|
|
||||||
|
|
||||||
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
|
|
||||||
inst = CustomInputForm()
|
|
||||||
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)
|
|
||||||
inst._map_parameters_on_template(node["template"])
|
|
||||||
if not getattr(inst, "_code", None):
|
|
||||||
inst.set_class_code()
|
|
||||||
node["template"]["code"] = {
|
|
||||||
"advanced": True,
|
|
||||||
"api_editable": False,
|
|
||||||
"dynamic": True,
|
|
||||||
"fileTypes": [],
|
|
||||||
"info": "",
|
|
||||||
"list": False,
|
|
||||||
"load_from_db": False,
|
|
||||||
"multiline": True,
|
|
||||||
"name": "code",
|
|
||||||
"password": False,
|
|
||||||
"placeholder": "",
|
|
||||||
"required": True,
|
|
||||||
"show": True,
|
|
||||||
"title_case": False,
|
|
||||||
"type": "code",
|
|
||||||
"value": inst._code,
|
|
||||||
}
|
|
||||||
node_dict = {
|
|
||||||
"data": {"node": node, "type": comp_name, "id": node_id},
|
|
||||||
"id": node_id,
|
|
||||||
}
|
|
||||||
node_dict = _normalize(node_dict, node_id, comp_name, *position)
|
|
||||||
for field, value in (values or {}).items():
|
|
||||||
node_dict["data"]["node"]["template"][field]["value"] = value
|
|
||||||
# lfx validate --level 4 errors on required inputs with no value and no
|
|
||||||
# incoming edge. Our form fields are filled at runtime via the
|
|
||||||
# form_values envelope (collect() merges), never via edges — so required
|
|
||||||
# template fields carry a single-space sentinel value. The form parser
|
|
||||||
# (app/flows/input_schema.py) treats whitespace-only values as "no
|
|
||||||
# default", keeping the web form clean.
|
|
||||||
for field_def in node_dict["data"]["node"]["template"].values():
|
|
||||||
if (
|
|
||||||
isinstance(field_def, dict)
|
|
||||||
and field_def.get("required")
|
|
||||||
and field_def.get("value") in (None, "", [], {})
|
|
||||||
):
|
|
||||||
field_def["value"] = " "
|
|
||||||
return node_dict
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# edges
|
# edges
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -206,7 +135,9 @@ def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# flow document
|
# 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 {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
|
|
@ -230,7 +161,7 @@ def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict])
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# custom component code
|
# custom component code
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
RULE_CHECK_CODE = """from lfx.custom import Component
|
RULE_CHECK_CODE = '''from lfx.custom import Component
|
||||||
from lfx.io import MessageTextInput, Output
|
from lfx.io import MessageTextInput, Output
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.message import Message
|
||||||
import json
|
import json
|
||||||
|
|
@ -274,9 +205,9 @@ class RuleCheckComponent(Component):
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
schwelle = 200000.0
|
schwelle = 200000.0
|
||||||
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
|
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
|
||||||
"""
|
'''
|
||||||
|
|
||||||
ENVELOPE_CODE = """from lfx.custom import Component
|
ENVELOPE_CODE = '''from lfx.custom import Component
|
||||||
from lfx.io import MessageTextInput, Output
|
from lfx.io import MessageTextInput, Output
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.message import Message
|
||||||
import json
|
import json
|
||||||
|
|
@ -302,11 +233,6 @@ class EnvelopeComponent(Component):
|
||||||
name="continuation",
|
name="continuation",
|
||||||
method="get_continuation",
|
method="get_continuation",
|
||||||
),
|
),
|
||||||
Output(
|
|
||||||
display_name="Callback Token",
|
|
||||||
name="callback_token",
|
|
||||||
method="get_callback_token",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def _text(self, value) -> str:
|
def _text(self, value) -> str:
|
||||||
|
|
@ -338,12 +264,9 @@ class EnvelopeComponent(Component):
|
||||||
def get_continuation(self) -> Message:
|
def get_continuation(self) -> Message:
|
||||||
value = self._env().get("continuation_flow_id")
|
value = self._env().get("continuation_flow_id")
|
||||||
return Message(text=str(value) if value else "")
|
return Message(text=str(value) if value else "")
|
||||||
|
'''
|
||||||
|
|
||||||
def get_callback_token(self) -> Message:
|
APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
|
||||||
return Message(text=str(self._env().get("callback_token", "")))
|
|
||||||
"""
|
|
||||||
|
|
||||||
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
|
|
||||||
from lfx.io import MessageTextInput, Output
|
from lfx.io import MessageTextInput, Output
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.message import Message
|
||||||
|
|
||||||
|
|
@ -356,10 +279,6 @@ class ApprovalCallbackComponent(Component):
|
||||||
MessageTextInput(name="trigger", display_name="Trigger"),
|
MessageTextInput(name="trigger", display_name="Trigger"),
|
||||||
MessageTextInput(name="run_id", display_name="Run ID"),
|
MessageTextInput(name="run_id", display_name="Run ID"),
|
||||||
MessageTextInput(name="callback_url", display_name="Callback URL"),
|
MessageTextInput(name="callback_url", display_name="Callback URL"),
|
||||||
MessageTextInput(
|
|
||||||
name="callback_token",
|
|
||||||
display_name="Callback Token",
|
|
||||||
),
|
|
||||||
MessageTextInput(
|
MessageTextInput(
|
||||||
name="continuation_flow_id",
|
name="continuation_flow_id",
|
||||||
display_name="Continuation Flow ID",
|
display_name="Continuation Flow ID",
|
||||||
|
|
@ -396,14 +315,13 @@ class ApprovalCallbackComponent(Component):
|
||||||
"payload": {"verdict": trigger, "facts": facts},
|
"payload": {"verdict": trigger, "facts": facts},
|
||||||
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
|
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
|
||||||
}
|
}
|
||||||
headers = {"X-Callback-Token": self._text(self.callback_token)}
|
|
||||||
try:
|
try:
|
||||||
response = httpx.post(url, json=body, headers=headers, timeout=10.0)
|
response = httpx.post(url, json=body, timeout=10.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return Message(text=f"CALLBACK_FAILED: {exc}")
|
return Message(text=f"CALLBACK_FAILED: {exc}")
|
||||||
return Message(text="PENDING_HUMAN_APPROVAL")
|
return Message(text="PENDING_HUMAN_APPROVAL")
|
||||||
"""
|
'''
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -466,7 +384,9 @@ def build_approval_handoff() -> dict:
|
||||||
"ChatInput-entry",
|
"ChatInput-entry",
|
||||||
position=(0, 300),
|
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))
|
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
|
||||||
router = builtin_node(
|
router = builtin_node(
|
||||||
"lfx.components.flow_controls.conditional_router",
|
"lfx.components.flow_controls.conditional_router",
|
||||||
|
|
@ -500,7 +420,6 @@ def build_approval_handoff() -> dict:
|
||||||
edge(router, "true_result", callback, "trigger"),
|
edge(router, "true_result", callback, "trigger"),
|
||||||
edge(envelope, "run_id", callback, "run_id"),
|
edge(envelope, "run_id", callback, "run_id"),
|
||||||
edge(envelope, "callback_url", callback, "callback_url"),
|
edge(envelope, "callback_url", callback, "callback_url"),
|
||||||
edge(envelope, "callback_token", callback, "callback_token"),
|
|
||||||
edge(envelope, "continuation", callback, "continuation_flow_id"),
|
edge(envelope, "continuation", callback, "continuation_flow_id"),
|
||||||
edge(envelope, "payload", callback, "facts"),
|
edge(envelope, "payload", callback, "facts"),
|
||||||
edge(callback, "result", out_pending, "input_value"),
|
edge(callback, "result", out_pending, "input_value"),
|
||||||
|
|
@ -514,43 +433,12 @@ def build_approval_handoff() -> dict:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_intake_form() -> dict:
|
|
||||||
entry = builtin_node(
|
|
||||||
"lfx.components.input_output.chat",
|
|
||||||
"ChatInput",
|
|
||||||
"ChatInput-entry",
|
|
||||||
position=(0, 200),
|
|
||||||
)
|
|
||||||
form = kirche_node(
|
|
||||||
"CustomInputForm-intake",
|
|
||||||
position=(400, 200),
|
|
||||||
)
|
|
||||||
out = builtin_node(
|
|
||||||
"lfx.components.input_output.chat_output",
|
|
||||||
"ChatOutput",
|
|
||||||
"ChatOutput-collected",
|
|
||||||
position=(800, 200),
|
|
||||||
)
|
|
||||||
nodes = [entry, form, out]
|
|
||||||
edges = [
|
|
||||||
edge(entry, "message", form, "form_values"),
|
|
||||||
edge(form, "form_data", out, "input_value"),
|
|
||||||
]
|
|
||||||
return flow_doc(
|
|
||||||
"intake_form",
|
|
||||||
"Structured intake via CustomInputForm (parsed into a web form).",
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
flows = {
|
flows = {
|
||||||
"simple_rule.json": build_simple_rule(),
|
"simple_rule.json": build_simple_rule(),
|
||||||
"approval_handoff.json": build_approval_handoff(),
|
"approval_handoff.json": build_approval_handoff(),
|
||||||
"intake_form.json": build_intake_form(),
|
|
||||||
}
|
}
|
||||||
for filename, doc in flows.items():
|
for filename, doc in flows.items():
|
||||||
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
|
||||||
|
|
@ -10,31 +10,18 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BACKEND = Path(__file__).resolve().parent.parent
|
BACKEND = Path(__file__).resolve().parent.parent
|
||||||
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
|
VENV_BIN = BACKEND / ".venv" / "bin"
|
||||||
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
|
|
||||||
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
|
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
|
||||||
|
|
||||||
|
LFX_PORT = 6004
|
||||||
def _free_port() -> int:
|
API_PORT = 8000
|
||||||
# Ephemeral ports: the dev API/LFX may already occupy the defaults
|
|
||||||
# (e.g. when smoke runs inside the api container itself).
|
|
||||||
with socket.socket() as s:
|
|
||||||
s.bind(("127.0.0.1", 0))
|
|
||||||
return s.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
LFX_PORT = _free_port()
|
|
||||||
API_PORT = _free_port()
|
|
||||||
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
|
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,9 +41,6 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
|
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
|
||||||
# Management + runs are login-gated; the smoke admin session is
|
|
||||||
# planted directly in the throwaway DB (see main()).
|
|
||||||
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
|
|
||||||
r = c.request(method, path, json=body)
|
r = c.request(method, path, json=body)
|
||||||
try:
|
try:
|
||||||
return r.status_code, r.json()
|
return r.status_code, r.json()
|
||||||
|
|
@ -74,7 +58,9 @@ def main() -> None:
|
||||||
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
|
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
|
||||||
flow_dir = Path("/tmp/smoke-e2e-flows")
|
flow_dir = Path("/tmp/smoke-e2e-flows")
|
||||||
flow_dir.mkdir(exist_ok=True)
|
flow_dir.mkdir(exist_ok=True)
|
||||||
(flow_dir / "seed.json").write_text((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.
|
# Migrate a throwaway SQLite schema to head before starting the API.
|
||||||
from alembic import command as alembic_command
|
from alembic import command as alembic_command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
@ -83,36 +69,8 @@ def main() -> None:
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
|
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
|
||||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||||
# migrations/env.py takes the URL from settings (not the ini value):
|
|
||||||
# point this process at the throwaway DB before migrating.
|
|
||||||
from app.settings import settings as app_settings
|
|
||||||
|
|
||||||
app_settings.database_url = db_url
|
|
||||||
alembic_command.upgrade(cfg, "head")
|
alembic_command.upgrade(cfg, "head")
|
||||||
|
|
||||||
# Plant an admin session: management + runs require login, and there is
|
|
||||||
# no SMTP in smoke to deliver a magic link.
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from sqlmodel import Session, create_engine
|
|
||||||
|
|
||||||
from app.models import User, UserSession
|
|
||||||
|
|
||||||
engine = create_engine(db_url)
|
|
||||||
with Session(engine) as s:
|
|
||||||
user = User(name="smoke", email="smoke@example.com", role="admin")
|
|
||||||
s.add(user)
|
|
||||||
s.flush()
|
|
||||||
s.add(
|
|
||||||
UserSession(
|
|
||||||
id="smoke-session",
|
|
||||||
user_id=user.id,
|
|
||||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
s.commit()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
|
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
|
||||||
|
|
@ -123,29 +81,16 @@ def main() -> None:
|
||||||
"FLOWS_WATCH_ENABLED": "false",
|
"FLOWS_WATCH_ENABLED": "false",
|
||||||
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
|
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
|
||||||
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
|
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
|
||||||
"CALLBACK_TOKEN": "smoke-callback-token",
|
|
||||||
}
|
}
|
||||||
lfx = subprocess.Popen(
|
lfx = subprocess.Popen(
|
||||||
[
|
[str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1",
|
||||||
str(VENV_BIN / "lfx"),
|
"--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)],
|
||||||
"serve",
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env,
|
||||||
"--host",
|
|
||||||
"127.0.0.1",
|
|
||||||
"--port",
|
|
||||||
str(LFX_PORT),
|
|
||||||
"--flow-dir",
|
|
||||||
str(flow_dir),
|
|
||||||
str(flow_dir),
|
|
||||||
],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
uvicorn = subprocess.Popen(
|
uvicorn = subprocess.Popen(
|
||||||
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
|
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1",
|
||||||
cwd=str(BACKEND),
|
"--port", str(API_PORT)],
|
||||||
stdout=subprocess.DEVNULL,
|
cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
@ -161,8 +106,7 @@ def main() -> None:
|
||||||
|
|
||||||
fx = json.loads((FIXTURES / "simple_rule.json").read_text())
|
fx = json.loads((FIXTURES / "simple_rule.json").read_text())
|
||||||
s, ver = api(
|
s, ver = api(
|
||||||
"POST",
|
"POST", f"/api/flows/{fid}/versions",
|
||||||
f"/api/flows/{fid}/versions",
|
|
||||||
{"langflow_json": fx, "author": "smoke"},
|
{"langflow_json": fx, "author": "smoke"},
|
||||||
)
|
)
|
||||||
check("create version", s == 201, f"v{ver.get('version_no')}")
|
check("create version", s == 201, f"v{ver.get('version_no')}")
|
||||||
|
|
@ -171,25 +115,17 @@ def main() -> None:
|
||||||
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
|
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
|
||||||
|
|
||||||
s, run = api(
|
s, run = api(
|
||||||
"POST",
|
"POST", f"/api/flows/{fid}/run",
|
||||||
f"/api/flows/{fid}/run",
|
|
||||||
{"input": {"betrag": 120000, "schwelle": 200000}},
|
{"input": {"betrag": 120000, "schwelle": 200000}},
|
||||||
)
|
)
|
||||||
check(
|
check("run standard", s == 200 and run.get("status") == "completed",
|
||||||
"run standard",
|
json.dumps(run.get("output"))[:120])
|
||||||
s == 200 and run.get("status") == "completed",
|
|
||||||
json.dumps(run.get("output"))[:120],
|
|
||||||
)
|
|
||||||
|
|
||||||
s, run2 = api(
|
s, run2 = api(
|
||||||
"POST",
|
"POST", f"/api/flows/{fid}/run",
|
||||||
f"/api/flows/{fid}/run",
|
|
||||||
{"input": {"betrag": 999000, "schwelle": 200000}},
|
{"input": {"betrag": 999000, "schwelle": 200000}},
|
||||||
)
|
)
|
||||||
check(
|
check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""))
|
||||||
"run sonderfall",
|
|
||||||
s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
s, tasks = api("GET", "/api/approvals")
|
s, tasks = api("GET", "/api/approvals")
|
||||||
check("approvals empty (no callback flow)", s == 200 and tasks == [])
|
check("approvals empty (no callback flow)", s == 200 and tasks == [])
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from sqlmodel import Session
|
||||||
|
|
||||||
from app.db import get_engine, reset_engine
|
from app.db import get_engine, reset_engine
|
||||||
from app.flows import export, sync
|
from app.flows import export, sync
|
||||||
from app.flows.langflow_client import LangflowClient, LangflowError
|
from app.flows.langflow_client import LangflowClient
|
||||||
from app.flows.lfx_client import LfxClient
|
from app.flows.lfx_client import LfxClient
|
||||||
from app.flows.schemas import SyncReport
|
from app.flows.schemas import SyncReport
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
@ -38,7 +38,7 @@ async def pull_from_langflow(flows_dir: Path) -> dict:
|
||||||
name = doc.get("name") or flow_id
|
name = doc.get("name") or flow_id
|
||||||
path = export.write_flow_file(flows_dir, doc, name=name)
|
path = export.write_flow_file(flows_dir, doc, name=name)
|
||||||
pulled.append(path.stem)
|
pulled.append(path.stem)
|
||||||
except (LangflowError, OSError, ValueError) as exc:
|
except Exception as exc: # noqa: BLE001 - pull-all is total
|
||||||
errors[flow_id] = str(exc)
|
errors[flow_id] = str(exc)
|
||||||
return {"pulled": sorted(pulled), "errors": errors}
|
return {"pulled": sorted(pulled), "errors": errors}
|
||||||
|
|
||||||
|
|
@ -73,11 +73,12 @@ async def main_async(args) -> int:
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument(
|
parser.add_argument("--pull", action="store_true",
|
||||||
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
|
help="export all Langflow flows to backend/flows first")
|
||||||
)
|
parser.add_argument("--sync", action="store_true",
|
||||||
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
|
help="reconcile backend/flows into DB + LFX")
|
||||||
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
|
parser.add_argument("--dir", default=None,
|
||||||
|
help="flows directory (default: settings.flows_dir)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if not args.pull and not args.sync:
|
if not args.pull and not args.sync:
|
||||||
args.sync = True
|
args.sync = True
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
|
||||||
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
||||||
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
||||||
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
|
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
|
||||||
# Flow callbacks (POST /api/approvals) authenticate with this token.
|
|
||||||
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
|
|
||||||
# The embedded watcher + startup sync stay off in tests; sync is exercised
|
# The embedded watcher + startup sync stay off in tests; sync is exercised
|
||||||
# directly (sync_from_directory) with per-test tmp dirs instead.
|
# directly (sync_from_directory) with per-test tmp dirs instead.
|
||||||
os.environ["FLOWS_WATCH_ENABLED"] = "false"
|
os.environ["FLOWS_WATCH_ENABLED"] = "false"
|
||||||
|
|
@ -83,48 +81,6 @@ async def client():
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def login(client, session):
|
|
||||||
"""Log the test client in as a fresh user: ``await login()`` (admin)
|
|
||||||
or ``await login(role="member", tenant_id="pfarramt-a")``."""
|
|
||||||
|
|
||||||
async def _login(
|
|
||||||
email: str = "admin@example.com",
|
|
||||||
role: str = "admin",
|
|
||||||
tenant_id: str = "default",
|
|
||||||
):
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from app.auth.deps import SESSION_COOKIE
|
|
||||||
from app.models import User, UserSession
|
|
||||||
|
|
||||||
# Idempotent: re-login updates the existing row (tests switch
|
|
||||||
# identity mid-test by calling login() again).
|
|
||||||
user = session.exec(select(User).where(User.email == email)).first()
|
|
||||||
if user is None:
|
|
||||||
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
|
|
||||||
session.add(user)
|
|
||||||
else:
|
|
||||||
user.role = role
|
|
||||||
user.tenant_id = tenant_id
|
|
||||||
session.add(user)
|
|
||||||
session.flush()
|
|
||||||
user_session = UserSession(
|
|
||||||
id=f"test-session-{uuid4().hex}",
|
|
||||||
user_id=user.id,
|
|
||||||
expires_at=datetime.now(UTC) + timedelta(days=1),
|
|
||||||
)
|
|
||||||
session.add(user_session)
|
|
||||||
session.commit()
|
|
||||||
client.cookies.set(SESSION_COOKIE, user_session.id)
|
|
||||||
return user
|
|
||||||
|
|
||||||
return _login
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def lfx_server(tmp_path_factory):
|
def lfx_server(tmp_path_factory):
|
||||||
"""Real `lfx serve` subprocess on a free port; points settings at it.
|
"""Real `lfx serve` subprocess on a free port; points settings at it.
|
||||||
|
|
@ -151,7 +107,9 @@ def lfx_server(tmp_path_factory):
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
flow_dir = tmp_path_factory.mktemp("lfx-flows")
|
flow_dir = tmp_path_factory.mktemp("lfx-flows")
|
||||||
seed = (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)
|
(flow_dir / "seed.json").write_text(seed)
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -399,20 +399,6 @@
|
||||||
"allows_loop": false,
|
"allows_loop": false,
|
||||||
"group_outputs": false,
|
"group_outputs": false,
|
||||||
"tool_mode": true
|
"tool_mode": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"types": [
|
|
||||||
"Message"
|
|
||||||
],
|
|
||||||
"selected": "Message",
|
|
||||||
"name": "callback_token",
|
|
||||||
"display_name": "Callback Token",
|
|
||||||
"method": "get_callback_token",
|
|
||||||
"value": "__UNDEFINED__",
|
|
||||||
"cache": true,
|
|
||||||
"allows_loop": false,
|
|
||||||
"group_outputs": false,
|
|
||||||
"tool_mode": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
|
@ -423,7 +409,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.envelope",
|
"module": "custom_components.envelope",
|
||||||
"code_hash": "02e69613e210",
|
"code_hash": "2b0bd3c3d529",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 1,
|
"total_dependencies": 1,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -872,32 +858,6 @@
|
||||||
"node": {
|
"node": {
|
||||||
"template": {
|
"template": {
|
||||||
"_type": "Component",
|
"_type": "Component",
|
||||||
"callback_token": {
|
|
||||||
"tool_mode": false,
|
|
||||||
"trace_as_input": true,
|
|
||||||
"trace_as_metadata": true,
|
|
||||||
"load_from_db": false,
|
|
||||||
"list": false,
|
|
||||||
"list_add_label": "Add More",
|
|
||||||
"override_skip": false,
|
|
||||||
"required": false,
|
|
||||||
"placeholder": "",
|
|
||||||
"show": true,
|
|
||||||
"name": "callback_token",
|
|
||||||
"value": "",
|
|
||||||
"display_name": "Callback Token",
|
|
||||||
"advanced": false,
|
|
||||||
"api_editable": false,
|
|
||||||
"input_types": [
|
|
||||||
"Message"
|
|
||||||
],
|
|
||||||
"dynamic": false,
|
|
||||||
"info": "",
|
|
||||||
"title_case": false,
|
|
||||||
"track_in_telemetry": false,
|
|
||||||
"type": "str",
|
|
||||||
"_input_type": "MessageTextInput"
|
|
||||||
},
|
|
||||||
"callback_url": {
|
"callback_url": {
|
||||||
"tool_mode": false,
|
"tool_mode": false,
|
||||||
"trace_as_input": true,
|
"trace_as_input": true,
|
||||||
|
|
@ -931,7 +891,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -1080,7 +1040,6 @@
|
||||||
"trigger",
|
"trigger",
|
||||||
"run_id",
|
"run_id",
|
||||||
"callback_url",
|
"callback_url",
|
||||||
"callback_token",
|
|
||||||
"continuation_flow_id",
|
"continuation_flow_id",
|
||||||
"facts"
|
"facts"
|
||||||
],
|
],
|
||||||
|
|
@ -1089,7 +1048,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.approval_callback",
|
"module": "custom_components.approval_callback",
|
||||||
"code_hash": "28b2f5894566",
|
"code_hash": "a334376410f7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 2,
|
"total_dependencies": 2,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -1845,34 +1804,6 @@
|
||||||
"target": "CustomComponent-callback",
|
"target": "CustomComponent-callback",
|
||||||
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"animated": false,
|
|
||||||
"className": "",
|
|
||||||
"data": {
|
|
||||||
"sourceHandle": {
|
|
||||||
"dataType": "CustomComponent",
|
|
||||||
"id": "CustomComponent-envelope",
|
|
||||||
"name": "callback_token",
|
|
||||||
"output_types": [
|
|
||||||
"Message"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"targetHandle": {
|
|
||||||
"fieldName": "callback_token",
|
|
||||||
"id": "CustomComponent-callback",
|
|
||||||
"inputTypes": [
|
|
||||||
"Message"
|
|
||||||
],
|
|
||||||
"type": "str"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
|
|
||||||
"selected": false,
|
|
||||||
"source": "CustomComponent-envelope",
|
|
||||||
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
|
|
||||||
"target": "CustomComponent-callback",
|
|
||||||
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"animated": false,
|
"animated": false,
|
||||||
"className": "",
|
"className": "",
|
||||||
|
|
|
||||||
991
backend/tests/fixtures/flows/intake_form.json
vendored
|
|
@ -30,70 +30,51 @@ async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def _handoff_task(client, live_server, monkeypatch):
|
async def _handoff_task(client, live_server, continuation_flow_id=None):
|
||||||
"""Run approval_handoff over threshold; returns (run, task)."""
|
"""Run approval_handoff over threshold; returns (run, task)."""
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
||||||
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
||||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||||
body = {"input": {"betrag": 250000, "schwelle": 200000}}
|
body = {
|
||||||
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
|
"input": {"betrag": 250000, "schwelle": 200000},
|
||||||
|
"callback_base_url": live_server,
|
||||||
|
}
|
||||||
|
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()
|
||||||
assert run["status"] == "pending_approval"
|
assert run["status"] == "pending_approval"
|
||||||
tasks = (await client.get("/api/approvals")).json()
|
tasks = (await client.get("/api/approvals")).json()
|
||||||
task = next(t for t in tasks if t["run_id"] == run["id"])
|
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||||
return run, task
|
return run, task
|
||||||
|
|
||||||
|
|
||||||
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
|
async def test_approve_without_continuation(client, lfx_server, live_server):
|
||||||
await login()
|
_, task = await _handoff_task(client, live_server)
|
||||||
_, task = await _handoff_task(client, live_server, monkeypatch)
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task['id']}/decision",
|
f"/api/approvals/{task['id']}/decision",
|
||||||
json={"decision": "approve", "note": "ok"},
|
json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["task"]["status"] == "approved"
|
assert body["task"]["status"] == "approved"
|
||||||
assert body["task"]["approver"] == "admin@example.com"
|
assert body["task"]["approver"] == "sachbearbeiter"
|
||||||
assert body["task"]["decided_at"] is not None
|
assert body["task"]["decided_at"] is not None
|
||||||
assert body["continuation_run"] is None
|
assert body["continuation_run"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_approve_with_continuation_runs_facts(
|
async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server):
|
||||||
client, lfx_server, live_server, login, monkeypatch
|
|
||||||
):
|
|
||||||
await login()
|
|
||||||
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
||||||
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
||||||
|
|
||||||
# Continuation is authoring-time wiring on the definition (admin-only);
|
_, task = await _handoff_task(
|
||||||
# run bodies cannot set it.
|
client, live_server, continuation_flow_id=cont["id"]
|
||||||
response = await client.post(
|
|
||||||
"/api/flows",
|
|
||||||
json={"name": f"handoff-{uuid4().hex[:8]}", "continuation_flow_id": cont["id"]},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 201, response.text
|
|
||||||
flow = response.json()
|
|
||||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
|
||||||
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
||||||
run = (
|
|
||||||
await client.post(
|
|
||||||
f"/api/flows/{flow['id']}/run",
|
|
||||||
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
|
||||||
)
|
|
||||||
).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"])
|
|
||||||
assert task["continuation_flow_id"] == cont["id"]
|
assert task["continuation_flow_id"] == cont["id"]
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task['id']}/decision",
|
f"/api/approvals/{task['id']}/decision",
|
||||||
json={"decision": "approve"},
|
json={"decision": "approve", "approver": "chef"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
@ -106,51 +87,21 @@ async def test_approve_with_continuation_runs_facts(
|
||||||
assert "Sonderfall" in cont_run["output"]["result"]
|
assert "Sonderfall" in cont_run["output"]["result"]
|
||||||
|
|
||||||
|
|
||||||
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
|
async def test_reject_task(client, lfx_server, live_server):
|
||||||
"""A run body cannot chain arbitrary flows (G)."""
|
_, task = await _handoff_task(client, live_server)
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
await login()
|
|
||||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
||||||
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
|
||||||
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
|
||||||
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
|
||||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
|
||||||
run = (
|
|
||||||
await client.post(
|
|
||||||
f"/api/flows/{flow['id']}/run",
|
|
||||||
json={
|
|
||||||
"input": {"betrag": 250000, "schwelle": 200000},
|
|
||||||
"continuation_flow_id": cont["id"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).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"])
|
|
||||||
assert task["continuation_flow_id"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
|
|
||||||
await login()
|
|
||||||
_, task = await _handoff_task(client, live_server, monkeypatch)
|
|
||||||
# Any logged-in user (not just admins) can decide; identity is the session.
|
|
||||||
await login(email="chef@example.com", role="member")
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task['id']}/decision",
|
f"/api/approvals/{task['id']}/decision",
|
||||||
json={"decision": "reject", "note": "zu teuer"},
|
json={"decision": "reject", "approver": "chef", "note": "zu teuer"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["task"]["status"] == "rejected"
|
assert body["task"]["status"] == "rejected"
|
||||||
assert body["task"]["approver"] == "chef@example.com"
|
|
||||||
assert body["task"]["decision_note"] == "zu teuer"
|
assert body["task"]["decision_note"] == "zu teuer"
|
||||||
assert body["continuation_run"] is None
|
assert body["continuation_run"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
|
async def test_decision_guards(client, lfx_server, live_server):
|
||||||
await login()
|
_, task = await _handoff_task(client, live_server)
|
||||||
_, task = await _handoff_task(client, live_server, monkeypatch)
|
|
||||||
url = f"/api/approvals/{task['id']}/decision"
|
url = f"/api/approvals/{task['id']}/decision"
|
||||||
|
|
||||||
response = await client.post(url, json={"decision": "approve"})
|
response = await client.post(url, json={"decision": "approve"})
|
||||||
|
|
@ -158,10 +109,12 @@ async def test_decision_guards(client, lfx_server, live_server, login, monkeypat
|
||||||
response = await client.post(url, json={"decision": "reject"})
|
response = await client.post(url, json={"decision": "reject"})
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
response = await client.post(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
|
assert response.status_code == 404
|
||||||
|
|
||||||
_, task2 = await _handoff_task(client, live_server, monkeypatch)
|
_, task2 = await _handoff_task(client, live_server)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
|
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
|
||||||
)
|
)
|
||||||
|
|
@ -175,10 +128,9 @@ async def test_decision_guards(client, lfx_server, live_server, login, monkeypat
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_decision_bad_continuation_reference(client, session, login):
|
async def test_decision_bad_continuation_reference(client, session):
|
||||||
from app.flows import store as flow_store
|
from app.flows import store as flow_store
|
||||||
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
|
||||||
version = (
|
version = (
|
||||||
await client.post(
|
await client.post(
|
||||||
|
|
@ -191,55 +143,55 @@ async def test_decision_bad_continuation_reference(client, session, login):
|
||||||
flow_store.get_version(session, UUID(version["id"])),
|
flow_store.get_version(session, UUID(version["id"])),
|
||||||
input={},
|
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)
|
# plant unknown continuation directly (store allows it pre-validation)
|
||||||
task.continuation_flow_id = uuid4()
|
task.continuation_flow_id = uuid4()
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
response = await client.post(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
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
async def test_tenant_isolation(client, lfx_server, login):
|
async def test_tenant_isolation(client, lfx_server):
|
||||||
"""Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
|
||||||
await _version_and_deploy(client, flow["id"])
|
await _version_and_deploy(client, flow["id"])
|
||||||
|
|
||||||
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
|
for tenant in ("pfarramt-a", "pfarramt-b"):
|
||||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
response = await client.post(
|
||||||
assert response.status_code == 200
|
f"/api/flows/{flow['id']}/run",
|
||||||
run_a = response.json()
|
json={"input": {"betrag": 1}, "tenant_id": tenant},
|
||||||
assert run_a["tenant_id"] == "pfarramt-a"
|
)
|
||||||
|
|
||||||
await login(email="b@example.com", role="member", tenant_id="pfarramt-b")
|
|
||||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# A forged tenant_id query param is ignored: b sees only b's runs.
|
|
||||||
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
|
|
||||||
runs_b = response.json()
|
|
||||||
assert len(runs_b) == 1
|
|
||||||
assert runs_b[0]["tenant_id"] == "pfarramt-b"
|
|
||||||
|
|
||||||
# Cross-tenant run read 404s with or without the forged param.
|
|
||||||
response = await client.get(f"/api/flows/runs/{run_a['id']}")
|
|
||||||
assert response.status_code == 404
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/api/flows/runs/{run_a['id']}", params={"tenant_id": "pfarramt-a"}
|
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"
|
||||||
|
|
||||||
|
response = await client.get(f"/api/flows/{flow['id']}/runs")
|
||||||
|
assert len(response.json()) == 2
|
||||||
|
|
||||||
|
# Cross-tenant read with scoping 404s.
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
response = await client.get(
|
||||||
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
|
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"}
|
||||||
response = await client.get(f"/api/flows/runs/{run_a['id']}")
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
|
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/flows/{flow['id']}/versions",
|
f"/api/flows/{flow['id']}/versions",
|
||||||
|
|
@ -254,26 +206,28 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
|
||||||
deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
||||||
assert deployed.status_code == 200
|
assert deployed.status_code == 200
|
||||||
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
|
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
|
||||||
response = await client.post(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
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
|
async def test_lfx_drift_maps_to_502(client, lfx_server):
|
||||||
from app.flows.lfx_client import LfxClient
|
from app.flows.lfx_client import LfxClient
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
|
||||||
deployed = await _version_and_deploy(client, flow["id"])
|
deployed = await _version_and_deploy(client, flow["id"])
|
||||||
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
|
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
|
||||||
deployed["lfx_flow_id"]
|
deployed["lfx_flow_id"]
|
||||||
)
|
)
|
||||||
response = await client.post(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
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
|
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
|
||||||
bogus = {
|
bogus = {
|
||||||
"name": "bogus",
|
"name": "bogus",
|
||||||
|
|
@ -293,7 +247,9 @@ async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
|
||||||
"edges": [],
|
"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
|
assert response.status_code == 201, response.text
|
||||||
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
"""Phase 1 tests: magic-link auth, access requests, RBAC, cookie flags."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import timedelta
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from app.auth import service
|
|
||||||
from app.auth.deps import SESSION_COOKIE, resolve_tenant
|
|
||||||
from app.models import MagicLinkToken, User
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
ADMIN = "admin@example.com"
|
|
||||||
MEMBER = "member@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _no_smtp_many_admins(monkeypatch):
|
|
||||||
# Never touch the network: dev fallback logs instead of mailing.
|
|
||||||
# (The real repo .env configures SMTP; tests must not use it.)
|
|
||||||
monkeypatch.setattr(settings, "smtp_host", "")
|
|
||||||
monkeypatch.setattr(settings, "admin_emails", ADMIN)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_make_user(session, email, role="member"):
|
|
||||||
user = service.get_user_by_email(session, email)
|
|
||||||
if user is None:
|
|
||||||
user = User(email=email, name="Test", role=role)
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _login(client, session, email, role="member"):
|
|
||||||
"""Consume a fresh magic token; persist the session cookie manually.
|
|
||||||
|
|
||||||
httpx honours the Secure cookie flag and would withhold it over the
|
|
||||||
http://test transport, so the session id is re-set as a plain cookie.
|
|
||||||
"""
|
|
||||||
user = _get_or_make_user(session, email, role)
|
|
||||||
token = service.issue_magic_token(session, user)
|
|
||||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
sid = response.cookies[SESSION_COOKIE]
|
|
||||||
client.cookies.set(SESSION_COOKIE, sid)
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_request_approve_consume_me_logout(client, session):
|
|
||||||
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"
|
|
||||||
|
|
||||||
created = await client.post(
|
|
||||||
"/api/access/requests",
|
|
||||||
json={"name": "Maria", "email": MEMBER, "reason": "choir planning"},
|
|
||||||
)
|
|
||||||
assert created.status_code == 201, created.text
|
|
||||||
assert created.json()["status"] == "pending"
|
|
||||||
|
|
||||||
queue = await client.get("/api/access/requests")
|
|
||||||
assert queue.status_code == 200
|
|
||||||
assert any(r["email"] == MEMBER for r in queue.json())
|
|
||||||
|
|
||||||
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"
|
|
||||||
assert body["email_sent"] is False # no SMTP in tests
|
|
||||||
|
|
||||||
# New client (no admin cookie) logs in as the approved member.
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as member:
|
|
||||||
user = service.get_user_by_email(session, MEMBER)
|
|
||||||
assert user is not None and user.role == "member"
|
|
||||||
token = service.issue_magic_token(session, user)
|
|
||||||
consumed = await member.post("/api/auth/magic/consume", json={"token": token})
|
|
||||||
assert consumed.status_code == 200
|
|
||||||
assert consumed.json()["email"] == MEMBER
|
|
||||||
member.cookies.set(SESSION_COOKIE, consumed.cookies[SESSION_COOKIE])
|
|
||||||
|
|
||||||
me = await member.get("/api/auth/me")
|
|
||||||
assert me.status_code == 200
|
|
||||||
assert me.json()["tenant_id"] == "default"
|
|
||||||
|
|
||||||
assert (await member.post("/api/auth/logout")).status_code == 200
|
|
||||||
assert (await member.get("/api/auth/me")).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_consume_unknown_token_400(client):
|
|
||||||
response = await client.post("/api/auth/magic/consume", json={"token": "nope"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
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))
|
|
||||||
).one()
|
|
||||||
record.expires_at = service.utcnow() - timedelta(seconds=1)
|
|
||||||
session.add(record)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "expired" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_token_reuse_400(client, session):
|
|
||||||
user = _get_or_make_user(session, MEMBER)
|
|
||||||
token = service.issue_magic_token(session, user)
|
|
||||||
assert (await client.post("/api/auth/magic/consume", json={"token": token})).status_code == 200
|
|
||||||
again = await client.post("/api/auth/magic/consume", json={"token": token})
|
|
||||||
assert again.status_code == 400
|
|
||||||
assert "used" in again.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_only_endpoints(client, session):
|
|
||||||
await _login(client, session, MEMBER, role="member")
|
|
||||||
created = await client.post(
|
|
||||||
"/api/access/requests",
|
|
||||||
json={"name": "X", "email": "x@example.com", "reason": ""},
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
|
|
||||||
assert (await client.get("/api/access/requests")).status_code == 403
|
|
||||||
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
|
|
||||||
assert approve.status_code == 403
|
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
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")
|
|
||||||
).status_code == 401
|
|
||||||
assert (await anon.get("/api/auth/me")).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
created = await client.post(
|
|
||||||
"/api/access/requests",
|
|
||||||
json={"name": "Y", "email": "y@example.com", "reason": ""},
|
|
||||||
)
|
|
||||||
rid = created.json()["id"]
|
|
||||||
assert (await client.post(f"/api/access/requests/{rid}/reject")).status_code == 200
|
|
||||||
again = await client.post(f"/api/access/requests/{rid}/approve")
|
|
||||||
assert again.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
async def test_bootstrap_once_and_forbidden(client):
|
|
||||||
assert (
|
|
||||||
await client.post("/api/auth/admin-bootstrap", json={"email": "stranger@example.com"})
|
|
||||||
).status_code == 403
|
|
||||||
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 == 409
|
|
||||||
|
|
||||||
|
|
||||||
async def test_session_cookie_flags(client, session):
|
|
||||||
user = _get_or_make_user(session, MEMBER)
|
|
||||||
token = service.issue_magic_token(session, user)
|
|
||||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
|
||||||
assert response.status_code == 200
|
|
||||||
raw = " ".join(response.headers.get_list("set-cookie")).lower()
|
|
||||||
assert "httponly" in raw
|
|
||||||
assert "secure" in raw
|
|
||||||
assert "samesite=lax" in raw
|
|
||||||
assert "path=/" in raw
|
|
||||||
|
|
||||||
|
|
||||||
async def test_no_token_or_session_in_logs(client, session, caplog):
|
|
||||||
user = _get_or_make_user(session, MEMBER)
|
|
||||||
token = service.issue_magic_token(session, user)
|
|
||||||
with caplog.at_level(logging.INFO):
|
|
||||||
caplog.clear()
|
|
||||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert token not in caplog.text
|
|
||||||
assert response.cookies[SESSION_COOKIE] not in caplog.text
|
|
||||||
assert service.hash_token(token) not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_access_request_rate_limit(client):
|
|
||||||
email = f"impatient-{uuid4()}@example.com"
|
|
||||||
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR):
|
|
||||||
response = await client.post(
|
|
||||||
"/api/access/requests",
|
|
||||||
json={"name": "I", "email": email, "reason": ""},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201, response.text
|
|
||||||
limited = await client.post(
|
|
||||||
"/api/access/requests", json={"name": "I", "email": email, "reason": ""}
|
|
||||||
)
|
|
||||||
assert limited.status_code == 429
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_tenant():
|
|
||||||
assert resolve_tenant(None, None) == "default"
|
|
||||||
assert resolve_tenant(None, "acme") == "acme"
|
|
||||||
user = User(email="a@b.c", tenant_id="parish-7")
|
|
||||||
assert resolve_tenant(user, None) == "parish-7"
|
|
||||||
assert resolve_tenant(user, "other") == "parish-7"
|
|
||||||
|
|
||||||
|
|
||||||
def _broken_smtp(monkeypatch):
|
|
||||||
import smtplib
|
|
||||||
|
|
||||||
from app.auth import mail
|
|
||||||
|
|
||||||
class ExplodingSMTP:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
raise smtplib.SMTPException("relay down")
|
|
||||||
|
|
||||||
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
|
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_delivery_failure_returns_false(monkeypatch):
|
|
||||||
from app.auth import mail
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_approve_survives_delivery_failure(client, session, monkeypatch):
|
|
||||||
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
|
||||||
_broken_smtp(monkeypatch)
|
|
||||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
|
||||||
await _login(client, session, ADMIN, role="admin")
|
|
||||||
created = await client.post(
|
|
||||||
"/api/access/requests",
|
|
||||||
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")
|
|
||||||
assert approved.status_code == 200, approved.text
|
|
||||||
body = approved.json()
|
|
||||||
assert body["request"]["status"] == "approved"
|
|
||||||
assert body["email_sent"] is False
|
|
||||||
|
|
||||||
|
|
||||||
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})
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
body = response.json()
|
|
||||||
assert body["message"] == "login link sent"
|
|
||||||
assert body["email_sent"] is False # no SMTP in tests
|
|
||||||
assert "token" not in response.text
|
|
||||||
tokens = session.exec(
|
|
||||||
select(MagicLinkToken).where(
|
|
||||||
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"})
|
|
||||||
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})
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
user = service.get_user_by_email(session, ADMIN)
|
|
||||||
assert user is not None and user.role == "admin"
|
|
||||||
|
|
||||||
|
|
||||||
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})
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
|
||||||
assert limited.status_code == 429
|
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
|
|
||||||
from app.auth import mail
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
|
||||||
message = mail._branded_mail(
|
|
||||||
subject="Dein Login-Link",
|
|
||||||
to_email="a@example.com",
|
|
||||||
headline="Anmeldung bei Kirche Einfach",
|
|
||||||
intro="Klicke auf den Button.",
|
|
||||||
button_label="Jetzt anmelden",
|
|
||||||
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 = 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
|
|
||||||
assert ">Jetzt anmelden</a>" in html
|
|
||||||
assert mail.BLUE in html # kirchenblau brand bar
|
|
||||||
assert 'lang="de"' in html
|
|
||||||
# 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")
|
|
||||||
assert "http://localhost:3000/login?token=GEHEIM" in plain
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
"""Prod env checker: failures block deploys, warnings don't."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
|
|
||||||
|
|
||||||
from check_prod import check
|
|
||||||
|
|
||||||
|
|
||||||
def _good() -> dict:
|
|
||||||
return {
|
|
||||||
"CALLBACK_TOKEN": "t" * 40,
|
|
||||||
"LANGFLOW_API_KEY": "k" * 24,
|
|
||||||
"LFX_API_KEY": "x" * 24,
|
|
||||||
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
|
|
||||||
"LANGFLOW_SECRET_KEY": "s" * 40,
|
|
||||||
"FRONTEND_MODE": "preview",
|
|
||||||
"ADMIN_EMAILS": "admin@example.com",
|
|
||||||
"SMTP_HOST": "smtp.example.com",
|
|
||||||
"SMTP_PASSWORD": "secret",
|
|
||||||
"CONTACT_TO": "team@example.com",
|
|
||||||
"FRONTEND_URL": "https://example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_good_env_passes_without_warnings():
|
|
||||||
failures, warnings = check(_good())
|
|
||||||
assert failures == []
|
|
||||||
assert warnings == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_and_short_secrets_fail():
|
|
||||||
values = _good()
|
|
||||||
values["CALLBACK_TOKEN"] = ""
|
|
||||||
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
|
|
||||||
failures, _ = check(values)
|
|
||||||
assert any("CALLBACK_TOKEN" in f for f in failures)
|
|
||||||
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
|
|
||||||
|
|
||||||
|
|
||||||
def test_placeholders_fail():
|
|
||||||
values = _good()
|
|
||||||
values["LANGFLOW_API_KEY"] = "sk-"
|
|
||||||
values["LFX_API_KEY"] = "lfx-abc"
|
|
||||||
failures, _ = check(values)
|
|
||||||
assert any("LANGFLOW_API_KEY" in f for f in failures)
|
|
||||||
assert any("LFX_API_KEY" in f for f in failures)
|
|
||||||
|
|
||||||
|
|
||||||
def test_insecure_modes_fail():
|
|
||||||
values = _good()
|
|
||||||
values["FRONTEND_MODE"] = "dev"
|
|
||||||
values["APP_ENV"] = "dev"
|
|
||||||
values["LANGFLOW_AUTO_LOGIN"] = "true"
|
|
||||||
failures, _ = check(values)
|
|
||||||
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
|
|
||||||
assert len([f for f in failures if "APP_ENV" in f]) == 1
|
|
||||||
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_soft_misconfigurations_warn_only():
|
|
||||||
values = _good()
|
|
||||||
values["ADMIN_EMAILS"] = ""
|
|
||||||
values["SMTP_PASSWORD"] = ""
|
|
||||||
values["CONTACT_TO"] = ""
|
|
||||||
values["FRONTEND_URL"] = "http://example.com"
|
|
||||||
failures, warnings = check(values)
|
|
||||||
assert failures == []
|
|
||||||
assert len(warnings) == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_example_file_would_not_pass():
|
|
||||||
from check_prod import parse_env
|
|
||||||
|
|
||||||
values = parse_env(REPO_ROOT / ".env.example")
|
|
||||||
failures, _ = check(values)
|
|
||||||
assert failures, "placeholders must be detectable"
|
|
||||||
|
|
@ -25,10 +25,9 @@ def _export_doc(name="importiert"):
|
||||||
|
|
||||||
|
|
||||||
async def test_import_creates_definition_version_and_deploys(
|
async def test_import_creates_definition_version_and_deploys(
|
||||||
client, login, lfx_server, override_langflow
|
client, lfx_server, override_langflow
|
||||||
):
|
):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
await login()
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/flows/import",
|
"/api/flows/import",
|
||||||
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
|
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
|
||||||
|
|
@ -38,22 +37,31 @@ async def test_import_creates_definition_version_and_deploys(
|
||||||
assert body["definition"]["name"] == "importiert"
|
assert body["definition"]["name"] == "importiert"
|
||||||
assert body["version"]["version_no"] == 1
|
assert body["version"]["version_no"] == 1
|
||||||
assert body["version"]["author"] == "admin"
|
assert body["version"]["author"] == "admin"
|
||||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(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"]["deployed_at"] is not None
|
||||||
assert body["version"]["langflow_json"]["data"]["nodes"]
|
assert body["version"]["langflow_json"]["data"]["nodes"]
|
||||||
|
|
||||||
# Imported flow actually runs on LFX.
|
# Imported flow actually runs on LFX.
|
||||||
definition_id = body["definition"]["id"]
|
definition_id = body["definition"]["id"]
|
||||||
response = await client.post(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.status_code == 200, response.text
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
async def test_import_same_content_is_idempotent(client, login, lfx_server, override_langflow):
|
async def test_import_same_content_is_idempotent(
|
||||||
|
client, lfx_server, override_langflow
|
||||||
|
):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
await login()
|
first = await client.post(
|
||||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||||
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
)
|
||||||
|
second = await client.post(
|
||||||
|
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||||
|
)
|
||||||
assert first.status_code == 201
|
assert first.status_code == 201
|
||||||
assert second.status_code == 201
|
assert second.status_code == 201
|
||||||
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
|
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
|
||||||
|
|
@ -61,37 +69,42 @@ async def test_import_same_content_is_idempotent(client, login, lfx_server, over
|
||||||
assert second.json()["version"]["version_no"] == 1
|
assert second.json()["version"]["version_no"] == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_import_changed_content_bumps_version(client, login, lfx_server, override_langflow):
|
async def test_import_changed_content_bumps_version(
|
||||||
|
client, lfx_server, override_langflow
|
||||||
|
):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
await login()
|
first = await client.post(
|
||||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||||
|
)
|
||||||
assert first.status_code == 201
|
assert first.status_code == 201
|
||||||
changed = _export_doc()
|
changed = _export_doc()
|
||||||
changed["description"] = "neue Beschreibung"
|
changed["description"] = "neue Beschreibung"
|
||||||
override_langflow(changed)
|
override_langflow(changed)
|
||||||
second = await client.post("/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.status_code == 201
|
||||||
assert second.json()["version"]["version_no"] == 2
|
assert second.json()["version"]["version_no"] == 2
|
||||||
|
|
||||||
|
|
||||||
async def test_import_writes_canonical_file(
|
async def test_import_writes_canonical_file(
|
||||||
client, login, lfx_server, override_langflow, tmp_path, monkeypatch
|
client, lfx_server, override_langflow, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
await login()
|
response = await client.post(
|
||||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||||
|
)
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
written = tmp_path / "importiert.json"
|
written = tmp_path / "importiert.json"
|
||||||
assert written.is_file()
|
assert written.is_file()
|
||||||
assert json.loads(written.read_text())["name"] == "importiert"
|
assert json.loads(written.read_text())["name"] == "importiert"
|
||||||
|
|
||||||
|
|
||||||
async def test_import_with_explicit_name(client, login, lfx_server, override_langflow):
|
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
||||||
override_langflow(_export_doc(name="langflow-name"))
|
override_langflow(_export_doc(name="langflow-name"))
|
||||||
await login()
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/flows/import",
|
"/api/flows/import",
|
||||||
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
|
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
|
||||||
|
|
@ -100,9 +113,8 @@ async def test_import_with_explicit_name(client, login, lfx_server, override_lan
|
||||||
assert response.json()["definition"]["name"] == "wunschname"
|
assert response.json()["definition"]["name"] == "wunschname"
|
||||||
|
|
||||||
|
|
||||||
async def test_import_without_deploy(client, login, override_langflow):
|
async def test_import_without_deploy(client, override_langflow):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
await login()
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/flows/import",
|
"/api/flows/import",
|
||||||
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
|
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
|
||||||
|
|
@ -113,23 +125,24 @@ async def test_import_without_deploy(client, login, override_langflow):
|
||||||
assert body["version"]["deployed_at"] is None
|
assert body["version"]["deployed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_import_langflow_not_found(client, login, override_langflow):
|
async def test_import_langflow_not_found(client, override_langflow):
|
||||||
override_langflow(status=404)
|
override_langflow(status=404)
|
||||||
await login()
|
response = await client.post(
|
||||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_import_langflow_unreachable(client, login, override_langflow):
|
async def test_import_langflow_unreachable(client, override_langflow):
|
||||||
override_langflow(status=0)
|
override_langflow(status=0)
|
||||||
await login()
|
response = await client.post(
|
||||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
|
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
|
||||||
|
)
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_list_langflow_flows(client, login, override_langflow):
|
async def test_list_langflow_flows(client, override_langflow):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
await login()
|
|
||||||
response = await client.get("/api/flows/langflow/available")
|
response = await client.get("/api/flows/langflow/available")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == [{"id": "fake-1", "name": "fake"}]
|
assert response.json() == [{"id": "fake-1", "name": "fake"}]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from uuid import UUID, uuid4
|
||||||
from app.flows.sync import lfx_id_for
|
from app.flows.sync import lfx_id_for
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||||
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
|
|
||||||
|
|
||||||
|
|
||||||
def load_fixture(name: str) -> dict:
|
def load_fixture(name: str) -> dict:
|
||||||
|
|
@ -15,7 +14,9 @@ def load_fixture(name: str) -> dict:
|
||||||
|
|
||||||
|
|
||||||
async def _create_flow(client, name="regeltest"):
|
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
|
assert response.status_code == 201, response.text
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -35,8 +36,7 @@ async def _deploy(client, flow_id, version_no=1):
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def test_flow_crud(client, login):
|
async def test_flow_crud(client):
|
||||||
await login()
|
|
||||||
created = await _create_flow(client, "crud-flow")
|
created = await _create_flow(client, "crud-flow")
|
||||||
assert created["status"] == "active"
|
assert created["status"] == "active"
|
||||||
|
|
||||||
|
|
@ -55,63 +55,7 @@ async def test_flow_crud(client, login):
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_management_endpoints_require_admin(client, login):
|
async def test_version_validation_and_numbering(client):
|
||||||
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
|
|
||||||
flow_id = uuid4()
|
|
||||||
probes = [
|
|
||||||
("POST", "/api/flows", {"name": "x"}),
|
|
||||||
("POST", "/api/flows/sync", None),
|
|
||||||
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
|
|
||||||
("GET", f"/api/flows/{flow_id}/versions", None),
|
|
||||||
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
|
|
||||||
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
|
|
||||||
("GET", "/api/flows/langflow/available", None),
|
|
||||||
]
|
|
||||||
for method, path, body in probes:
|
|
||||||
if method == "POST":
|
|
||||||
response = await client.post(path, json=body or {})
|
|
||||||
else:
|
|
||||||
response = await client.get(path)
|
|
||||||
assert response.status_code == 401, (method, path, response.text)
|
|
||||||
|
|
||||||
await login(email="member@example.com", role="member")
|
|
||||||
for method, path, body in probes:
|
|
||||||
if method == "POST":
|
|
||||||
response = await client.post(path, json=body or {})
|
|
||||||
else:
|
|
||||||
response = await client.get(path)
|
|
||||||
assert response.status_code == 403, (method, path, response.text)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_runs_and_reads_require_login(client, login):
|
|
||||||
"""Run, run reads and approval reads are login-gated (A)."""
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "gated")
|
|
||||||
run_id = uuid4()
|
|
||||||
authed_paths = [
|
|
||||||
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
|
|
||||||
("GET", f"/api/flows/{flow['id']}/runs", None),
|
|
||||||
("GET", f"/api/flows/runs/{run_id}", None),
|
|
||||||
("GET", "/api/approvals", None),
|
|
||||||
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
|
|
||||||
]
|
|
||||||
# Fresh client without a session cookie.
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
|
||||||
for method, path, body in authed_paths:
|
|
||||||
if method == "POST":
|
|
||||||
response = await anon.post(path, json=body or {})
|
|
||||||
else:
|
|
||||||
response = await anon.get(path)
|
|
||||||
assert response.status_code == 401, (method, path, response.text)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_version_validation_and_numbering(client, login):
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "versioned")
|
flow = await _create_flow(client, "versioned")
|
||||||
|
|
||||||
v1 = await _create_version(client, flow["id"])
|
v1 = await _create_version(client, flow["id"])
|
||||||
|
|
@ -135,8 +79,7 @@ async def test_version_validation_and_numbering(client, login):
|
||||||
assert [v["version_no"] for v in response.json()] == [1, 2]
|
assert [v["version_no"] for v in response.json()] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
|
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "dachsanierung")
|
flow = await _create_flow(client, "dachsanierung")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
deployed = await _deploy(client, flow["id"])
|
deployed = await _deploy(client, flow["id"])
|
||||||
|
|
@ -167,16 +110,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login)
|
||||||
assert response.json()["id"] == run["id"]
|
assert response.json()["id"] == run["id"]
|
||||||
|
|
||||||
|
|
||||||
async def test_run_undeployed_version_fails(client, login):
|
async def test_run_undeployed_version_fails(client):
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "undeployed")
|
flow = await _create_flow(client, "undeployed")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
response = await client.post(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
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
async def test_run_explicit_version_no(client, lfx_server, login):
|
async def test_run_explicit_version_no(client, lfx_server):
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "explicit-version")
|
flow = await _create_flow(client, "explicit-version")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
|
|
@ -189,8 +132,7 @@ async def test_run_explicit_version_no(client, lfx_server, login):
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
async def test_run_stream_events(client, lfx_server, login):
|
async def test_run_stream_events(client, lfx_server):
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "streamed")
|
flow = await _create_flow(client, "streamed")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
await _deploy(client, flow["id"])
|
await _deploy(client, flow["id"])
|
||||||
|
|
@ -223,18 +165,17 @@ async def test_run_stream_events(client, lfx_server, login):
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
|
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
await login()
|
|
||||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
||||||
flow = await _create_flow(client, "freigabe")
|
flow = await _create_flow(client, "freigabe")
|
||||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||||
await _deploy(client, flow["id"])
|
await _deploy(client, flow["id"])
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/flows/{flow['id']}/run",
|
f"/api/flows/{flow['id']}/run",
|
||||||
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
json={
|
||||||
|
"input": {"betrag": 250000, "schwelle": 200000},
|
||||||
|
"callback_base_url": live_server,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
run = response.json()
|
run = response.json()
|
||||||
|
|
@ -251,20 +192,17 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server, logi
|
||||||
assert task["payload"]["facts"]["betrag"] == 250000
|
assert task["payload"]["facts"]["betrag"] == 250000
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_handoff_auto_creates_no_task(
|
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
|
||||||
client, lfx_server, live_server, login, monkeypatch
|
|
||||||
):
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
await login()
|
|
||||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
||||||
flow = await _create_flow(client, "freigabe-auto")
|
flow = await _create_flow(client, "freigabe-auto")
|
||||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||||
await _deploy(client, flow["id"])
|
await _deploy(client, flow["id"])
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/flows/{flow['id']}/run",
|
f"/api/flows/{flow['id']}/run",
|
||||||
json={"input": {"betrag": 50000, "schwelle": 200000}},
|
json={
|
||||||
|
"input": {"betrag": 50000, "schwelle": 200000},
|
||||||
|
"callback_base_url": live_server,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
@ -274,12 +212,11 @@ async def test_approval_handoff_auto_creates_no_task(
|
||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_callback_contract(client, session, login):
|
async def test_approval_callback_contract(client, session):
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.flows import store as flow_store
|
from app.flows import store as flow_store
|
||||||
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "callback-target")
|
flow = await _create_flow(client, "callback-target")
|
||||||
version = await _create_version(client, flow["id"])
|
version = await _create_version(client, flow["id"])
|
||||||
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
||||||
|
|
@ -289,7 +226,6 @@ async def test_approval_callback_contract(client, session, login):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/approvals",
|
"/api/approvals",
|
||||||
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
||||||
headers=CALLBACK_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
task = response.json()
|
task = response.json()
|
||||||
|
|
@ -297,46 +233,18 @@ async def test_approval_callback_contract(client, session, login):
|
||||||
assert task["tenant_id"] == "default"
|
assert task["tenant_id"] == "default"
|
||||||
assert task["version_id"] == version["id"]
|
assert task["version_id"] == version["id"]
|
||||||
|
|
||||||
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS)
|
response = await client.get(f"/api/flows/runs/{run.id}")
|
||||||
assert response.json()["status"] == "pending_approval"
|
assert response.json()["status"] == "pending_approval"
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
||||||
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/approvals",
|
"/api/approvals",
|
||||||
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
||||||
headers=CALLBACK_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
|
||||||
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_callback_token_required(client, session, login):
|
|
||||||
"""Forged callbacks without the shared token are rejected (C)."""
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from app.flows import store as flow_store
|
|
||||||
|
|
||||||
await login()
|
|
||||||
flow = await _create_flow(client, "callback-gated")
|
|
||||||
version = await _create_version(client, flow["id"])
|
|
||||||
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
|
||||||
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
|
||||||
|
|
||||||
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
"/api/approvals",
|
|
||||||
json={"run_id": str(run.id)},
|
|
||||||
headers={"X-Callback-Token": "wrong"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"""Phase 2 tests: kirche CustomInput* library + intake fixture flow."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
|
||||||
|
|
||||||
FORM_FIELDS = [
|
|
||||||
"full_name",
|
|
||||||
"notes",
|
|
||||||
"password",
|
|
||||||
"age",
|
|
||||||
"amount",
|
|
||||||
"newsletter",
|
|
||||||
"department",
|
|
||||||
"topics",
|
|
||||||
"satisfaction",
|
|
||||||
"attachment",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_component_contract():
|
|
||||||
assert issubclass(CustomInputForm, Component)
|
|
||||||
assert CustomInputForm.display_name.startswith("CustomInput")
|
|
||||||
inputs = {i.name: i for i in CustomInputForm.inputs}
|
|
||||||
# Ten form fields plus the hidden run-envelope plumbing input.
|
|
||||||
assert sorted(n for n in inputs if n != "form_values") == sorted(FORM_FIELDS)
|
|
||||||
assert inputs["form_values"].show is False
|
|
||||||
outputs = {o.name: o for o in CustomInputForm.outputs}
|
|
||||||
assert set(outputs) == {"form_data"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_collect_merges_submitted_values_over_defaults():
|
|
||||||
from lfx.schema.message import Message
|
|
||||||
|
|
||||||
form = CustomInputForm()
|
|
||||||
default = json.loads(form.collect().text)
|
|
||||||
assert default["full_name"] == ""
|
|
||||||
assert default["department"] == "Music"
|
|
||||||
assert isinstance(form.collect(), Message)
|
|
||||||
|
|
||||||
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
|
||||||
merged = json.loads(form.collect().text)
|
|
||||||
assert merged["full_name"] == "Maria"
|
|
||||||
assert merged["age"] == 41
|
|
||||||
assert merged["department"] == "Music" # default kept
|
|
||||||
|
|
||||||
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
|
||||||
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
|
||||||
assert json.loads(form.collect().text)["notes"] == "hi"
|
|
||||||
|
|
||||||
form.form_values = "not json"
|
|
||||||
assert json.loads(form.collect().text)["full_name"] == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_intake_fixture_shape():
|
|
||||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
||||||
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
|
||||||
form = nodes["CustomInputForm-intake"]
|
|
||||||
assert form["data"]["type"] == "CustomInputForm"
|
|
||||||
assert form["data"]["node"]["display_name"].startswith("CustomInput")
|
|
||||||
template = form["data"]["node"]["template"]
|
|
||||||
for field in FORM_FIELDS:
|
|
||||||
assert field in template, field
|
|
||||||
assert template["form_values"]["show"] is False
|
|
||||||
assert len(template["code"]["value"]) > 200
|
|
||||||
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
|
||||||
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
|
||||||
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
|
||||||
|
|
||||||
|
|
||||||
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
|
|
||||||
await login()
|
|
||||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
||||||
created = await client.post("/api/flows", json={"name": "intake-e2e"})
|
|
||||||
assert created.status_code == 201, created.text
|
|
||||||
flow_id = created.json()["id"]
|
|
||||||
|
|
||||||
version = await client.post(
|
|
||||||
f"/api/flows/{flow_id}/versions",
|
|
||||||
json={"langflow_json": doc, "author": "test"},
|
|
||||||
)
|
|
||||||
assert version.status_code == 201, version.text
|
|
||||||
|
|
||||||
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
|
||||||
assert deployed.status_code == 200, deployed.text
|
|
||||||
|
|
||||||
run = await client.post(
|
|
||||||
f"/api/flows/{flow_id}/run",
|
|
||||||
json={"input": {"full_name": "Maria Muster", "age": 41}},
|
|
||||||
)
|
|
||||||
assert run.status_code == 200, run.text
|
|
||||||
assert run.json()["status"] == "completed"
|
|
||||||
result = json.loads(run.json()["output"]["result"])
|
|
||||||
assert result["full_name"] == "Maria Muster"
|
|
||||||
assert result["age"] == 41
|
|
||||||
assert result["department"] == "Music" # default preserved
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
"""Phase 3 tests: form-schema extraction, sync cache, input-schema endpoint."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.flows import input_schema as parser
|
|
||||||
from app.flows import sync
|
|
||||||
from app.flows.lfx_client import LfxClient
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
|
||||||
|
|
||||||
|
|
||||||
def _field(name, itype, **kw):
|
|
||||||
base = {
|
|
||||||
"name": name,
|
|
||||||
"display_name": name.replace("_", " ").title(),
|
|
||||||
"info": "",
|
|
||||||
"value": None,
|
|
||||||
"required": False,
|
|
||||||
"advanced": False,
|
|
||||||
"show": True,
|
|
||||||
"_input_type": itype,
|
|
||||||
}
|
|
||||||
base.update(kw)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
|
|
||||||
template = {f["name"]: f for f in fields}
|
|
||||||
node = {
|
|
||||||
"data": {
|
|
||||||
"type": node_type,
|
|
||||||
"node": {"display_name": display, "template": template},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {"data": {"nodes": [node], "edges": []}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_type_mapping_table():
|
|
||||||
doc = _doc(
|
|
||||||
_field("a", "StrInput", value="x"),
|
|
||||||
_field("b", "MessageTextInput"),
|
|
||||||
_field("c", "MultilineInput"),
|
|
||||||
_field("d", "SecretStrInput"),
|
|
||||||
_field("e", "IntInput", value=3),
|
|
||||||
_field("f", "FloatInput", value=1.5),
|
|
||||||
_field("g", "BoolInput", value=True),
|
|
||||||
_field(
|
|
||||||
"h",
|
|
||||||
"DropdownInput",
|
|
||||||
options=["Music", "Youth"],
|
|
||||||
value="Youth",
|
|
||||||
),
|
|
||||||
_field(
|
|
||||||
"i",
|
|
||||||
"MultiselectInput",
|
|
||||||
options=["Worship", "Courses"],
|
|
||||||
value=["Worship"],
|
|
||||||
),
|
|
||||||
_field(
|
|
||||||
"j",
|
|
||||||
"SliderInput",
|
|
||||||
value=3,
|
|
||||||
range_spec={"min": 1, "max": 5, "step": 1},
|
|
||||||
),
|
|
||||||
_field("k", "FileInput"),
|
|
||||||
_field("l", "FutureFancyInput"),
|
|
||||||
)
|
|
||||||
schema = parser.extract_form_schema(doc)
|
|
||||||
assert "fallback" not in schema
|
|
||||||
props = schema["json_schema"]["properties"]
|
|
||||||
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
|
|
||||||
assert props["b"]["type"] == "string"
|
|
||||||
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
|
|
||||||
assert props["f"]["type"] == "number"
|
|
||||||
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
|
|
||||||
assert props["h"] == {
|
|
||||||
"title": "H",
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["Music", "Youth"],
|
|
||||||
"default": "Youth",
|
|
||||||
}
|
|
||||||
assert props["i"]["type"] == "array"
|
|
||||||
assert props["i"]["items"] == {"type": "string", "enum": ["Worship", "Courses"]}
|
|
||||||
assert props["i"]["uniqueItems"] is True
|
|
||||||
assert props["j"]["minimum"] == 1
|
|
||||||
assert props["j"]["maximum"] == 5
|
|
||||||
assert props["j"]["multipleOf"] == 1
|
|
||||||
assert props["k"]["type"] == "string"
|
|
||||||
assert props["l"]["type"] == "string"
|
|
||||||
ui = schema["ui_schema"]
|
|
||||||
assert ui["c"] == {"ui:widget": "textarea"}
|
|
||||||
assert ui["d"] == {"ui:widget": "password"}
|
|
||||||
assert ui["j"] == {"ui:widget": "range"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dropdown_default_outside_enum_omitted():
|
|
||||||
doc = _doc(_field("h", "DropdownInput", options=["Music"], value=""))
|
|
||||||
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
|
|
||||||
assert props["h"] == {"title": "H", "type": "string", "enum": ["Music"]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_required_advanced_hidden_code():
|
|
||||||
doc = _doc(
|
|
||||||
_field("a", "StrInput", required=True),
|
|
||||||
_field("b", "StrInput", advanced=True),
|
|
||||||
_field("c", "StrInput", show=False),
|
|
||||||
)
|
|
||||||
doc["data"]["nodes"][0]["data"]["node"]["template"]["code"] = {"value": "x"}
|
|
||||||
schema = parser.extract_form_schema(doc)
|
|
||||||
assert schema["json_schema"]["required"] == ["a"]
|
|
||||||
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
|
|
||||||
assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
|
|
||||||
|
|
||||||
|
|
||||||
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"}
|
|
||||||
plain = {
|
|
||||||
"data": {
|
|
||||||
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
|
||||||
"edges": [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema = parser.extract_form_schema(plain)
|
|
||||||
assert schema["fallback"] == "textarea"
|
|
||||||
assert schema["json_schema"]["properties"] == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_real_intake_fixture_parses():
|
|
||||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
||||||
schema = parser.extract_form_schema(doc)
|
|
||||||
props = schema["json_schema"]["properties"]
|
|
||||||
assert "fallback" not in schema
|
|
||||||
assert set(props) == {
|
|
||||||
"full_name",
|
|
||||||
"notes",
|
|
||||||
"password",
|
|
||||||
"age",
|
|
||||||
"amount",
|
|
||||||
"newsletter",
|
|
||||||
"department",
|
|
||||||
"topics",
|
|
||||||
"satisfaction",
|
|
||||||
"attachment",
|
|
||||||
}
|
|
||||||
assert "full_name" in schema["json_schema"]["required"]
|
|
||||||
assert props["department"]["enum"] == ["Music", "Youth", "Seniors", "Admin"]
|
|
||||||
assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"}
|
|
||||||
|
|
||||||
|
|
||||||
def _lfx() -> LfxClient:
|
|
||||||
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
|
||||||
assert not report.errors, report.errors
|
|
||||||
from app.flows import store
|
|
||||||
|
|
||||||
defs = store.list_definitions(session)
|
|
||||||
definition = next(d for d in defs if d.name == "intake_form")
|
|
||||||
versions = store.list_versions(session, definition.id)
|
|
||||||
assert len(versions) == 1
|
|
||||||
cached = versions[0].input_schema
|
|
||||||
assert cached["json_schema"]["properties"]["full_name"]["type"] == "string"
|
|
||||||
assert "form_values" not in cached["json_schema"]["properties"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_input_schema_endpoint(client, lfx_server, login):
|
|
||||||
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
|
|
||||||
|
|
||||||
await login()
|
|
||||||
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})
|
|
||||||
assert version.status_code == 201, version.text
|
|
||||||
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
|
|
||||||
|
|
||||||
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
|
||||||
assert deployed.status_code == 200, deployed.text
|
|
||||||
response = await client.get(f"/api/flows/{flow_id}/input-schema")
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
body = response.json()
|
|
||||||
assert body["version_no"] == 1
|
|
||||||
assert body["fallback"] is None
|
|
||||||
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
|
||||||
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
|
|
||||||
|
|
@ -20,7 +20,9 @@ def _client():
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_export_flow_sends_api_key_and_returns_doc():
|
async def test_export_flow_sends_api_key_and_returns_doc():
|
||||||
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
|
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
|
||||||
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(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")
|
result = await _client().export_flow("abc")
|
||||||
assert result == doc
|
assert result == doc
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
"""Canonical flows must pass lfx's strict validator (level 4).
|
|
||||||
|
|
||||||
Guards the source-of-truth directory against authoring mistakes: the
|
|
||||||
input_schema parser and the LFX runtime both assume well-formed exports.
|
|
||||||
Runs `lfx validate --level 4` (the dev dep, same minor as Langflow).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_flows_pass_lfx_validate():
|
|
||||||
files = sorted(FLOW_DIR.glob("*.json"))
|
|
||||||
assert files, "no canonical flows found"
|
|
||||||
process = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"lfx",
|
|
||||||
"validate",
|
|
||||||
"--level",
|
|
||||||
"4",
|
|
||||||
*[str(f) for f in files],
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert process.returncode == 0, f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""Edge hardening: docs off, headers, body cap, run throttle, mail scrub."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.middleware import reset_throttle
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean_throttle():
|
|
||||||
reset_throttle()
|
|
||||||
yield
|
|
||||||
reset_throttle()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_docs_disabled_outside_debug(client):
|
|
||||||
assert (await client.get("/api/docs")).status_code == 404
|
|
||||||
assert (await client.get("/api/redoc")).status_code == 404
|
|
||||||
assert (await client.get("/api/openapi.json")).status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_security_headers_present(client):
|
|
||||||
response = await client.get("/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
|
||||||
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
|
|
||||||
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
|
|
||||||
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oversize_body_rejected(client):
|
|
||||||
# Default cap is 2 MiB (settings.max_body_bytes); the middleware binds
|
|
||||||
# it at startup, so this test exceeds the real default.
|
|
||||||
big = "x" * (3 * 1024 * 1024)
|
|
||||||
response = await client.post("/api/auth/magic/consume", json={"token": big})
|
|
||||||
assert response.status_code == 413
|
|
||||||
|
|
||||||
|
|
||||||
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
|
|
||||||
await login()
|
|
||||||
|
|
||||||
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
|
|
||||||
await client.post(
|
|
||||||
f"/api/flows/{flow['id']}/versions",
|
|
||||||
json={
|
|
||||||
"langflow_json": {
|
|
||||||
"name": "t",
|
|
||||||
"data": {
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "ChatInput-x",
|
|
||||||
"data": {
|
|
||||||
"id": "ChatInput-x",
|
|
||||||
"type": "ChatInput",
|
|
||||||
"node": {"template": {}, "outputs": []},
|
|
||||||
},
|
|
||||||
"type": "genericNode",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"edges": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Undeployed: 422s still pass the throttle (only 429 matters here).
|
|
||||||
for _ in range(2):
|
|
||||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
|
|
||||||
assert response.status_code == 422
|
|
||||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
|
|
||||||
assert response.status_code == 429
|
|
||||||
assert response.headers["Retry-After"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_never_logged(caplog, monkeypatch):
|
|
||||||
from app.auth import mail
|
|
||||||
from app.settings import settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "smtp_host", "")
|
|
||||||
with caplog.at_level("INFO", logger="app.auth.mail"):
|
|
||||||
assert mail.send_magic_link("a@example.com", "http://x/login?token=SECRET") is False
|
|
||||||
assert "SECRET" not in caplog.text
|
|
||||||
assert "http://x/login" not in caplog.text
|
|
||||||
assert "a@example.com" in caplog.text
|
|
||||||
|
|
@ -5,6 +5,7 @@ import json
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -118,8 +119,7 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
|
||||||
assert definition.name == "umbenannt"
|
assert definition.name == "umbenannt"
|
||||||
|
|
||||||
|
|
||||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
|
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
|
||||||
await login()
|
|
||||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
_seed_dir(tmp_path)
|
_seed_dir(tmp_path)
|
||||||
response = await client.post("/api/flows/sync")
|
response = await client.post("/api/flows/sync")
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,10 @@ services:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
- .env
|
||||||
# containers must still create so `config`/`build` work everywhere.
|
|
||||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
volumes:
|
volumes:
|
||||||
# No venv volume by design: the venv lives at /opt/venv (image-local,
|
|
||||||
# see backend/Dockerfile). A named volume here used to shadow the image
|
|
||||||
# content with an empty dir on first start (split-brain with the host
|
|
||||||
# checkout, plus root-owned interpreters).
|
|
||||||
- .:/srv/kirche-einfach
|
- .:/srv/kirche-einfach
|
||||||
|
- kirche-venv:/srv/kirche-einfach/backend/.venv
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000" # FastAPI
|
- "8000:8000" # FastAPI
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -47,20 +40,13 @@ services:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
- .env
|
||||||
# containers must still create so `config`/`build` work everywhere.
|
|
||||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
# Fail closed: unset means the production preview server. Dev sets
|
# Dev default (HMR); prod .env sets `preview` (build + serve bundle).
|
||||||
# FRONTEND_MODE=dev in .env (provisioned by .devcontainer/setup.sh).
|
FRONTEND_MODE: ${FRONTEND_MODE:-dev}
|
||||||
FRONTEND_MODE: ${FRONTEND_MODE:-preview}
|
|
||||||
volumes:
|
volumes:
|
||||||
# No node_modules volume by design: deps live in the bind-mount and the
|
|
||||||
# entrypoint runs `npm ci` on first start (a volume used to shadow the
|
|
||||||
# image install with an empty dir and diverge from the host checkout).
|
|
||||||
- .:/srv/kirche-einfach
|
- .:/srv/kirche-einfach
|
||||||
|
- kirche-node-modules:/srv/kirche-einfach/frontend/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000" # Vite
|
- "3000:3000" # Vite
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -69,16 +55,9 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
langflow:
|
langflow:
|
||||||
# Pinned (never :latest): Langflow and LFX are versioned together, their
|
image: langflowai/langflow:latest
|
||||||
# major.minor must match. Bump via LANGFLOW_VERSION in .env (see
|
|
||||||
# .env.example) together with Dockerfile.lfx + backend `lfx~=` pin.
|
|
||||||
image: langflowai/langflow:${LANGFLOW_VERSION:-1.12.0}
|
|
||||||
env_file:
|
env_file:
|
||||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
- .env
|
||||||
# containers must still create so `config`/`build` work everywhere.
|
|
||||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
# Static values only; secrets/URLs come from .env via env_file.
|
# Static values only; secrets/URLs come from .env via env_file.
|
||||||
LANGFLOW_CONFIG_DIR: /app/langflow
|
LANGFLOW_CONFIG_DIR: /app/langflow
|
||||||
|
|
@ -89,17 +68,12 @@ services:
|
||||||
# SQLite file inside the langflow-data volume (absolute path required
|
# SQLite file inside the langflow-data volume (absolute path required
|
||||||
# by Langflow); overrides any postgres URL that may linger in .env.
|
# by Langflow); overrides any postgres URL that may linger in .env.
|
||||||
LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db
|
LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db
|
||||||
# Custom form-input components (backend/components, category folders).
|
|
||||||
LANGFLOW_COMPONENTS_PATH: /app/components
|
|
||||||
# Seed the authoring UI with the canonical flows (read-only: the files
|
|
||||||
# stay the source of truth, the API watcher is the only sync path).
|
|
||||||
LANGFLOW_LOAD_FLOWS_PATH: /app/flows
|
|
||||||
volumes:
|
volumes:
|
||||||
- langflow-data:/app/langflow
|
- langflow-data:/app/langflow
|
||||||
- ./backend/components:/app/components:ro
|
|
||||||
- ./backend/flows:/app/flows:ro
|
|
||||||
ports:
|
ports:
|
||||||
- "7860:7860"
|
- "7860:7860"
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
|
|
@ -117,17 +91,7 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.lfx
|
dockerfile: Dockerfile.lfx
|
||||||
env_file:
|
env_file:
|
||||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
- .env
|
||||||
# containers must still create so `config`/`build` work everywhere.
|
|
||||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
|
||||||
# lfx resolves its serve key via get_api_key(), which prefers
|
|
||||||
# LANGFLOW_API_KEY over LFX_API_KEY. Our backend talks to LFX with
|
|
||||||
# LFX_API_KEY, so blank the former here (empty counts as unset) —
|
|
||||||
# otherwise every flow deploy 401s and /flows stays empty.
|
|
||||||
LANGFLOW_API_KEY: ""
|
|
||||||
volumes:
|
volumes:
|
||||||
- lfx-data:/flows
|
- lfx-data:/flows
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -146,8 +110,6 @@ services:
|
||||||
|
|
||||||
# Nightly auto-updates of :latest images (prod). Never started in dev
|
# Nightly auto-updates of :latest images (prod). Never started in dev
|
||||||
# (not in devcontainer.json runServices; also needs a docker socket).
|
# (not in devcontainer.json runServices; also needs a docker socket).
|
||||||
# langflow/lfx carry no watchtower label on purpose: both are version-pinned
|
|
||||||
# (major.minor must match) and update only via deliberate version bumps.
|
|
||||||
watchtower:
|
watchtower:
|
||||||
image: nickfedor/watchtower:latest
|
image: nickfedor/watchtower:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -162,5 +124,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
kirche-venv:
|
||||||
|
kirche-node-modules:
|
||||||
langflow-data:
|
langflow-data:
|
||||||
lfx-data:
|
lfx-data:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# Never bake host build output into the Linux image: the host checkout runs
|
|
||||||
# on Windows, so a copied node_modules would shadow the image install with
|
|
||||||
# wrong-platform binaries and diverge from package-lock.json.
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
@ -3,21 +3,11 @@
|
||||||
# file. DevDeps are included: `npm run dev` must work with zero steps.
|
# file. DevDeps are included: `npm run dev` must work with zero steps.
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
# gosu drops privileges before serving (node user, uid 1000 — same as the
|
|
||||||
# vscode editor user, so the bind-mounted node_modules stays writable).
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends gosu \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /srv/kirche-einfach/frontend
|
WORKDIR /srv/kirche-einfach/frontend
|
||||||
COPY package.json package-lock.json entrypoint.sh ./
|
COPY package.json package-lock.json entrypoint.sh ./
|
||||||
RUN npm ci
|
RUN npm ci && chmod +x entrypoint.sh
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN chmod +x entrypoint.sh && chown -R node:node /srv/kirche-einfach/frontend
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Explicit `sh`: the repo bind-mount shadows the image at runtime, and git
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
# tracks entrypoint.sh as 100644 — relying on the exec bit breaks Windows
|
|
||||||
# checkouts (and fresh Linux clones). `sh script` needs read-only.
|
|
||||||
ENTRYPOINT ["sh", "./entrypoint.sh"]
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Frontend entrypoint (dev + prod).
|
# Frontend entrypoint (dev + prod).
|
||||||
# Fail closed: unset means the production preview server; dev sets
|
# FRONTEND_MODE=dev (compose default) runs the HMR dev server.
|
||||||
# FRONTEND_MODE=dev in .env (see .devcontainer/setup.sh).
|
# FRONTEND_MODE=preview (prod .env) builds once and serves the bundle.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd /srv/kirche-einfach/frontend
|
cd /srv/kirche-einfach/frontend
|
||||||
|
|
||||||
# node_modules lives in the repo bind-mount (no named volume by design):
|
if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then
|
||||||
# install on first start after a fresh checkout (dev and prod).
|
exec npm run dev -- --host 0.0.0.0 --port 3000
|
||||||
if [ ! -x "node_modules/.bin/vite" ]; then
|
|
||||||
gosu node npm ci
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${FRONTEND_MODE:-preview}" = "dev" ]; then
|
|
||||||
exec gosu node npm run dev -- --host 0.0.0.0 --port 3000
|
|
||||||
else
|
else
|
||||||
# Build as node so dist/ stays editor-writable; preview serves as node.
|
npm run build
|
||||||
gosu node npm run build
|
exec npm run preview -- --host 0.0.0.0 --port 3000
|
||||||
exec gosu node npm run preview -- --host 0.0.0.0 --port 3000
|
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#1d4a79" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche"
|
|
||||||
/>
|
|
||||||
<title>Kirche Einfach</title>
|
<title>Kirche Einfach</title>
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
||||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
<script>
|
|
||||||
try {
|
|
||||||
var s = localStorage.getItem("kirche-color-scheme");
|
|
||||||
if (
|
|
||||||
s === "dark" ||
|
|
||||||
(!s && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
) {
|
|
||||||
document.documentElement.setAttribute(
|
|
||||||
"data-mantine-color-scheme",
|
|
||||||
"dark",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
645
frontend/package-lock.json
generated
|
|
@ -8,21 +8,13 @@
|
||||||
"name": "kirche-einfach-frontend",
|
"name": "kirche-einfach-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^9.6.1",
|
|
||||||
"@mantine/hooks": "^9.6.1",
|
|
||||||
"@rjsf/core": "^6.10.0",
|
|
||||||
"@rjsf/mantine": "^6.10.0",
|
|
||||||
"@rjsf/validator-ajv8": "^6.10.0",
|
|
||||||
"@tabler/icons-react": "^3.46.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"react-router-dom": "^7.18.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"playwright": "^1.63.0",
|
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -751,59 +743,6 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/utils": "^0.2.12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/dom": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/core": "^1.8.0",
|
|
||||||
"@floating-ui/utils": "^0.2.12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/react": {
|
|
||||||
"version": "0.27.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
|
|
||||||
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/react-dom": "^2.1.9",
|
|
||||||
"@floating-ui/utils": "^0.2.12",
|
|
||||||
"tabbable": "^6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=17.0.0",
|
|
||||||
"react-dom": ">=17.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/react-dom": {
|
|
||||||
"version": "2.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
|
|
||||||
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/dom": "^1.8.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0",
|
|
||||||
"react-dom": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/utils": {
|
|
||||||
"version": "0.2.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
|
||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|
@ -854,50 +793,6 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mantine/core": {
|
|
||||||
"version": "9.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.1.tgz",
|
|
||||||
"integrity": "sha512-UkcPo8Zq0uSdodvyXV62HVmfIfHCcrjFrFrH0TggTy6Wtp8a78znGP8RJOz8zC9XSPpJmKjBQjg0M+nOvIzftA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/react": "^0.27.20",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"react-number-format": "^5.4.5",
|
|
||||||
"react-remove-scroll": "^2.7.2",
|
|
||||||
"type-fest": "^5.8.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@mantine/hooks": "9.6.1",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@mantine/dates": {
|
|
||||||
"version": "9.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.1.tgz",
|
|
||||||
"integrity": "sha512-3NxfqrcwMuYB9wvxIhGhEhhazK3wcA2wG4AuNVFGJDd97ujWWAy+jGHqBF9jOusp/Mk4bn8u+3SuDICtLPDf5w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"clsx": "^2.1.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@mantine/core": "9.6.1",
|
|
||||||
"@mantine/hooks": "9.6.1",
|
|
||||||
"dayjs": ">=1.0.0",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@mantine/hooks": {
|
|
||||||
"version": "9.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
|
|
||||||
"integrity": "sha512-pfxM95WozngB0ZALw6h99QV1TQ0qniOkFhzi0+dmSnKpxkVSR0xeNsvPpGDj9Teu+M69fRoE4IkzyMnu5Xld3w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^19.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
|
|
@ -915,78 +810,6 @@
|
||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/core": {
|
|
||||||
"version": "6.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
|
|
||||||
"integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"markdown-to-jsx": "^9.8.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@rjsf/utils": "^6.10.0",
|
|
||||||
"react": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rjsf/mantine": {
|
|
||||||
"version": "6.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/mantine/-/mantine-6.10.0.tgz",
|
|
||||||
"integrity": "sha512-tqD2v7rCEaI5Dme66MiaIMem/zTuKJBn9+B1d5J8cVCOh77FmEyn1neVvB6rPK+jXNVY6jJD8DRW8vAzK6OPRQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"dayjs": "^1.11.21"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@mantine/core": ">=8",
|
|
||||||
"@mantine/dates": ">=8",
|
|
||||||
"@mantine/hooks": ">=8",
|
|
||||||
"@rjsf/core": "^6.10.0",
|
|
||||||
"@rjsf/utils": "^6.10.0",
|
|
||||||
"react": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rjsf/utils": {
|
|
||||||
"version": "6.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
|
|
||||||
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@x0k/json-schema-merge": "^1.0.3",
|
|
||||||
"fast-equals": "^6.0.0",
|
|
||||||
"fast-uri": "^4.1.4",
|
|
||||||
"jsonpointer": "^5.0.1",
|
|
||||||
"react-is": "^18.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rjsf/validator-ajv8": {
|
|
||||||
"version": "6.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz",
|
|
||||||
"integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"ajv": "^8.20.0",
|
|
||||||
"ajv-formats": "^2.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@rjsf/utils": "^6.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -1344,32 +1167,6 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@tabler/icons": {
|
|
||||||
"version": "3.46.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
|
|
||||||
"integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/codecalm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tabler/icons-react": {
|
|
||||||
"version": "3.46.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
|
|
||||||
"integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tabler/icons": "3.46.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/codecalm"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">= 16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1422,18 +1219,11 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
|
||||||
"version": "7.0.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
|
|
@ -1470,65 +1260,6 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@x0k/json-schema-merge": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@types/json-schema": "^7.0.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"fast-uri": "^3.0.1",
|
|
||||||
"json-schema-traverse": "^1.0.0",
|
|
||||||
"require-from-string": "^2.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/epoberezkin"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv-formats": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ajv": "^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ajv": "^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"ajv": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv/node_modules/fast-uri": {
|
|
||||||
"version": "3.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.11.21",
|
"version": "2.11.21",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||||
|
|
@ -1597,15 +1328,6 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/clsx": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
|
@ -1613,30 +1335,11 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/dayjs": {
|
|
||||||
"version": "1.11.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
|
||||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
|
|
@ -1657,12 +1360,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-node-es": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.420",
|
"version": "1.5.420",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
|
||||||
|
|
@ -1722,39 +1419,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fast-equals": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-uri": {
|
|
||||||
"version": "4.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
|
||||||
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
|
@ -1798,15 +1462,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-nonce": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -1827,12 +1482,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
|
|
@ -1846,16 +1495,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsonpointer": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
@ -1866,34 +1505,6 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/markdown-to-jsx": {
|
|
||||||
"version": "9.10.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
|
|
||||||
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">= 16.0.0",
|
|
||||||
"solid-js": ">=1.0.0",
|
|
||||||
"vue": ">=3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-native": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"solid-js": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -1950,35 +1561,6 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.63.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|
@ -2029,23 +1611,6 @@
|
||||||
"react": "^19.2.8"
|
"react": "^19.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
|
||||||
"version": "18.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/react-number-format": {
|
|
||||||
"version": "5.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
|
|
||||||
"integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
@ -2056,122 +1621,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
|
||||||
"version": "2.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
|
||||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"react-remove-scroll-bar": "^2.3.7",
|
|
||||||
"react-style-singleton": "^2.2.3",
|
|
||||||
"tslib": "^2.1.0",
|
|
||||||
"use-callback-ref": "^1.3.3",
|
|
||||||
"use-sidecar": "^1.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-remove-scroll-bar": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"react-style-singleton": "^2.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-router": {
|
|
||||||
"version": "7.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
|
|
||||||
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "^1.0.1",
|
|
||||||
"set-cookie-parser": "^2.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18",
|
|
||||||
"react-dom": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-router-dom": {
|
|
||||||
"version": "7.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
|
|
||||||
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"react-router": "7.18.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18",
|
|
||||||
"react-dom": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-style-singleton": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"get-nonce": "^1.0.0",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/require-from-string": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.63.1",
|
"version": "4.63.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
|
||||||
|
|
@ -2234,12 +1683,6 @@
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
|
||||||
"version": "2.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
@ -2250,24 +1693,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tabbable": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tagged-tag": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|
@ -2285,27 +1710,6 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/type-fest": {
|
|
||||||
"version": "5.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
|
|
||||||
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
|
||||||
"dependencies": {
|
|
||||||
"tagged-tag": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||||
|
|
@ -2351,49 +1755,6 @@
|
||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-callback-ref": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/use-sidecar": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-node-es": "^1.1.0",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,16 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"screenshots": "node scripts/capture.mjs",
|
|
||||||
"icons": "node scripts/icons.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^9.6.1",
|
|
||||||
"@mantine/hooks": "^9.6.1",
|
|
||||||
"@rjsf/core": "^6.10.0",
|
|
||||||
"@rjsf/mantine": "^6.10.0",
|
|
||||||
"@rjsf/validator-ajv8": "^6.10.0",
|
|
||||||
"@tabler/icons-react": "^3.46.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"react-router-dom": "^7.18.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"playwright": "^1.63.0",
|
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 679 B |
|
|
@ -1,15 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
||||||
<style>
|
|
||||||
.bg { fill: #1d4a79; }
|
|
||||||
.fg { fill: #ffffff; }
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.bg { fill: #356fae; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
|
|
||||||
<!-- stylized bell tower: spire + shaft + bell arch -->
|
|
||||||
<path class="fg" d="M32 9 L41 25 H23 Z"/>
|
|
||||||
<rect class="fg" x="25" y="25" width="14" height="27"/>
|
|
||||||
<!-- bell arch knockout -->
|
|
||||||
<path class="bg" d="M28.5 40 v-4 a3.5 3.5 0 0 1 7 0 v4 Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,20 +0,0 @@
|
||||||
# Landing-page photo sources
|
|
||||||
|
|
||||||
All files in this directory are self-hosted (no hotlinking, no tracking).
|
|
||||||
Licensed for free use including commercial purposes, no attribution
|
|
||||||
required (Pexels License). Re-fetch any time:
|
|
||||||
|
|
||||||
docker compose exec api /opt/venv/bin/python \
|
|
||||||
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
|
|
||||||
|
|
||||||
Rejected during visual review (do NOT re-add without checking):
|
|
||||||
US-megachurch conference screens, dark baroque interior, pandemic masks,
|
|
||||||
Dutch-language documents.
|
|
||||||
|
|
||||||
| File | Motif | Source |
|
|
||||||
| ---------------------- | ------------------------------ | ------ |
|
|
||||||
| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ |
|
|
||||||
| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ |
|
|
||||||
| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ |
|
|
||||||
| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ |
|
|
||||||
| pilot-meeting.webp | Consultation meeting, notes | https://www.pexels.com/photo/two-people-shaking-hands-5668858/ |
|
|
||||||
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 894 KiB |
|
Before Width: | Height: | Size: 957 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Kirche Einfach",
|
|
||||||
"short_name": "Kirche",
|
|
||||||
"description": "KI-gestützte Verwaltungsplattform für Kirche",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"theme_color": "#1d4a79",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
/* Repeatable landing-page screenshots (re-run after UI changes).
|
|
||||||
*
|
|
||||||
* Usage (frontend container, dev server running):
|
|
||||||
* npm run screenshots # light scheme (default)
|
|
||||||
* npm run screenshots -- --scheme=dark
|
|
||||||
*
|
|
||||||
* Captures real app states into public/screenshots/ (committed, so the
|
|
||||||
* landing page always shows current UI). Authenticated states are out of
|
|
||||||
* scope (magic-link login is not scriptable without SMTP).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const outDir = join(root, "public", "screenshots");
|
|
||||||
const base = process.env.SCREENSHOT_BASE_URL ?? "http://localhost:3000";
|
|
||||||
const schemeArg = process.argv.find((a) => a.startsWith("--scheme="));
|
|
||||||
const scheme = schemeArg ? schemeArg.split("=")[1] : "light";
|
|
||||||
|
|
||||||
const shots = [
|
|
||||||
{ name: "hero", path: "/", fullPage: true },
|
|
||||||
{
|
|
||||||
name: "workflows",
|
|
||||||
path: "/app/workflows",
|
|
||||||
fullPage: false,
|
|
||||||
selectFlow: "intake_form",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
try {
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: { width: 1440, height: 900 },
|
|
||||||
colorScheme: scheme === "dark" ? "dark" : "light",
|
|
||||||
});
|
|
||||||
await context.addInitScript(
|
|
||||||
(value) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem("kirche-color-scheme", value);
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scheme === "dark" ? "dark" : "light",
|
|
||||||
);
|
|
||||||
const page = await context.newPage();
|
|
||||||
for (const shot of shots) {
|
|
||||||
await page.goto(base + shot.path, { waitUntil: "networkidle" });
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
if (shot.selectFlow) {
|
|
||||||
// Show the generated RJSF form instead of the default first flow.
|
|
||||||
await page.locator('[role="combobox"]').first().click();
|
|
||||||
await page.getByRole("option", { name: shot.selectFlow }).click();
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
const file = join(outDir, `${shot.name}-${scheme}.png`);
|
|
||||||
await page.screenshot({ path: file, fullPage: shot.fullPage });
|
|
||||||
console.log(`wrote ${file}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""Fetch landing-page photos (one-time + re-runnable).
|
|
||||||
|
|
||||||
Downloads royalty-free originals (see SOURCES.md next to the photos),
|
|
||||||
resizes to max 1600px wide and stores them as WebP q80 in
|
|
||||||
frontend/public/photos/ (self-hosted: no hotlinking, no tracking).
|
|
||||||
|
|
||||||
Run in the api container (has network + Pillow):
|
|
||||||
docker compose exec api /opt/venv/bin/python \\
|
|
||||||
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
sys.exit("Pillow required (present in the api image)")
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
OUT = ROOT / "frontend" / "public" / "photos"
|
|
||||||
|
|
||||||
|
|
||||||
def pexels(photo_id: int) -> str:
|
|
||||||
return (
|
|
||||||
f"https://images.pexels.com/photos/{photo_id}/"
|
|
||||||
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PHOTOS = {
|
|
||||||
# target file -> (direct file URL, source page for SOURCES.md)
|
|
||||||
"hero-church.webp": (
|
|
||||||
pexels(31630127),
|
|
||||||
"https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/",
|
|
||||||
),
|
|
||||||
"church-pews.webp": (
|
|
||||||
pexels(7566207),
|
|
||||||
"https://www.pexels.com/photo/church-pews-7566207/",
|
|
||||||
),
|
|
||||||
"paperwork-desk.webp": (
|
|
||||||
pexels(18687845),
|
|
||||||
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
|
|
||||||
),
|
|
||||||
"team-laptop.webp": (
|
|
||||||
pexels(7793702),
|
|
||||||
"https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/",
|
|
||||||
),
|
|
||||||
"pilot-meeting.webp": (
|
|
||||||
pexels(5668858),
|
|
||||||
"https://www.pexels.com/photo/two-people-shaking-hands-5668858/",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch(url: str) -> bytes:
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"})
|
|
||||||
with urllib.request.urlopen(req, timeout=120) as response:
|
|
||||||
return response.read()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUT.mkdir(parents=True, exist_ok=True)
|
|
||||||
for filename, (url, _page) in PHOTOS.items():
|
|
||||||
raw = fetch(url)
|
|
||||||
with Image.open(BytesIO(raw)) as img:
|
|
||||||
img = img.convert("RGB")
|
|
||||||
if img.width > 1600:
|
|
||||||
img = img.resize(
|
|
||||||
(1600, round(img.height * 1600 / img.width)), Image.LANCZOS
|
|
||||||
)
|
|
||||||
target = OUT / Path(filename).with_suffix(".webp").name
|
|
||||||
img.save(target, "WEBP", quality=80, method=6)
|
|
||||||
print(f"wrote {target} ({target.stat().st_size // 1024} KB)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable).
|
|
||||||
*
|
|
||||||
* Usage (frontend container, Chromium already installed for screenshots):
|
|
||||||
* npm run icons
|
|
||||||
*
|
|
||||||
* Renders the SVG at each target size (crisp native rasterization) into
|
|
||||||
* public/. apple-touch-icon keeps the solid kirchenblau background (iOS
|
|
||||||
* fills transparency black). The 512 icon doubles as maskable: the glyph
|
|
||||||
* stays inside the inner 80% safe zone.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join, dirname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8");
|
|
||||||
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
||||||
|
|
||||||
const targets = [
|
|
||||||
["favicon-32x32.png", 32],
|
|
||||||
["apple-touch-icon.png", 180],
|
|
||||||
["icon-192.png", 192],
|
|
||||||
["icon-512.png", 512],
|
|
||||||
];
|
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
for (const [file, size] of targets) {
|
|
||||||
await page.setViewportSize({ width: size, height: size });
|
|
||||||
await page.setContent(
|
|
||||||
`<body style="margin:0"><img src="${dataUrl}" width="${size}" height="${size}"/></body>`,
|
|
||||||
);
|
|
||||||
await page.locator("img").screenshot({
|
|
||||||
path: join(root, "public", file),
|
|
||||||
omitBackground: true,
|
|
||||||
});
|
|
||||||
console.log(`wrote public/${file}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +1,39 @@
|
||||||
import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
import { useState } from "react";
|
||||||
import {
|
import AdminTab from "./components/AdminTab";
|
||||||
Anchor,
|
import ApprovalsTab from "./components/ApprovalsTab";
|
||||||
AppShell,
|
import FlowsTab from "./components/FlowsTab";
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { AuthProvider, useAuth } from "./auth";
|
|
||||||
import SchemeToggle from "./components/SchemeToggle";
|
|
||||||
import StartPage from "./pages/StartPage";
|
|
||||||
import AccessPage from "./pages/AccessPage";
|
|
||||||
import LoginPage from "./pages/LoginPage";
|
|
||||||
import WorkflowsPage from "./pages/WorkflowsPage";
|
|
||||||
import TasksPage from "./pages/TasksPage";
|
|
||||||
import AdminPage from "./pages/AdminPage";
|
|
||||||
|
|
||||||
function Nav() {
|
type Tab = "flows" | "approvals" | "admin";
|
||||||
const { me, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
async function onLogout() {
|
const tabStyle = (active: boolean): React.CSSProperties => ({
|
||||||
await logout();
|
fontWeight: active ? "bold" : "normal",
|
||||||
navigate("/");
|
textDecoration: active ? "underline" : "none",
|
||||||
}
|
});
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap="md">
|
|
||||||
<Anchor component={Link} to="/">
|
|
||||||
Start
|
|
||||||
</Anchor>
|
|
||||||
{!me && (
|
|
||||||
<Anchor component={Link} to="/access">
|
|
||||||
Zugang
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
{me && (
|
|
||||||
<>
|
|
||||||
<Anchor component={Link} to="/app/workflows">
|
|
||||||
Workflows
|
|
||||||
</Anchor>
|
|
||||||
<Anchor component={Link} to="/app/tasks">
|
|
||||||
Aufgaben
|
|
||||||
</Anchor>
|
|
||||||
<Anchor component={Link} to="/app/admin">
|
|
||||||
Verwaltung
|
|
||||||
</Anchor>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<SchemeToggle />
|
|
||||||
{me && (
|
|
||||||
<>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{me.email}
|
|
||||||
</Text>
|
|
||||||
<Button variant="subtle" size="compact-md" onClick={() => void onLogout()}>
|
|
||||||
Abmelden
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
|
||||||
const { me, loading } = useAuth();
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Center mt="xl">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Runs, run history and approvals are login-gated server-side;
|
|
||||||
// anonymous visitors go to the magic-link login.
|
|
||||||
if (!me) return <Navigate to="/login" replace />;
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Shell() {
|
|
||||||
const { loading } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell header={{ height: 64 }} padding="md">
|
|
||||||
<AppShell.Header>
|
|
||||||
<Container
|
|
||||||
h="100%"
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 16 }}
|
|
||||||
>
|
|
||||||
<Anchor
|
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
underline="never"
|
|
||||||
style={{ color: "inherit" }}
|
|
||||||
>
|
|
||||||
<Title order={3}>Kirche Einfach</Title>
|
|
||||||
</Anchor>
|
|
||||||
<div style={{ marginLeft: "auto" }}>
|
|
||||||
<Nav />
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</AppShell.Header>
|
|
||||||
<AppShell.Main>
|
|
||||||
<Container>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
KI-gestützte Verwaltungsplattform für Kirche
|
|
||||||
</Text>
|
|
||||||
{loading ? (
|
|
||||||
<Center mt="xl">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<StartPage />} />
|
|
||||||
<Route path="/access" element={<AccessPage />} />
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route
|
|
||||||
path="/app/workflows"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<WorkflowsPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/app/tasks"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<TasksPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/app/admin"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<AdminPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="*" element={<StartPage />} />
|
|
||||||
</Routes>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</AppShell.Main>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [tab, setTab] = useState<Tab>("flows");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
|
||||||
<Shell />
|
<h1>Kirche Einfach</h1>
|
||||||
</AuthProvider>
|
<p>KI-gestützte Verwaltungsplattform für Kirche</p>
|
||||||
|
<nav style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
||||||
|
<button style={tabStyle(tab === "flows")} onClick={() => setTab("flows")}>
|
||||||
|
Vorgänge
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={tabStyle(tab === "approvals")}
|
||||||
|
onClick={() => setTab("approvals")}
|
||||||
|
>
|
||||||
|
Freigaben
|
||||||
|
</button>
|
||||||
|
<button style={tabStyle(tab === "admin")} onClick={() => setTab("admin")}>
|
||||||
|
Administration
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{tab === "flows" && <FlowsTab />}
|
||||||
|
{tab === "approvals" && <ApprovalsTab />}
|
||||||
|
{tab === "admin" && <AdminTab />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,33 +69,6 @@ export interface SyncReport {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Me {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
tenant_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccessRequest {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
reason: string;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
decided_at: string | null;
|
|
||||||
decided_by: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InputSchema {
|
|
||||||
flow_id: string;
|
|
||||||
version_no: number;
|
|
||||||
json_schema: Record<string, unknown>;
|
|
||||||
ui_schema: Record<string, unknown>;
|
|
||||||
fallback: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string) {
|
||||||
|
|
@ -106,8 +79,6 @@ export class ApiError extends Error {
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
// Session cookie (HttpOnly) must travel with every request.
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
@ -143,21 +114,22 @@ export const api = {
|
||||||
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
|
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
runFlow: (flowId: string, input: unknown) =>
|
runFlow: (flowId: string, input: unknown, tenantId: string) =>
|
||||||
req<Run>(`/api/flows/${flowId}/run`, {
|
req<Run>(`/api/flows/${flowId}/run`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
// Tenant comes from the session server-side; no tenant box anymore.
|
body: JSON.stringify({ input, tenant_id: tenantId }),
|
||||||
body: JSON.stringify({ input }),
|
|
||||||
}),
|
}),
|
||||||
getInputSchema: (flowId: string) =>
|
|
||||||
req<InputSchema>(`/api/flows/${flowId}/input-schema`),
|
|
||||||
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
|
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
|
||||||
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
|
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
|
||||||
decide: (taskId: string, decision: "approve" | "reject", note: string) =>
|
decide: (
|
||||||
|
taskId: string,
|
||||||
|
decision: "approve" | "reject",
|
||||||
|
approver: string,
|
||||||
|
note: string,
|
||||||
|
) =>
|
||||||
req<Decision>(`/api/approvals/${taskId}/decision`, {
|
req<Decision>(`/api/approvals/${taskId}/decision`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
// Identity comes from the session; the server records the user.
|
body: JSON.stringify({ decision, approver, note }),
|
||||||
body: JSON.stringify({ decision, note }),
|
|
||||||
}),
|
}),
|
||||||
importFlow: (langflowFlowId: string, name?: string) =>
|
importFlow: (langflowFlowId: string, name?: string) =>
|
||||||
req<{ definition: FlowDefinition; version: FlowVersion }>(
|
req<{ definition: FlowDefinition; version: FlowVersion }>(
|
||||||
|
|
@ -176,38 +148,4 @@ export const api = {
|
||||||
req<SyncReport>("/api/flows/sync", {
|
req<SyncReport>("/api/flows/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
me: async (): Promise<Me | null> => {
|
|
||||||
try {
|
|
||||||
return await req<Me>("/api/auth/me");
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.status === 401) return null;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
consumeMagic: (token: string) =>
|
|
||||||
req<Me>("/api/auth/magic/consume", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
}),
|
|
||||||
requestMagicLink: (email: string) =>
|
|
||||||
req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
}),
|
|
||||||
logout: () =>
|
|
||||||
req<{ message: string }>("/api/auth/logout", { method: "POST" }),
|
|
||||||
requestAccess: (name: string, email: string, reason: string) =>
|
|
||||||
req<AccessRequest>("/api/access/requests", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ name, email, reason }),
|
|
||||||
}),
|
|
||||||
listAccessRequests: (status?: string) =>
|
|
||||||
req<AccessRequest[]>(
|
|
||||||
`/api/access/requests${status ? `?status=${status}` : ""}`,
|
|
||||||
),
|
|
||||||
decideAccess: (id: string, approve: boolean) =>
|
|
||||||
req<{ request: AccessRequest; email_sent: boolean }>(
|
|
||||||
`/api/access/requests/${id}/${approve ? "approve" : "reject"}`,
|
|
||||||
{ method: "POST" },
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { api, type Me } from "./api";
|
|
||||||
|
|
||||||
interface Auth {
|
|
||||||
me: Me | null;
|
|
||||||
loading: boolean;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<Auth>({
|
|
||||||
me: null,
|
|
||||||
loading: true,
|
|
||||||
refresh: async () => {},
|
|
||||||
logout: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setMe(await api.me());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
await api.logout();
|
|
||||||
setMe(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ me, loading, refresh, logout }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth(): Auth {
|
|
||||||
return useContext(AuthContext);
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconCheck,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { ApiError, api, type AccessRequest } from "../api";
|
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
|
|
||||||
export default function AccessQueue() {
|
|
||||||
const [requests, setRequests] = useState<AccessRequest[]>([]);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [info, setInfo] = useState("");
|
|
||||||
const [busyId, setBusyId] = useState("");
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setRequests(await api.listAccessRequests());
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
async function decide(id: string, approve: boolean) {
|
|
||||||
setBusyId(id);
|
|
||||||
setError("");
|
|
||||||
setInfo("");
|
|
||||||
try {
|
|
||||||
const res = await api.decideAccess(id, approve);
|
|
||||||
setRequests((prev) =>
|
|
||||||
prev.map((r) => (r.id === id ? res.request : r)),
|
|
||||||
);
|
|
||||||
if (approve && !res.email_sent) {
|
|
||||||
setInfo("Genehmigt — die E-Mail kam nicht an (SMTP prüfen).");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
|
||||||
} finally {
|
|
||||||
setBusyId("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = requests.filter((r) => r.status === "pending");
|
|
||||||
const done = requests.filter((r) => r.status !== "pending");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Title order={3} mb="xs">
|
|
||||||
Zugangsanfragen
|
|
||||||
</Title>
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{info && (
|
|
||||||
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
|
|
||||||
{info}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{pending.length === 0 && (
|
|
||||||
<Text c="dimmed" mb="md">
|
|
||||||
Keine offenen Anfragen.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Stack mb="md">
|
|
||||||
{pending.map((r) => (
|
|
||||||
<Card withBorder padding="md" key={r.id}>
|
|
||||||
<Group gap="xs" mb="xs">
|
|
||||||
<StatusBadge status={r.status} />
|
|
||||||
<strong>{r.name}</strong>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{r.email}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{r.reason && (
|
|
||||||
<Text size="sm" mb="sm">
|
|
||||||
{r.reason}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
color="green"
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onClick={() => void decide(r.id, true)}
|
|
||||||
>
|
|
||||||
Genehmigen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onClick={() => void decide(r.id, false)}
|
|
||||||
>
|
|
||||||
Ablehnen
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{done.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
Entschieden ({done.length})
|
|
||||||
</Title>
|
|
||||||
{done.map((r) => (
|
|
||||||
<Group key={r.id} gap="xs" mb={4}>
|
|
||||||
<StatusBadge status={r.status} />
|
|
||||||
<Text size="sm">
|
|
||||||
{r.name} · {r.email}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,4 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Anchor,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconCheck,
|
|
||||||
IconExternalLink,
|
|
||||||
IconRefresh,
|
|
||||||
IconUpload,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
LANGFLOW_URL,
|
LANGFLOW_URL,
|
||||||
|
|
@ -24,7 +6,6 @@ import {
|
||||||
type FlowDefinition,
|
type FlowDefinition,
|
||||||
type FlowVersion,
|
type FlowVersion,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
|
|
||||||
export default function AdminTab() {
|
export default function AdminTab() {
|
||||||
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||||
|
|
@ -105,110 +86,61 @@ export default function AdminTab() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title order={3} mb="xs">
|
<h2>Administration</h2>
|
||||||
Flows & Versionen
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
</Title>
|
{info && <p style={{ color: "green" }}>{info}</p>}
|
||||||
{error && (
|
<p>
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{info && (
|
|
||||||
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
|
|
||||||
{info}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Card withBorder padding="md" mb="md">
|
|
||||||
<Text mb="xs">
|
|
||||||
Flows werden visuell in Langflow entworfen:{" "}
|
Flows werden visuell in Langflow entworfen:{" "}
|
||||||
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
<a href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
||||||
Langflow öffnen <IconExternalLink size={14} />
|
Langflow öffnen
|
||||||
</Anchor>
|
</a>
|
||||||
</Text>
|
</p>
|
||||||
<Group align="flex-end">
|
<h3>Import aus Langflow</h3>
|
||||||
<TextInput
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
label="Langflow Flow-ID (UUID)"
|
<input
|
||||||
value={langflowId}
|
value={langflowId}
|
||||||
onChange={(e) => setLangflowId(e.target.value)}
|
onChange={(e) => setLangflowId(e.target.value)}
|
||||||
style={{ flex: 1 }}
|
placeholder="Langflow Flow-ID (UUID)"
|
||||||
styles={{ input: { fontFamily: "monospace" } }}
|
style={{ flex: 1, fontFamily: "monospace" }}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<input
|
||||||
label="Name (optional)"
|
|
||||||
value={importName}
|
value={importName}
|
||||||
onChange={(e) => setImportName(e.target.value)}
|
onChange={(e) => setImportName(e.target.value)}
|
||||||
|
placeholder="Name (optional)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button onClick={() => void onImport()} disabled={!langflowId.trim()}>
|
||||||
leftSection={<IconUpload size={16} />}
|
|
||||||
onClick={() => void onImport()}
|
|
||||||
disabled={!langflowId.trim()}
|
|
||||||
>
|
|
||||||
Importieren + Deployen
|
Importieren + Deployen
|
||||||
</Button>
|
</button>
|
||||||
</Group>
|
</div>
|
||||||
</Card>
|
<h3>Flows + Versionen</h3>
|
||||||
<Card withBorder padding="md" mb="md">
|
<div style={{ marginBottom: 12 }}>
|
||||||
<Group>
|
<button onClick={() => void onSync()}>
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconRefresh size={16} />}
|
|
||||||
onClick={() => void onSync()}
|
|
||||||
>
|
|
||||||
Aus backend/flows synchronisieren
|
Aus backend/flows synchronisieren
|
||||||
</Button>
|
</button>
|
||||||
</Group>
|
</div>
|
||||||
</Card>
|
{flows.length === 0 && <p>Keine Flows vorhanden.</p>}
|
||||||
{flows.length === 0 ? (
|
<ul>
|
||||||
<Text c="dimmed">Keine Flows vorhanden.</Text>
|
{flows.map((f) => (
|
||||||
) : (
|
<li key={f.id} style={{ marginBottom: 8 }}>
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Flow</Table.Th>
|
|
||||||
<Table.Th>Version</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th>Autor</Table.Th>
|
|
||||||
<Table.Th></Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{flows.map((f) =>
|
|
||||||
(versions[f.id] ?? []).map((v, i) => (
|
|
||||||
<Table.Tr key={v.id}>
|
|
||||||
<Table.Td>
|
|
||||||
{i === 0 && (
|
|
||||||
<>
|
|
||||||
<strong>{f.name}</strong>{" "}
|
<strong>{f.name}</strong>{" "}
|
||||||
<Text size="xs" c="dimmed" component="span">
|
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>
|
||||||
{f.source_path ? `(${f.source_path})` : "(API)"}
|
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||||
</Text>
|
</span>
|
||||||
</>
|
<ul>
|
||||||
)}
|
{(versions[f.id] ?? []).map((v) => (
|
||||||
</Table.Td>
|
<li key={v.id}>
|
||||||
<Table.Td>v{v.version_no}</Table.Td>
|
v{v.version_no} ·{" "}
|
||||||
<Table.Td>
|
{v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "}
|
||||||
{v.deployed_at ? (
|
{v.author || "–"}{" "}
|
||||||
<StatusBadge status="deployed" />
|
<button onClick={() => void onDeploy(f.id, v.version_no)}>
|
||||||
) : (
|
|
||||||
<StatusBadge status="pending" />
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{v.author || "–"}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => void onDeploy(f.id, v.version_no)}
|
|
||||||
>
|
|
||||||
Deployen
|
Deployen
|
||||||
</Button>
|
</button>
|
||||||
</Table.Td>
|
</li>
|
||||||
</Table.Tr>
|
))}
|
||||||
)),
|
</ul>
|
||||||
)}
|
</li>
|
||||||
</Table.Tbody>
|
))}
|
||||||
</Table>
|
</ul>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,9 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Code,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
|
|
||||||
import { ApiError, api, type ApprovalTask } from "../api";
|
import { ApiError, api, type ApprovalTask } from "../api";
|
||||||
import { useAuth } from "../auth";
|
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
|
|
||||||
export default function ApprovalsTab() {
|
export default function ApprovalsTab() {
|
||||||
const { me } = useAuth();
|
|
||||||
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
|
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
|
||||||
|
const [approver, setApprover] = useState("");
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busyId, setBusyId] = useState("");
|
const [busyId, setBusyId] = useState("");
|
||||||
|
|
@ -38,7 +24,7 @@ export default function ApprovalsTab() {
|
||||||
setBusyId(taskId);
|
setBusyId(taskId);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await api.decide(taskId, decision, note);
|
const result = await api.decide(taskId, decision, approver, note);
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((t) => (t.id === taskId ? result.task : t)),
|
prev.map((t) => (t.id === taskId ? result.task : t)),
|
||||||
);
|
);
|
||||||
|
|
@ -54,88 +40,56 @@ export default function ApprovalsTab() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title order={3} mb="xs">
|
<h2>Freigaben</h2>
|
||||||
Freigaben
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
</Title>
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
{error && (
|
<input
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
value={approver}
|
||||||
{error}
|
onChange={(e) => setApprover(e.target.value)}
|
||||||
</Alert>
|
placeholder="Name (Bearbeiter)"
|
||||||
)}
|
/>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<input
|
||||||
Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem
|
|
||||||
Konto zugeordnet.
|
|
||||||
</Text>
|
|
||||||
<Group mb="md" align="flex-end">
|
|
||||||
<TextInput
|
|
||||||
label="Notiz"
|
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="Notiz"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<button onClick={() => void load()}>Aktualisieren</button>
|
||||||
variant="light"
|
</div>
|
||||||
leftSection={<IconRefresh size={16} />}
|
<h3>Offen ({open.length})</h3>
|
||||||
onClick={() => void load()}
|
{open.length === 0 && <p>Keine offenen Freigaben.</p>}
|
||||||
>
|
<ul>
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
Offen ({open.length})
|
|
||||||
</Title>
|
|
||||||
{open.length === 0 && (
|
|
||||||
<Text c="dimmed" mb="md">
|
|
||||||
Keine offenen Freigaben.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Stack mb="md">
|
|
||||||
{open.map((t) => (
|
{open.map((t) => (
|
||||||
<Card withBorder padding="md" key={t.id}>
|
<li key={t.id} style={{ marginBottom: 8 }}>
|
||||||
<Group gap="xs" mb="xs">
|
<strong>{t.title || t.id}</strong> · {t.tenant_id}
|
||||||
<StatusBadge status={t.status} />
|
<br />
|
||||||
<strong>{t.title || t.id}</strong>
|
<code>{JSON.stringify(t.payload)}</code>
|
||||||
<Text size="sm" c="dimmed">
|
<br />
|
||||||
{t.tenant_id}
|
<button
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Code block mb="sm">
|
|
||||||
{JSON.stringify(t.payload)}
|
|
||||||
</Code>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
color="green"
|
|
||||||
leftSection={<IconCheck size={16} />}
|
|
||||||
loading={busyId === t.id}
|
|
||||||
onClick={() => void decide(t.id, "approve")}
|
onClick={() => void decide(t.id, "approve")}
|
||||||
|
disabled={busyId === t.id}
|
||||||
>
|
>
|
||||||
Freigeben
|
Freigeben
|
||||||
</Button>
|
</button>{" "}
|
||||||
<Button
|
<button
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconX size={16} />}
|
|
||||||
loading={busyId === t.id}
|
|
||||||
onClick={() => void decide(t.id, "reject")}
|
onClick={() => void decide(t.id, "reject")}
|
||||||
|
disabled={busyId === t.id}
|
||||||
>
|
>
|
||||||
Ablehnen
|
Ablehnen
|
||||||
</Button>
|
</button>
|
||||||
</Group>
|
</li>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</ul>
|
||||||
<Title order={4} mb="xs">
|
<h3>Entschieden ({done.length})</h3>
|
||||||
Entschieden ({done.length})
|
<ul>
|
||||||
</Title>
|
|
||||||
{done.map((t) => (
|
{done.map((t) => (
|
||||||
<Group key={t.id} gap="xs" mb={4}>
|
<li key={t.id}>
|
||||||
<StatusBadge status={t.status} />
|
<strong>{t.status}</strong> · {t.title || t.id} ·{" "}
|
||||||
<Text size="sm">
|
{t.approver ?? "–"}
|
||||||
{t.title || t.id} · {t.approver ?? "–"}
|
|
||||||
{t.decision_note ? ` · „${t.decision_note}“` : ""}
|
{t.decision_note ? ` · „${t.decision_note}“` : ""}
|
||||||
</Text>
|
</li>
|
||||||
</Group>
|
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
frontend/src/components/FlowsTab.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ApiError, api, type FlowDefinition, type Run } from "../api";
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FlowsTab() {
|
||||||
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||||
|
const [flowId, setFlowId] = useState("");
|
||||||
|
const [inputJson, setInputJson] = useState(
|
||||||
|
'{\n "betrag": 120000,\n "schwelle": 200000\n}',
|
||||||
|
);
|
||||||
|
const [tenant, setTenant] = useState("default");
|
||||||
|
const [runs, setRuns] = useState<Run[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const loadFlows = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listFlows();
|
||||||
|
setFlows(list);
|
||||||
|
const first = list[0];
|
||||||
|
if (first && !flowId) setFlowId(first.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||||
|
}
|
||||||
|
}, [flowId]);
|
||||||
|
|
||||||
|
const loadRuns = useCallback(async (id: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
setRuns(await api.listRuns(id));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFlows();
|
||||||
|
}, [loadFlows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRuns(flowId);
|
||||||
|
}, [flowId, loadRuns]);
|
||||||
|
|
||||||
|
async function onRun() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const payload: unknown = JSON.parse(inputJson);
|
||||||
|
const run = await api.runFlow(flowId, payload, tenant);
|
||||||
|
setRuns((prev) => [run, ...prev]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) setError("Ungültiges JSON in der Eingabe.");
|
||||||
|
else setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Vorgang starten</h2>
|
||||||
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
|
||||||
|
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}>
|
||||||
|
<option value="">— Flow wählen —</option>
|
||||||
|
{flows.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={tenant}
|
||||||
|
onChange={(e) => setTenant(e.target.value)}
|
||||||
|
placeholder="Mandant"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
<button onClick={() => void onRun()} disabled={!flowId || busy}>
|
||||||
|
{busy ? "Läuft …" : "Starten"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
style={inputStyle}
|
||||||
|
value={inputJson}
|
||||||
|
onChange={(e) => setInputJson(e.target.value)}
|
||||||
|
/>
|
||||||
|
<h3>Letzte Läufe</h3>
|
||||||
|
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
||||||
|
<ul>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
|
||||||
|
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { ActionIcon, useMantineColorScheme } from "@mantine/core";
|
|
||||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export default function SchemeToggle() {
|
|
||||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
|
||||||
const dark = colorScheme === "dark";
|
|
||||||
return (
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="lg"
|
|
||||||
onClick={() => toggleColorScheme()}
|
|
||||||
aria-label={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
|
|
||||||
title={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
|
|
||||||
>
|
|
||||||
{dark ? <IconSun size={20} /> : <IconMoon size={20} />}
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { Badge } from "@mantine/core";
|
|
||||||
|
|
||||||
/* Single color language for every status in the app. */
|
|
||||||
|
|
||||||
const COLORS: Record<string, string> = {
|
|
||||||
completed: "green",
|
|
||||||
approved: "green",
|
|
||||||
genehmigt: "green",
|
|
||||||
deployed: "green",
|
|
||||||
running: "blue",
|
|
||||||
open: "yellow",
|
|
||||||
pending: "yellow",
|
|
||||||
pending_approval: "yellow",
|
|
||||||
failed: "red",
|
|
||||||
rejected: "red",
|
|
||||||
abgelehnt: "red",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StatusBadge({ status }: { status: string }) {
|
|
||||||
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,9 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
import "@mantine/core/styles.css";
|
|
||||||
import "./photos.css";
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { colorSchemeManager, theme } from "./theme";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<MantineProvider
|
|
||||||
theme={theme}
|
|
||||||
colorSchemeManager={colorSchemeManager}
|
|
||||||
defaultColorScheme="light"
|
|
||||||
>
|
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
|
||||||
</MantineProvider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||