Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
.data
|
||||||
|
.preview-cache
|
||||||
|
__pycache__
|
||||||
|
tests
|
||||||
30
.env.example
Normal file
30
.env.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
||||||
|
# Datenbank: Postgres (docker-compose setzt sie selbst) oder SQLite für lokale Entwicklung
|
||||||
|
DATABASE_URL=sqlite:///./.data/onyscribe-admin.db
|
||||||
|
# Master-Key für Secrets in der DB UND Session-Cookies (Fernet-Key). SICHERN – ohne ihn sind Secrets unlesbar.
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# ── Login der Admin-Plattform (login.onyva.dev) ───────────────────────────────
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
OIDC_ISSUER=https://login.onyva.dev
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_REDIRECT_URI=https://admin.example.de/auth/callback
|
||||||
|
OIDC_LOGOUT_REDIRECT_URI=https://admin.example.de/
|
||||||
|
APP_COOKIE_SECURE=true
|
||||||
|
# Zugriffsregel (mindestens eine, sonst Default-Deny):
|
||||||
|
ADMIN_ALLOWED_EMAILS=
|
||||||
|
ADMIN_REQUIRED_ROLE=
|
||||||
|
|
||||||
|
# ── Deploys ───────────────────────────────────────────────────────────────────
|
||||||
|
DEPLOY_ALLOWED_TENANTS= # Tenants, die LIVE angefasst werden dürfen (kommagetrennt, * = alle, leer = keiner) – zum Start nur den Test-Tenant, z. B. demo-admin
|
||||||
|
DEPLOY_LIVE=false # true erlaubt ECHTE Schreibzugriffe auf Mittwald – bewusst aus lassen, bis der Core umgebaut ist
|
||||||
|
MITTWALD_API_TOKEN= # optional für lesende Dry-Run-Aufrufe
|
||||||
|
|
||||||
|
# ── OIDC-Clients der Tenants automatisch verwalten (login.onyva.dev /api/v1) ──
|
||||||
|
ONYVA_LOGIN_API_KEY= # Admin-Key aus dem Login-Dashboard (API-Keys); wie ein Passwort behandeln
|
||||||
|
OIDC_SYNC_LIVE=false # true = darf im Login Apps anlegen/ändern; ohne: nur lesen/Plan (Dry-Run)
|
||||||
|
|
||||||
|
# ── KI ────────────────────────────────────────────────────────────────────────
|
||||||
|
CORTECS_API_KEY= # alternativ: Einstellungen → Geteilte Secrets
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
.preview-cache/
|
||||||
|
.data/
|
||||||
|
.idea/
|
||||||
|
.gstack/
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# WeasyPrint (PDF-Vorschau) + Fonts – identisch zum Core-Image der Plattform
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi8 \
|
||||||
|
fonts-noto-core fonts-noto-color-emoji fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY vendor ./vendor
|
||||||
|
COPY seed ./seed
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
ENV PORT=8300 PYTHONUNBUFFERED=1
|
||||||
|
EXPOSE 8300
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8300')+'/healthz')"
|
||||||
|
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8300} --proxy-headers --forwarded-allow-ips='*'"]
|
||||||
24
Dockerfile.fromcore
Normal file
24
Dockerfile.fromcore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Admin-Image auf Basis des Core-Images: Sandbox-Vorschau, Schriften, Python-Pakete und WeasyPrint sind
|
||||||
|
# damit PER KONSTRUKTION identisch zur Produktion, und der Core-Vertrag (Schema, Manifest) kommt direkt aus dem Image.
|
||||||
|
#
|
||||||
|
# docker build -f Dockerfile.fromcore --build-arg CORE_IMAGE=<registry>/transkript-platform:<sha> -t onyscribe-admin .
|
||||||
|
#
|
||||||
|
# Coolify: ADMIN_DOCKERFILE=Dockerfile.fromcore, CORE_IMAGE=<registry>/transkript-platform:<sha oder latest>.
|
||||||
|
# Läuft der Admin auf einem anderen Core als ein gemeldetes Release, zeigt „Core“ das an und lässt keine Prüfung/Freigabe zu.
|
||||||
|
ARG CORE_IMAGE
|
||||||
|
FROM ${CORE_IMAGE}
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
COPY requirements-extra.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements-extra.txt \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends fonts-liberation && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY seed ./seed
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
# Core liegt im Image unter /app (core/, requirements.txt, BUILD_INFO.json)
|
||||||
|
ENV PLATFORM_DIR=/app PORT=8300 PYTHONUNBUFFERED=1 ONYSCRIBE_SKIP_DOTENV=1
|
||||||
|
EXPOSE 8300
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8300')+'/healthz')"
|
||||||
|
CMD ["sh", "-c", "cd /srv && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8300} --proxy-headers --forwarded-allow-ips='*'"]
|
||||||
141
README.md
Normal file
141
README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Onyscribe Admin
|
||||||
|
|
||||||
|
Separat gehostete Admin-Plattform für die Tenants von **Onyscribe** (Repo `transkript-platform`): Tenants anlegen und bearbeiten,
|
||||||
|
Live-Vorschau mit dem echten Core, versionieren, Secrets/OIDC/Domain pflegen, deployen, mit KI-Agent (Cortecs).
|
||||||
|
|
||||||
|
## Stand
|
||||||
|
|
||||||
|
Alles, was **ohne Änderung am Core und ohne echte Deployments** möglich ist, ist umgesetzt:
|
||||||
|
|
||||||
|
| Bereich | Stand |
|
||||||
|
|---|---|
|
||||||
|
| Tenants (7 importiert, Hash-identisch zu Git) | ✔ Import, Seed, Anlegen/Klonen, Status, Ressourcen, Notizen, Archivieren/Löschen |
|
||||||
|
| Entwurf → Diff → Version | ✔ unveränderliche Snapshots, Rollback als Entwurf, Hook-Code-Freigabe |
|
||||||
|
| Editor | ✔ Marke/Farben, Absender, PDF-Layout, Prompt, Dateien, Flags/STT, Hook-Code, tenant.json |
|
||||||
|
| Live-Vorschau | ✔ Web-UI + PDF mit dem echten Core in Sandbox (kein Secret, CPU-/Datei-Limits), Validierung |
|
||||||
|
| Prompt-Test | ✔ echter LLM-Lauf (braucht Cortecs-Key), finaler Prompt inkl. Hooks |
|
||||||
|
| KI-Agent | ✔ echter Tool-Loop (Messages- oder Chat-API gegen Cortecs), Workspace-Entwurf, Diff, Freigabe; ✔ Demo-Modus ohne Key; ⚠ gegen Live-Cortecs noch **ungetestet** (kein Key) |
|
||||||
|
| Login (OIDC) | ✔ Code + Tests (Mock-IdP); ⚠ gegen login.onyva.dev noch **ungetestet** |
|
||||||
|
| Secrets | ✔ Fernet-verschlüsselt, Import aus SOPS (`--with-secrets`) |
|
||||||
|
| OIDC-Clients | ✔ **automatischer Abgleich bei jedem Deploy** über die Login-Developer-API: Client anlegen, Redirect-URIs, auth/totp, Freigaben; Secret wird sofort verschlüsselt gespeichert; Dry-Run liest nur. Gegen Fake-Login getestet, gegen login.onyva.dev **ungetestet** (kein Key) |
|
||||||
|
| Domain | ✔ Subdomain/eigene Domain, DNS-Check |
|
||||||
|
| Deploy-Engine | ✔ Port von `infra/deploy.py` (PATCH, Guard, Ingress, Offboard, Abbruch bei Fehler), per Fake-HTTP getestet; **Schreibzugriffe gesperrt** (`DEPLOY_LIVE`) |
|
||||||
|
| Bundle-Endpoint | ✔ `GET /api/tenants/<id>/bundle` (Token); Gegenstück im Core: Branch `admin-platform-integration` (`core/bundle.py`) |
|
||||||
|
| **Core-Vertrag** | ✔ Schema/Manifest/Build-Info aus dem Core gelesen (`app/services/contract.py`); Formulare („Funktion & STT“ + automatisch alle neuen Optionen), Einstellungen und Container-Env werden daraus **erzeugt**; Pflicht-Secrets laut Schema |
|
||||||
|
| **Core-Releases** (Menü „Core“) | ✔ Release-Eingang für die Core-CI, Kompatibilitätsprüfung **aller** Tenants mit dem Core des Admins (Validierung + Fingerabdrücke von PDF/Web/Prompt gegen Baseline), Freigabe, Dry-Run, Canary-Rollout mit Abbruch, Health-Verifikation (Core-SHA, Bundle-Version), Pins pro Tenant, Rollback (Release/Tenant) |
|
||||||
|
| Zustandsabgleich | ✔ „Zustand prüfen“ im Dashboard und „Läuft?“ pro Tenant: fragt `/health` ab und zeigt Abweichungen |
|
||||||
|
|
||||||
|
**Bewusst offen (braucht Core- bzw. Produktionsentscheidung):**
|
||||||
|
1. Core-Branch `admin-platform-integration` (Vertrag, Bundle-Loader, `/health`) reviewen und mergen/deployen. Erst danach `core_supports_bundle` aktivieren (Einstellungen) – bis dahin sind Deploys von Tenants mit Quelle „Admin“ gesperrt.
|
||||||
|
2. `DEPLOY_LIVE=true` + `MITTWALD_API_TOKEN` + Projekt-/Stack-ID, sobald echte Deployments gewollt sind.
|
||||||
|
3. Cortecs-Key hinterlegen → Agent-Modell auswählen (Einstellungen → „Modelle von Cortecs laden“) und gegen Live testen.
|
||||||
|
4. OIDC-Client für die Admin-Plattform in login.onyva.dev anlegen, `AUTH_ENABLED=true`.
|
||||||
|
4b. Admin-Key im Login-Dashboard erzeugen → `ONYVA_LOGIN_API_KEY` setzen; mit „Verbindung testen“ (Einstellungen) prüfen; zum Schreiben `OIDC_SYNC_LIVE=true`.
|
||||||
|
5. Datenbank-Migrationen (Alembic) vor dem ersten produktiven Schemawechsel; Tabellen entstehen derzeit per `create_all`.
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lokal (SQLite, kein Docker)
|
||||||
|
python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt
|
||||||
|
# System-Libs für WeasyPrint: libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-core
|
||||||
|
cp .env.example .env # Keys eintragen (wird beim Start eingelesen; echte Env-Variablen haben Vorrang)
|
||||||
|
uvicorn app.main:app --port 8300 # http://localhost:8300 (Seed lädt die 7 Tenants automatisch) – nach .env-Änderungen neu starten
|
||||||
|
|
||||||
|
# mit Postgres (wie Coolify)
|
||||||
|
export POSTGRES_PASSWORD=... SECRET_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coolify:** Neue Ressource → Docker Compose → dieses Repo. Env `POSTGRES_PASSWORD` und `SECRET_KEY` setzen (weitere Variablen: `.env.example`),
|
||||||
|
Domain auf Service `admin`, Port `8300`. Backup: Volume `pgdata` **und** `SECRET_KEY`.
|
||||||
|
|
||||||
|
**Login aktivieren:** Client in login.onyva.dev anlegen (Redirect `https://<admin>/auth/callback`), dann `AUTH_ENABLED=true`, `OIDC_CLIENT_ID/SECRET`,
|
||||||
|
`OIDC_REDIRECT_URI`, `OIDC_LOGOUT_REDIRECT_URI` und eine Zugriffsregel (`ADMIN_ALLOWED_EMAILS` und/oder `ADMIN_REQUIRED_ROLE`). Ohne Regel wird jeder Login abgelehnt.
|
||||||
|
|
||||||
|
## Zusammenspiel mit dem Core
|
||||||
|
|
||||||
|
Spezifikation: `transkript-platform/docs/CONTRACT.md`. Kurz: Der Core liefert **Image + Schema + Manifest + Bundle-Format + `/health`**, der Admin liest das –
|
||||||
|
er hält keine eigene Kopie der Tenant-Optionen.
|
||||||
|
|
||||||
|
```
|
||||||
|
Core-Push → CI: Tests → Image (:sha) → POST /api/core/releases (Job „notify-admin“)
|
||||||
|
→ Admin neu bauen (Dockerfile.fromcore, FROM Core-Image) → „Core“: Prüfen (alle Tenants) → Freigeben → Ausrollen (Canary zuerst) → Health-Check je Tenant
|
||||||
|
Tenant-Änderung (ohne Core): Veröffentlichen → Deploy mit dem aktiven Core-Tag (nur Bundle-Neustart, kein Image-Build)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Admin auf Basis des Core-Images:** `Dockerfile.fromcore` (`CORE_IMAGE=<registry>/transkript-platform:<tag>`). Sandbox, Schriften und Pakete sind identisch zur Produktion. Läuft der Admin auf einem anderen Core als ein gemeldetes Release, zeigt „Core“ das an und erlaubt keine Prüfung/Freigabe. *Noch zu verifizieren in Coolify:* wie der Neubau mit neuem Tag ausgelöst wird (Webhook + `CORE_IMAGE` per Env/API oder `latest` mit „immer pullen“).
|
||||||
|
- **Lokal ohne Core-Image:** vendored Snapshot (`scripts/sync_core.sh`, zieht den aktuellen Stand der Quelle inkl. Branch).
|
||||||
|
- **Neue Core-Option?** Steht sie im Schema des Cores (CI erzwingt das), erscheint sie hier automatisch in Formularen, Einstellungen und Container-Env.
|
||||||
|
- **Release-Eingang:** Env `CORE_RELEASE_TOKEN` (Admin) = Secret `ADMIN_RELEASE_TOKEN` (Core-CI) + Variable `ADMIN_URL`.
|
||||||
|
|
||||||
|
## OIDC-Abgleich (Login-Developer-API)
|
||||||
|
|
||||||
|
Pro Tenant (Domain & Login) gibt es einen Soll-Zustand und eine **Verwaltung**:
|
||||||
|
|
||||||
|
| Modus | Verhalten beim Deploy |
|
||||||
|
|---|---|
|
||||||
|
| `off` | nichts anfassen |
|
||||||
|
| `adopt` (Standard für die 7 importierten Altclients) | prüfen, dass die Redirect-URIs (Subdomain + ggf. eigene Domain) enthalten sind, fehlende ergänzen – sonst nichts ändern |
|
||||||
|
| `managed` (Standard für neue Tenants) | Client fehlt → **anlegen**; sonst Redirect-URIs, `auth_mode`, `totp_mode`, `allow_registration`, Backchannel exakt wie konfiguriert; Freigaben setzen (optional „exakt“: überzählige entziehen) |
|
||||||
|
|
||||||
|
Ablauf eines Deploys: **1.** OIDC abgleichen (bei Neuanlage Client-ID/-Secret sofort verschlüsselt speichern) → **2.** Pflicht-Secrets prüfen → **3.** Service-PATCH mit den Werten
|
||||||
|
im Env → **4.** Ingress/Health. Schlägt der Abgleich fehl, wird **vor** dem Service-Eingriff abgebrochen. Dry-Run liest nur und zeigt „würde anlegen/anpassen“.
|
||||||
|
Zusätzlich manuell: „Gegen Login prüfen“, „Jetzt abgleichen“, „Aus Login übernehmen“ (Freigaben), „Secret rotieren“, „OIDC-Client im Login löschen“ (Gefahrenbereich).
|
||||||
|
Das Secret ist nur bei Anlage/Rotation sichtbar; ein Altclient ohne Secret in der Admin-DB wird **nie** automatisch rotiert (er würde laufende Logins unterbrechen) – Secret eintragen oder bewusst rotieren.
|
||||||
|
Lokal ausprobieren ohne echtes Login: `python scripts/fake_login_server.py` (siehe Datei-Kopf).
|
||||||
|
|
||||||
|
## KI-Agent
|
||||||
|
|
||||||
|
**Bedienung:** Header → „KI-Agent“. Der Chat ersetzt in der Mitte das Formular, die **Live-Vorschau bleibt rechts** und zeigt den Arbeitsstand des Agenten *während er arbeitet*
|
||||||
|
(„Agent-Stand (live)“, rendert nach jeder Änderung neu), danach den „Agent-Vorschlag“. „In Entwurf übernehmen“ schreibt ihn in den Entwurf, „Verwerfen“ lässt alles unverändert.
|
||||||
|
**Key-Suche:** geteiltes Secret (Einstellungen) → Env/`.env` `CORTECS_API_KEY` → Key des Tenants. Der Chat zeigt, woher der Key kommt, bzw. „Demo-Modus – kein Key gefunden“.
|
||||||
|
|
||||||
|
Cortecs bietet neben Chat-Completions auch das **Anthropic-Messages-Protokoll** (`/v1/messages`) – der Agent nutzt standardmäßig dieses
|
||||||
|
(Einstellung `agent_api`), mit Fallback auf Chat-Completions. Modell: Einstellung `agent_model` (Standard `claude-4-6-sonnet`, wie das Protokoll-Modell;
|
||||||
|
„Modelle von Cortecs laden“ zeigt die verfügbaren IDs und markiert Tool-Unterstützung). Der Agent arbeitet nur auf einem Entwurfs-Workspace; Namenslisten
|
||||||
|
sind gesperrt; Secrets kennt er nicht; Veröffentlichen/Deployen kann er nicht.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
main.py FastAPI, Startup (DB anlegen, Seed laden)
|
||||||
|
models.py Tenant, Version (unveränderlich), Blob (content-addressed), Secret (verschlüsselt), Deployment, AuditLog, ChatMessage
|
||||||
|
services/
|
||||||
|
store.py Entwurf ↔ Version, Diff, Bundle (tar.gz) – Kern der Versionierung
|
||||||
|
preview.py Live-Vorschau: Subprozess mit Kopie des Cores + Tenant, ohne Secrets (preview_worker.py)
|
||||||
|
importer.py Import aus ../transkript-platform (tenants/*, Secrets nur als Schlüsselnamen)
|
||||||
|
deploy_sim.py Deploy-Plan (wie infra/deploy.py) + Simulation ← hier später die echte Mittwald-Logik
|
||||||
|
agent_mock.py Mock-Agent ← hier später der Cortecs-Loop
|
||||||
|
seed.py Start-Daten (seed/tenants.seed.json.gz)
|
||||||
|
routers/ api.py (JSON), pages.py (HTML)
|
||||||
|
vendor/platform/ Snapshot des Cores (CORE_VERSION) – Vorschau führt genau diesen Stand aus
|
||||||
|
scripts/ import_tenants.py, verify_import.py, export_seed.py, sync_core.sh
|
||||||
|
tests/test_flow.py E2E: Import, Vorschau, Entwurf→Veröffentlichen, Validierung, Bundle, Deploy-Sim, Agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige Abläufe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/import_tenants.py # Tenants (+ Secret-SCHLÜSSEL) aus ../transkript-platform importieren
|
||||||
|
python scripts/import_tenants.py --with-secrets # zusätzlich Secret-WERTE via `sops -d` lesen und verschlüsselt speichern (Cut-over)
|
||||||
|
python scripts/verify_import.py # Beweis: DB-Bundle == Git-Ordner (Hash-Vergleich)
|
||||||
|
python scripts/export_seed.py # DB-Stand als Seed-Datei ins Repo (ohne Secret-Werte)
|
||||||
|
scripts/sync_core.sh # vendored Core aus transkript-platform aktualisieren
|
||||||
|
python -m pytest tests # Tests (51) (isoliert: lesen weder .env noch echte Keys, eigene Temp-DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bundle-Endpoint (Schnittstelle zum Core)
|
||||||
|
|
||||||
|
`GET /api/tenants/<id>/bundle` mit `Authorization: Bearer <ADMIN_BUNDLE_TOKEN>` liefert `tenant.json` + alle Dateien
|
||||||
|
(+ `MANIFEST.json` mit Hashes) – ohne Secrets. Das ist, was ein Tenant-Container später beim Start abholt
|
||||||
|
(`TENANT_SOURCE=admin`, Phase 1 im Core – noch nicht umgesetzt).
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. Core: `TENANT_SOURCE=git|admin` + Bundle-Loader mit Cache (Phase 1)
|
||||||
|
2. OIDC-Login der Admin-Plattform über login.onyva.dev
|
||||||
|
3. Spike: Cortecs-Modell für den Agent (Tool-Calling, Streaming, Bild-Input) → `agent_mock.py` ersetzen
|
||||||
|
4. Echte Deploys (Mittwald) aus `deploy_sim.py`, Rollout-Abbruch bei Fehler, Health-Check
|
||||||
|
5. Hook-Code-Freigabe härten (Sandbox ohne Netz), Rollen
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
129
app/agent/backends.py
Normal file
129
app/agent/backends.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""LLM-Backends für den Agenten. Interne Nachrichten im Anthropic-Block-Format:
|
||||||
|
assistant: [{"type":"text"|"tool_use",...}] · user: str | [{"type":"tool_result","tool_use_id","content"}]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Turn:
|
||||||
|
blocks: list[dict]
|
||||||
|
usage: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MessagesBackend:
|
||||||
|
"""Anthropic-Messages-Protokoll – Cortecs bietet es unter https://api.cortecs.ai/v1/messages an."""
|
||||||
|
name = "messages"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str, model: str):
|
||||||
|
import anthropic
|
||||||
|
root = re.sub(r"/v1/?$", "", base_url.rstrip("/")) # SDK hängt /v1/messages selbst an
|
||||||
|
self.client = anthropic.Anthropic(api_key=api_key, auth_token=api_key, base_url=root, max_retries=2, timeout=180)
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def step(self, system: str, messages: list[dict], tools: list[dict]) -> Turn:
|
||||||
|
r = self.client.messages.create(
|
||||||
|
model=self.model, max_tokens=8000, system=system, messages=messages,
|
||||||
|
tools=[{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools])
|
||||||
|
blocks = []
|
||||||
|
for b in r.content:
|
||||||
|
if b.type == "text":
|
||||||
|
blocks.append({"type": "text", "text": b.text})
|
||||||
|
elif b.type == "tool_use":
|
||||||
|
blocks.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
|
||||||
|
return Turn(blocks, {"in": r.usage.input_tokens, "out": r.usage.output_tokens})
|
||||||
|
|
||||||
|
|
||||||
|
class ChatBackend:
|
||||||
|
"""OpenAI-Chat-Completions (Fallback, falls ein Modell nur diesen Weg mit Tools kann)."""
|
||||||
|
name = "chat"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str, model: str):
|
||||||
|
from openai import OpenAI
|
||||||
|
self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=2, timeout=180)
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert(system: str, messages: list[dict]) -> list[dict]:
|
||||||
|
out = [{"role": "system", "content": system}]
|
||||||
|
for m in messages:
|
||||||
|
c = m["content"]
|
||||||
|
if isinstance(c, str):
|
||||||
|
out.append({"role": m["role"], "content": c})
|
||||||
|
elif m["role"] == "assistant":
|
||||||
|
text = "".join(b["text"] for b in c if b["type"] == "text")
|
||||||
|
calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b["input"])}}
|
||||||
|
for b in c if b["type"] == "tool_use"]
|
||||||
|
msg = {"role": "assistant", "content": text or None}
|
||||||
|
if calls:
|
||||||
|
msg["tool_calls"] = calls
|
||||||
|
out.append(msg)
|
||||||
|
else:
|
||||||
|
for b in c:
|
||||||
|
out.append({"role": "tool", "tool_call_id": b["tool_use_id"], "content": b["content"]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def step(self, system, messages, tools) -> Turn:
|
||||||
|
r = self.client.chat.completions.create(
|
||||||
|
model=self.model, max_tokens=8000, temperature=0.2, messages=self._convert(system, messages),
|
||||||
|
tools=[{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}} for t in tools])
|
||||||
|
msg = r.choices[0].message
|
||||||
|
blocks = []
|
||||||
|
if msg.content:
|
||||||
|
blocks.append({"type": "text", "text": msg.content})
|
||||||
|
for tc in msg.tool_calls or []:
|
||||||
|
try:
|
||||||
|
args = json.loads(tc.function.arguments or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
blocks.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": args})
|
||||||
|
u = r.usage
|
||||||
|
return Turn(blocks, {"in": getattr(u, "prompt_tokens", 0), "out": getattr(u, "completion_tokens", 0)})
|
||||||
|
|
||||||
|
|
||||||
|
HEX = re.compile(r"#[0-9a-fA-F]{6}\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _shade(h, f):
|
||||||
|
h = h.lstrip("#"); c = [max(0, min(255, int(int(h[i:i + 2], 16) * f))) for i in (0, 2, 4)]
|
||||||
|
return "#%02X%02X%02X" % tuple(c)
|
||||||
|
|
||||||
|
|
||||||
|
def _tint(h, a):
|
||||||
|
h = h.lstrip("#"); c = [int(int(h[i:i + 2], 16) + (255 - int(h[i:i + 2], 16)) * a) for i in (0, 2, 4)]
|
||||||
|
return "#%02X%02X%02X" % tuple(c)
|
||||||
|
|
||||||
|
|
||||||
|
class MockBackend:
|
||||||
|
"""Demo ohne Cortecs-Key: spielt über dieselben Tools zwei einfache Aufgaben durch."""
|
||||||
|
name = "mock"
|
||||||
|
model = "mock"
|
||||||
|
|
||||||
|
def step(self, system, messages, tools) -> Turn:
|
||||||
|
used = sum(1 for m in messages if m["role"] == "assistant")
|
||||||
|
user = next(m["content"] for m in messages if m["role"] == "user" and isinstance(m["content"], str))
|
||||||
|
low = user.lower()
|
||||||
|
tid = lambda: "mock_" + uuid.uuid4().hex[:8] # noqa: E731
|
||||||
|
call = lambda name, **a: {"type": "tool_use", "id": tid(), "name": name, "input": a} # noqa: E731
|
||||||
|
hexes = HEX.findall(user)
|
||||||
|
if hexes and any(w in low for w in ("farbe", "akzent", "color", "design")):
|
||||||
|
a = hexes[0].upper()
|
||||||
|
plan = [[call("set_config", key=f"colors.{k}", value=v) for k, v in
|
||||||
|
{"accent": a, "accent_dark": _shade(a, .72), "accent_mid": _shade(a, 1.12),
|
||||||
|
"accent_light": _tint(a, .88), "accent_xlight": _tint(a, .94)}.items()],
|
||||||
|
[call("validate")]]
|
||||||
|
final = f"Akzentfarbe auf {a} gesetzt und die abgeleiteten Töne angepasst."
|
||||||
|
elif "prompt" in low or "regel" in low:
|
||||||
|
rule = re.sub(r"^(bitte\s+)?(füge\s+)?(dem\s+)?prompt\s*(hinzu)?[:,]?\s*", "", user.strip(), flags=re.I)
|
||||||
|
plan = [[call("read_core_default", name="prompt.txt")], [call("__append_prompt__", rule=rule)], [call("validate")]]
|
||||||
|
final = "Zusatzregel im Prompt ergänzt."
|
||||||
|
else:
|
||||||
|
return Turn([{"type": "text", "text": "Demo-Modus – es wurde kein Cortecs-Key gefunden (geprüft: Einstellungen → Geteilte Secrets, Env/.env CORTECS_API_KEY, Key des Tenants). "
|
||||||
|
"Key setzen und die Seite neu laden. Ohne Key verstehe ich nur „Setze die Akzentfarbe auf #RRGGBB“ und „Füge dem Prompt hinzu: …“."}])
|
||||||
|
if used < len(plan):
|
||||||
|
return Turn(plan[used])
|
||||||
|
return Turn([{"type": "text", "text": final}])
|
||||||
175
app/agent/loop.py
Normal file
175
app/agent/loop.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Agent-Loop: Modell ↔ Werkzeuge auf dem Entwurfs-Workspace → Vorschlag zur Freigabe."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.agent import backends
|
||||||
|
from app.agent.tools import TOOLS, ToolRunner
|
||||||
|
from app.agent.workspace import Workspace
|
||||||
|
from app.models import ChatMessage, Setting, Tenant
|
||||||
|
from app.services import llm, store
|
||||||
|
|
||||||
|
MAX_TURNS = 14
|
||||||
|
|
||||||
|
# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24.
|
||||||
|
WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict()
|
||||||
|
|
||||||
|
|
||||||
|
def remember(run_id: str, ws: Workspace) -> None:
|
||||||
|
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files))
|
||||||
|
WORKSPACES.move_to_end(run_id)
|
||||||
|
while len(WORKSPACES) > 24:
|
||||||
|
WORKSPACES.popitem(last=False)
|
||||||
|
DEFAULT_MODEL = "claude-4-6-sonnet"
|
||||||
|
|
||||||
|
SYSTEM = """Du bist der Konfigurations-Agent der Onyscribe-Admin-Plattform. Onyscribe erzeugt aus Gesprächsaufzeichnungen Protokolle
|
||||||
|
(Transkription → LLM-Protokoll → PDF/Word). Jeder Kunde ist ein „Tenant“; alles, was ihn vom Standard unterscheidet, sind DATEN:
|
||||||
|
tenant.json (Name, Farben, Kontaktdaten, Flags, STT), prompt.txt, protocol.html/protocol.css (PDF-Layout), optional overrides.py
|
||||||
|
(Hook-Code) und Zusatzdateien. Der Core-Code der Plattform ist NICHT veränderbar und nicht sichtbar – du arbeitest ausschließlich an Tenant-Daten.
|
||||||
|
|
||||||
|
Arbeitsweise:
|
||||||
|
- Du arbeitest auf einem ENTWURF im Speicher. Nichts ist live; am Ende prüft ein Mensch deinen Vorschlag und gibt ihn frei.
|
||||||
|
- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese.
|
||||||
|
- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht.
|
||||||
|
- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest.
|
||||||
|
- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes.
|
||||||
|
- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an.
|
||||||
|
- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden.
|
||||||
|
- Antworte am Ende kurz auf Deutsch (du-Form): was du geändert hast und worauf der Mensch beim Prüfen achten soll. Keine Markdown-Überschriften."""
|
||||||
|
|
||||||
|
|
||||||
|
def _setting(db: Session, key: str, default: str = "") -> str:
|
||||||
|
s = db.get(Setting, key)
|
||||||
|
return s.value if s and s.value else default
|
||||||
|
|
||||||
|
|
||||||
|
def make_backend(db: Session, tenant_id: str | None = None):
|
||||||
|
model = _setting(db, "agent_model", DEFAULT_MODEL)
|
||||||
|
key = llm.api_key(db, tenant_id)
|
||||||
|
if not key:
|
||||||
|
return backends.MockBackend()
|
||||||
|
api = _setting(db, "agent_api", "messages")
|
||||||
|
cls = backends.MessagesBackend if api == "messages" else backends.ChatBackend
|
||||||
|
return cls(key, llm.base_url(db), model)
|
||||||
|
|
||||||
|
|
||||||
|
def _context(tenant: Tenant, ws: Workspace) -> str:
|
||||||
|
return (f"Tenant: {tenant.id} („{ws.cfg.get('name', '')}“). Dateien: "
|
||||||
|
+ ", ".join(f['path'] for f in ws.list_files()) + ". Der Entwurf kann bereits unveröffentlichte Änderungen enthalten.")
|
||||||
|
|
||||||
|
|
||||||
|
def _history(db: Session, tenant_id: str, limit: int = 8) -> list[dict]:
|
||||||
|
rows = db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == tenant_id).order_by(ChatMessage.id.desc()).limit(limit)).all()
|
||||||
|
out = []
|
||||||
|
for m in reversed(rows):
|
||||||
|
if m.content:
|
||||||
|
out.append({"role": m.role, "content": m.content})
|
||||||
|
# Anthropic verlangt Rollenwechsel beginnend mit user
|
||||||
|
while out and out[0]["role"] != "user":
|
||||||
|
out.pop(0)
|
||||||
|
merged = []
|
||||||
|
for m in out:
|
||||||
|
if merged and merged[-1]["role"] == m["role"]:
|
||||||
|
merged[-1]["content"] += "\n" + m["content"]
|
||||||
|
else:
|
||||||
|
merged.append(dict(m))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterator[dict]:
|
||||||
|
"""Generator von Ereignissen: step | text | proposal | usage | error. Schreibt NICHT in den Entwurf."""
|
||||||
|
backend = backend or make_backend(db, tenant.id)
|
||||||
|
ws = Workspace(db, tenant)
|
||||||
|
run_id = uuid.uuid4().hex[:12]
|
||||||
|
remember(run_id, ws)
|
||||||
|
last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
|
||||||
|
runner = ToolRunner(ws)
|
||||||
|
system = SYSTEM + "\n\n" + _context(tenant, ws)
|
||||||
|
messages = _history(db, tenant.id)
|
||||||
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
usage = {"in": 0, "out": 0}
|
||||||
|
final_text = ""
|
||||||
|
yield {"type": "mode", "backend": backend.name, "model": getattr(backend, "model", ""), "run": run_id}
|
||||||
|
try:
|
||||||
|
for _ in range(MAX_TURNS):
|
||||||
|
turn = backend.step(system, messages, TOOLS)
|
||||||
|
usage["in"] += turn.usage.get("in", 0)
|
||||||
|
usage["out"] += turn.usage.get("out", 0)
|
||||||
|
messages.append({"role": "assistant", "content": turn.blocks})
|
||||||
|
calls = [b for b in turn.blocks if b["type"] == "tool_use"]
|
||||||
|
texts = [b["text"] for b in turn.blocks if b["type"] == "text" and b["text"].strip()]
|
||||||
|
if texts:
|
||||||
|
final_text = "\n".join(texts)
|
||||||
|
if calls:
|
||||||
|
yield {"type": "thought", "text": final_text}
|
||||||
|
if not calls:
|
||||||
|
break
|
||||||
|
results = []
|
||||||
|
for c in calls:
|
||||||
|
name, args = c["name"], c["input"]
|
||||||
|
if name == "__append_prompt__": # nur Mock: Standard-Prompt kopieren + Regel anhängen
|
||||||
|
base = ws.read("prompt.txt") if "prompt.txt" in ws.files else (
|
||||||
|
__import__("app.settings", fromlist=["PLATFORM_DIR"]).PLATFORM_DIR / "core" / "defaults" / "prompt.txt").read_text(encoding="utf-8")
|
||||||
|
ws.write("prompt.txt", base.rstrip() + f"\n\nZusätzliche Regel:\n{args['rule'].strip()}\n")
|
||||||
|
arg, res = "prompt.txt", "Zusatzregel angehängt"
|
||||||
|
name = "edit_file"
|
||||||
|
else:
|
||||||
|
arg, res = runner.run(name, args)
|
||||||
|
short = res if len(res) < 220 else res[:220] + " …"
|
||||||
|
if name == "validate" and not res.startswith("FEHLER"):
|
||||||
|
try:
|
||||||
|
v = json.loads(res)
|
||||||
|
short = "✔ bestanden" + (f" ({len(v['warnings'])} Hinweis(e))" if v.get("warnings") else "") if v.get("ok") else "✘ " + "; ".join(v.get("errors", []))[:200]
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")}
|
||||||
|
sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
|
||||||
|
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
|
||||||
|
last_sig = sig
|
||||||
|
remember(run_id, ws)
|
||||||
|
yield {"type": "changed", "run": run_id}
|
||||||
|
results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res})
|
||||||
|
messages.append({"role": "user", "content": results})
|
||||||
|
else:
|
||||||
|
final_text = (final_text + "\n\n(Abgebrochen: maximale Schrittzahl erreicht.)").strip()
|
||||||
|
except llm.LLMNotConfigured as exc:
|
||||||
|
yield {"type": "error", "text": str(exc)}
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 – Provider-/Netzfehler sauber an die UI melden
|
||||||
|
yield {"type": "error", "text": f"{type(exc).__name__}: {str(exc)[:400]}"}
|
||||||
|
return
|
||||||
|
prop = ws.proposal(final_text.split("\n")[0][:140] or "Änderungsvorschlag")
|
||||||
|
if prop is None and any(True for m in messages if m["role"] == "assistant" and isinstance(m["content"], list)
|
||||||
|
and any(b.get("name") in ("write_file", "edit_file", "set_config", "delete_file") for b in m["content"] if b["type"] == "tool_use")):
|
||||||
|
final_text = (final_text + "\n\n(Keine Änderung gegenüber dem aktuellen Entwurf.)").strip()
|
||||||
|
yield {"type": "text", "text": final_text or "(keine Antwort)"}
|
||||||
|
yield {"type": "proposal", "proposal": prop}
|
||||||
|
yield {"type": "usage", "usage": usage}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
|
||||||
|
"""Freigabe: Vorschlag in den Entwurf übernehmen (nur die betroffenen Pfade)."""
|
||||||
|
if proposal.get("config") is not None:
|
||||||
|
store.set_config(db, tenant, proposal["config"])
|
||||||
|
for path, sha in (proposal.get("files") or {}).items():
|
||||||
|
store.set_file(db, tenant, path, store.get_blob(db, sha))
|
||||||
|
for path in proposal.get("deleted") or []:
|
||||||
|
store.delete_file(db, tenant, path)
|
||||||
|
|
||||||
|
|
||||||
|
def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]:
|
||||||
|
"""Inhalt, der nach Freigabe im Entwurf stünde (für die Diff-Anzeige)."""
|
||||||
|
cfg, files = store.content(db, tenant, "draft")
|
||||||
|
if proposal.get("config") is not None:
|
||||||
|
cfg = proposal["config"]
|
||||||
|
files = {**files, **(proposal.get("files") or {})}
|
||||||
|
for p in proposal.get("deleted") or []:
|
||||||
|
files.pop(p, None)
|
||||||
|
return cfg, files
|
||||||
24
app/agent/testrun.py
Normal file
24
app/agent/testrun.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Prompt-Test: finalen Prompt vom Core bauen lassen (inkl. Hooks), LLM aufrufen."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services import llm, preview
|
||||||
|
from app.services.samples import SAMPLE_TRANSCRIPT, ROTARY_TRANSCRIPT
|
||||||
|
|
||||||
|
|
||||||
|
def user_content(transcript: str, terminart="", projekt="", kunde="", title="Beispiel") -> str:
|
||||||
|
return (f"Termin-Art: {terminart or '(aus Gespräch erkennen)'}\nProjektname: {projekt or '(keiner)'}\n"
|
||||||
|
f"Kunde / Unternehmen: {kunde or '(aus Gespräch erkennen)'}\nAnsprechpartner Kunde: (aus Gespräch erkennen)\n"
|
||||||
|
f"Meeting-Titel: {title}\n\nGesprächsprotokoll:\n{transcript}")
|
||||||
|
|
||||||
|
|
||||||
|
def default_transcript(tenant_id: str) -> str:
|
||||||
|
return ROTARY_TRANSCRIPT if tenant_id.startswith("rotary") else SAMPLE_TRANSCRIPT
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_test(db, tenant, cfg, files, transcript: str | None, terminart="", projekt="", kunde="") -> dict:
|
||||||
|
transcript = (transcript or "").strip() or default_transcript(tenant.id)
|
||||||
|
r = preview.run_content(db, tenant, cfg, files, "prompt",
|
||||||
|
args={"title": "Prompt-Test", "terminart": terminart, "projekt": projekt, "kunde": kunde})
|
||||||
|
out = llm.summarize(db, r["prompt"], user_content(transcript, terminart, projekt, kunde, "Prompt-Test"), tenant_id=tenant.id)
|
||||||
|
out["prompt_chars"] = r["chars"]
|
||||||
|
return out
|
||||||
110
app/agent/tools.py
Normal file
110
app/agent/tools.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Werkzeuge des Agenten (neutrale Definition + Ausführung auf dem Workspace)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.agent.workspace import Workspace, is_personal
|
||||||
|
from app.services import llm, preview
|
||||||
|
from app.settings import PLATFORM_DIR
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{"name": "list_files", "description": "Listet alle Dateien des Tenants (inkl. tenant.json) mit Größe und Änderungsstatus.",
|
||||||
|
"schema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "read_file", "description": "Liest eine Textdatei des Tenants (prompt.txt, protocol.html, protocol.css, overrides.py, tenant.json …). "
|
||||||
|
"Fehlt eine Datei, gilt der Core-Standard – nutze dann read_core_default.",
|
||||||
|
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
|
||||||
|
{"name": "read_core_default", "description": "Liest eine Standarddatei des Cores als Vorlage: prompt.txt, protocol.html, protocol.css oder hooks.py (Hook-Dokumentation).",
|
||||||
|
"schema": {"type": "object", "properties": {"name": {"type": "string", "enum": ["prompt.txt", "protocol.html", "protocol.css", "hooks.py"]}}, "required": ["name"]}},
|
||||||
|
{"name": "write_file", "description": "Schreibt/ersetzt eine Textdatei des Tenants vollständig (z. B. um eine eigene prompt.txt oder protocol.css anzulegen). Nur im Entwurf – ein Mensch gibt frei.",
|
||||||
|
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||||
|
{"name": "edit_file", "description": "Ersetzt in einer Datei genau eine Stelle: old_str muss exakt 1× vorkommen. Bevorzuge das gegenüber write_file bei kleinen Änderungen.",
|
||||||
|
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_str": {"type": "string"}, "new_str": {"type": "string"}}, "required": ["path", "old_str", "new_str"]}},
|
||||||
|
{"name": "delete_file", "description": "Löscht eine Tenant-Datei (z. B. um auf den Core-Standard zurückzufallen).",
|
||||||
|
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
|
||||||
|
{"name": "set_config", "description": "Setzt einen Wert in tenant.json per Punkt-Pfad, z. B. key='colors.accent', value='#0B6E4F' oder key='flags.ui_kontakt_field', value=false.",
|
||||||
|
"schema": {"type": "object", "properties": {"key": {"type": "string"}, "value": {}}, "required": ["key", "value"]}},
|
||||||
|
{"name": "validate", "description": "Prüft den aktuellen Workspace mit dem ECHTEN Core (Web-UI + PDF rendern, Prompt laden). Gibt Fehler/Warnungen zurück. Nach Änderungen immer aufrufen.",
|
||||||
|
"schema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "render_pdf", "description": "Rendert das Beispielprotokoll als PDF und gibt Seitenzahl und extrahierten Text zurück, damit du Aufbau und Inhalt prüfen kannst.",
|
||||||
|
"schema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.",
|
||||||
|
"schema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.",
|
||||||
|
"schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRunner:
|
||||||
|
def __init__(self, ws: Workspace):
|
||||||
|
self.ws, self.db, self.tenant = ws, ws.db, ws.tenant
|
||||||
|
self.llm_calls = 0
|
||||||
|
|
||||||
|
def _preview(self, kind, **kw):
|
||||||
|
return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw)
|
||||||
|
|
||||||
|
def run(self, name: str, a: dict) -> tuple[str, str]:
|
||||||
|
"""→ (Kurzbeschreibung des Arguments für die Anzeige, Ergebnistext)."""
|
||||||
|
try:
|
||||||
|
return getattr(self, f"t_{name}")(a)
|
||||||
|
except (KeyError, ValueError, PermissionError, SyntaxError, json.JSONDecodeError) as exc:
|
||||||
|
return a.get("path") or a.get("key") or "", f"FEHLER: {exc}"
|
||||||
|
except preview.PreviewError as exc:
|
||||||
|
return "", f"FEHLER (Core-Rendering): {str(exc)[-900:]}"
|
||||||
|
|
||||||
|
def t_list_files(self, a):
|
||||||
|
rows = [{"path": "tenant.json", "text": True}] + self.ws.list_files()
|
||||||
|
return "", json.dumps(rows, ensure_ascii=False)
|
||||||
|
|
||||||
|
def t_read_file(self, a):
|
||||||
|
return a["path"], self.ws.read(a["path"])
|
||||||
|
|
||||||
|
def t_read_core_default(self, a):
|
||||||
|
n = a["name"]
|
||||||
|
p = (PLATFORM_DIR / "core" / ("hooks.py" if n == "hooks.py" else ("defaults" if n == "prompt.txt" else "templates")) / n) \
|
||||||
|
if n != "hooks.py" else PLATFORM_DIR / "core" / "hooks.py"
|
||||||
|
return n, p.read_text(encoding="utf-8")[:60_000]
|
||||||
|
|
||||||
|
def t_write_file(self, a):
|
||||||
|
self.ws.write(a["path"], a["content"])
|
||||||
|
return a["path"], f"geschrieben ({len(a['content'])} Zeichen)"
|
||||||
|
|
||||||
|
def t_edit_file(self, a):
|
||||||
|
self.ws.edit(a["path"], a["old_str"], a["new_str"])
|
||||||
|
return a["path"], "ersetzt"
|
||||||
|
|
||||||
|
def t_delete_file(self, a):
|
||||||
|
self.ws.delete(a["path"])
|
||||||
|
return a["path"], "gelöscht"
|
||||||
|
|
||||||
|
def t_set_config(self, a):
|
||||||
|
self.ws.set_config_value(a["key"], a["value"])
|
||||||
|
return a["key"], f"gesetzt = {json.dumps(a['value'], ensure_ascii=False)}"
|
||||||
|
|
||||||
|
def t_validate(self, a):
|
||||||
|
r = self._preview("validate")
|
||||||
|
return "Core-Rendering", json.dumps(r, ensure_ascii=False)
|
||||||
|
|
||||||
|
def t_render_pdf(self, a):
|
||||||
|
pdf = self._preview("pdf")
|
||||||
|
from pypdf import PdfReader
|
||||||
|
rd = PdfReader(io.BytesIO(pdf))
|
||||||
|
text = "\n".join((pg.extract_text() or "") for pg in rd.pages)
|
||||||
|
return "Beispielprotokoll", f"{len(rd.pages)} Seite(n)\n---\n{text[:3500]}"
|
||||||
|
|
||||||
|
def t_show_final_prompt(self, a):
|
||||||
|
r = self._preview("prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
|
||||||
|
p = r["prompt"]
|
||||||
|
# Namenslisten (personenbezogen) nicht an das Agenten-Modell geben
|
||||||
|
marker = "Namensregister"
|
||||||
|
if marker in p:
|
||||||
|
p = p[:p.index(marker)] + "[Namensregister mit personenbezogenen Daten ausgeblendet]"
|
||||||
|
return f"{r['chars']} Zeichen", p[:40_000]
|
||||||
|
|
||||||
|
def t_run_prompt_test(self, a):
|
||||||
|
if self.llm_calls >= 1:
|
||||||
|
return "", "FEHLER: Pro Antwort ist nur ein Prompt-Test erlaubt."
|
||||||
|
self.llm_calls += 1
|
||||||
|
from app.agent.testrun import prompt_test
|
||||||
|
res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript"))
|
||||||
|
return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}"
|
||||||
106
app/agent/workspace.py
Normal file
106
app/agent/workspace.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Entwurfs-Workspace des Agenten: Kopie des Tenant-Entwurfs im Speicher.
|
||||||
|
|
||||||
|
Der Agent ändert NUR diese Kopie. Daraus entsteht ein Vorschlag (Diff), den der Mensch freigibt.
|
||||||
|
Datenschutz: Dateien mit personenbezogenem Inhalt (Namenslisten) sind für den Agenten nicht lesbar.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Tenant
|
||||||
|
from app.services import store
|
||||||
|
|
||||||
|
PERSONAL_PREFIXES = ("namensliste", "mitglieder", "members", "personen")
|
||||||
|
READ_LIMIT = 60_000
|
||||||
|
PROTECTED = {"tenant.json"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_personal(path: str) -> bool:
|
||||||
|
name = path.rsplit("/", 1)[-1].lower()
|
||||||
|
return name.startswith(PERSONAL_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace:
|
||||||
|
def __init__(self, db: Session, tenant: Tenant):
|
||||||
|
self.db, self.tenant = db, tenant
|
||||||
|
cfg, files = store.content(db, tenant, "draft")
|
||||||
|
self.base_cfg, self.base_files = deepcopy(cfg), dict(files)
|
||||||
|
self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha}
|
||||||
|
|
||||||
|
# ── Lesen ──
|
||||||
|
def list_files(self) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for p, sha in sorted(self.files.items()):
|
||||||
|
b = self.db.get(__import__("app.models", fromlist=["Blob"]).Blob, sha)
|
||||||
|
out.append({"path": p, "bytes": b.size, "text": store.is_text_path(p), "personal_data": is_personal(p),
|
||||||
|
"changed": self.base_files.get(p) != sha})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def read(self, path: str) -> str:
|
||||||
|
if path in ("tenant.json",):
|
||||||
|
return json.dumps(self.cfg, indent=2, ensure_ascii=False)
|
||||||
|
if path not in self.files:
|
||||||
|
raise KeyError(f"Datei nicht im Tenant: {path}")
|
||||||
|
if is_personal(path):
|
||||||
|
n = len(store.get_blob(self.db, self.files[path]).decode("utf-8", "replace").splitlines())
|
||||||
|
return f"[Datei enthält personenbezogene Daten und ist für den Agenten gesperrt – {n} Zeilen. Inhalt nicht verfügbar.]"
|
||||||
|
if not store.is_text_path(path):
|
||||||
|
return "[Binärdatei]"
|
||||||
|
text = store.get_blob(self.db, self.files[path]).decode("utf-8", "replace")
|
||||||
|
return text[:READ_LIMIT] + ("\n[… gekürzt]" if len(text) > READ_LIMIT else "")
|
||||||
|
|
||||||
|
# ── Schreiben ──
|
||||||
|
def write(self, path: str, content: str) -> None:
|
||||||
|
path = store.clean_path(path)
|
||||||
|
if is_personal(path):
|
||||||
|
raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht ändern.")
|
||||||
|
if path.endswith(".py"):
|
||||||
|
compile(content, path, "exec") # Syntaxfehler früh melden
|
||||||
|
self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path)
|
||||||
|
|
||||||
|
def edit(self, path: str, old: str, new: str) -> None:
|
||||||
|
cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False)
|
||||||
|
if cur.count(old) != 1:
|
||||||
|
raise ValueError(f"old_str muss genau 1× vorkommen (gefunden: {cur.count(old)}×).")
|
||||||
|
if path == "tenant.json":
|
||||||
|
self.set_config_json(cur.replace(old, new))
|
||||||
|
else:
|
||||||
|
self.write(path, cur.replace(old, new))
|
||||||
|
|
||||||
|
def delete(self, path: str) -> None:
|
||||||
|
if is_personal(path):
|
||||||
|
raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht löschen.")
|
||||||
|
self.files.pop(path, None)
|
||||||
|
|
||||||
|
def set_config_json(self, text: str) -> None:
|
||||||
|
cfg = json.loads(text)
|
||||||
|
cfg.pop("deploy", None)
|
||||||
|
cfg["id"] = self.tenant.id
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
def set_config_value(self, key_path: str, value) -> None:
|
||||||
|
cur = self.cfg
|
||||||
|
keys = key_path.split(".")
|
||||||
|
for k in keys[:-1]:
|
||||||
|
cur = cur.setdefault(k, {})
|
||||||
|
cur[keys[-1]] = value
|
||||||
|
|
||||||
|
# ── Ergebnis ──
|
||||||
|
def changed(self) -> bool:
|
||||||
|
return self.cfg != self.base_cfg or self.files != self.base_files
|
||||||
|
|
||||||
|
def proposal(self, summary: str) -> dict | None:
|
||||||
|
if not self.changed():
|
||||||
|
return None
|
||||||
|
files = {p: s for p, s in self.files.items() if self.base_files.get(p) != s}
|
||||||
|
deleted = [p for p in self.base_files if p not in self.files]
|
||||||
|
items = []
|
||||||
|
if self.cfg != self.base_cfg:
|
||||||
|
items.append({"path": "tenant.json", "kind": "geändert"})
|
||||||
|
items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files]
|
||||||
|
items += [{"path": p, "kind": "gelöscht"} for p in deleted]
|
||||||
|
return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None,
|
||||||
|
"files": files, "deleted": deleted, "items": items}
|
||||||
162
app/auth.py
Normal file
162
app/auth.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""OIDC-Login der Admin-Plattform über login.onyva.dev (Authorization-Code + PKCE, wie im Core).
|
||||||
|
|
||||||
|
Aktiv mit AUTH_ENABLED=true. Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI,
|
||||||
|
OIDC_LOGOUT_REDIRECT_URI, APP_COOKIE_SECURE, sowie die Zugriffsregel (mind. eine):
|
||||||
|
ADMIN_ALLOWED_EMAILS=a@x.de,b@y.de und/oder ADMIN_REQUIRED_ROLE=admin (gegen das Claim `app_roles`)
|
||||||
|
Ohne Zugriffsregel wird jeder Login abgelehnt (Default-Deny) – die Admin-Plattform hält alle Secrets.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.settings import AUTH_ENABLED, SECRET_KEY
|
||||||
|
|
||||||
|
COOKIE, STATE_COOKIE, STATE_TTL, SESSION_TTL = "oa_session", "oa_oauth", 600, 12 * 3600
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
PUBLIC = re.compile(r"^/(healthz|auth/.*|static/.*|api/tenants/[^/]+/bundle|api/core/releases)$") # bundle: Token · releases: CI-Token (POST), GET prüft die Session selbst
|
||||||
|
DEV_USER = {"sub": "dev", "email": "dev@local", "name": "Dev (Auth aus)", "app_roles": ["admin"]}
|
||||||
|
|
||||||
|
|
||||||
|
def cfg() -> dict:
|
||||||
|
g = lambda k, d="": os.getenv(k, d).strip() # noqa: E731
|
||||||
|
return {
|
||||||
|
"issuer": g("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/"), "client_id": g("OIDC_CLIENT_ID"),
|
||||||
|
"client_secret": g("OIDC_CLIENT_SECRET"), "redirect": g("OIDC_REDIRECT_URI", "http://localhost:8300/auth/callback"),
|
||||||
|
"logout_redirect": g("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8300/"),
|
||||||
|
"secure": g("APP_COOKIE_SECURE", "false").lower() == "true",
|
||||||
|
"emails": {e.strip().lower() for e in g("ADMIN_ALLOWED_EMAILS").split(",") if e.strip()},
|
||||||
|
"role": g("ADMIN_REQUIRED_ROLE"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(data: dict) -> str:
|
||||||
|
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=")
|
||||||
|
return f"{payload}.{hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _verify(token: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
payload, _, sig = token.partition(".")
|
||||||
|
if not sig or not hmac.compare_digest(sig, hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()):
|
||||||
|
return None
|
||||||
|
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
||||||
|
return data if int(data.get("exp", 0)) > time.time() else None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def session_user(request: Request) -> dict | None:
|
||||||
|
if not AUTH_ENABLED:
|
||||||
|
return DEV_USER
|
||||||
|
tok = request.cookies.get(COOKIE)
|
||||||
|
return _verify(tok) if tok else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed(user: dict) -> bool:
|
||||||
|
c = cfg()
|
||||||
|
if not c["emails"] and not c["role"]:
|
||||||
|
return False
|
||||||
|
if user.get("email", "").lower() in c["emails"]:
|
||||||
|
return True
|
||||||
|
return bool(c["role"]) and c["role"] in (user.get("app_roles") or [])
|
||||||
|
|
||||||
|
|
||||||
|
async def middleware(request: Request, call_next):
|
||||||
|
user = session_user(request)
|
||||||
|
request.state.user = user or {}
|
||||||
|
if user is None and not PUBLIC.match(request.url.path):
|
||||||
|
if request.url.path.startswith(("/api/", "/preview/")):
|
||||||
|
return JSONResponse({"detail": "Nicht angemeldet"}, status_code=401)
|
||||||
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_name(request: Request) -> str:
|
||||||
|
u = getattr(request.state, "user", None) or {}
|
||||||
|
return u.get("email") or u.get("name") or "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routen ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def exchange_code(code: str, verifier: str) -> dict:
|
||||||
|
"""Token-Tausch beim IdP (in Tests ersetzbar)."""
|
||||||
|
c = cfg()
|
||||||
|
r = httpx.post(f"{c['issuer']}/oauth/token", timeout=20, data={
|
||||||
|
"grant_type": "authorization_code", "code": code, "redirect_uri": c["redirect"], "client_id": c["client_id"],
|
||||||
|
"client_secret": c["client_secret"], "code_verifier": verifier})
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise HTTPException(502, f"Token-Tausch fehlgeschlagen ({r.status_code})")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _claims(id_token: str) -> dict:
|
||||||
|
try:
|
||||||
|
p = id_token.split(".")[1]
|
||||||
|
return json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/login")
|
||||||
|
def login():
|
||||||
|
c = cfg()
|
||||||
|
if not AUTH_ENABLED:
|
||||||
|
return RedirectResponse("/", 302)
|
||||||
|
if not c["client_id"] or not c["client_secret"]:
|
||||||
|
raise HTTPException(503, "OIDC nicht konfiguriert (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).")
|
||||||
|
state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(64)
|
||||||
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||||
|
q = urlencode({"response_type": "code", "client_id": c["client_id"], "redirect_uri": c["redirect"], "scope": "openid profile email",
|
||||||
|
"state": state, "code_challenge": challenge, "code_challenge_method": "S256"})
|
||||||
|
resp = RedirectResponse(f"{c['issuer']}/oauth/authorize?{q}", 302)
|
||||||
|
resp.set_cookie(STATE_COOKIE, _sign({"state": state, "cv": verifier, "exp": int(time.time()) + STATE_TTL}),
|
||||||
|
max_age=STATE_TTL, httponly=True, secure=c["secure"], samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/callback")
|
||||||
|
def callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
||||||
|
c = cfg()
|
||||||
|
if error:
|
||||||
|
raise HTTPException(400, f"Login abgebrochen: {error}")
|
||||||
|
pending = _verify(request.cookies.get(STATE_COOKIE, "") or "")
|
||||||
|
if not pending or not hmac.compare_digest(str(pending.get("state", "")), state) or not code:
|
||||||
|
return RedirectResponse("/auth/login", 303) # abgelaufen/Reload → neu starten
|
||||||
|
tokens = exchange_code(code, pending["cv"])
|
||||||
|
cl = _claims(tokens.get("id_token", ""))
|
||||||
|
user = {"sub": cl.get("sub", ""), "email": cl.get("email", ""), "name": cl.get("name", ""), "app_roles": cl.get("app_roles", [])}
|
||||||
|
if not is_allowed(user):
|
||||||
|
return HTMLResponse(f"<h2 style='font-family:sans-serif'>Kein Zugriff</h2><p style='font-family:sans-serif'>{user['email'] or 'Dieses Konto'} "
|
||||||
|
"ist für die Onyscribe-Admin-Plattform nicht freigeschaltet.</p><p><a href='/auth/logout'>Abmelden</a></p>", status_code=403)
|
||||||
|
resp = RedirectResponse("/", 303)
|
||||||
|
resp.set_cookie(COOKIE, _sign({**user, "exp": int(time.time()) + SESSION_TTL}), max_age=SESSION_TTL, httponly=True,
|
||||||
|
secure=c["secure"], samesite="lax")
|
||||||
|
resp.delete_cookie(STATE_COOKIE)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/logout")
|
||||||
|
def logout():
|
||||||
|
c = cfg()
|
||||||
|
url = f"{c['issuer']}/logout?{urlencode({'post_logout_redirect_uri': c['logout_redirect']})}" if AUTH_ENABLED else "/"
|
||||||
|
resp = RedirectResponse(url, 302)
|
||||||
|
resp.delete_cookie(COOKIE)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/me")
|
||||||
|
def me(request: Request):
|
||||||
|
return {"user": getattr(request.state, "user", {}), "auth_enabled": AUTH_ENABLED}
|
||||||
42
app/db.py
Normal file
42
app/db.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
from app.settings import DATABASE_URL
|
||||||
|
|
||||||
|
_kwargs = {"connect_args": {"check_same_thread": False}} if DATABASE_URL.startswith("sqlite") else {"pool_pre_ping": True}
|
||||||
|
engine = create_engine(DATABASE_URL, **_kwargs)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False, autoflush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
from app import models # noqa: F401
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
19
app/deps.py
Normal file
19
app/deps.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import actor_name
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
def actor(request: Request) -> str:
|
||||||
|
return actor_name(request)
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_or_404(tenant_id: str, db: Session = Depends(get_db)) -> Tenant:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
raise HTTPException(404, f"Tenant „{tenant_id}“ nicht gefunden")
|
||||||
|
return t
|
||||||
42
app/main.py
Normal file
42
app/main.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.db import init_db, session
|
||||||
|
from app import auth
|
||||||
|
from app.routers import api, pages
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models import Tenant
|
||||||
|
from app.services import seed
|
||||||
|
from app.services.importer import seed_settings
|
||||||
|
from app.settings import ROOT
|
||||||
|
|
||||||
|
SEED_FILE = ROOT / "seed" / "tenants.seed.json.gz"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
init_db()
|
||||||
|
with session() as db:
|
||||||
|
# Frische Instanz → mit den importierten Tenants starten (ohne Secret-Werte)
|
||||||
|
if SEED_FILE.exists() and not db.scalar(select(func.count()).select_from(Tenant)):
|
||||||
|
print(f"[seed] {seed.load(db, SEED_FILE)} Tenants aus {SEED_FILE.name} geladen")
|
||||||
|
seed_settings(db)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Onyscribe Admin", lifespan=lifespan)
|
||||||
|
app.middleware("http")(auth.middleware)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(ROOT / "app" / "static")), name="static")
|
||||||
|
app.include_router(api.router)
|
||||||
|
app.include_router(pages.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {"ok": True}
|
||||||
149
app/models.py
Normal file
149
app/models.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Datenmodell. Inhalte sind content-addressed (Blob) und versioniert (Version)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(Base):
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True) # Slug, == TENANT-Env im Container
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="active") # active | paused | archived
|
||||||
|
source: Mapped[str] = mapped_column(String(10), default="git") # TENANT_SOURCE im Container: git | admin
|
||||||
|
subdomain: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
custom_domain: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
domain_status: Mapped[str] = mapped_column(String(20), default="none") # none | pending | verified
|
||||||
|
cpu: Mapped[float] = mapped_column(Float, default=0.5)
|
||||||
|
memory: Mapped[str] = mapped_column(String(20), default="512M")
|
||||||
|
current_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
deployed_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
deployed_image: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
draft: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"config": {...}, "files": {path: sha}}
|
||||||
|
preview_sample: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
oidc: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Soll-Zustand des OIDC-Clients + letzter Abgleich
|
||||||
|
core_pin: Mapped[str | None] = mapped_column(String(100), nullable=True) # fester Core-Tag (nimmt an Rollouts nicht teil)
|
||||||
|
render_baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {version, core, fp:{…}} Referenz für die Kompatibilitätsprüfung
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
versions: Mapped[list["Version"]] = relationship(back_populates="tenant", order_by="Version.number.desc()")
|
||||||
|
|
||||||
|
|
||||||
|
class Blob(Base):
|
||||||
|
"""Dateiinhalt, adressiert über SHA-256. Später: große Binärdateien nach S3 auslagern."""
|
||||||
|
__tablename__ = "blobs"
|
||||||
|
|
||||||
|
sha: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
content: Mapped[bytes] = mapped_column(LargeBinary)
|
||||||
|
size: Mapped[int] = mapped_column(Integer)
|
||||||
|
is_text: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Version(Base):
|
||||||
|
"""Unveränderlicher Snapshot eines Tenants."""
|
||||||
|
__tablename__ = "versions"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "number"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
|
||||||
|
number: Mapped[int] = mapped_column(Integer)
|
||||||
|
message: Mapped[str] = mapped_column(String(500), default="")
|
||||||
|
author: Mapped[str] = mapped_column(String(100), default="") # "ruben" | "agent:<run>" | "import"
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
config: Mapped[dict] = mapped_column(JSON) # Inhalt der tenant.json (ohne deploy-Block)
|
||||||
|
files: Mapped[dict] = mapped_column(JSON) # {pfad: sha}
|
||||||
|
|
||||||
|
tenant: Mapped[Tenant] = relationship(back_populates="versions")
|
||||||
|
|
||||||
|
|
||||||
|
class Secret(Base):
|
||||||
|
"""Verschlüsselter Secret-Wert. tenant_id NULL = geteilt für alle Tenants."""
|
||||||
|
__tablename__ = "secrets"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "key"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100))
|
||||||
|
value_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(30), default="manual") # manual | sops-import
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class Deployment(Base):
|
||||||
|
__tablename__ = "deployments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
batch_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), default="deploy") # deploy | dry-run | offboard
|
||||||
|
version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
image_tag: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="running") # running | success | failed
|
||||||
|
simulated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
log: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
actor: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||||
|
actor: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
action: Mapped[str] = mapped_column(String(60))
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
detail: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(Base):
|
||||||
|
__tablename__ = "chat_messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(20)) # user | assistant
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
steps: Mapped[list | None] = mapped_column(JSON, nullable=True) # Tool-Aufrufe (Anzeige)
|
||||||
|
proposal: Mapped[dict | None] = mapped_column(JSON, nullable=True) # vorgeschlagene Änderung
|
||||||
|
proposal_status: Mapped[str] = mapped_column(String(20), default="") # "" | open | applied | rejected
|
||||||
|
usage: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"in":…, "out":…, "backend":…, "model":…}
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreRelease(Base):
|
||||||
|
"""Vom Core-CI gemeldetes Release (Image-Tag). Wird geprüft, freigegeben und ausgerollt."""
|
||||||
|
__tablename__ = "core_releases"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
sha: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
tag: Mapped[str] = mapped_column(String(100))
|
||||||
|
ref: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
image: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
message: Mapped[str] = mapped_column(String(500), default="")
|
||||||
|
announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
# new → checked → approved → rolling → active (rejected / failed / superseded)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="new")
|
||||||
|
compat: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {checked_at, core_sha, tenants:{id:{status,…}}, summary:{…}}
|
||||||
|
approved_by: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
previous_tag: Mapped[str] = mapped_column(String(100), default="") # aktiver Tag vor dem Rollout (für Rollback)
|
||||||
|
rollout_batch: Mapped[str] = mapped_column(String(40), default="")
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
770
app/routers/api.py
Normal file
770
app/routers/api.py
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.agent import loop as agent_loop
|
||||||
|
from app.agent.testrun import default_transcript, prompt_test
|
||||||
|
from app.agent.workspace import is_personal
|
||||||
|
from app.db import get_db, session
|
||||||
|
from app.deps import actor, tenant_or_404
|
||||||
|
from app.models import Blob, ChatMessage, CoreRelease, Deployment, Secret, Setting, Tenant, Version
|
||||||
|
from app.services import contract, crypto, defaults, deploy, guard, llm, onyva_login, preflight, preview, releases, status as status_svc, store, tenants as tsvc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(d):
|
||||||
|
return d.isoformat() if d else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tenants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NewTenant(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
clone_from: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants")
|
||||||
|
def create_tenant(body: NewTenant, db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
t = tsvc.create(db, body.id, body.name, body.clone_from or None, who)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"id": t.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/state")
|
||||||
|
def state(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
cfg, files = store.content(db, t, "draft")
|
||||||
|
meta = {p: {"size": db.get(Blob, sha).size, "text": store.is_text_path(p), "personal": is_personal(p)} for p, sha in files.items()}
|
||||||
|
return {
|
||||||
|
"id": t.id, "name": t.name, "status": t.status, "source": t.source, "notes": t.notes,
|
||||||
|
"has_draft": bool(t.draft), "current_version": t.current_version, "deployed_version": t.deployed_version,
|
||||||
|
"deployed_image": t.deployed_image, "config": cfg, "files": meta,
|
||||||
|
"host": deploy.host_for(db, t), "subdomain": t.subdomain, "custom_domain": t.custom_domain,
|
||||||
|
"domain_status": t.domain_status, "cpu": t.cpu, "memory": t.memory,
|
||||||
|
"base_domain": deploy.setting(db, "base_domain", "example.de"),
|
||||||
|
"color_labels": defaults.COLOR_LABELS, "core_version": preview.core_version(),
|
||||||
|
"sample": preview.sample_for(t), "has_custom_sample": bool(t.preview_sample),
|
||||||
|
"sample_transcript": default_transcript(t.id), "llm_ready": llm.configured(db, t.id),
|
||||||
|
"core": {"tag": deploy.effective_tag(db, t), "pin": t.core_pin, "sha": contract.sha()[:10], "contract_ok": contract.supported()},
|
||||||
|
"schema_problems": contract.check_tenant_json(cfg),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/meta")
|
||||||
|
def put_meta(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
if "cpu" in body:
|
||||||
|
cpu = float(body["cpu"])
|
||||||
|
if not 0.1 <= cpu <= 8:
|
||||||
|
raise HTTPException(400, "CPU zwischen 0,1 und 8")
|
||||||
|
t.cpu = cpu
|
||||||
|
if "memory" in body:
|
||||||
|
if not re.fullmatch(r"\d{1,5}[MG]", str(body["memory"])):
|
||||||
|
raise HTTPException(400, "RAM z. B. 512M oder 1G")
|
||||||
|
t.memory = str(body["memory"])
|
||||||
|
if "status" in body:
|
||||||
|
if body["status"] not in ("active", "paused", "archived"):
|
||||||
|
raise HTTPException(400, "status: active | paused | archived")
|
||||||
|
t.status = body["status"]
|
||||||
|
if "notes" in body:
|
||||||
|
t.notes = str(body["notes"])[:5000]
|
||||||
|
store.audit(db, who, "tenant.meta", t.id, ", ".join(body))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}")
|
||||||
|
def delete_tenant(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
"""Löscht nur die Datenbank-Einträge. Infrastruktur (Container/Ingress) vorher per Offboard entfernen."""
|
||||||
|
if t.status != "archived":
|
||||||
|
raise HTTPException(400, "Nur archivierte Tenants können gelöscht werden.")
|
||||||
|
if confirm != t.id:
|
||||||
|
raise HTTPException(400, "Bestätigung: ID des Tenants angeben.")
|
||||||
|
for model in (Secret, Version, ChatMessage):
|
||||||
|
for row in db.scalars(select(model).where(model.tenant_id == t.id)):
|
||||||
|
db.delete(row)
|
||||||
|
store.audit(db, who, "tenant.delete", t.id)
|
||||||
|
db.delete(t)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/config")
|
||||||
|
def put_config(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
store.set_config(db, t, body)
|
||||||
|
db.commit()
|
||||||
|
return {"has_draft": bool(t.draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/files/{path:path}")
|
||||||
|
def get_file(path: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
_, files = preview.resolve_content(db, t, source)
|
||||||
|
except preview.SourceGone:
|
||||||
|
_, files = store.content(db, t, "draft")
|
||||||
|
if path not in files:
|
||||||
|
from app.settings import PLATFORM_DIR
|
||||||
|
for base in ("defaults", "templates"):
|
||||||
|
p = PLATFORM_DIR / "core" / base / path
|
||||||
|
if p.exists() and p.is_file():
|
||||||
|
return Response(p.read_bytes(), headers={"X-Default": "1"}, media_type="text/plain; charset=utf-8")
|
||||||
|
raise HTTPException(404, "Datei nicht vorhanden")
|
||||||
|
data = store.get_blob(db, files[path])
|
||||||
|
mt = "image/svg+xml" if path.endswith(".svg") else "image/png" if path.endswith(".png") else \
|
||||||
|
"application/pdf" if path.endswith(".pdf") else "text/plain; charset=utf-8"
|
||||||
|
return Response(data, media_type=mt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/files/{path:path}")
|
||||||
|
def put_file(path: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
if path.endswith(".py"):
|
||||||
|
compile(body.get("content", ""), path, "exec")
|
||||||
|
store.set_file(db, t, path, body.get("content", "").encode("utf-8"))
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise HTTPException(400, f"Python-Syntaxfehler in Zeile {exc.lineno}: {exc.msg}")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"has_draft": bool(t.draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/upload")
|
||||||
|
async def upload(file: UploadFile = File(...), path: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > 8 * 1024 * 1024:
|
||||||
|
raise HTTPException(413, "Datei größer als 8 MB")
|
||||||
|
try:
|
||||||
|
store.set_file(db, t, path or file.filename, data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/files/{path:path}")
|
||||||
|
def del_file(path: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
store.delete_file(db, t, path)
|
||||||
|
db.commit()
|
||||||
|
return {"has_draft": bool(t.draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/sample")
|
||||||
|
def put_sample(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
t.preview_sample = body.get("sample") or None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/draft/discard")
|
||||||
|
def discard(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
store.discard_draft(db, t)
|
||||||
|
store.audit(db, who, "draft.discard", t.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/diff")
|
||||||
|
def diff(a: str = "current", b: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
return store.diff_summary(db, t, a, b, with_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishBody(BaseModel):
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/publish")
|
||||||
|
def publish(body: PublishBody, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
v = store.publish(db, t, body.message, who)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"version": v.number}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/validate")
|
||||||
|
def validate(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "draft"):
|
||||||
|
try:
|
||||||
|
res = dict(preview.run(db, t, source, "validate"))
|
||||||
|
except (preview.PreviewError, preview.SourceGone) as exc:
|
||||||
|
return {"ok": False, "errors": [str(exc)], "warnings": []}
|
||||||
|
cfg, _ = preview.resolve_content(db, t, source)
|
||||||
|
res["warnings"] = [*res.get("warnings", []), *(f"Schema: {p}" for p in contract.check_tenant_json(cfg))] # unbekannte Optionen/Typfehler (Tippfehler)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/versions")
|
||||||
|
def versions(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
out = []
|
||||||
|
for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number.desc())):
|
||||||
|
out.append({"number": v.number, "message": v.message, "author": v.author, "created_at": _iso(v.created_at),
|
||||||
|
"files": len(v.files), "current": v.number == t.current_version, "deployed": v.number == t.deployed_version})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/versions/{number}/restore")
|
||||||
|
def restore(number: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
store.restore_as_draft(db, t, number)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Version nicht gefunden")
|
||||||
|
store.audit(db, who, "version.restore_draft", t.id, f"v{number}")
|
||||||
|
db.commit()
|
||||||
|
return {"has_draft": bool(t.draft)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prompt-Test & LLM ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/prompt-test")
|
||||||
|
def prompt_test_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
"""Echter LLM-Lauf mit dem (Entwurfs-)Prompt auf einem Beispieltranskript. Kostet Cortecs-Guthaben."""
|
||||||
|
cfg, files = store.content(db, t, body.get("source", "draft"))
|
||||||
|
try:
|
||||||
|
r = prompt_test(db, t, cfg, files, body.get("transcript"), body.get("terminart", ""), body.get("projekt", ""), body.get("kunde", ""))
|
||||||
|
except llm.LLMNotConfigured as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
except preview.PreviewError as exc:
|
||||||
|
raise HTTPException(422, f"Core-Rendering fehlgeschlagen: {str(exc)[-600:]}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(502, f"LLM-Aufruf fehlgeschlagen: {type(exc).__name__}: {str(exc)[:300]}")
|
||||||
|
store.audit(db, who, "prompt.test", t.id, f"{r['model']} {r['input_tokens']}→{r['output_tokens']} Tokens")
|
||||||
|
db.commit()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/final-prompt")
|
||||||
|
def final_prompt(source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
"""Finaler System-Prompt, wie der Core ihn an das LLM schickt (Platzhalter ersetzt, Hooks angewandt)."""
|
||||||
|
try:
|
||||||
|
return preview.run(db, t, source, "prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
|
||||||
|
except preview.PreviewError as exc:
|
||||||
|
raise HTTPException(422, str(exc)[-800:])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm/status")
|
||||||
|
def llm_status(tenant: str = "", db: Session = Depends(get_db)):
|
||||||
|
key, source, error = llm.key_info(db, tenant or None)
|
||||||
|
return {"configured": bool(key), "source": source, "error": error, "model": deploy.setting(db, "agent_model", "claude-4-6-sonnet"),
|
||||||
|
"api": deploy.setting(db, "agent_api", "messages"), "base_url": llm.base_url(db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm/models")
|
||||||
|
def llm_models(db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
return llm.list_models(db)
|
||||||
|
except llm.LLMNotConfigured as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(502, f"Modellliste nicht abrufbar: {type(exc).__name__}: {str(exc)[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Domain, Secrets, Quelle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/domain")
|
||||||
|
def put_domain(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
sub = (body.get("subdomain") or "").strip().lower()
|
||||||
|
custom = (body.get("custom_domain") or "").strip().lower()
|
||||||
|
if sub and not re.fullmatch(r"[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?", sub):
|
||||||
|
raise HTTPException(400, "Subdomain: a-z, 0-9, Bindestrich")
|
||||||
|
if custom and not re.fullmatch(r"([a-z0-9-]+\.)+[a-z]{2,}", custom):
|
||||||
|
raise HTTPException(400, "Eigene Domain ungültig")
|
||||||
|
t.subdomain = sub or t.subdomain
|
||||||
|
if custom != t.custom_domain:
|
||||||
|
t.custom_domain = custom
|
||||||
|
t.domain_status = "pending" if custom else ("verified" if t.source == "git" else "none")
|
||||||
|
store.audit(db, who, "domain.set", t.id, f"{t.subdomain} / {custom or '–'}")
|
||||||
|
db.commit()
|
||||||
|
return {"host": deploy.host_for(db, t), "domain_status": t.domain_status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/domain/check")
|
||||||
|
def check_domain(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
host = t.custom_domain or deploy.host_for(db, t)
|
||||||
|
try:
|
||||||
|
ips = sorted({ai[4][0] for ai in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)})
|
||||||
|
except OSError:
|
||||||
|
ips = []
|
||||||
|
if ips:
|
||||||
|
t.domain_status = "verified"
|
||||||
|
msg = f"{host} löst auf {', '.join(ips)} auf. (Die Ziel-Prüfung gegen das Mittwald-Projekt folgt mit dem Live-Deploy.)"
|
||||||
|
else:
|
||||||
|
t.domain_status = "pending" if t.custom_domain else t.domain_status
|
||||||
|
msg = f"{host} löst noch nicht auf. DNS-Einträge prüfen; Änderungen können bis zu 24 h dauern."
|
||||||
|
db.commit()
|
||||||
|
return {"status": t.domain_status, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OIDC-Client (Developer-API von login.onyva.dev) ─────────────────────────
|
||||||
|
|
||||||
|
def _oidc_view(db: Session, t: Tenant) -> dict:
|
||||||
|
cfg = onyva_login.config_of(t)
|
||||||
|
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
|
||||||
|
sec = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_SECRET"))
|
||||||
|
base = deploy.setting(db, "base_domain", "example.de")
|
||||||
|
return {**cfg, "client_id": crypto.decrypt(cid.value_enc) if cid and cid.value_enc else None, "secret_set": bool(sec and sec.value_enc),
|
||||||
|
"redirect_uris": onyva_login.desired_redirects(base, t), "api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(),
|
||||||
|
"api_base": onyva_login.base_url(db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/oidc")
|
||||||
|
def oidc_get(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
return _oidc_view(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/oidc")
|
||||||
|
def oidc_put(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
new = onyva_login.validate_config({**onyva_login.config_of(t), **body})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
new["last_sync"] = onyva_login.config_of(t)["last_sync"]
|
||||||
|
t.oidc = new
|
||||||
|
store.audit(db, who, "oidc.config", t.id, f"mode={new['mode']} auth={new['auth_mode']} members={len(new['members'])}")
|
||||||
|
db.commit()
|
||||||
|
return _oidc_view(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/oidc/sync")
|
||||||
|
def oidc_sync(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
|
||||||
|
"""apply=false: nur lesen und Plan zeigen. apply=true: schreiben (nur mit OIDC_SYNC_LIVE/DEPLOY_LIVE)."""
|
||||||
|
apply = bool(body.get("apply"))
|
||||||
|
if apply and not guard.live_allowed(t.id):
|
||||||
|
raise HTTPException(403, guard.denial(t.id))
|
||||||
|
if apply and not onyva_login.writes_allowed():
|
||||||
|
raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt (Env OIDC_SYNC_LIVE oder DEPLOY_LIVE ≠ true). Plan (Dry-Run) ist möglich.")
|
||||||
|
lines: list[str] = []
|
||||||
|
rep = onyva_login.sync_tenant(t.id, apply=apply, log=lines.append, rotate=bool(body.get("rotate")), actor=who)
|
||||||
|
return {**rep, "log": lines}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/oidc/import-members")
|
||||||
|
def oidc_import_members(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
"""Liest die aktuellen Freigaben aus dem Login und übernimmt sie als Soll-Liste (nur lesender API-Zugriff)."""
|
||||||
|
client = onyva_login.client_from_env(db)
|
||||||
|
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
|
||||||
|
if client is None:
|
||||||
|
raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.")
|
||||||
|
if not (cid and cid.value_enc):
|
||||||
|
raise HTTPException(400, "Tenant hat noch keine OIDC_CLIENT_ID.")
|
||||||
|
try:
|
||||||
|
ms = client.memberships(crypto.decrypt(cid.value_enc))
|
||||||
|
except onyva_login.OnyvaError as exc:
|
||||||
|
raise HTTPException(502, str(exc))
|
||||||
|
cfg = onyva_login.config_of(t)
|
||||||
|
cfg["members"] = [{"email": m["email"], "role": m.get("role", "viewer")} for m in ms]
|
||||||
|
t.oidc = {**cfg, "last_sync": cfg["last_sync"]}
|
||||||
|
store.audit(db, who, "oidc.import_members", t.id, f"{len(ms)} Freigaben")
|
||||||
|
db.commit()
|
||||||
|
return _oidc_view(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/oidc/app")
|
||||||
|
def oidc_delete_app(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
"""Löscht den OIDC-Client im Login (nicht umkehrbar, beendet alle Logins dieser App)."""
|
||||||
|
if confirm != t.id:
|
||||||
|
raise HTTPException(400, "Bestätigung: ID des Tenants angeben.")
|
||||||
|
if not onyva_login.writes_allowed():
|
||||||
|
raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt.")
|
||||||
|
if not guard.live_allowed(t.id):
|
||||||
|
raise HTTPException(403, guard.denial(t.id))
|
||||||
|
client = onyva_login.client_from_env(db)
|
||||||
|
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
|
||||||
|
if client is None or not (cid and cid.value_enc):
|
||||||
|
raise HTTPException(400, "Kein API-Key oder keine Client-ID vorhanden.")
|
||||||
|
try:
|
||||||
|
client.delete_app(crypto.decrypt(cid.value_enc))
|
||||||
|
except onyva_login.OnyvaError as exc:
|
||||||
|
raise HTTPException(502, str(exc))
|
||||||
|
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"):
|
||||||
|
tsvc.set_secret(db, t.id, key, None, who)
|
||||||
|
store.audit(db, who, "oidc.app_deleted", t.id)
|
||||||
|
db.commit()
|
||||||
|
return _oidc_view(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oidc/status")
|
||||||
|
def oidc_status(db: Session = Depends(get_db)):
|
||||||
|
return {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/oidc/test")
|
||||||
|
def oidc_test(db: Session = Depends(get_db)):
|
||||||
|
client = onyva_login.client_from_env(db)
|
||||||
|
if client is None:
|
||||||
|
raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.")
|
||||||
|
try:
|
||||||
|
apps = client.list_apps()
|
||||||
|
except onyva_login.OnyvaError as exc:
|
||||||
|
raise HTTPException(502, str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(502, f"Login-API nicht erreichbar: {type(exc).__name__}: {str(exc)[:200]}")
|
||||||
|
return {"ok": True, "apps": len(apps)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/secrets")
|
||||||
|
def secrets_list(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
return tsvc.secret_rows(db, t.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/secrets/{key}")
|
||||||
|
def secret_put(key: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
value = body.get("value")
|
||||||
|
if body.get("generate"):
|
||||||
|
value = defaults.new_token()
|
||||||
|
try:
|
||||||
|
tsvc.set_secret(db, t.id, key, value, who)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/source")
|
||||||
|
def set_source(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
src = body.get("source")
|
||||||
|
if src not in ("git", "admin"):
|
||||||
|
raise HTTPException(400, "source: git | admin")
|
||||||
|
t.source = src
|
||||||
|
store.audit(db, who, "tenant.source", t.id, f"TENANT_SOURCE={src}")
|
||||||
|
db.commit()
|
||||||
|
return {"source": t.source}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/cutover")
|
||||||
|
def cutover(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
val = preview.run(db, t, "current", "validate")
|
||||||
|
except preview.PreviewError:
|
||||||
|
val = {"ok": False}
|
||||||
|
return {"checks": tsvc.cutover_checks(db, t, val), "source": t.source}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Deploy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _dep(d: Deployment):
|
||||||
|
return {"id": d.id, "batch": d.batch_id, "tenant": d.tenant_id, "kind": d.kind, "version": d.version,
|
||||||
|
"image": d.image_tag, "status": d.status, "simulated": d.simulated, "log": d.log, "actor": d.actor,
|
||||||
|
"created_at": _iso(d.created_at), "finished_at": _iso(d.finished_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deploy/mode")
|
||||||
|
def deploy_mode(db: Session = Depends(get_db)):
|
||||||
|
return {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe(), "project": bool(deploy.setting(db, "mittwald_project_id")),
|
||||||
|
"core_bundle": deploy.setting(db, "core_supports_bundle", "false") == "true"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/preflight")
|
||||||
|
def tenant_preflight(t: Tenant = Depends(tenant_or_404)):
|
||||||
|
"""Vorflug-Check für einen Live-Deploy (liest nur, schreibt nichts)."""
|
||||||
|
return preflight.run(t.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/plan")
|
||||||
|
def plan(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
return deploy.plan(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
def _kind(body: dict, base: str, tenant_id: str | None = None) -> str:
|
||||||
|
dry = bool(body.get("dry_run"))
|
||||||
|
if not dry and not deploy.live_enabled():
|
||||||
|
raise HTTPException(403, "Echte Deployments sind gesperrt (Env DEPLOY_LIVE != true). Nur Dry-Run möglich.")
|
||||||
|
if not dry and tenant_id and not guard.live_allowed(tenant_id):
|
||||||
|
raise HTTPException(403, guard.denial(tenant_id))
|
||||||
|
return f"{base}-dry" if base == "offboard" and dry else ("dry-run" if dry else base)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/deploy")
|
||||||
|
def deploy_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
|
||||||
|
return {"id": deploy.start(t.id, _kind(body, "deploy", t.id), who)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/offboard")
|
||||||
|
def offboard_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
|
||||||
|
return {"id": deploy.start(t.id, _kind(body, "offboard", t.id), who)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/deploy-all")
|
||||||
|
def deploy_all(body: dict = Body(default={}), who: str = Depends(actor)):
|
||||||
|
batch, ids = deploy.start_all(_kind(body, "deploy"), who)
|
||||||
|
return {"batch": batch, "ids": ids}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments/{dep_id}")
|
||||||
|
def deployment(dep_id: int, db: Session = Depends(get_db)):
|
||||||
|
d = db.get(Deployment, dep_id)
|
||||||
|
if d is None:
|
||||||
|
raise HTTPException(404)
|
||||||
|
return _dep(d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments")
|
||||||
|
def deployments(batch: str = "", db: Session = Depends(get_db)):
|
||||||
|
q = select(Deployment).order_by(Deployment.id.desc()).limit(60)
|
||||||
|
if batch:
|
||||||
|
q = select(Deployment).where(Deployment.batch_id == batch).order_by(Deployment.id)
|
||||||
|
return [_dep(d) for d in db.scalars(q)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Core: Vertrag, Releases, Rollout, Zustand ───────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/contract")
|
||||||
|
def contract_info():
|
||||||
|
return {"available": contract.available(), "supported": contract.supported(), "manifest": contract.manifest(), "schema": contract.schema(),
|
||||||
|
"supported_versions": sorted(contract.SUPPORTED_CONTRACT)}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session(request: Request) -> None:
|
||||||
|
if not getattr(request.state, "user", None):
|
||||||
|
raise HTTPException(401, "Nicht angemeldet")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/core/releases")
|
||||||
|
def core_release_intake(request: Request, body: dict = Body(...), authorization: str = Header(default=""), db: Session = Depends(get_db)):
|
||||||
|
"""Release-Eingang für die Core-CI (Bearer CORE_RELEASE_TOKEN). Meldet nur an – ausgerollt wird erst nach Prüfung und Freigabe."""
|
||||||
|
expected = os.getenv("CORE_RELEASE_TOKEN", "").strip()
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(503, "Release-Eingang nicht konfiguriert (Env CORE_RELEASE_TOKEN fehlt).")
|
||||||
|
if not hmac.compare_digest(authorization.removeprefix("Bearer ").strip(), expected):
|
||||||
|
raise HTTPException(401, "Ungültiges Token")
|
||||||
|
try:
|
||||||
|
r, created = releases.announce(db, str(body.get("sha", "")), body.get("tag"), str(body.get("ref", "")), str(body.get("image", "")), str(body.get("message", "")))
|
||||||
|
except releases.ReleaseError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"id": r.id, "created": created, "status": r.status, "admin_matches": releases.admin_matches(r)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/core/releases")
|
||||||
|
def core_releases(request: Request, db: Session = Depends(get_db)):
|
||||||
|
_require_session(request)
|
||||||
|
active = deploy.setting(db, "core_image_tag", "latest")
|
||||||
|
rows = db.scalars(select(CoreRelease).order_by(CoreRelease.id.desc()).limit(40)).all()
|
||||||
|
return {"admin_core": {**contract.build_info(), "contract": contract.contract_version(), "supported": contract.supported(),
|
||||||
|
"schema_sha": contract.manifest().get("schema_sha256"), "fields": contract.manifest().get("fields"),
|
||||||
|
"env": contract.manifest().get("env")},
|
||||||
|
"active_tag": active, "canary": releases.canary_list(db), "live": deploy.live_enabled(),
|
||||||
|
"intake": bool(os.getenv("CORE_RELEASE_TOKEN", "").strip()), "releases": [releases.view(r, active) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/core/releases/{release_id}/check")
|
||||||
|
def core_check(release_id: int, body: dict = Body(default={})):
|
||||||
|
try:
|
||||||
|
releases.check(release_id, force=bool(body.get("force")))
|
||||||
|
except releases.ReleaseError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
return {"started": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/core/releases/{release_id}/approve")
|
||||||
|
def core_approve(release_id: int, body: dict = Body(default={}), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
releases.approve(db, release_id, who, bool(body.get("acknowledge")))
|
||||||
|
except releases.ReleaseError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/core/releases/{release_id}/reject")
|
||||||
|
def core_reject(release_id: int, db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if r is None:
|
||||||
|
raise HTTPException(404)
|
||||||
|
r.status = "rejected"
|
||||||
|
store.audit(db, who, "core.reject", None, r.sha[:10])
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/core/releases/{release_id}/rollout")
|
||||||
|
def core_rollout(release_id: int, body: dict = Body(default={}), who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
batch, ids = releases.rollout(release_id, who, dry_run=bool(body.get("dry_run", True)))
|
||||||
|
except releases.ReleaseError as exc:
|
||||||
|
raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc))
|
||||||
|
return {"batch": batch, "ids": ids}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/core/releases/{release_id}/rollback")
|
||||||
|
def core_rollback(release_id: int, who: str = Depends(actor)):
|
||||||
|
try:
|
||||||
|
batch, ids = releases.rollback_release(release_id, who)
|
||||||
|
except releases.ReleaseError as exc:
|
||||||
|
raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc))
|
||||||
|
return {"batch": batch, "ids": ids}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/core-pin")
|
||||||
|
def tenant_pin(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
pin = (body.get("pin") or "").strip() or None
|
||||||
|
if pin and not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", pin):
|
||||||
|
raise HTTPException(400, "Tag: Buchstaben, Ziffern, . _ -")
|
||||||
|
t.core_pin = pin
|
||||||
|
store.audit(db, who, "tenant.core_pin", t.id, pin or "aufgehoben")
|
||||||
|
db.commit()
|
||||||
|
return {"pin": t.core_pin}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/rollback")
|
||||||
|
def tenant_rollback(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
if not deploy.live_enabled():
|
||||||
|
raise HTTPException(403, "Echte Deployments sind gesperrt (DEPLOY_LIVE != true).")
|
||||||
|
if not guard.live_allowed(t.id):
|
||||||
|
raise HTTPException(403, guard.denial(t.id))
|
||||||
|
tag = releases.tenant_rollback_tag(db, t)
|
||||||
|
if not tag:
|
||||||
|
raise HTTPException(400, "Kein früherer, erfolgreich deployter Core-Tag bekannt.")
|
||||||
|
return {"id": deploy.start(t.id, "deploy", who, core_tag=tag), "tag": tag}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def tenants_status(refresh: bool = False):
|
||||||
|
"""Was läuft wirklich? Fragt /health jedes aktiven Tenants ab (30 s Cache)."""
|
||||||
|
return status_svc.probe_all(refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Einstellungen ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_settings(db: Session = Depends(get_db)):
|
||||||
|
return {s.key: s.value for s in db.scalars(select(Setting))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
def put_settings(body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
for k, v in body.items():
|
||||||
|
s = db.get(Setting, k)
|
||||||
|
if s is None:
|
||||||
|
s = Setting(key=k)
|
||||||
|
db.add(s)
|
||||||
|
s.value = str(v)
|
||||||
|
store.audit(db, who, "settings.update", None, ", ".join(body))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/shared-secrets/{key}")
|
||||||
|
def shared_secret(key: str, body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
r = db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == key))
|
||||||
|
if r is None:
|
||||||
|
r = Secret(tenant_id=None, key=key)
|
||||||
|
db.add(r)
|
||||||
|
r.value_enc = crypto.encrypt(body["value"]) if body.get("value") else None
|
||||||
|
r.source = "manual"
|
||||||
|
store.audit(db, who, "shared-secret.set", None, key)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Agent ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _msg(m: ChatMessage):
|
||||||
|
return {"id": m.id, "role": m.role, "content": m.content, "steps": m.steps or [], "proposal": m.proposal,
|
||||||
|
"proposal_status": m.proposal_status, "usage": m.usage, "created_at": _iso(m.created_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/chat")
|
||||||
|
def chat_history(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
return [_msg(m) for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id).order_by(ChatMessage.id))]
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(ev: dict) -> str:
|
||||||
|
return f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/chat")
|
||||||
|
def chat(tenant_id: str, body: dict = Body(...), who: str = Depends(actor)):
|
||||||
|
"""Agent-Lauf als Server-Sent-Events: mode, step*, thought*, text, proposal, usage, done."""
|
||||||
|
text = (body.get("message") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(400, "Leere Nachricht")
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
steps, final, proposal, usage, mode, error = [], "", None, None, {}, None
|
||||||
|
for ev in agent_loop.run(db, t, text):
|
||||||
|
if ev["type"] == "step":
|
||||||
|
steps.append({k: ev[k] for k in ("tool", "arg", "result", "error")})
|
||||||
|
elif ev["type"] == "text":
|
||||||
|
final = ev["text"]
|
||||||
|
elif ev["type"] == "proposal":
|
||||||
|
proposal = ev["proposal"]
|
||||||
|
elif ev["type"] == "usage":
|
||||||
|
usage = ev["usage"]
|
||||||
|
elif ev["type"] == "mode":
|
||||||
|
mode = ev
|
||||||
|
elif ev["type"] == "error":
|
||||||
|
error = ev["text"]
|
||||||
|
yield _sse(ev if ev["type"] != "proposal" else {"type": "proposal", "summary": (proposal or {}).get("summary"),
|
||||||
|
"items": (proposal or {}).get("items", [])})
|
||||||
|
db.add(ChatMessage(tenant_id=tenant_id, role="user", content=text))
|
||||||
|
m = ChatMessage(tenant_id=tenant_id, role="assistant", content=error and f"⚠ {error}" or final, steps=steps, proposal=proposal,
|
||||||
|
proposal_status="open" if proposal else "",
|
||||||
|
usage={**(usage or {}), "backend": mode.get("backend"), "model": mode.get("model")})
|
||||||
|
db.add(m)
|
||||||
|
store.audit(db, who, "agent.run", tenant_id, f"{mode.get('backend')}/{mode.get('model')} {text[:80]}")
|
||||||
|
db.commit()
|
||||||
|
yield _sse({"type": "done", "message": _msg(m)})
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/chat/{msg_id}/diff")
|
||||||
|
def chat_diff(msg_id: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
m = db.get(ChatMessage, msg_id)
|
||||||
|
if m is None or m.tenant_id != t.id or not m.proposal:
|
||||||
|
raise HTTPException(404)
|
||||||
|
return store.diff_content(db, store.content(db, t, "draft"), agent_loop.proposal_content(db, t, m.proposal), with_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/chat/{msg_id}/{decision}")
|
||||||
|
def chat_decide(msg_id: int, decision: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
|
||||||
|
m = db.get(ChatMessage, msg_id)
|
||||||
|
if m is None or m.tenant_id != t.id or m.proposal_status != "open":
|
||||||
|
raise HTTPException(400, "Kein offener Vorschlag")
|
||||||
|
if decision == "apply":
|
||||||
|
agent_loop.apply_proposal(db, t, m.proposal)
|
||||||
|
m.proposal_status = "applied"
|
||||||
|
store.audit(db, "agent", "agent.proposal.apply", t.id, f"freigegeben von {who}: {(m.proposal or {}).get('summary')}")
|
||||||
|
elif decision == "reject":
|
||||||
|
m.proposal_status = "rejected"
|
||||||
|
else:
|
||||||
|
raise HTTPException(400)
|
||||||
|
db.commit()
|
||||||
|
return _msg(m)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/chat")
|
||||||
|
def chat_clear(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id)):
|
||||||
|
db.delete(m)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bundle: genau das holt ein Tenant-Container beim Start ──────────────────
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/bundle")
|
||||||
|
def bundle(tenant_id: str, authorization: str = Header(default=""), db: Session = Depends(get_db)):
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
if not tsvc.check_bundle_token(db, tenant_id, token):
|
||||||
|
raise HTTPException(401, "Ungültiges Bundle-Token")
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
data, manifest = store.bundle_tar(db, t, "current")
|
||||||
|
return Response(data, media_type="application/gzip", headers={"X-Bundle-Version": str(t.current_version), "ETag": f'"{t.current_version}"'})
|
||||||
107
app/routers/pages.py
Normal file
107
app/routers/pages.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
|
from app.deps import tenant_or_404
|
||||||
|
from app.models import AuditLog, Deployment, Secret, Setting, Tenant
|
||||||
|
from app.services import deploy, guard, onyva_login, preview, store
|
||||||
|
from app.services.importer import DEFAULT_SETTINGS
|
||||||
|
from app.templating import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _logo(db, t: Tenant, source="current") -> str | None:
|
||||||
|
_, files = store.content(db, t, source)
|
||||||
|
for name in ("logo.svg", "logo.png"):
|
||||||
|
if name in files:
|
||||||
|
return f"/api/tenants/{t.id}/files/{name}?source={source}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||||
|
rows = []
|
||||||
|
for t in db.scalars(select(Tenant).order_by(Tenant.id)):
|
||||||
|
cfg, files = store.content(db, t, "current")
|
||||||
|
missing = db.scalar(select(func.count()).select_from(Secret).where(Secret.tenant_id == t.id, Secret.value_enc.is_(None))) or 0
|
||||||
|
rows.append({
|
||||||
|
"t": t, "cfg": cfg, "accent": (cfg.get("colors") or {}).get("accent", "#327F24"),
|
||||||
|
"logo": _logo(db, t), "host": deploy.host_for(db, t), "outdated": t.deployed_version != t.current_version,
|
||||||
|
"missing_secrets": missing, "stt": (cfg.get("stt") or {}).get("provider", "mistral"),
|
||||||
|
"has_code": "overrides.py" in files, "custom_prompt": "prompt.txt" in files,
|
||||||
|
"custom_pdf": "protocol.html" in files or "protocol.css" in files,
|
||||||
|
})
|
||||||
|
stats = {
|
||||||
|
"tenants": len(rows), "admin": sum(1 for r in rows if r["t"].source == "admin"),
|
||||||
|
"drafts": sum(1 for r in rows if r["t"].draft), "outdated": sum(1 for r in rows if r["outdated"]),
|
||||||
|
"secrets": sum(1 for r in rows if r["missing_secrets"]),
|
||||||
|
}
|
||||||
|
last = db.scalars(select(Deployment).order_by(Deployment.id.desc()).limit(5)).all()
|
||||||
|
return templates.TemplateResponse(request, "dashboard.html", {
|
||||||
|
"rows": rows, "stats": stats, "last": last, "core": preview.core_version(),
|
||||||
|
"tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "dashboard"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}", response_class=HTMLResponse)
|
||||||
|
def tenant_page(request: Request, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
return templates.TemplateResponse(request, "tenant.html", {"t": t, "nav": "dashboard", "logo": _logo(db, t, "draft")})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/bundle.tar.gz")
|
||||||
|
def bundle_download(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "current"):
|
||||||
|
data, _ = store.bundle_tar(db, t, source)
|
||||||
|
return Response(data, media_type="application/gzip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{t.id}-v{t.current_version}.tar.gz"'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview/{tenant_id}/{kind}")
|
||||||
|
def preview_view(kind: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
|
||||||
|
if kind not in ("web", "pdf"):
|
||||||
|
raise HTTPException(404)
|
||||||
|
try:
|
||||||
|
data = preview.run(db, t, source, "index" if kind == "web" else "pdf")
|
||||||
|
except (preview.PreviewError, preview.SourceGone) as exc:
|
||||||
|
return Response(str(exc), status_code=422, media_type="text/plain; charset=utf-8")
|
||||||
|
if kind == "pdf":
|
||||||
|
return Response(data, media_type="application/pdf", headers={"Content-Disposition": "inline"})
|
||||||
|
html = data.decode("utf-8")
|
||||||
|
# PNG-Logos referenziert der Core über /brand/logo → auf die Admin-Datei umbiegen
|
||||||
|
html = html.replace('src="/brand/logo"', f'src="/api/tenants/{t.id}/files/logo.png?source={source}"')
|
||||||
|
html = html.replace("</head>", "<base target='_blank'></head>", 1)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments", response_class=HTMLResponse)
|
||||||
|
def deployments_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
tenants = db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)).all()
|
||||||
|
return templates.TemplateResponse(request, "deployments.html", {
|
||||||
|
"tenants": tenants, "tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "deployments"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
values = {k: deploy.setting(db, k, "") for k in DEFAULT_SETTINGS}
|
||||||
|
shared = {s.key: bool(s.value_enc) for s in db.scalars(select(Secret).where(Secret.tenant_id.is_(None)))}
|
||||||
|
from app.services import llm
|
||||||
|
return templates.TemplateResponse(request, "settings.html", {
|
||||||
|
"v": values, "shared": shared, "core": preview.core_version(), "nav": "settings",
|
||||||
|
"llm_ready": llm.configured(db), "mode": {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe()},
|
||||||
|
"oidc": {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit", response_class=HTMLResponse)
|
||||||
|
def audit_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
rows = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(200)).all()
|
||||||
|
return templates.TemplateResponse(request, "audit.html", {"rows": rows, "nav": "audit"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/core", response_class=HTMLResponse)
|
||||||
|
def core_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "core.html", {"nav": "core"})
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
125
app/services/contract.py
Normal file
125
app/services/contract.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Vertrag mit dem Core (siehe transkript-platform/docs/CONTRACT.md): Schema, Manifest, Build-Info, Env-Spezifikation.
|
||||||
|
|
||||||
|
Gelesen wird aus dem Core, auf dem die Admin-Plattform läuft (PLATFORM_DIR: im Container das Core-Image, lokal der vendored Stand).
|
||||||
|
`core/contract.py` ist import-sicher (nur Standardbibliothek) und wird per Dateipfad geladen – der Core-Code selbst wird NIE
|
||||||
|
in diesem Prozess importiert (er hält Prozess-Singletons); gerendert wird in der Sandbox.
|
||||||
|
Ältere Cores ohne Vertrag („legacy“) funktionieren weiter: Schema fehlt → feste Admin-Formulare und feste Env-Liste.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.settings import PLATFORM_DIR
|
||||||
|
|
||||||
|
SUPPORTED_CONTRACT = {1}
|
||||||
|
|
||||||
|
# Rückfall für Cores ohne Schema (entspricht dem Stand vor dem Vertrag)
|
||||||
|
LEGACY_SHARED_ENV = {
|
||||||
|
"OIDC_ISSUER": "oidc_issuer", "AUTH_ENABLED": "auth_enabled", "APP_COOKIE_SECURE": "app_cookie_secure",
|
||||||
|
"MAX_FILE_SIZE_MB": "max_file_size_mb", "LLM_MODEL": "llm_model", "LLM_MAX_TOKENS": "llm_max_tokens",
|
||||||
|
"LLM_TEMPERATURE": "llm_temperature", "CORTECS_BASE_URL": "cortecs_base_url", "SMTP_HOST": "smtp_host",
|
||||||
|
"SMTP_PORT": "smtp_port", "SMTP_USER": "smtp_user", "SMTP_FROM": "smtp_from",
|
||||||
|
"MISTRAL_TRANSCRIBE_MODEL": "mistral_transcribe_model", "MISTRAL_DIARIZE": "mistral_diarize",
|
||||||
|
"MISTRAL_TIMEOUT": "mistral_timeout", "COST_API_BASE": "cost_api_base", "COST_TRACKING_ENABLED": "cost_tracking_enabled",
|
||||||
|
}
|
||||||
|
LEGACY_REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET"]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _module():
|
||||||
|
path = Path(PLATFORM_DIR) / "core" / "contract.py"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
spec = importlib.util.spec_from_file_location("onyscribe_core_contract", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def reload() -> None:
|
||||||
|
_module.cache_clear()
|
||||||
|
schema.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
return _module() is not None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def schema() -> dict | None:
|
||||||
|
m = _module()
|
||||||
|
return m.load_schema() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_info() -> dict:
|
||||||
|
m = _module()
|
||||||
|
if m:
|
||||||
|
return m.build_info()
|
||||||
|
f = Path(PLATFORM_DIR) / "CORE_VERSION"
|
||||||
|
return {"sha": f.read_text().split()[0] if f.exists() else "unbekannt", "built_at": None, "source": "legacy"}
|
||||||
|
|
||||||
|
|
||||||
|
def manifest() -> dict:
|
||||||
|
m = _module()
|
||||||
|
if m:
|
||||||
|
return m.build_manifest()
|
||||||
|
return {"contract": 0, "core": build_info(), "legacy": True}
|
||||||
|
|
||||||
|
|
||||||
|
def contract_version() -> int:
|
||||||
|
return int(manifest().get("contract", 0))
|
||||||
|
|
||||||
|
|
||||||
|
def supported() -> bool:
|
||||||
|
return (not available()) or contract_version() in SUPPORTED_CONTRACT
|
||||||
|
|
||||||
|
|
||||||
|
def sha() -> str:
|
||||||
|
return build_info()["sha"]
|
||||||
|
|
||||||
|
|
||||||
|
def same_sha(a: str, b: str) -> bool:
|
||||||
|
a, b = (a or "").lower(), (b or "").lower()
|
||||||
|
return bool(a and b) and (a.startswith(b) or b.startswith(a)) and min(len(a), len(b)) >= 7
|
||||||
|
|
||||||
|
|
||||||
|
def check_tenant_json(raw: dict) -> list[str]:
|
||||||
|
m = _module()
|
||||||
|
return m.check_tenant_json(raw, schema()) if m else []
|
||||||
|
|
||||||
|
|
||||||
|
def field_paths() -> set[str]:
|
||||||
|
s = schema()
|
||||||
|
return {f["path"] for f in s["fields"]} if s else set()
|
||||||
|
|
||||||
|
|
||||||
|
def env_spec() -> list[dict]:
|
||||||
|
s = schema()
|
||||||
|
if s:
|
||||||
|
return s["env"]
|
||||||
|
return [{"name": n, "kind": "setting", "scope": "shared", "setting": k, "default": None, "label": n, "group": "Allgemein"} for n, k in LEGACY_SHARED_ENV.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def required_secrets(tenant_cfg: dict | None = None) -> list[str]:
|
||||||
|
"""Pflicht-Secrets laut Schema (required, required_if wie „stt.provider!=cortecs“ gegen die Tenant-Config ausgewertet)."""
|
||||||
|
s = schema()
|
||||||
|
if not s:
|
||||||
|
return list(LEGACY_REQUIRED_SECRETS)
|
||||||
|
out = []
|
||||||
|
for e in s["env"]:
|
||||||
|
if e["kind"] != "secret":
|
||||||
|
continue
|
||||||
|
if e.get("required"):
|
||||||
|
out.append(e["name"])
|
||||||
|
elif e.get("required_if"):
|
||||||
|
path, _, rhs = e["required_if"].partition("!=")
|
||||||
|
cur = tenant_cfg or {}
|
||||||
|
for k in path.split("."):
|
||||||
|
cur = cur.get(k, {}) if isinstance(cur, dict) else {}
|
||||||
|
val = cur if isinstance(cur, str) else ""
|
||||||
|
if (val or "mistral") != rhs: # Provider-Standard ist mistral
|
||||||
|
out.append(e["name"])
|
||||||
|
return out
|
||||||
15
app/services/crypto.py
Normal file
15
app/services/crypto.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app.settings import SECRET_KEY
|
||||||
|
|
||||||
|
_f = Fernet(SECRET_KEY.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(v: str) -> str:
|
||||||
|
return _f.encrypt(v.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(v: str) -> str:
|
||||||
|
return _f.decrypt(v.encode()).decode()
|
||||||
44
app/services/defaults.py
Normal file
44
app/services/defaults.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# Standardfarben des Cores (core/config.py DEFAULT_COLORS) – Basis für neue Tenants
|
||||||
|
DEFAULT_COLORS = {
|
||||||
|
"accent": "#327F24", "accent_dark": "#235A19", "accent_mid": "#3E9130", "accent_light": "#EAF4E7",
|
||||||
|
"accent_xlight": "#F3FAF1", "bg": "#F1F5F0", "surface": "#FFFFFF", "surface2": "#F6FAF5",
|
||||||
|
"border": "#DCE8D9", "border2": "#BDD1B8", "text": "#444444", "text_2": "#666666", "text_3": "#9AA89A",
|
||||||
|
"red": "#DC2626", "progress_to": "#8bbf43",
|
||||||
|
}
|
||||||
|
|
||||||
|
COLOR_LABELS = {
|
||||||
|
"accent": "Akzent", "accent_dark": "Akzent dunkel", "accent_mid": "Akzent mittel", "accent_light": "Akzent hell",
|
||||||
|
"accent_xlight": "Akzent extra hell", "bg": "Seitenhintergrund", "surface": "Karten", "surface2": "Karten 2",
|
||||||
|
"border": "Rahmen", "border2": "Rahmen 2", "text": "Text", "text_2": "Text sekundär", "text_3": "Text tertiär",
|
||||||
|
"red": "Rot (Hinweis)", "progress_to": "Fortschritt (Verlauf-Ende)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Secret-Schlüssel = Env-Namen im Container (siehe infra/deploy.py)
|
||||||
|
REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY"]
|
||||||
|
OPTIONAL_SECRETS = ["MISTRAL_API_KEY"]
|
||||||
|
SECRET_HELP = {
|
||||||
|
"OIDC_CLIENT_ID": "Client-ID aus login.onyva.dev (manuell angelegt)",
|
||||||
|
"OIDC_CLIENT_SECRET": "Client-Secret aus login.onyva.dev",
|
||||||
|
"SESSION_SECRET": "Signiert Session-Cookies – kann hier erzeugt werden",
|
||||||
|
"COST_INGEST_KEY": "Schlüssel für die Kostenerfassung (Cost-API)",
|
||||||
|
"MISTRAL_API_KEY": "Nur nötig bei STT-Provider „mistral“ (bei „cortecs“ wird der geteilte Key genutzt)",
|
||||||
|
"ADMIN_BUNDLE_TOKEN": "Token, mit dem der Container sein Bundle bei der Admin-Plattform abholt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def new_token() -> str:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def skeleton_config(tenant_id: str, name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": tenant_id, "name": name, "title": f"{name} – Gesprächsprotokoll",
|
||||||
|
"description": f"Gesprächsprotokoll-Tool für {name}", "ai_detect_org": name,
|
||||||
|
"org_short": name, "org_profile": "",
|
||||||
|
"flags": {}, "stt": {"provider": "cortecs", "model": "voxtral-mini-transcribe-2602"},
|
||||||
|
"colors": dict(DEFAULT_COLORS),
|
||||||
|
"office": {"name": name, "subtitle": "", "address": "", "tel": "", "email": "", "website": ""},
|
||||||
|
}
|
||||||
314
app/services/deploy.py
Normal file
314
app/services/deploy.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""Deploy-Orchestrierung: Plan (wie infra/deploy.py), Ausführung über den Mittwald-Client.
|
||||||
|
|
||||||
|
Sicherheit: Ohne `DEPLOY_LIVE=true` (Env der Admin-Plattform) ist JEDE schreibende Operation gesperrt – es gibt nur Dry-Runs.
|
||||||
|
Außerdem blockiert der Core-Kompatibilitäts-Schalter (`core_supports_bundle`) Tenants mit Quelle „Admin“,
|
||||||
|
solange der Core das Bundle noch nicht laden kann.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import session
|
||||||
|
from app.models import Deployment, Secret, Setting, Tenant
|
||||||
|
from app.services import contract, crypto, guard, onyva_login, store
|
||||||
|
from app.services.mittwald import DeployError, Mittwald, mask_env
|
||||||
|
|
||||||
|
PORT = "8276"
|
||||||
|
|
||||||
|
|
||||||
|
def live_enabled() -> bool:
|
||||||
|
return os.getenv("DEPLOY_LIVE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def token_present() -> bool:
|
||||||
|
return bool(os.getenv("MITTWALD_API_TOKEN", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def setting(db, key: str, default: str = "") -> str:
|
||||||
|
s = db.get(Setting, key)
|
||||||
|
return s.value if s and s.value != "" else default
|
||||||
|
|
||||||
|
|
||||||
|
def host_for(db, t: Tenant) -> str:
|
||||||
|
if t.custom_domain and t.domain_status == "verified":
|
||||||
|
return t.custom_domain
|
||||||
|
return f"{t.subdomain or t.id}.{setting(db, 'base_domain', 'example.de')}"
|
||||||
|
|
||||||
|
|
||||||
|
def effective_tag(db, t: Tenant | None = None, override: str | None = None) -> str:
|
||||||
|
"""Welcher Core-Tag gilt? Override (Rollout) > Pin des Tenants > freigegebener Standard-Tag."""
|
||||||
|
return override or (t.core_pin if t is not None and t.core_pin else None) or setting(db, "core_image_tag", "latest")
|
||||||
|
|
||||||
|
|
||||||
|
def image_ref(db, t: Tenant | None = None, tag: str | None = None) -> str:
|
||||||
|
registry = setting(db, "registry", "").rstrip("/")
|
||||||
|
name = setting(db, "image_name", "transkript-platform")
|
||||||
|
base = f"{registry}/{name}" if registry else name
|
||||||
|
return f"{base.lower()}:{effective_tag(db, t, tag)}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_env(db, t: Tenant, reveal: bool) -> dict[str, str]:
|
||||||
|
"""Container-Env, abgeleitet aus dem Env-Schema des Cores (kind/scope) – neue Core-Variablen erscheinen ohne Admin-Änderung.
|
||||||
|
reveal=False → Secrets als Platzhalter."""
|
||||||
|
host = host_for(db, t)
|
||||||
|
env = {"TENANT": t.id, "DATA_DIR": "/data", "PORT": PORT,
|
||||||
|
"OIDC_REDIRECT_URI": f"https://{host}/auth/callback", "OIDC_LOGOUT_REDIRECT_URI": f"https://{host}/"}
|
||||||
|
spec = contract.env_spec()
|
||||||
|
for e in spec: # geteilte, nicht geheime Einstellungen
|
||||||
|
if e["kind"] == "setting" and e["scope"] == "shared":
|
||||||
|
v = setting(db, e.get("setting") or e["name"].lower())
|
||||||
|
if v:
|
||||||
|
env[e["name"]] = v
|
||||||
|
shared = db.scalars(select(Secret).where(Secret.tenant_id.is_(None))).all()
|
||||||
|
own = db.scalars(select(Secret).where(Secret.tenant_id == t.id)).all()
|
||||||
|
for s_ in [*shared, *own]: # Tenant-Werte überschreiben geteilte; auch Secrets ohne Schema-Eintrag werden durchgereicht
|
||||||
|
if s_.key == "ADMIN_BUNDLE_TOKEN" and t.source != "admin":
|
||||||
|
continue
|
||||||
|
if s_.value_enc:
|
||||||
|
env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt"
|
||||||
|
elif not reveal:
|
||||||
|
env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)"
|
||||||
|
if t.source == "admin":
|
||||||
|
env["TENANT_SOURCE"] = "admin"
|
||||||
|
env["ADMIN_URL"] = setting(db, "admin_public_url", "https://<admin-host>")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def missing_secrets(db, t: Tenant) -> list[str]:
|
||||||
|
"""Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben."""
|
||||||
|
cfg, _ = store.content(db, t, "current")
|
||||||
|
have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc}
|
||||||
|
need = set(contract.required_secrets(cfg))
|
||||||
|
if t.source == "admin":
|
||||||
|
need.add("ADMIN_BUNDLE_TOKEN")
|
||||||
|
return sorted(need - have)
|
||||||
|
|
||||||
|
|
||||||
|
def plan(db, t: Tenant) -> dict:
|
||||||
|
cfg, _ = store.content(db, t, "current")
|
||||||
|
return {
|
||||||
|
"service": f"app-{t.id}", "volume": f"vol-{t.id}",
|
||||||
|
"patch": {"description": cfg.get("name") or f"Onyscribe {t.id}", "image": image_ref(db, t),
|
||||||
|
"environment": build_env(db, t, reveal=False), "ports": [f"{PORT}:{PORT}/tcp"], "volumes": [f"vol-{t.id}:/data"]},
|
||||||
|
"ingress": {"host": host_for(db, t), "target": f"app-{t.id}:{PORT}"},
|
||||||
|
"missing_secrets": missing_secrets(db, t),
|
||||||
|
"oidc": {"mode": onyva_login.config_of(t)["mode"], "key": onyva_login.key_present(), "writes": onyva_login.writes_allowed(),
|
||||||
|
"last_sync": onyva_login.config_of(t)["last_sync"]},
|
||||||
|
"mode": {"live": live_enabled(), "token": token_present(), "tenant_allowed": guard.live_allowed(t.id), "allowed": guard.describe(), "project": bool(setting(db, "mittwald_project_id")),
|
||||||
|
"core_bundle": setting(db, "core_supports_bundle", "false") == "true"},
|
||||||
|
"core": {"tag": effective_tag(db, t), "pinned": bool(t.core_pin), "admin_core": contract.sha()[:10], "contract": contract.contract_version()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ausführung ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _append(dep_id: int, line: str) -> None:
|
||||||
|
with session() as db:
|
||||||
|
db.get(Deployment, dep_id).log += f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} {line}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(dep_id: int, status: str) -> None:
|
||||||
|
with session() as db:
|
||||||
|
d = db.get(Deployment, dep_id)
|
||||||
|
d.status, d.finished_at = status, datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(live: bool, log, http=None) -> Mittwald:
|
||||||
|
return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http)
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_tag(tag: str) -> bool:
|
||||||
|
return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7
|
||||||
|
|
||||||
|
|
||||||
|
def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0,
|
||||||
|
sleep=time.sleep) -> None:
|
||||||
|
"""Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle).
|
||||||
|
Wirft DeployError, wenn der Container nach `tries` Versuchen nicht gesund ist oder die falsche Version meldet."""
|
||||||
|
if get is None:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
def get(url):
|
||||||
|
r = httpx.get(url, timeout=8, follow_redirects=True)
|
||||||
|
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
|
||||||
|
last = "keine Antwort"
|
||||||
|
for i in range(tries):
|
||||||
|
try:
|
||||||
|
status, body = get(f"https://{host}/health")
|
||||||
|
problems = []
|
||||||
|
if status != 200 or body.get("status") != "ok":
|
||||||
|
problems.append(f"HTTP {status}")
|
||||||
|
core = body.get("core")
|
||||||
|
if expect_sha and _hex_tag(expect_sha):
|
||||||
|
if core is None:
|
||||||
|
log("⚠ /health meldet keine Core-Version (Image vor dem Vertrag) – Version nicht prüfbar")
|
||||||
|
elif not contract.same_sha(core.get("version", ""), expect_sha):
|
||||||
|
problems.append(f"Core {str(core.get('version'))[:10]} statt {expect_sha[:10]}")
|
||||||
|
if expect_bundle is not None and body.get("bundle"):
|
||||||
|
b = body["bundle"]
|
||||||
|
if b.get("source") != "admin" or b.get("version") != expect_bundle or b.get("stale"):
|
||||||
|
problems.append(f"Bundle {b.get('source')} v{b.get('version')}{' (veraltet)' if b.get('stale') else ''} statt admin v{expect_bundle}"
|
||||||
|
+ (f" – {b.get('error')}" if b.get("error") else ""))
|
||||||
|
if not problems:
|
||||||
|
log(f"Health-Check https://{host}/health ✔" + (f" · Core {core['version'][:10]}" if core else "")
|
||||||
|
+ (f" · Bundle v{body['bundle']['version']}" if body.get("bundle", {}).get("source") == "admin" else ""))
|
||||||
|
return
|
||||||
|
last = "; ".join(problems)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last = f"{type(exc).__name__}: {exc}"
|
||||||
|
if i < tries - 1:
|
||||||
|
sleep(delay)
|
||||||
|
raise DeployError(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None) -> bool:
|
||||||
|
"""Führt einen Deployment-Datensatz aus. kind: dry-run | deploy | offboard-dry | offboard.
|
||||||
|
core_tag überschreibt den Core-Tag (Rollout/Rollback); `health(host, expect_sha, expect_bundle, log)` ist in Tests ersetzbar."""
|
||||||
|
log = lambda m: _append(dep_id, m) # noqa: E731
|
||||||
|
dry = kind in ("dry-run", "offboard-dry")
|
||||||
|
live = live_enabled() and not dry
|
||||||
|
try:
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
ver = t.current_version
|
||||||
|
project, stack = setting(db, "mittwald_project_id"), setting(db, "mittwald_stack_id")
|
||||||
|
host = host_for(db, t) if setting(db, "base_domain") else None
|
||||||
|
source, bundle_ok = t.source, setting(db, "core_supports_bundle", "false") == "true"
|
||||||
|
image, tag = image_ref(db, t, core_tag), effective_tag(db, t, core_tag)
|
||||||
|
pinned = bool(t.core_pin) and not core_tag
|
||||||
|
log(f"▶ {kind} „{tenant_id}“ · Version v{ver} · {image}{' (gepinnt)' if pinned else ''} · Modus: {'LIVE' if live else 'Dry-Run (keine Schreibzugriffe)'}")
|
||||||
|
if not contract.supported():
|
||||||
|
raise DeployError(f"Core-Vertrag {contract.contract_version()} wird von dieser Admin-Version nicht unterstützt – Admin aktualisieren.")
|
||||||
|
if source == "admin" and not bundle_ok and not dry:
|
||||||
|
raise DeployError("Quelle „Admin“ ist noch gesperrt: Der Core kann das Bundle noch nicht laden (Einstellung core_supports_bundle).")
|
||||||
|
if not dry and not live_enabled():
|
||||||
|
raise DeployError("Echte Deployments sind gesperrt (DEPLOY_LIVE != true).")
|
||||||
|
if not dry and not guard.live_allowed(tenant_id):
|
||||||
|
raise DeployError(guard.denial(tenant_id))
|
||||||
|
if live and source == "admin":
|
||||||
|
with session() as db:
|
||||||
|
url = setting(db, "admin_public_url")
|
||||||
|
if not url.startswith("https://") or "<" in url:
|
||||||
|
raise DeployError("Einstellung „Öffentliche URL der Admin-Plattform“ (https://…) fehlt – der Container könnte sein Bundle nicht holen.")
|
||||||
|
|
||||||
|
if not kind.startswith("offboard"):
|
||||||
|
# 1) OIDC-Client abgleichen (legt bei Bedarf an) – VOR dem Service, weil dessen Env die Client-Daten braucht
|
||||||
|
rep = onyva_login.sync_tenant(tenant_id, apply=live, log=log, client=oidc_client, actor="deploy")
|
||||||
|
if live and not rep["ok"]:
|
||||||
|
raise DeployError(f"OIDC-Abgleich fehlgeschlagen: {rep['error']}")
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
if live:
|
||||||
|
lacking = missing_secrets(db, t)
|
||||||
|
if lacking:
|
||||||
|
raise DeployError("Pflicht-Secrets ohne Wert: " + ", ".join(lacking) + " (Tenant → Secrets bzw. Domain & Login, geteilte unter Einstellungen).")
|
||||||
|
definition = {"description": plan(db, t)["patch"]["description"], "image": image, "ports": [f"{PORT}:{PORT}/tcp"],
|
||||||
|
"volumes": [f"vol-{t.id}:/data"], "environment": build_env(db, t, reveal=live)}
|
||||||
|
mw = _client(live, log, http)
|
||||||
|
sid = mw.resolve_stack_id(project, stack)
|
||||||
|
if kind.startswith("offboard"):
|
||||||
|
mw.offboard_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", project_id=project, hostname=host)
|
||||||
|
log("Hinweis: Der OIDC-Client bleibt im Login bestehen (Tenant → Übersicht → Gefahrenbereich: „OIDC-Client im Login löschen“).")
|
||||||
|
else:
|
||||||
|
mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition,
|
||||||
|
container_port=PORT, project_id=project, hostname=host)
|
||||||
|
if live and not kind.startswith("offboard") and host:
|
||||||
|
(health or verify_health)(host, tag, ver if source == "admin" else None, log)
|
||||||
|
if not dry:
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
if kind == "deploy":
|
||||||
|
t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc)
|
||||||
|
store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}")
|
||||||
|
log("✔ Fertig" if live else "Dry-Run beendet – es wurde nichts geschrieben.")
|
||||||
|
return True
|
||||||
|
except DeployError as exc:
|
||||||
|
log(f"✘ {exc}")
|
||||||
|
return False
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log(f"✘ Unerwarteter Fehler: {type(exc).__name__}: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def start(tenant_id: str, kind: str, actor: str, batch_id: str = "", http=None, wait: bool = False, oidc_client=None,
|
||||||
|
core_tag: str | None = None, health=None) -> int:
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
d = Deployment(tenant_id=tenant_id, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag),
|
||||||
|
actor=actor, batch_id=batch_id, simulated=not (live_enabled() and kind in ("deploy", "offboard")))
|
||||||
|
db.add(d)
|
||||||
|
db.flush()
|
||||||
|
dep_id = d.id
|
||||||
|
|
||||||
|
def work():
|
||||||
|
ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health)
|
||||||
|
_finish(dep_id, "success" if ok else "failed")
|
||||||
|
|
||||||
|
th = threading.Thread(target=work, daemon=True)
|
||||||
|
th.start()
|
||||||
|
if wait:
|
||||||
|
th.join()
|
||||||
|
return dep_id
|
||||||
|
|
||||||
|
|
||||||
|
def run_sequence(tenant_ids: list[str], kind: str, actor: str, *, batch: str, core_tag: str | None = None, http=None, oidc_client=None,
|
||||||
|
health=None, canary: list[str] | None = None, wait: bool = False, on_done=None) -> list[int]:
|
||||||
|
"""Tenants nacheinander (zuerst die Canary-Tenants), Abbruch beim ersten Fehler – der Rest wird „cancelled“.
|
||||||
|
on_done(ok, results) wird nach dem Lauf aufgerufen (z. B. um ein Release abzuschließen)."""
|
||||||
|
if kind in ("deploy", "offboard") and live_enabled(): # Live: nur freigegebene Tenants (siehe guard.py)
|
||||||
|
skipped = [t for t in tenant_ids if not guard.live_allowed(t)]
|
||||||
|
tenant_ids = [t for t in tenant_ids if guard.live_allowed(t)]
|
||||||
|
if skipped:
|
||||||
|
with session() as db:
|
||||||
|
store.audit(db, actor, "deploy.skipped_not_allowed", None, ", ".join(skipped))
|
||||||
|
canary = [c for c in (canary or []) if c in tenant_ids]
|
||||||
|
order = canary + [t for t in tenant_ids if t not in canary]
|
||||||
|
with session() as db:
|
||||||
|
rows = []
|
||||||
|
for tid in order:
|
||||||
|
t = db.get(Tenant, tid)
|
||||||
|
d = Deployment(tenant_id=tid, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag), actor=actor,
|
||||||
|
batch_id=batch, status="queued", simulated=not (live_enabled() and kind == "deploy"))
|
||||||
|
db.add(d)
|
||||||
|
rows.append(d)
|
||||||
|
db.flush()
|
||||||
|
dep_ids = [d.id for d in rows]
|
||||||
|
|
||||||
|
def seq():
|
||||||
|
failed, results = False, {}
|
||||||
|
for tid, did in zip(order, dep_ids):
|
||||||
|
if failed:
|
||||||
|
_append(did, "übersprungen – vorheriger Tenant fehlgeschlagen (Rollout abgebrochen)")
|
||||||
|
_finish(did, "cancelled")
|
||||||
|
results[tid] = "cancelled"
|
||||||
|
continue
|
||||||
|
with session() as db:
|
||||||
|
db.get(Deployment, did).status = "running"
|
||||||
|
ok = execute(did, tid, kind, http, oidc_client, core_tag, health)
|
||||||
|
_finish(did, "success" if ok else "failed")
|
||||||
|
results[tid] = "success" if ok else "failed"
|
||||||
|
failed = not ok
|
||||||
|
if tid in canary and ok and tid == canary[-1]:
|
||||||
|
_append(did, "Canary erfolgreich – restliche Tenants folgen")
|
||||||
|
if on_done:
|
||||||
|
on_done(not failed, results)
|
||||||
|
|
||||||
|
th = threading.Thread(target=seq, daemon=True)
|
||||||
|
th.start()
|
||||||
|
if wait:
|
||||||
|
th.join()
|
||||||
|
return dep_ids
|
||||||
|
|
||||||
|
|
||||||
|
def start_all(kind: str, actor: str, http=None, wait: bool = False, oidc_client=None, core_tag: str | None = None, health=None) -> tuple[str, list[int]]:
|
||||||
|
"""Alle aktiven, nicht gepinnten Tenants nacheinander; beim ersten Fehler Abbruch."""
|
||||||
|
batch = uuid.uuid4().hex[:10]
|
||||||
|
with session() as db:
|
||||||
|
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not (core_tag and t.core_pin)]
|
||||||
|
return batch, run_sequence(ids, kind, actor, batch=batch, core_tag=core_tag, http=http, oidc_client=oidc_client, health=health, wait=wait)
|
||||||
35
app/services/guard.py
Normal file
35
app/services/guard.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Schreib-Freigabe für externe Systeme (Mittwald, Login-API) – pro Tenant.
|
||||||
|
|
||||||
|
`DEPLOY_ALLOWED_TENANTS` (Env der Admin-Plattform) legt fest, welche Tenants live angefasst werden dürfen:
|
||||||
|
(leer/nicht gesetzt) keiner – auch mit DEPLOY_LIVE=true wird nichts geschrieben
|
||||||
|
demo-admin,demo2 nur diese
|
||||||
|
* alle
|
||||||
|
Greift bei Live-Deploy, Offboard, Rollout/Rollback und beim Schreiben im Login-System (OIDC-Abgleich/Löschen).
|
||||||
|
Dry-Runs und Lesezugriffe sind nie betroffen. Damit lässt sich die Plattform schrittweise und gefahrlos parallel zum Altbetrieb einführen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_tenants() -> set[str] | None:
|
||||||
|
"""None = alle, sonst die erlaubten IDs (leere Menge = keiner)."""
|
||||||
|
raw = os.getenv("DEPLOY_ALLOWED_TENANTS", "").strip()
|
||||||
|
if raw == "*":
|
||||||
|
return None
|
||||||
|
return {x.strip() for x in raw.split(",") if x.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def live_allowed(tenant_id: str) -> bool:
|
||||||
|
a = allowed_tenants()
|
||||||
|
return a is None or tenant_id in a
|
||||||
|
|
||||||
|
|
||||||
|
def describe() -> str:
|
||||||
|
a = allowed_tenants()
|
||||||
|
return "alle Tenants" if a is None else (", ".join(sorted(a)) if a else "keiner")
|
||||||
|
|
||||||
|
|
||||||
|
def denial(tenant_id: str) -> str:
|
||||||
|
return (f"Tenant „{tenant_id}“ ist für Live-Schreibzugriffe nicht freigegeben (Env DEPLOY_ALLOWED_TENANTS = {describe()}). "
|
||||||
|
"Dry-Runs sind weiterhin möglich.")
|
||||||
151
app/services/importer.py
Normal file
151
app/services/importer.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Importiert die Tenants der bestehenden transkript-platform (Git-Ordner) in die Datenbank.
|
||||||
|
|
||||||
|
Idempotent: ein Tenant, dessen Inhalt bereits identisch als aktuelle Version existiert,
|
||||||
|
wird übersprungen. Secrets werden standardmäßig NUR als Schlüsselnamen importiert
|
||||||
|
(Wert „extern/SOPS“); mit with_secrets=True werden sie via `sops -d` entschlüsselt
|
||||||
|
und verschlüsselt in der DB abgelegt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Deployment, Secret, Setting, Tenant, Version
|
||||||
|
from app.services import crypto, store
|
||||||
|
from app.services.samples import DEFAULT_SAMPLE, ROTARY_SAMPLE
|
||||||
|
|
||||||
|
IGNORE = {"secrets.enc.yaml", "tenant.json"}
|
||||||
|
IGNORE_DIRS = {"__pycache__"}
|
||||||
|
|
||||||
|
# Bisherige Gitea-„Variables“ (docs/DEPLOYMENT.md) → geteilte Einstellungen
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"base_domain": "onyscribe.de",
|
||||||
|
"mittwald_project_id": "",
|
||||||
|
"mittwald_stack_id": "",
|
||||||
|
"registry": "",
|
||||||
|
"image_name": "transkript-platform",
|
||||||
|
"oidc_issuer": "https://login.onyva.dev",
|
||||||
|
"onyva_login_api_base": "",
|
||||||
|
"auth_enabled": "true",
|
||||||
|
"app_cookie_secure": "true",
|
||||||
|
"max_file_size_mb": "300",
|
||||||
|
"cortecs_base_url": "https://api.cortecs.ai/v1",
|
||||||
|
"llm_model": "claude-4-6-sonnet",
|
||||||
|
"llm_max_tokens": "16000",
|
||||||
|
"llm_temperature": "0.2",
|
||||||
|
"smtp_host": "",
|
||||||
|
"smtp_port": "587",
|
||||||
|
"smtp_user": "",
|
||||||
|
"smtp_from": "Gesprächsprotokoll",
|
||||||
|
"mistral_transcribe_model": "voxtral-mini-latest",
|
||||||
|
"mistral_diarize": "true",
|
||||||
|
"mistral_timeout": "600",
|
||||||
|
"core_image_tag": "latest",
|
||||||
|
"core_supports_bundle": "false",
|
||||||
|
"canary_tenants": "demo",
|
||||||
|
"admin_public_url": "",
|
||||||
|
"cost_api_base": "",
|
||||||
|
"cost_tracking_enabled": "",
|
||||||
|
"agent_model": "claude-4-6-sonnet",
|
||||||
|
"agent_api": "messages",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_settings(db: Session) -> None:
|
||||||
|
for k, v in DEFAULT_SETTINGS.items():
|
||||||
|
if db.get(Setting, k) is None:
|
||||||
|
db.add(Setting(key=k, value=v))
|
||||||
|
|
||||||
|
|
||||||
|
def _sops_keys(path: Path) -> list[str]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
data = yaml.safe_load(path.read_text()) or {}
|
||||||
|
return [k for k in data if k != "sops"]
|
||||||
|
|
||||||
|
|
||||||
|
def _sops_decrypt(path: Path) -> dict[str, str]:
|
||||||
|
out = subprocess.run(["sops", "-d", "--output-type", "json", str(path)], capture_output=True, text=True, check=True)
|
||||||
|
return {k: str(v) for k, v in json.loads(out.stdout).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_secrets(db: Session, tenant_id: str | None, path: Path, with_secrets: bool) -> int:
|
||||||
|
keys = _sops_keys(path)
|
||||||
|
values = _sops_decrypt(path) if (with_secrets and keys) else {}
|
||||||
|
n = 0
|
||||||
|
for key in keys:
|
||||||
|
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
|
||||||
|
if row is None:
|
||||||
|
row = Secret(tenant_id=tenant_id, key=key)
|
||||||
|
db.add(row)
|
||||||
|
if key in values and values[key]:
|
||||||
|
row.value_enc = crypto.encrypt(values[key])
|
||||||
|
row.source = "manual"
|
||||||
|
elif row.value_enc is None:
|
||||||
|
row.source = "sops-import"
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def import_all(db: Session, root: Path, with_secrets: bool = False, force: bool = False) -> list[str]:
|
||||||
|
log: list[str] = []
|
||||||
|
seed_settings(db)
|
||||||
|
tenants_dir = root / "tenants"
|
||||||
|
src_rev = ""
|
||||||
|
try:
|
||||||
|
src_rev = subprocess.run(["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
n = _upsert_secrets(db, None, root / "secrets" / "shared.enc.yaml", with_secrets)
|
||||||
|
log.append(f"geteilte Secrets: {n} Schlüssel")
|
||||||
|
|
||||||
|
for tdir in sorted(p for p in tenants_dir.iterdir() if p.is_dir()):
|
||||||
|
tid = tdir.name
|
||||||
|
cfg_file = tdir / "tenant.json"
|
||||||
|
if not cfg_file.exists():
|
||||||
|
continue
|
||||||
|
raw = json.loads(cfg_file.read_text(encoding="utf-8"))
|
||||||
|
deploy = raw.pop("deploy", {}) or {}
|
||||||
|
files: dict[str, bytes] = {}
|
||||||
|
for p in sorted(tdir.rglob("*")):
|
||||||
|
if p.is_dir() or p.name in IGNORE or any(part in IGNORE_DIRS for part in p.relative_to(tdir).parts):
|
||||||
|
continue
|
||||||
|
files[p.relative_to(tdir).as_posix()] = p.read_bytes()
|
||||||
|
|
||||||
|
t = db.get(Tenant, tid)
|
||||||
|
if t is None:
|
||||||
|
res = deploy.get("resources") or {}
|
||||||
|
t = Tenant(
|
||||||
|
id=tid, name=raw.get("name", tid), subdomain=deploy.get("subdomain", tid),
|
||||||
|
cpu=float(res.get("cpu", 0.5)), memory=str(res.get("memory", "512M")),
|
||||||
|
domain_status="verified", source="git", deployed_image="git-baked",
|
||||||
|
preview_sample=ROTARY_SAMPLE if tid.startswith("rotary") else None,
|
||||||
|
# Bestehende Clients wurden von Hand angelegt → nur „adoptieren“: fehlende Redirect-URI ergänzen, sonst nichts anfassen
|
||||||
|
oidc={"mode": "adopt"},
|
||||||
|
)
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
shas = {p: store.put_blob(db, data, p) for p, data in files.items()}
|
||||||
|
cur = store.current_version(db, t)
|
||||||
|
same = cur is not None and cur.files == shas and json.dumps(cur.config, sort_keys=True) == json.dumps(raw, sort_keys=True)
|
||||||
|
if same and not force:
|
||||||
|
log.append(f"{tid}: unverändert (v{t.current_version})")
|
||||||
|
else:
|
||||||
|
number = (t.current_version or 0) + 1
|
||||||
|
db.add(Version(tenant_id=tid, number=number, message=f"Import aus transkript-platform @{src_rev}",
|
||||||
|
author="import", config=raw, files=shas))
|
||||||
|
t.current_version = number
|
||||||
|
if t.deployed_version is None:
|
||||||
|
t.deployed_version = number
|
||||||
|
store.audit(db, "import", "tenant.import", tid, f"v{number} aus {src_rev}")
|
||||||
|
log.append(f"{tid}: importiert als v{number} ({len(files)} Dateien)")
|
||||||
|
k = _upsert_secrets(db, tid, tdir / "secrets.enc.yaml", with_secrets)
|
||||||
|
log.append(f" Secrets: {k} Schlüssel")
|
||||||
|
db.flush()
|
||||||
|
return log
|
||||||
121
app/services/llm.py
Normal file
121
app/services/llm.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Zugang zu Cortecs (OpenAI-kompatibel + Anthropic-Messages) für Agent und Prompt-Test.
|
||||||
|
|
||||||
|
Der Key kommt aus dem geteilten Secret CORTECS_API_KEY (verschlüsselt in der DB) oder der Env CORTECS_API_KEY.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Secret, Setting
|
||||||
|
from app.services import crypto
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://api.cortecs.ai/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class LLMNotConfigured(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _setting(db: Session, key: str, default: str = "") -> str:
|
||||||
|
s = db.get(Setting, key)
|
||||||
|
return s.value if s and s.value else default
|
||||||
|
|
||||||
|
|
||||||
|
def key_info(db: Session, tenant_id: str | None = None) -> tuple[str, str, str | None]:
|
||||||
|
"""(key, quelle, fehler). Reihenfolge: geteiltes Secret (DB) → Env CORTECS_API_KEY → Key des Tenants (Rückfall).
|
||||||
|
Quelle: 'shared' | 'env' | 'tenant' | 'none'. fehler: z. B. nicht entschlüsselbar (SECRET_KEY geändert?)."""
|
||||||
|
error = None
|
||||||
|
rows = [("shared", db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == "CORTECS_API_KEY")))]
|
||||||
|
for source, row in rows:
|
||||||
|
if row and row.value_enc:
|
||||||
|
try:
|
||||||
|
return crypto.decrypt(row.value_enc), source, None
|
||||||
|
except Exception: # noqa: BLE001 – falscher SECRET_KEY o. Ä.
|
||||||
|
error = "Geteilter CORTECS_API_KEY in der DB ist nicht entschlüsselbar (SECRET_KEY geändert?) – bitte neu setzen."
|
||||||
|
env = os.getenv("CORTECS_API_KEY", "").strip()
|
||||||
|
if env:
|
||||||
|
return env, "env", error
|
||||||
|
if tenant_id:
|
||||||
|
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "CORTECS_API_KEY"))
|
||||||
|
if row and row.value_enc:
|
||||||
|
try:
|
||||||
|
return crypto.decrypt(row.value_enc), "tenant", error
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
error = error or "CORTECS_API_KEY dieses Tenants ist nicht entschlüsselbar."
|
||||||
|
return "", "none", error
|
||||||
|
|
||||||
|
|
||||||
|
def api_key(db: Session, tenant_id: str | None = None) -> str:
|
||||||
|
return key_info(db, tenant_id)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def base_url(db: Session) -> str:
|
||||||
|
return _setting(db, "cortecs_base_url", DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def configured(db: Session, tenant_id: str | None = None) -> bool:
|
||||||
|
return bool(api_key(db, tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
def require_key(db: Session, tenant_id: str | None = None) -> str:
|
||||||
|
k, _, err = key_info(db, tenant_id)
|
||||||
|
if not k:
|
||||||
|
raise LLMNotConfigured(err or "CORTECS_API_KEY fehlt – unter Einstellungen → Geteilte Secrets setzen (oder Env CORTECS_API_KEY).")
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def list_models(db: Session) -> list[dict]:
|
||||||
|
"""GET /v1/models – Rohdaten von Cortecs, auf die für die Auswahl nötigen Felder reduziert."""
|
||||||
|
key = require_key(db)
|
||||||
|
r = httpx.get(f"{base_url(db)}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
items = data.get("data") if isinstance(data, dict) else data
|
||||||
|
out = []
|
||||||
|
for m in items or []:
|
||||||
|
if isinstance(m, str):
|
||||||
|
m = {"id": m}
|
||||||
|
caps = {k: v for k, v in m.items() if isinstance(v, (bool, list, dict)) or k in ("context_size", "context_length")}
|
||||||
|
out.append({"id": m.get("id") or m.get("name"), "raw": caps,
|
||||||
|
"tools": _supports_tools(m)})
|
||||||
|
return sorted((o for o in out if o["id"]), key=lambda o: o["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_tools(m: dict) -> bool | None:
|
||||||
|
"""Cortecs dokumentiert die Feldnamen nicht eindeutig → mehrere plausible Varianten prüfen; None = unbekannt."""
|
||||||
|
flat = {}
|
||||||
|
def walk(prefix, v):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
for k, x in v.items():
|
||||||
|
walk(f"{prefix}.{k}".lower(), x)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
flat[prefix] = [str(i).lower() for i in v]
|
||||||
|
else:
|
||||||
|
flat[prefix] = v
|
||||||
|
walk("", m)
|
||||||
|
for k, v in flat.items():
|
||||||
|
if "tool" in k or "function" in k:
|
||||||
|
return bool(v)
|
||||||
|
if isinstance(v, list) and any("tool" in i or "function" in i for i in v):
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(db: Session, system_prompt: str, user_content: str, model: str | None = None, tenant_id: str | None = None) -> dict:
|
||||||
|
"""Ein Protokoll-Lauf wie im Core (Chat Completions), für den Prompt-Test."""
|
||||||
|
from openai import OpenAI
|
||||||
|
client = OpenAI(api_key=require_key(db, tenant_id), base_url=base_url(db), max_retries=2, timeout=300)
|
||||||
|
model = model or _setting(db, "llm_model", "claude-4-6-sonnet")
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=model, temperature=float(_setting(db, "llm_temperature", "0.2")),
|
||||||
|
max_tokens=int(_setting(db, "llm_max_tokens", "16000")), stream=False,
|
||||||
|
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}],
|
||||||
|
)
|
||||||
|
text = "\n".join(c.message.content for c in resp.choices if c.message and c.message.content).strip()
|
||||||
|
u = getattr(resp, "usage", None)
|
||||||
|
return {"text": text, "model": model, "input_tokens": getattr(u, "prompt_tokens", 0), "output_tokens": getattr(u, "completion_tokens", 0),
|
||||||
|
"truncated": any(getattr(c, "finish_reason", "") in ("length", "max_tokens") for c in resp.choices)}
|
||||||
159
app/services/mittwald.py
Normal file
159
app/services/mittwald.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Mittwald-API-Client (Port der Logik aus transkript-platform/infra/deploy.py).
|
||||||
|
|
||||||
|
Sicherheitsnetze wie dort: differenzielles PATCH (nie PUT), Namensraum `app-`, Post-Write-Guard.
|
||||||
|
Zusätzlich: `live=False` (Standard) blockiert JEDEN schreibenden Aufruf – es wird nur geloggt, was passieren würde.
|
||||||
|
Lesende Aufrufe laufen nur, wenn ein Token gesetzt ist.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class DeployError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunSkip(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def mask_env(env: dict) -> dict:
|
||||||
|
import re
|
||||||
|
return {k: ("••••" if re.search(r"(SECRET|PASSWORD|KEY|TOKEN)$", k) else v) for k, v in env.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class Mittwald:
|
||||||
|
def __init__(self, token: str, base: str, live: bool, log: Callable[[str], None], http: Callable | None = None):
|
||||||
|
self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log
|
||||||
|
self._http = http or self._real_http
|
||||||
|
self._project_uuid: str | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _real_http(method, url, headers, body):
|
||||||
|
r = httpx.request(method, url, headers=headers, json=body, timeout=60)
|
||||||
|
try:
|
||||||
|
data = r.json() if r.content else None
|
||||||
|
except ValueError:
|
||||||
|
data = None
|
||||||
|
return r.status_code, data, r.text[:500]
|
||||||
|
|
||||||
|
def api(self, method: str, path: str, body: dict | None = None, *, write: bool, fatal: bool = True):
|
||||||
|
if write and not self.live:
|
||||||
|
self.log(f"[DRY-RUN] {method} {path}")
|
||||||
|
if body is not None:
|
||||||
|
shown = json.loads(json.dumps(body))
|
||||||
|
for svc in (shown.get("services") or {}).values():
|
||||||
|
if isinstance(svc, dict) and "environment" in svc:
|
||||||
|
svc["environment"] = mask_env(svc["environment"])
|
||||||
|
self.log(" body: " + json.dumps(shown, ensure_ascii=False)[:1500])
|
||||||
|
raise DryRunSkip()
|
||||||
|
if not self.token:
|
||||||
|
if not self.live:
|
||||||
|
self.log(f"(übersprungen: lesender Aufruf {method} {path} – kein MITTWALD_API_TOKEN)")
|
||||||
|
raise DryRunSkip()
|
||||||
|
raise DeployError("MITTWALD_API_TOKEN nicht gesetzt.")
|
||||||
|
status, data, text = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, body)
|
||||||
|
if status >= 400:
|
||||||
|
msg = f"{method} {path} → HTTP {status}: {text}"
|
||||||
|
if fatal:
|
||||||
|
raise DeployError(msg)
|
||||||
|
self.log(f"WARNUNG (nicht fatal): {msg}")
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def try_api(self, *a, **kw):
|
||||||
|
try:
|
||||||
|
return self.api(*a, **kw)
|
||||||
|
except DryRunSkip:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Stack ──
|
||||||
|
def resolve_stack_id(self, project_id: str, stack_id: str) -> str:
|
||||||
|
if stack_id:
|
||||||
|
return stack_id
|
||||||
|
if not project_id:
|
||||||
|
if not self.live:
|
||||||
|
self.log("(Hinweis: Mittwald PROJECT_ID/STACK_ID sind noch nicht in den Einstellungen gesetzt – Platzhalter im Dry-Run)")
|
||||||
|
return "<STACK_ID>"
|
||||||
|
raise DeployError("Mittwald PROJECT_ID (oder STACK_ID) fehlt in den Einstellungen.")
|
||||||
|
try:
|
||||||
|
stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False)
|
||||||
|
except DryRunSkip:
|
||||||
|
return "<STACK_ID>"
|
||||||
|
items = stacks if isinstance(stacks, list) else (stacks or {}).get("items", [])
|
||||||
|
for s in items:
|
||||||
|
if str(s.get("description") or s.get("name") or "").lower() == "default":
|
||||||
|
return s["id"]
|
||||||
|
if items:
|
||||||
|
return items[0]["id"]
|
||||||
|
raise DeployError("Kein Stack im Projekt gefunden.")
|
||||||
|
|
||||||
|
def get_stack(self, stack_id: str) -> dict | None:
|
||||||
|
return self.try_api("GET", f"/stacks/{stack_id}/", write=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def services_of(stack: dict | None) -> dict[str, dict]:
|
||||||
|
if not stack:
|
||||||
|
return {}
|
||||||
|
svcs = stack.get("services")
|
||||||
|
if isinstance(svcs, dict):
|
||||||
|
return svcs
|
||||||
|
if isinstance(svcs, list):
|
||||||
|
return {(s.get("serviceName") or s.get("name") or s.get("id")): s for s in svcs if (s.get("serviceName") or s.get("name") or s.get("id"))}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def project_uuid(self, project_id: str) -> str:
|
||||||
|
if self._project_uuid:
|
||||||
|
return self._project_uuid
|
||||||
|
proj = self.try_api("GET", f"/projects/{project_id}/", write=False, fatal=False)
|
||||||
|
self._project_uuid = (proj or {}).get("id") or project_id
|
||||||
|
return self._project_uuid
|
||||||
|
|
||||||
|
# ── Operationen ──
|
||||||
|
def deploy_service(self, *, stack_id: str, service: str, volume: str, definition: dict, container_port: str,
|
||||||
|
project_id: str, hostname: str | None) -> None:
|
||||||
|
stack = self.get_stack(stack_id)
|
||||||
|
existing = set(self.services_of(stack))
|
||||||
|
protected = existing - {service}
|
||||||
|
if protected:
|
||||||
|
self.log(f"geschützte bestehende Services (bleiben unangetastet): {sorted(protected)}")
|
||||||
|
self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {"name": volume}}}, write=True)
|
||||||
|
self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: definition}}, write=True)
|
||||||
|
svc_id = "<service-id>"
|
||||||
|
if self.live:
|
||||||
|
after = self.services_of(self.get_stack(stack_id))
|
||||||
|
missing = protected - set(after)
|
||||||
|
if missing:
|
||||||
|
raise DeployError(f"ABBRUCH: bestehende Services verschwunden nach PATCH: {sorted(missing)}")
|
||||||
|
svc_id = after.get(service, {}).get("id", service)
|
||||||
|
self.try_api("POST", f"/stacks/{stack_id}/services/{svc_id}/actions/recreate/", write=True)
|
||||||
|
self.log("Post-Write-Guard: alle vorherigen Services noch vorhanden ✔")
|
||||||
|
if hostname:
|
||||||
|
self.ensure_ingress(project_id, hostname, svc_id, container_port)
|
||||||
|
|
||||||
|
def ensure_ingress(self, project_id: str, hostname: str, container_id: str, port: str) -> None:
|
||||||
|
listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False)
|
||||||
|
items = listing if isinstance(listing, list) else (listing or {}).get("items", [])
|
||||||
|
if any(i.get("hostname") == hostname for i in items):
|
||||||
|
self.log(f"Ingress {hostname} existiert bereits → ok")
|
||||||
|
return
|
||||||
|
body = {"hostname": hostname, "projectId": self.project_uuid(project_id),
|
||||||
|
"paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]}
|
||||||
|
res = self.try_api("POST", "/ingresses", body, write=True, fatal=False)
|
||||||
|
if self.live:
|
||||||
|
self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else
|
||||||
|
f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).")
|
||||||
|
|
||||||
|
def offboard_service(self, *, stack_id: str, service: str, volume: str, project_id: str, hostname: str | None) -> None:
|
||||||
|
self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: {}}}, write=True)
|
||||||
|
self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {}}}, write=True)
|
||||||
|
if hostname:
|
||||||
|
listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False)
|
||||||
|
items = listing if isinstance(listing, list) else (listing or {}).get("items", [])
|
||||||
|
for i in items:
|
||||||
|
if i.get("hostname") == hostname:
|
||||||
|
self.try_api("DELETE", f"/ingresses/{i['id']}", write=True, fatal=False)
|
||||||
|
self.log(f"Ingress {hostname} entfernt")
|
||||||
312
app/services/onyva_login.py
Normal file
312
app/services/onyva_login.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""Abgleich der OIDC-Clients mit der Developer-API von login.onyva.dev (`/api/v1`, siehe LoginSeite/docs/developer-api.md).
|
||||||
|
|
||||||
|
Pro Tenant gibt es einen Soll-Zustand (`tenant.oidc`). Bei jedem Deploy wird der Ist-Zustand gelesen und bei Bedarf angelegt/angepasst:
|
||||||
|
|
||||||
|
mode = off nichts anfassen
|
||||||
|
mode = adopt (Standard für importierte Altclients) nur sicherstellen, dass die Redirect-URIs des Tenants enthalten sind
|
||||||
|
mode = managed Redirect-URIs, auth_mode, totp_mode, allow_registration, backchannel_logout_uri exakt wie konfiguriert
|
||||||
|
|
||||||
|
Freigaben (Memberships): `members` werden angelegt/aktualisiert; mit members_mode = exact werden zusätzliche Freigaben entfernt.
|
||||||
|
|
||||||
|
Sicherheit: Lesen (GET) läuft immer, wenn ein Key da ist. SCHREIBEN nur mit apply=True – und apply erlaubt die Plattform nur,
|
||||||
|
wenn OIDC_SYNC_LIVE=true oder DEPLOY_LIVE=true. Das `client_secret` ist nur bei Anlage/Rotation sichtbar und wird SOFORT verschlüsselt gespeichert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import session
|
||||||
|
from app.models import Secret, Setting, Tenant
|
||||||
|
from app.services import crypto, guard, store
|
||||||
|
|
||||||
|
MODES = ("off", "adopt", "managed")
|
||||||
|
AUTH_MODES = ("required", "optional", "disabled")
|
||||||
|
TOTP_MODES = ("disabled", "optional", "required")
|
||||||
|
ROLES = ("viewer", "editor", "admin")
|
||||||
|
DEFAULTS = {"mode": "adopt", "auth_mode": "required", "totp_mode": "disabled", "allow_registration": False,
|
||||||
|
"backchannel_logout_uri": None, "members": [], "members_mode": "add", "last_sync": None}
|
||||||
|
|
||||||
|
|
||||||
|
class OnyvaError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def key_present() -> bool:
|
||||||
|
return bool(os.getenv("ONYVA_LOGIN_API_KEY", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def writes_allowed() -> bool:
|
||||||
|
return os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def base_url(db) -> str:
|
||||||
|
s = db.get(Setting, "onyva_login_api_base")
|
||||||
|
if s and s.value:
|
||||||
|
return s.value.rstrip("/")
|
||||||
|
issuer = db.get(Setting, "oidc_issuer")
|
||||||
|
return f"{(issuer.value if issuer and issuer.value else 'https://login.onyva.dev').rstrip('/')}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class OnyvaLogin:
|
||||||
|
"""Dünner Client für /api/v1/apps. `http(method, url, headers, body) -> (status, data, headers)` ist in Tests ersetzbar."""
|
||||||
|
|
||||||
|
def __init__(self, base: str, key: str, http: Callable | None = None, sleep: Callable[[float], None] = time.sleep):
|
||||||
|
self.base, self.key, self._http, self._sleep = base.rstrip("/"), key, http or self._real, sleep
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _real(method, url, headers, body):
|
||||||
|
r = httpx.request(method, url, headers=headers, json=body, timeout=30)
|
||||||
|
try:
|
||||||
|
data = r.json() if r.content else None
|
||||||
|
except ValueError:
|
||||||
|
data = None
|
||||||
|
return r.status_code, data, dict(r.headers)
|
||||||
|
|
||||||
|
def _req(self, method: str, path: str, body: dict | None = None):
|
||||||
|
for attempt in range(4):
|
||||||
|
status, data, headers = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}, body)
|
||||||
|
if status == 429 and attempt < 3: # Rate-Limit: 120 Requests / 60 s pro Key
|
||||||
|
self._sleep(min(int({k.lower(): v for k, v in headers.items()}.get("retry-after", 5)), 60))
|
||||||
|
continue
|
||||||
|
if status == 401:
|
||||||
|
raise OnyvaError("API-Key ungültig, widerrufen oder abgelaufen (401).")
|
||||||
|
return status, data
|
||||||
|
raise OnyvaError("Rate-Limit des Login-Systems (429) dauerhaft überschritten.")
|
||||||
|
|
||||||
|
def _ok(self, method, path, body=None, ok=(200, 201, 204)):
|
||||||
|
status, data = self._req(method, path, body)
|
||||||
|
if status not in ok:
|
||||||
|
detail = data.get("detail") if isinstance(data, dict) else data
|
||||||
|
raise OnyvaError(f"{method} {path} → HTTP {status}: {str(detail)[:300]}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def list_apps(self) -> list[dict]:
|
||||||
|
return (self._ok("GET", "/apps") or {}).get("apps", [])
|
||||||
|
|
||||||
|
def get_app(self, cid: str) -> dict | None:
|
||||||
|
status, data = self._req("GET", f"/apps/{cid}")
|
||||||
|
if status == 404:
|
||||||
|
return None
|
||||||
|
if status != 200:
|
||||||
|
raise OnyvaError(f"GET /apps/{cid} → HTTP {status}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create_app(self, body: dict) -> dict:
|
||||||
|
return self._ok("POST", "/apps", body, ok=(201, 200))
|
||||||
|
|
||||||
|
def patch_app(self, cid: str, body: dict) -> dict:
|
||||||
|
return self._ok("PATCH", f"/apps/{cid}", body)
|
||||||
|
|
||||||
|
def delete_app(self, cid: str) -> None:
|
||||||
|
self._ok("DELETE", f"/apps/{cid}", ok=(204, 200))
|
||||||
|
|
||||||
|
def rotate_secret(self, cid: str) -> dict:
|
||||||
|
return self._ok("POST", f"/apps/{cid}/rotate-secret")
|
||||||
|
|
||||||
|
def memberships(self, cid: str) -> list[dict]:
|
||||||
|
return (self._ok("GET", f"/apps/{cid}/memberships") or {}).get("memberships", [])
|
||||||
|
|
||||||
|
def put_membership(self, cid: str, email: str, role: str) -> bool:
|
||||||
|
status, _ = self._req("PUT", f"/apps/{cid}/memberships", {"user_email": email, "role": role})
|
||||||
|
if status == 404:
|
||||||
|
return False # Nutzer existiert im Login nicht
|
||||||
|
if status not in (200, 201):
|
||||||
|
raise OnyvaError(f"PUT memberships → HTTP {status}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_membership(self, cid: str, email: str) -> None:
|
||||||
|
self._req("DELETE", f"/apps/{cid}/memberships/{email}")
|
||||||
|
|
||||||
|
|
||||||
|
def client_from_env(db, http=None) -> OnyvaLogin | None:
|
||||||
|
key = os.getenv("ONYVA_LOGIN_API_KEY", "").strip()
|
||||||
|
return OnyvaLogin(base_url(db), key, http) if key else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Soll-Zustand ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def config_of(t: Tenant) -> dict:
|
||||||
|
return {**DEFAULTS, **(t.oidc or {})}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(cfg: dict) -> dict:
|
||||||
|
out = {**DEFAULTS, **cfg}
|
||||||
|
if out["mode"] not in MODES:
|
||||||
|
raise ValueError("mode: off | adopt | managed")
|
||||||
|
if out["auth_mode"] not in AUTH_MODES:
|
||||||
|
raise ValueError("auth_mode: required | optional | disabled")
|
||||||
|
if out["totp_mode"] not in TOTP_MODES:
|
||||||
|
raise ValueError("totp_mode: disabled | optional | required")
|
||||||
|
if out["members_mode"] not in ("add", "exact"):
|
||||||
|
raise ValueError("members_mode: add | exact")
|
||||||
|
out["allow_registration"] = bool(out["allow_registration"])
|
||||||
|
out["backchannel_logout_uri"] = (out["backchannel_logout_uri"] or "").strip() or None
|
||||||
|
if out["backchannel_logout_uri"] and not out["backchannel_logout_uri"].startswith("https://"):
|
||||||
|
raise ValueError("Backchannel-Logout-URI muss https:// sein und öffentlich erreichbar")
|
||||||
|
members, seen = [], set()
|
||||||
|
for m in out["members"] or []:
|
||||||
|
email, role = str(m.get("email", "")).strip().lower(), m.get("role", "viewer")
|
||||||
|
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
|
||||||
|
raise ValueError(f"Ungültige E-Mail: {email!r}")
|
||||||
|
if role not in ROLES:
|
||||||
|
raise ValueError(f"Rolle von {email}: viewer | editor | admin")
|
||||||
|
if email not in seen:
|
||||||
|
seen.add(email)
|
||||||
|
members.append({"email": email, "role": role})
|
||||||
|
out["members"] = members
|
||||||
|
out.pop("last_sync", None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def desired_redirects(base_domain: str, t: Tenant) -> list[str]:
|
||||||
|
hosts = [f"{t.subdomain or t.id}.{base_domain}"]
|
||||||
|
if t.custom_domain:
|
||||||
|
hosts.append(t.custom_domain)
|
||||||
|
return [f"https://{h}/auth/callback" for h in hosts]
|
||||||
|
|
||||||
|
|
||||||
|
def diff_app(actual: dict, want: dict, mode: str) -> dict:
|
||||||
|
"""Welche PATCH-Felder wären nötig? adopt: nur fehlende URIs ergänzen; managed: alles exakt."""
|
||||||
|
changes: dict = {}
|
||||||
|
have, need = list(actual.get("redirect_uris") or []), want["redirect_uris"]
|
||||||
|
if mode == "adopt":
|
||||||
|
missing = [u for u in need if u not in have]
|
||||||
|
if missing:
|
||||||
|
changes["redirect_uris"] = have + missing
|
||||||
|
elif mode == "managed":
|
||||||
|
if set(have) != set(need):
|
||||||
|
changes["redirect_uris"] = need
|
||||||
|
for f in ("auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri"):
|
||||||
|
if actual.get(f) != want[f]:
|
||||||
|
changes[f] = want[f]
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def _secret(db, tenant_id: str, key: str) -> str | None:
|
||||||
|
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
|
||||||
|
return crypto.decrypt(row.value_enc) if row and row.value_enc else None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_credentials(tenant_id: str, client_id: str | None, client_secret: str | None, actor="onyva-login-sync") -> None:
|
||||||
|
"""Sofort und in eigener Transaktion sichern – das Secret ist danach nicht mehr abrufbar."""
|
||||||
|
from app.services import tenants as tsvc
|
||||||
|
with session() as db:
|
||||||
|
if client_id:
|
||||||
|
tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_ID", client_id, actor)
|
||||||
|
if client_secret:
|
||||||
|
tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_SECRET", client_secret, actor)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Abgleich ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sync_tenant(tenant_id: str, *, apply: bool, log: Callable[[str], None] = lambda m: None, client: OnyvaLogin | None = None,
|
||||||
|
rotate: bool = False, actor: str = "system") -> dict:
|
||||||
|
"""Gleicht den OIDC-Client des Tenants ab. apply=False → nur lesen und Plan melden.
|
||||||
|
Rückgabe: {ok, applied, skipped, client_id, actions:[{kind,detail}], warnings:[...], created, error}."""
|
||||||
|
rep = {"ok": True, "applied": False, "skipped": None, "client_id": None, "actions": [], "warnings": [], "created": False, "error": None}
|
||||||
|
act = lambda kind, detail: (rep["actions"].append({"kind": kind, "detail": detail}), log(f"OIDC: {detail}")) # noqa: E731
|
||||||
|
warn = lambda msg: (rep["warnings"].append(msg), log(f"OIDC ⚠ {msg}")) # noqa: E731
|
||||||
|
|
||||||
|
if apply and not writes_allowed():
|
||||||
|
rep.update(ok=False, error="Schreibzugriffe auf das Login-System sind gesperrt (OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv).")
|
||||||
|
return rep
|
||||||
|
if apply and not guard.live_allowed(tenant_id):
|
||||||
|
rep.update(ok=False, error=guard.denial(tenant_id))
|
||||||
|
return rep
|
||||||
|
try:
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
cfg = config_of(t)
|
||||||
|
base_domain = (db.get(Setting, "base_domain").value if db.get(Setting, "base_domain") else "example.de")
|
||||||
|
want = {"name": f"Onyscribe – {t.name}", "redirect_uris": desired_redirects(base_domain, t), "auth_mode": cfg["auth_mode"],
|
||||||
|
"totp_mode": cfg["totp_mode"], "allow_registration": cfg["allow_registration"], "backchannel_logout_uri": cfg["backchannel_logout_uri"]}
|
||||||
|
cid, has_secret = _secret(db, tenant_id, "OIDC_CLIENT_ID"), bool(_secret(db, tenant_id, "OIDC_CLIENT_SECRET"))
|
||||||
|
client = client or client_from_env(db)
|
||||||
|
mode = cfg["mode"]
|
||||||
|
if mode == "off":
|
||||||
|
rep["skipped"] = "OIDC-Verwaltung ist für diesen Tenant aus."
|
||||||
|
log("OIDC: Verwaltung aus – nichts zu tun")
|
||||||
|
return rep
|
||||||
|
if client is None:
|
||||||
|
rep["skipped"] = "ONYVA_LOGIN_API_KEY nicht gesetzt."
|
||||||
|
rep["ok"] = not apply # beim echten Deploy ist ein fehlender Key ein Fehler, im Dry-Run nur ein Hinweis
|
||||||
|
rep["error"] = None if rep["ok"] else rep["skipped"]
|
||||||
|
log("OIDC: (übersprungen – ONYVA_LOGIN_API_KEY nicht gesetzt)")
|
||||||
|
return rep
|
||||||
|
|
||||||
|
app = client.get_app(cid) if cid else None
|
||||||
|
new_app = app is None
|
||||||
|
verb = "" if apply else "würde "
|
||||||
|
if app is None:
|
||||||
|
if cid:
|
||||||
|
warn(f"Client {cid} existiert im Login nicht mehr – es wird ein neuer angelegt.")
|
||||||
|
body = {k: want[k] for k in ("name", "redirect_uris", "auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri")}
|
||||||
|
act("create", f"{verb}App „{want['name']}“ anlegen (Redirect: {', '.join(want['redirect_uris'])}; auth_mode={want['auth_mode']})")
|
||||||
|
if apply:
|
||||||
|
created = client.create_app(body)
|
||||||
|
cid = created["client_id"]
|
||||||
|
_store_credentials(tenant_id, cid, created.get("client_secret"), actor) # ZUERST sichern
|
||||||
|
rep["created"], has_secret = True, True
|
||||||
|
log(f"OIDC: App angelegt → {cid} (Secret verschlüsselt gespeichert)")
|
||||||
|
app = {"redirect_uris": want["redirect_uris"], "auth_mode": want["auth_mode"]}
|
||||||
|
else:
|
||||||
|
changes = diff_app(app, want, mode)
|
||||||
|
if changes:
|
||||||
|
act("patch", f"{verb}App {cid} anpassen: " + ", ".join(f"{k}={v}" for k, v in changes.items()))
|
||||||
|
if apply:
|
||||||
|
client.patch_app(cid, changes)
|
||||||
|
else:
|
||||||
|
log(f"OIDC: App {cid} ist abgeglichen")
|
||||||
|
rep["client_id"] = cid
|
||||||
|
|
||||||
|
if not has_secret:
|
||||||
|
warn("Client-Secret ist nicht in der Admin-DB (beim Login nicht abrufbar) – eintragen oder „Secret rotieren“.")
|
||||||
|
if rotate and cid:
|
||||||
|
act("rotate", f"{verb}Secret von {cid} rotieren (altes sofort ungültig)")
|
||||||
|
if apply:
|
||||||
|
_store_credentials(tenant_id, None, client.rotate_secret(cid)["client_secret"], actor)
|
||||||
|
|
||||||
|
# Freigaben
|
||||||
|
members = cfg["members"]
|
||||||
|
effective_auth = want["auth_mode"] if mode == "managed" else (app.get("auth_mode") or want["auth_mode"])
|
||||||
|
if (members or cfg["members_mode"] == "exact") and (cid or new_app):
|
||||||
|
current = {} if new_app else {m["email"].lower(): m["role"] for m in client.memberships(cid)}
|
||||||
|
for m in members:
|
||||||
|
if current.get(m["email"]) != m["role"]:
|
||||||
|
act("member_put", f"{verb}{m['email']} als {m['role']} freigeben")
|
||||||
|
if apply and not client.put_membership(cid, m["email"], m["role"]):
|
||||||
|
warn(f"{m['email']} existiert im Login nicht – Nutzer dort anlegen, dann erneut abgleichen.")
|
||||||
|
if cfg["members_mode"] == "exact":
|
||||||
|
for email in sorted(set(current) - {m["email"] for m in members}):
|
||||||
|
act("member_delete", f"{verb}Freigabe von {email} entziehen")
|
||||||
|
if apply:
|
||||||
|
client.delete_membership(cid, email)
|
||||||
|
elif effective_auth == "required" and not members:
|
||||||
|
log("OIDC: auth_mode=required ohne gepflegte Freigaben – bestehende Freigaben bleiben unverändert")
|
||||||
|
rep["applied"] = apply and bool(rep["actions"])
|
||||||
|
except OnyvaError as exc:
|
||||||
|
rep.update(ok=False, error=str(exc))
|
||||||
|
log(f"OIDC ✘ {exc}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
rep.update(ok=False, error=f"{type(exc).__name__}: {exc}")
|
||||||
|
log(f"OIDC ✘ {rep['error']}")
|
||||||
|
if apply:
|
||||||
|
_record(tenant_id, rep)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
def _record(tenant_id: str, rep: dict) -> None:
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
cfg = dict(t.oidc or {})
|
||||||
|
cfg["last_sync"] = {"ts": datetime.now(timezone.utc).isoformat(), "ok": rep["ok"], "created": rep["created"], "error": rep["error"],
|
||||||
|
"actions": [a["detail"] for a in rep["actions"]], "warnings": rep["warnings"]}
|
||||||
|
t.oidc = cfg
|
||||||
|
store.audit(db, "system", "oidc.sync", tenant_id, "ok" if rep["ok"] else f"Fehler: {rep['error']}")
|
||||||
114
app/services/preflight.py
Normal file
114
app/services/preflight.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Vorflug-Check: ist ein Tenant bereit für einen Live-Deploy? Liest nur (Mittwald-GET, Login-API-GET, eigener Health) und schreibt nichts.
|
||||||
|
|
||||||
|
Jeder Punkt: {id, level: ok|warn|fail|info, label, detail, fix}. Ein Deploy ist „bereit“, wenn es kein `fail` gibt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import session
|
||||||
|
from app.models import Secret, Tenant
|
||||||
|
from app.services import contract, deploy, guard, onyva_login, preview, store
|
||||||
|
from app.services.mittwald import DeployError, DryRunSkip, Mittwald
|
||||||
|
|
||||||
|
|
||||||
|
def _item(id_, level, label, detail="", fix=""):
|
||||||
|
return {"id": id_, "level": level, "label": label, "detail": detail, "fix": fix}
|
||||||
|
|
||||||
|
|
||||||
|
def run(tenant_id: str, *, mw_http=None, oidc_client=None, self_get=None) -> dict:
|
||||||
|
out: list[dict] = []
|
||||||
|
add = lambda *a, **k: out.append(_item(*a, **k)) # noqa: E731
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
cfg, _ = store.content(db, t, "current")
|
||||||
|
draft, source, pin = bool(t.draft), t.source, t.core_pin
|
||||||
|
base_domain, project, stack = deploy.setting(db, "base_domain"), deploy.setting(db, "mittwald_project_id"), deploy.setting(db, "mittwald_stack_id")
|
||||||
|
registry, admin_url = deploy.setting(db, "registry"), deploy.setting(db, "admin_public_url")
|
||||||
|
bundle_ok = deploy.setting(db, "core_supports_bundle", "false") == "true"
|
||||||
|
tag = deploy.effective_tag(db, t)
|
||||||
|
host = deploy.host_for(db, t)
|
||||||
|
missing = deploy.missing_secrets(db, t)
|
||||||
|
oc = onyva_login.config_of(t)
|
||||||
|
has_oidc_secrets = all(db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == k, Secret.value_enc.is_not(None))) is not None
|
||||||
|
for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))
|
||||||
|
stt_provider = (cfg.get("stt") or {}).get("provider") or "mistral"
|
||||||
|
|
||||||
|
# Freigaben / Modus
|
||||||
|
add("live", "ok" if deploy.live_enabled() else "warn", "DEPLOY_LIVE", "aktiv" if deploy.live_enabled() else "nicht aktiv – nur Dry-Run möglich",
|
||||||
|
"" if deploy.live_enabled() else "Env DEPLOY_LIVE=true der Admin-Plattform setzen und neu starten (erst nach erfolgreichem Dry-Run).")
|
||||||
|
add("allow", "ok" if guard.live_allowed(tenant_id) else "warn", "Live-Freigabe für diesen Tenant", f"DEPLOY_ALLOWED_TENANTS = {guard.describe()}",
|
||||||
|
"" if guard.live_allowed(tenant_id) else f"Env DEPLOY_ALLOWED_TENANTS um „{tenant_id}“ ergänzen.")
|
||||||
|
# Vertrag / Core
|
||||||
|
add("contract", "ok" if contract.supported() else "fail", "Core-Vertrag", f"Admin-Core {contract.sha()[:10]}, Vertrag v{contract.contract_version()}",
|
||||||
|
"" if contract.supported() else "Admin-Plattform aktualisieren.")
|
||||||
|
hex_tag = deploy._hex_tag(tag)
|
||||||
|
add("tag", "ok" if hex_tag else "warn", "Core-Image-Tag", f":{tag}" + (" (gepinnt)" if pin else ""),
|
||||||
|
"" if hex_tag else "Besser den vollen Git-SHA als Pin setzen (Tenant → Übersicht → Core-Version) – dann prüft der Health-Check die Core-Version.")
|
||||||
|
if source == "admin":
|
||||||
|
add("bundle", "ok" if bundle_ok else "fail", "Core kann Bundle laden", "Einstellung core_supports_bundle", "" if bundle_ok else "Einstellungen → „Core kann Bundle laden“ aktivieren (erst wenn das Image den Bundle-Loader enthält).")
|
||||||
|
good_url = admin_url.startswith("https://") and "<" not in admin_url
|
||||||
|
add("adminurl", "ok" if good_url else "fail", "Öffentliche Admin-URL", admin_url or "nicht gesetzt",
|
||||||
|
"" if good_url else "Einstellungen → „Öffentliche URL der Admin-Plattform“ (https://…): so holt der Tenant-Container sein Bundle.")
|
||||||
|
if good_url:
|
||||||
|
try:
|
||||||
|
status = (self_get or _get)(f"{admin_url.rstrip('/')}/healthz")
|
||||||
|
add("adminreach", "ok" if status == 200 else "fail", "Admin von außen erreichbar", f"GET /healthz → {status}", "" if status == 200 else "Domain/Proxy in Coolify prüfen.")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
add("adminreach", "fail", "Admin von außen erreichbar", f"{type(exc).__name__}: {str(exc)[:120]}", "Domain/Proxy in Coolify prüfen.")
|
||||||
|
# Mittwald
|
||||||
|
add("domain", "ok" if base_domain else "fail", "Basis-Domain", base_domain or "nicht gesetzt", "" if base_domain else "Einstellungen → Basis-Domain (= Gitea-Variable DOMAIN).")
|
||||||
|
add("registry", "ok" if registry else "fail", "Registry", registry or "nicht gesetzt", "" if registry else "Einstellungen → Registry (= Gitea-Variable REGISTRY).")
|
||||||
|
add("mtoken", "ok" if deploy.token_present() else "fail", "MITTWALD_API_TOKEN", "gesetzt" if deploy.token_present() else "nicht gesetzt",
|
||||||
|
"" if deploy.token_present() else "Env MITTWALD_API_TOKEN der Admin-Plattform setzen (derselbe Token wie in Gitea).")
|
||||||
|
add("mproject", "ok" if (project or stack) else "fail", "Mittwald PROJECT_ID / STACK_ID", f"{project or '–'} / {stack or '–'}", "" if (project or stack) else "Einstellungen → PROJECT_ID (und optional STACK_ID).")
|
||||||
|
if deploy.token_present() and (project or stack):
|
||||||
|
try:
|
||||||
|
mw = Mittwald(__import__("os").getenv("MITTWALD_API_TOKEN", ""), __import__("os").getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), False, lambda m: None, mw_http)
|
||||||
|
sid = mw.resolve_stack_id(project, stack)
|
||||||
|
services = sorted(Mittwald.services_of(mw.api("GET", f"/stacks/{sid}/", write=False)))
|
||||||
|
mine = f"app-{tenant_id}"
|
||||||
|
protected = [s for s in services if s != mine]
|
||||||
|
add("stack", "ok", "Mittwald-Stack lesbar", f"{len(services)} Services; bestehende bleiben unangetastet: {', '.join(protected) or '–'}")
|
||||||
|
add("svc", "info", "Service dieses Tenants", f"{mine} " + ("existiert bereits (wird aktualisiert)" if mine in services else "existiert noch nicht (wird angelegt)"))
|
||||||
|
except (DeployError, DryRunSkip) as exc:
|
||||||
|
add("stack", "fail", "Mittwald-Stack lesbar", str(exc)[:200], "Token-Rechte, PROJECT_ID/STACK_ID prüfen.")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
add("stack", "fail", "Mittwald-Stack lesbar", f"{type(exc).__name__}: {str(exc)[:160]}", "Netzwerk/Token prüfen.")
|
||||||
|
# Secrets / OIDC / STT
|
||||||
|
will_create = oc["mode"] == "managed" and onyva_login.key_present() and not has_oidc_secrets
|
||||||
|
real_missing = [m for m in missing if not (will_create and m in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))]
|
||||||
|
add("secrets", "ok" if not real_missing else "fail", "Pflicht-Secrets", "vollständig" if not real_missing else "fehlen: " + ", ".join(real_missing),
|
||||||
|
"" if not real_missing else "Tenant → Secrets (tenantspezifisch) bzw. Einstellungen → Geteilte Secrets (z. B. CORTECS_API_KEY).")
|
||||||
|
if will_create:
|
||||||
|
add("oidcnew", "info", "OIDC-Client", "wird beim Deploy automatisch im Login angelegt (Client-ID/-Secret sofort gespeichert)")
|
||||||
|
elif oc["mode"] != "off" and not onyva_login.key_present():
|
||||||
|
add("oidckey", "fail", "ONYVA_LOGIN_API_KEY", "nicht gesetzt – OIDC-Abgleich beim Deploy nicht möglich", "Env ONYVA_LOGIN_API_KEY setzen oder OIDC-Verwaltung auf „off“ stellen und Client manuell eintragen.")
|
||||||
|
elif oc["mode"] != "off":
|
||||||
|
add("oidckey", "ok", "Login-API", f"Modus {oc['mode']}")
|
||||||
|
if oc["mode"] != "off" and oc["auth_mode"] == "required" and not oc["members"]:
|
||||||
|
add("members", "warn", "Freigaben", "auth_mode „required“, aber keine Nutzer gepflegt – niemand kann sich anmelden",
|
||||||
|
"Domain & Login → Freigaben: mindestens dich eintragen (Nutzer muss im Login existieren).")
|
||||||
|
if stt_provider != "cortecs":
|
||||||
|
add("stt", "info", "STT-Provider", stt_provider + " – braucht MISTRAL_API_KEY")
|
||||||
|
# Inhalt
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tenant_id)
|
||||||
|
try:
|
||||||
|
val = preview.run(db, t, "current", "validate")
|
||||||
|
add("render", "ok" if val.get("ok") else "fail", "Veröffentlichter Stand rendert (echter Core)", "; ".join(val.get("errors", []))[:200] or "Web-UI und PDF ok",
|
||||||
|
"" if val.get("ok") else "Tenant → Editor korrigieren und neu veröffentlichen.")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
add("render", "fail", "Rendering", str(exc)[-200:])
|
||||||
|
if draft:
|
||||||
|
add("draft", "warn", "Unveröffentlichter Entwurf", "wird NICHT mit deployt", "Erst veröffentlichen, wenn er mit soll.")
|
||||||
|
add("host", "info", "Ziel-URL", f"https://{host}", "")
|
||||||
|
ready = not any(i["level"] == "fail" for i in out)
|
||||||
|
return {"ready": ready, "items": out, "host": host, "tag": tag}
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str) -> int:
|
||||||
|
import httpx
|
||||||
|
return httpx.get(url, timeout=8, follow_redirects=True).status_code
|
||||||
109
app/services/preview.py
Normal file
109
app/services/preview.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Live-Vorschau: führt den vendored Core mit dem (Entwurfs-)Tenant in einem Subprozess aus."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Tenant
|
||||||
|
from app.services import store
|
||||||
|
from app.services.samples import DEFAULT_SAMPLE
|
||||||
|
from app.settings import PLATFORM_DIR, PREVIEW_CACHE
|
||||||
|
|
||||||
|
WORKER = Path(__file__).with_name("preview_worker.py")
|
||||||
|
TIMEOUT = 90
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def core_version() -> str:
|
||||||
|
from app.services import contract
|
||||||
|
return contract.sha()[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def sample_for(t: Tenant) -> str:
|
||||||
|
return t.preview_sample or DEFAULT_SAMPLE
|
||||||
|
|
||||||
|
|
||||||
|
EXT = {"index": "html", "pdf": "pdf", "validate": "json", "prompt": "json"}
|
||||||
|
|
||||||
|
|
||||||
|
class SourceGone(LookupError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
|
||||||
|
"""draft | current | v<N> | ws:<run> (Live-Arbeitsstand des Agenten) | proposal:<msg> (Vorschlag des Agenten)."""
|
||||||
|
if source.startswith("ws:"):
|
||||||
|
from app.agent.loop import WORKSPACES
|
||||||
|
if source[3:] not in WORKSPACES:
|
||||||
|
raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.")
|
||||||
|
cfg, files = WORKSPACES[source[3:]]
|
||||||
|
return dict(cfg), dict(files)
|
||||||
|
if source.startswith("proposal:"):
|
||||||
|
from app.agent.loop import proposal_content
|
||||||
|
from app.models import ChatMessage
|
||||||
|
m = db.get(ChatMessage, int(source[9:] or 0))
|
||||||
|
if m is None or m.tenant_id != t.id or not m.proposal:
|
||||||
|
raise SourceGone("Vorschlag nicht gefunden.")
|
||||||
|
return proposal_content(db, t, m.proposal)
|
||||||
|
return store.content(db, t, source)
|
||||||
|
|
||||||
|
|
||||||
|
def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict:
|
||||||
|
cfg, files = resolve_content(db, t, source)
|
||||||
|
return run_content(db, t, cfg, files, kind, sample, args)
|
||||||
|
|
||||||
|
|
||||||
|
def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None,
|
||||||
|
args: dict | None = None) -> bytes | dict:
|
||||||
|
"""Rendert beliebigen Inhalt (cfg + {pfad: sha}) – für Entwurf, Version oder Agent-Workspace."""
|
||||||
|
sample = sample if sample is not None else sample_for(t)
|
||||||
|
h = hashlib.sha256(json.dumps([cfg, files, kind, sample, args or {}, core_version()], sort_keys=True).encode())
|
||||||
|
cached = PREVIEW_CACHE / f"{h.hexdigest()[:32]}.{EXT[kind]}"
|
||||||
|
if not cached.exists():
|
||||||
|
_execute(db, t.id, cfg, files, kind, sample, cached, args or {})
|
||||||
|
data = cached.read_bytes()
|
||||||
|
return json.loads(data) if EXT[kind] == "json" else data
|
||||||
|
|
||||||
|
|
||||||
|
def _limits() -> None:
|
||||||
|
import resource
|
||||||
|
resource.setrlimit(resource.RLIMIT_CPU, (90, 90))
|
||||||
|
resource.setrlimit(resource.RLIMIT_FSIZE, (200 * 1024 * 1024,) * 2)
|
||||||
|
os.setsid()
|
||||||
|
|
||||||
|
|
||||||
|
def _execute(db: Session, tenant_id: str, cfg: dict, files: dict, kind: str, sample: str, out: Path, args: dict) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="onyscribe-preview-") as tmp_s:
|
||||||
|
tmp = Path(tmp_s)
|
||||||
|
shutil.copytree(PLATFORM_DIR / "core", tmp / "core", ignore=shutil.ignore_patterns("__pycache__"))
|
||||||
|
store.write_tenant_dir(db, cfg, files, tmp / "tenants" / tenant_id)
|
||||||
|
(tmp / "data").mkdir()
|
||||||
|
shutil.copy(WORKER, tmp / "preview_worker.py")
|
||||||
|
env = {
|
||||||
|
"PATH": os.environ.get("PATH", ""), "HOME": str(tmp), "LANG": "C.UTF-8",
|
||||||
|
"TENANT": tenant_id, "DATA_DIR": str(tmp / "data"), "AUTH_ENABLED": "false",
|
||||||
|
"PYTHONPATH": str(tmp), "PYTHONDONTWRITEBYTECODE": "1",
|
||||||
|
# Kein einziges echtes Secret im Sandbox-Prozess
|
||||||
|
"OIDC_ISSUER": "https://login.example.invalid", "SESSION_SECRET": "preview",
|
||||||
|
}
|
||||||
|
result = tmp / f"result.{out.suffix.lstrip('.')}"
|
||||||
|
payload = json.dumps({"summary": sample, "title": f"{cfg.get('name', tenant_id)} – Vorschau", **args})
|
||||||
|
try:
|
||||||
|
proc = subprocess.run([sys.executable, "-I", "preview_worker.py", kind, str(result)], input=payload,
|
||||||
|
cwd=tmp, env=env, capture_output=True, text=True, timeout=TIMEOUT, preexec_fn=_limits)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise PreviewError("Vorschau-Timeout (Tenant-Code läuft zu lange)") from exc
|
||||||
|
if proc.returncode != 0 or not result.exists():
|
||||||
|
raise PreviewError((proc.stderr or proc.stdout or "unbekannter Fehler")[-2500:])
|
||||||
|
shutil.copy(result, out)
|
||||||
65
app/services/preview_worker.py
Normal file
65
app/services/preview_worker.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Läuft in einem isolierten Verzeichnis mit Kopie des Cores + materialisiertem Tenant.
|
||||||
|
|
||||||
|
Aufruf: python preview_worker.py <index|pdf|validate> <ausgabedatei> (JSON-Eingabe auf stdin)
|
||||||
|
Führt den ECHTEN Core aus (auch overrides.py) – deshalb nur als Subprozess ohne Secrets/Netz-Keys.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
sys.path.insert(0, os.getcwd())
|
||||||
|
|
||||||
|
kind, out = sys.argv[1], sys.argv[2]
|
||||||
|
args = json.loads(sys.stdin.read() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from core import app as appmod
|
||||||
|
from core.config import CFG
|
||||||
|
|
||||||
|
if kind == "index":
|
||||||
|
page = appmod._render_index()
|
||||||
|
open(out, "w", encoding="utf-8").write(page)
|
||||||
|
elif kind == "pdf":
|
||||||
|
pdf = appmod.build_pdf(args.get("title") or "Vorschau", args["summary"], "SPRECHER_0: Beispiel.")
|
||||||
|
open(out, "wb").write(pdf)
|
||||||
|
elif kind == "prompt":
|
||||||
|
base = appmod._summary_prompt()
|
||||||
|
ctx = {"title": args.get("title", ""), "insurance_type": args.get("terminart", ""),
|
||||||
|
"customer_2": args.get("projekt", ""), "customer_name": args.get("kunde", ""), "customer_address": ""}
|
||||||
|
final = appmod.HOOKS.build_prompt(base, ctx)
|
||||||
|
json.dump({"prompt": final, "chars": len(final)}, open(out, "w"))
|
||||||
|
elif kind == "validate":
|
||||||
|
errors, warnings = [], []
|
||||||
|
page = appmod._render_index()
|
||||||
|
if "@@" in page:
|
||||||
|
errors.append("Web-UI: nicht ersetzte @@PLATZHALTER@@ im Template")
|
||||||
|
if not CFG.logo_path:
|
||||||
|
warnings.append("Kein Logo (logo.svg/logo.png) hinterlegt")
|
||||||
|
if not CFG.favicon_path:
|
||||||
|
warnings.append("Kein favicon.svg – es wird ein generisches Favicon verwendet")
|
||||||
|
try:
|
||||||
|
pdf = appmod.build_pdf("Validierung", args["summary"], "SPRECHER_0: Beispiel.")
|
||||||
|
if pdf[:5] != b"%PDF-":
|
||||||
|
errors.append("PDF-Rendering lieferte kein PDF")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"PDF-Rendering fehlgeschlagen: {exc}")
|
||||||
|
prompt = CFG.prompt_path.read_text(encoding="utf-8")
|
||||||
|
if len(prompt.strip()) < 200:
|
||||||
|
warnings.append("Prompt ist sehr kurz")
|
||||||
|
json.dump({"ok": not errors, "errors": errors, "warnings": warnings,
|
||||||
|
"tenant": CFG.id, "prompt_chars": len(prompt)}, open(out, "w"))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except BaseException: # noqa: BLE001
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
if kind == "validate":
|
||||||
|
json.dump({"ok": False, "errors": ["Tenant lässt sich nicht laden:\n" + tb[-1500:]], "warnings": []}, open(out, "w"))
|
||||||
|
else:
|
||||||
|
sys.stderr.write(tb)
|
||||||
|
sys.exit(2)
|
||||||
254
app/services/releases.py
Normal file
254
app/services/releases.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""Core-Releases: Eingang (CI), Kompatibilitätsprüfung aller Tenants, Freigabe, Canary-Rollout, Rollback.
|
||||||
|
|
||||||
|
Ablauf: CI meldet Image-Tag → Admin (läuft auf genau diesem Core) prüft alle Tenants in der Sandbox → Freigabe →
|
||||||
|
Rollout (Canary zuerst, Abbruch beim ersten Fehler, Health-Check pro Tenant) → aktiver Tag wird der neue Standard.
|
||||||
|
Gepinnte Tenants nehmen nicht teil. Echte Rollouts brauchen DEPLOY_LIVE=true; sonst nur Dry-Run.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import session
|
||||||
|
from app.models import CoreRelease, Deployment, Setting, Tenant
|
||||||
|
from app.services import contract, deploy, guard, preview, store
|
||||||
|
|
||||||
|
DATE = re.compile(r"\b\d{1,2}\.\d{1,2}\.\d{2,4}\b")
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Eingang ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def announce(db, sha: str, tag: str | None, ref: str, image: str, message: str) -> tuple[CoreRelease, bool]:
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{7,64}", sha or ""):
|
||||||
|
raise ReleaseError("sha: 7–64 Hex-Zeichen erwartet")
|
||||||
|
sha = sha.lower()
|
||||||
|
r = db.scalar(select(CoreRelease).where(CoreRelease.sha == sha))
|
||||||
|
created = r is None
|
||||||
|
if created:
|
||||||
|
r = CoreRelease(sha=sha, tag=(tag or sha)[:100], ref=ref[:100], image=image[:300], message=message[:500])
|
||||||
|
db.add(r)
|
||||||
|
store.audit(db, "ci", "core.release", None, f"{sha[:10]} ({ref}) {message[:80]}")
|
||||||
|
else:
|
||||||
|
r.message, r.ref, r.image = message[:500] or r.message, ref[:100] or r.ref, image[:300] or r.image
|
||||||
|
db.flush()
|
||||||
|
return r, created
|
||||||
|
|
||||||
|
|
||||||
|
def admin_matches(r: CoreRelease) -> bool:
|
||||||
|
return contract.same_sha(contract.sha(), r.sha)
|
||||||
|
|
||||||
|
|
||||||
|
def view(r: CoreRelease, active_tag: str) -> dict:
|
||||||
|
return {"id": r.id, "sha": r.sha, "tag": r.tag, "ref": r.ref, "image": r.image, "message": r.message, "status": r.status,
|
||||||
|
"announced_at": r.announced_at.isoformat() if r.announced_at else None, "admin_matches": admin_matches(r),
|
||||||
|
"approved_by": r.approved_by, "compat": r.compat, "rollout_batch": r.rollout_batch, "previous_tag": r.previous_tag,
|
||||||
|
"active": r.tag == active_tag or contract.same_sha(r.sha, active_tag)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kompatibilitätsprüfung ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _pdf_fingerprint(pdf: bytes) -> tuple[int, str]:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
rd = PdfReader(io.BytesIO(pdf))
|
||||||
|
text = DATE.sub("<datum>", "\n".join((p.extract_text() or "") for p in rd.pages)) # das Tagesdatum steht im PDF
|
||||||
|
return len(rd.pages), hashlib.sha256(text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(db, t: Tenant) -> dict:
|
||||||
|
"""Rendert den veröffentlichten Stand mit dem aktuellen Admin-Core und bildet Fingerabdrücke."""
|
||||||
|
cfg, files = store.content(db, t, "current")
|
||||||
|
sample = preview.sample_for(t)
|
||||||
|
fp: dict = {"sample": hashlib.sha256(sample.encode()).hexdigest()[:12]}
|
||||||
|
val = preview.run_content(db, t, cfg, files, "validate", sample)
|
||||||
|
fp["validate"] = {"ok": val.get("ok", False), "errors": val.get("errors", []), "warnings": val.get("warnings", [])}
|
||||||
|
if val.get("ok"):
|
||||||
|
pages, text_sha = _pdf_fingerprint(preview.run_content(db, t, cfg, files, "pdf", sample))
|
||||||
|
fp.update(pdf_pages=pages, pdf_text=text_sha,
|
||||||
|
web=hashlib.sha256(preview.run_content(db, t, cfg, files, "index", sample)).hexdigest(),
|
||||||
|
prompt=hashlib.sha256(preview.run_content(db, t, cfg, files, "prompt", sample, {"title": "x"})["prompt"].encode()).hexdigest())
|
||||||
|
fp["schema_problems"] = contract.check_tenant_json(cfg)
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def compare(fp: dict, baseline: dict | None, version: int) -> dict:
|
||||||
|
"""→ {status: ok|changed|new|error, changed:[…], errors:[…], warnings:[…]}"""
|
||||||
|
out = {"status": "ok", "changed": [], "errors": fp["validate"]["errors"], "warnings": list(fp["validate"]["warnings"]) + fp.get("schema_problems", [])}
|
||||||
|
if not fp["validate"]["ok"]:
|
||||||
|
out["status"] = "error"
|
||||||
|
return out
|
||||||
|
if not baseline or baseline.get("version") != version or baseline["fp"].get("sample") != fp["sample"]:
|
||||||
|
out["status"] = "new"
|
||||||
|
return out
|
||||||
|
labels = {"pdf_pages": "PDF-Seitenzahl", "pdf_text": "PDF-Text", "web": "Web-UI", "prompt": "Finaler Prompt"}
|
||||||
|
out["changed"] = [labels[k] for k in labels if baseline["fp"].get(k) != fp.get(k)]
|
||||||
|
if out["changed"]:
|
||||||
|
out["status"] = "changed"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check(release_id: int, *, force: bool = False, wait: bool = False, workers: int = 3) -> None:
|
||||||
|
"""Prüft ALLE Tenants (außer archivierten) mit dem Core, auf dem die Admin-Plattform läuft. Ergebnis in release.compat."""
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if r is None:
|
||||||
|
raise ReleaseError("Release nicht gefunden")
|
||||||
|
if not force and not admin_matches(r):
|
||||||
|
raise ReleaseError(f"Die Admin-Plattform läuft noch auf Core {contract.sha()[:10]}, das Release ist {r.sha[:10]}. "
|
||||||
|
"Erst den Admin neu bauen (FROM neues Core-Image) – sonst würde gegen den falschen Core geprüft.")
|
||||||
|
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status != "archived").order_by(Tenant.id))]
|
||||||
|
r.compat = {"checked_at": None, "core_sha": contract.sha(), "running": True, "tenants": {}, "summary": {}, "forced": force and not admin_matches(r)}
|
||||||
|
r.status = "new" if r.status in ("new", "checked") else r.status
|
||||||
|
|
||||||
|
def one(tid: str):
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, tid)
|
||||||
|
try:
|
||||||
|
fp = fingerprint(db, t)
|
||||||
|
res = compare(fp, t.render_baseline, t.current_version)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
fp, res = {}, {"status": "error", "changed": [], "errors": [f"{type(exc).__name__}: {str(exc)[-300:]}"], "warnings": []}
|
||||||
|
return tid, fp, res
|
||||||
|
|
||||||
|
def work():
|
||||||
|
results = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
for tid, fp, res in ex.map(one, ids):
|
||||||
|
results[tid] = {**res, "fp": fp}
|
||||||
|
summary = {k: sum(1 for v in results.values() if v["status"] == k) for k in ("ok", "changed", "new", "error")}
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
r.compat = {"checked_at": datetime.now(timezone.utc).isoformat(), "core_sha": contract.sha(), "running": False, "tenants": results,
|
||||||
|
"summary": summary, "forced": bool(r.compat and r.compat.get("forced"))}
|
||||||
|
if r.status == "new":
|
||||||
|
r.status = "checked"
|
||||||
|
store.audit(db, "system", "core.check", None, f"{r.sha[:10]}: {summary}")
|
||||||
|
|
||||||
|
th = threading.Thread(target=work, daemon=True)
|
||||||
|
th.start()
|
||||||
|
if wait:
|
||||||
|
th.join()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Freigabe & Rollout ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def approve(db, release_id: int, actor: str, acknowledge: bool = False) -> CoreRelease:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if r is None:
|
||||||
|
raise ReleaseError("Release nicht gefunden")
|
||||||
|
c = r.compat
|
||||||
|
if not c or c.get("running"):
|
||||||
|
raise ReleaseError("Erst die Kompatibilitätsprüfung abschließen.")
|
||||||
|
if not c.get("forced") and not contract.same_sha(c.get("core_sha", ""), r.sha):
|
||||||
|
raise ReleaseError("Die Prüfung stammt von einem anderen Core als dieses Release – bitte erneut prüfen.")
|
||||||
|
if c["summary"].get("error"):
|
||||||
|
bad = [t for t, v in c["tenants"].items() if v["status"] == "error"]
|
||||||
|
raise ReleaseError(f"Nicht freigebbar: {len(bad)} Tenant(s) mit Fehlern ({', '.join(bad)}).")
|
||||||
|
if c["summary"].get("changed") and not acknowledge:
|
||||||
|
raise ReleaseError("Bei einigen Tenants hat sich das Rendering geändert – bitte prüfen und bewusst bestätigen (acknowledge).")
|
||||||
|
r.status, r.approved_by, r.approved_at = "approved", actor, datetime.now(timezone.utc)
|
||||||
|
store.audit(db, actor, "core.approve", None, r.sha[:10])
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def canary_list(db) -> list[str]:
|
||||||
|
s = db.get(Setting, "canary_tenants")
|
||||||
|
return [x.strip() for x in (s.value if s and s.value else "demo").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_setting(db, key: str, value: str) -> None:
|
||||||
|
s = db.get(Setting, key)
|
||||||
|
if s is None:
|
||||||
|
s = Setting(key=key)
|
||||||
|
db.add(s)
|
||||||
|
s.value = value
|
||||||
|
|
||||||
|
|
||||||
|
def rollout(release_id: int, actor: str, *, dry_run: bool, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
|
||||||
|
live = not dry_run
|
||||||
|
if live and not deploy.live_enabled():
|
||||||
|
raise ReleaseError("Echte Rollouts sind gesperrt (DEPLOY_LIVE != true). Dry-Run ist möglich.")
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if r is None:
|
||||||
|
raise ReleaseError("Release nicht gefunden")
|
||||||
|
allowed = {"approved", "active", "failed", "rolled_back"} | ({"new", "checked"} if dry_run else set()) # Dry-Run schon vor der Freigabe
|
||||||
|
if r.status not in allowed:
|
||||||
|
raise ReleaseError(f"Release ist im Status „{r.status}“ – erst prüfen und freigeben.")
|
||||||
|
tag, sha = r.tag, r.sha
|
||||||
|
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin]
|
||||||
|
if live: # Live: nur für Live-Schreibzugriffe freigegebene Tenants (guard.py)
|
||||||
|
ids = [t for t in ids if guard.live_allowed(t)]
|
||||||
|
if not ids:
|
||||||
|
raise ReleaseError(f"Kein Tenant für Live-Rollouts freigegeben (DEPLOY_ALLOWED_TENANTS = {guard.describe()}).")
|
||||||
|
canary = canary_list(db)
|
||||||
|
batch = uuid.uuid4().hex[:10]
|
||||||
|
if live:
|
||||||
|
r.status, r.previous_tag, r.rollout_batch = "rolling", deploy.setting(db, "core_image_tag", "latest"), batch
|
||||||
|
store.audit(db, actor, "core.rollout", None, f"{sha[:10]} → {len(ids)} Tenants")
|
||||||
|
|
||||||
|
def done(ok: bool, results: dict):
|
||||||
|
if not live:
|
||||||
|
return
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if ok:
|
||||||
|
r.status = "active"
|
||||||
|
_set_setting(db, "core_image_tag", tag)
|
||||||
|
for other in db.scalars(select(CoreRelease).where(CoreRelease.status == "active", CoreRelease.id != release_id)):
|
||||||
|
other.status = "superseded"
|
||||||
|
for tid in results: # Baselines für künftige Kompatibilitätsprüfungen
|
||||||
|
t = db.get(Tenant, tid)
|
||||||
|
c = ((r.compat or {}).get("tenants") or {}).get(tid)
|
||||||
|
if t and c and c.get("fp"):
|
||||||
|
t.render_baseline = {"version": t.current_version, "core": sha, "fp": c["fp"]}
|
||||||
|
else:
|
||||||
|
r.status = "failed"
|
||||||
|
store.audit(db, "system", "core.rollout.done", None, f"{sha[:10]} {'ok' if ok else 'FEHLER'} {results}")
|
||||||
|
|
||||||
|
dep_ids = deploy.run_sequence(ids, "dry-run" if dry_run else "deploy", actor, batch=batch, core_tag=tag, http=http, oidc_client=oidc_client,
|
||||||
|
health=health, canary=canary, wait=wait, on_done=done)
|
||||||
|
return batch, dep_ids
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_release(release_id: int, actor: str, *, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
|
||||||
|
"""Alle (nicht gepinnten) Tenants auf den Tag vor diesem Rollout zurück."""
|
||||||
|
if not deploy.live_enabled():
|
||||||
|
raise ReleaseError("Echte Rollbacks sind gesperrt (DEPLOY_LIVE != true).")
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if r is None or not r.previous_tag:
|
||||||
|
raise ReleaseError("Kein vorheriger Tag bekannt – Rollback nicht möglich.")
|
||||||
|
prev = r.previous_tag
|
||||||
|
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin and guard.live_allowed(t.id)]
|
||||||
|
batch = uuid.uuid4().hex[:10]
|
||||||
|
store.audit(db, actor, "core.rollback", None, f"{r.sha[:10]} → :{prev}")
|
||||||
|
|
||||||
|
def done(ok, results):
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, release_id)
|
||||||
|
if ok:
|
||||||
|
r.status = "rolled_back"
|
||||||
|
_set_setting(db, "core_image_tag", prev)
|
||||||
|
store.audit(db, "system", "core.rollback.done", None, f"{'ok' if ok else 'FEHLER'} {results}")
|
||||||
|
|
||||||
|
return batch, deploy.run_sequence(ids, "deploy", actor, batch=batch, core_tag=prev, http=http, oidc_client=oidc_client, health=health, wait=wait, on_done=done)
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_rollback_tag(db, t: Tenant) -> str | None:
|
||||||
|
"""Letzter erfolgreich live deployter Tag dieses Tenants, der vom aktuellen abweicht."""
|
||||||
|
for d in db.scalars(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "deploy", Deployment.status == "success",
|
||||||
|
Deployment.simulated.is_(False)).order_by(Deployment.id.desc())):
|
||||||
|
if d.image_tag and d.image_tag != t.deployed_image:
|
||||||
|
return d.image_tag
|
||||||
|
return None
|
||||||
76
app/services/samples.py
Normal file
76
app/services/samples.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Beispielprotokolle für die PDF-Vorschau (so, wie der LLM sie liefern würde)."""
|
||||||
|
|
||||||
|
DEFAULT_SAMPLE = """===TERMINART===
|
||||||
|
Kick-off
|
||||||
|
===/TERMINART===
|
||||||
|
===PROJEKT===
|
||||||
|
Muster GmbH
|
||||||
|
Relaunch 2026
|
||||||
|
===/PROJEKT===
|
||||||
|
===KONTAKT===
|
||||||
|
Max Mustermann - Geschäftsführer
|
||||||
|
Erika Beispiel - Projektleitung
|
||||||
|
===/KONTAKT===
|
||||||
|
Anlass & Kontext
|
||||||
|
Erstes Kennenlernen und Abstecken des Projektrahmens. Ziel ist ein gemeinsames Verständnis von Umfang, Zeitplan und Zuständigkeiten.
|
||||||
|
Besprochene Themen
|
||||||
|
• Zeitplan und Meilensteine bis zum Go-live im Herbst
|
||||||
|
• Budgetrahmen und Freigabeprozess
|
||||||
|
• Anbindung des bestehenden CRM
|
||||||
|
Entscheidungen
|
||||||
|
• Projektstart im Juli
|
||||||
|
• Wöchentlicher Jour fixe, dienstags 10:00 Uhr
|
||||||
|
Action Items
|
||||||
|
• Angebot bis Freitag senden [Verantwortlich: Erika Beispiel, Frist: 18.07.]
|
||||||
|
• Zugänge zum CRM bereitstellen [Verantwortlich: Max Mustermann, Frist: 22.07.]
|
||||||
|
Offene Punkte
|
||||||
|
• Finale Designrichtung noch nicht entschieden
|
||||||
|
• Datenschutzfreigabe der Fachabteilung steht aus
|
||||||
|
Nächste Schritte
|
||||||
|
• Folgetermin zum Konzept-Review vereinbaren
|
||||||
|
"""
|
||||||
|
|
||||||
|
ROTARY_SAMPLE = """===TERMINART===
|
||||||
|
Clubmeeting
|
||||||
|
===/TERMINART===
|
||||||
|
===PROJEKT===
|
||||||
|
Lieferketten im Wandel
|
||||||
|
===/PROJEKT===
|
||||||
|
Begrüßung
|
||||||
|
Der Präsident eröffnet das Meeting und begrüßt die anwesenden Freunde sowie zwei Gäste.
|
||||||
|
Geburtstage
|
||||||
|
• Anna Beispiel
|
||||||
|
• Peter Muster
|
||||||
|
Aktuelle 5 Minuten
|
||||||
|
Kurzer Bericht zum Stand der Spendenaktion für das Jugendprojekt. Bisher sind 4.200 Euro zusammengekommen.
|
||||||
|
Happy Euro
|
||||||
|
• Für die gelungene Veranstaltung am Samstag
|
||||||
|
• Für das gute Wetter beim Grillfest
|
||||||
|
Vortrag / Referat
|
||||||
|
Globalisierung – historische Einordnung
|
||||||
|
Der Referent ordnet die Entwicklung der globalen Lieferketten seit den 1990er-Jahren ein.
|
||||||
|
Folgen für die Region
|
||||||
|
• Abhängigkeit von wenigen Zulieferern
|
||||||
|
• Chancen durch regionale Wertschöpfung
|
||||||
|
Dank
|
||||||
|
Der Präsident dankt dem Referenten für den anregenden Vortrag.
|
||||||
|
Abschluss
|
||||||
|
Nächstes Meeting am kommenden Montag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SAMPLE_TRANSCRIPT = """SPRECHER_0: Guten Morgen zusammen, schön, dass wir uns heute zum Kick-off treffen. Ich bin Erika Beispiel, Projektleitung auf unserer Seite.
|
||||||
|
SPRECHER_1: Morgen. Max Mustermann, ich bin Geschäftsführer der Muster GmbH. Wir wollen unsere Website bis zum Herbst neu aufsetzen.
|
||||||
|
SPRECHER_0: Gut. Dann starten wir im Juli. Ich würde einen wöchentlichen Jour fixe vorschlagen, dienstags um zehn.
|
||||||
|
SPRECHER_1: Passt. Das Budget liegt bei etwa vierzigtausend Euro, die Freigabe läuft über mich.
|
||||||
|
SPRECHER_0: Ich schicke Ihnen bis Freitag das Angebot. Und wir brauchen von Ihnen die Zugänge zum CRM, möglichst bis zum zweiundzwanzigsten Juli.
|
||||||
|
SPRECHER_1: Mache ich. Offen ist noch die Designrichtung und ob die Datenschutzabteilung zustimmt.
|
||||||
|
SPRECHER_0: Dann vereinbaren wir als Nächstes einen Termin zum Konzept-Review.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ROTARY_TRANSCRIPT = """SPRECHER_0: Liebe Freunde, ich begrüße euch zum Clubmeeting und freue mich über zwei Gäste heute Abend.
|
||||||
|
SPRECHER_0: Geburtstag haben diese Woche Anna Beispiel und Peter Muster, herzlichen Glückwunsch.
|
||||||
|
SPRECHER_1: Zur Spendenaktion für das Jugendprojekt: Wir haben bisher viertausendzweihundert Euro zusammen.
|
||||||
|
SPRECHER_2: Happy Euro für das schöne Wetter beim Grillfest und für die gelungene Veranstaltung am Samstag.
|
||||||
|
SPRECHER_3: Mein Vortrag heute: Globalisierung und Lieferketten. Seit den Neunzigern hat sich die Abhängigkeit von wenigen Zulieferern stark erhöht. Für unsere Region bedeutet das Risiken, aber auch Chancen durch regionale Wertschöpfung.
|
||||||
|
SPRECHER_0: Vielen Dank für den Vortrag. Nächstes Meeting ist am kommenden Montag.
|
||||||
|
"""
|
||||||
54
app/services/seed.py
Normal file
54
app/services/seed.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Seed-Export/-Import: Startdaten (importierte Tenants) als Datei im Repo.
|
||||||
|
|
||||||
|
So startet eine frische Instanz (z. B. Coolify) mit den aktuellen Tenants, ohne Zugriff auf das alte Git-Repo.
|
||||||
|
Secrets-WERTE sind nie enthalten – nur die Schlüsselnamen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Blob, Secret, Setting, Tenant, Version
|
||||||
|
|
||||||
|
COLS = ["id", "name", "status", "source", "subdomain", "custom_domain", "domain_status", "cpu", "memory",
|
||||||
|
"current_version", "deployed_version", "deployed_image", "preview_sample", "oidc", "notes"]
|
||||||
|
|
||||||
|
|
||||||
|
def export(db: Session, path: Path) -> None:
|
||||||
|
tenants = db.scalars(select(Tenant).order_by(Tenant.id)).all()
|
||||||
|
shas = set()
|
||||||
|
out = {"tenants": [], "versions": [], "secrets": [], "settings": {}}
|
||||||
|
for t in tenants:
|
||||||
|
out["tenants"].append({c: getattr(t, c) for c in COLS})
|
||||||
|
for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number)):
|
||||||
|
out["versions"].append({"tenant_id": t.id, "number": v.number, "message": v.message, "author": v.author,
|
||||||
|
"config": v.config, "files": v.files})
|
||||||
|
shas |= set(v.files.values())
|
||||||
|
for s in db.scalars(select(Secret)):
|
||||||
|
out["secrets"].append({"tenant_id": s.tenant_id, "key": s.key})
|
||||||
|
out["settings"] = {s.key: s.value for s in db.scalars(select(Setting))}
|
||||||
|
out["blobs"] = {sha: {"b64": base64.b64encode(db.get(Blob, sha).content).decode(), "text": db.get(Blob, sha).is_text} for sha in shas}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(gzip.compress(json.dumps(out).encode(), 9))
|
||||||
|
|
||||||
|
|
||||||
|
def load(db: Session, path: Path) -> int:
|
||||||
|
data = json.loads(gzip.decompress(path.read_bytes()))
|
||||||
|
for sha, b in data["blobs"].items():
|
||||||
|
raw = base64.b64decode(b["b64"])
|
||||||
|
db.add(Blob(sha=sha, content=raw, size=len(raw), is_text=b["text"]))
|
||||||
|
for t in data["tenants"]:
|
||||||
|
db.add(Tenant(**t))
|
||||||
|
db.flush()
|
||||||
|
for v in data["versions"]:
|
||||||
|
db.add(Version(**v))
|
||||||
|
for s in data["secrets"]:
|
||||||
|
db.add(Secret(tenant_id=s["tenant_id"], key=s["key"], value_enc=None, source="sops-import"))
|
||||||
|
for k, v in data["settings"].items():
|
||||||
|
db.add(Setting(key=k, value=v))
|
||||||
|
return len(data["tenants"])
|
||||||
61
app/services/status.py
Normal file
61
app/services/status.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Zustandsabgleich: was läuft wirklich? (Vertrag: GET /health → core.version, bundle.source/version/stale)"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import session
|
||||||
|
from app.models import Tenant
|
||||||
|
from app.services import contract, deploy
|
||||||
|
|
||||||
|
_CACHE: dict = {"ts": 0.0, "data": None}
|
||||||
|
TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _http_get(url: str):
|
||||||
|
import httpx
|
||||||
|
r = httpx.get(url, timeout=6, follow_redirects=True)
|
||||||
|
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
|
||||||
|
|
||||||
|
|
||||||
|
def probe(host: str, expect_tag: str, expect_bundle: int | None, get=_http_get) -> dict:
|
||||||
|
out = {"host": host, "reachable": False, "core": None, "bundle": None, "drift": [], "error": None}
|
||||||
|
try:
|
||||||
|
status, body = get(f"https://{host}/health")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"{type(exc).__name__}: {str(exc)[:120]}"
|
||||||
|
out["drift"].append("nicht erreichbar")
|
||||||
|
return out
|
||||||
|
out["reachable"] = status == 200
|
||||||
|
if status != 200:
|
||||||
|
out["drift"].append(f"HTTP {status}")
|
||||||
|
core, bundle = body.get("core"), body.get("bundle")
|
||||||
|
out["core"], out["bundle"] = core, bundle
|
||||||
|
if core is None:
|
||||||
|
out["drift"].append("Core ohne Vertrag (alte Version)")
|
||||||
|
elif expect_tag and deploy._hex_tag(expect_tag) and not contract.same_sha(core.get("version", ""), expect_tag):
|
||||||
|
out["drift"].append(f"Core {core.get('version', '')[:10]} statt {expect_tag[:10]}")
|
||||||
|
if expect_bundle is not None:
|
||||||
|
if not bundle:
|
||||||
|
out["drift"].append("meldet keine Bundle-Quelle")
|
||||||
|
elif bundle.get("source") != "admin" or bundle.get("version") != expect_bundle or bundle.get("stale"):
|
||||||
|
out["drift"].append(f"Bundle {bundle.get('source')} v{bundle.get('version')}{' (veraltet)' if bundle.get('stale') else ''} statt admin v{expect_bundle}")
|
||||||
|
elif bundle and bundle.get("source") not in ("git", None):
|
||||||
|
out["drift"].append(f"Bundle-Quelle {bundle.get('source')}, erwartet git")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def probe_all(get=_http_get, refresh: bool = False, workers: int = 6) -> dict:
|
||||||
|
if not refresh and _CACHE["data"] is not None and time.time() - _CACHE["ts"] < TTL:
|
||||||
|
return _CACHE["data"]
|
||||||
|
with session() as db:
|
||||||
|
items = []
|
||||||
|
for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)):
|
||||||
|
items.append((t.id, deploy.host_for(db, t), deploy.effective_tag(db, t), t.current_version if t.source == "admin" else None))
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
res = list(ex.map(lambda it: (it[0], probe(it[1], it[2], it[3], get)), items))
|
||||||
|
data = {tid: r for tid, r in res}
|
||||||
|
_CACHE.update(ts=time.time(), data=data)
|
||||||
|
return data
|
||||||
225
app/services/store.py
Normal file
225
app/services/store.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Inhalte eines Tenants: Blobs, Entwurf, Versionen, Diff, Bundle.
|
||||||
|
|
||||||
|
Ein Tenant besteht aus `config` (Inhalt der tenant.json ohne deploy-Block) und
|
||||||
|
`files` ({pfad: sha}). Bearbeitet wird immer ein Entwurf (tenant.draft);
|
||||||
|
„Veröffentlichen“ schreibt daraus eine neue unveränderliche Version.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from app.models import AuditLog, Blob, Tenant, Version
|
||||||
|
|
||||||
|
TEXT_SUFFIXES = {".txt", ".html", ".css", ".py", ".json", ".svg", ".md", ".yaml", ".yml", ".csv"}
|
||||||
|
RESERVED = {"tenant.json"} # wird aus config erzeugt
|
||||||
|
|
||||||
|
|
||||||
|
def is_text_path(path: str) -> bool:
|
||||||
|
return any(path.lower().endswith(s) for s in TEXT_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def put_blob(db: Session, data: bytes, path: str = "") -> str:
|
||||||
|
sha = hashlib.sha256(data).hexdigest()
|
||||||
|
if db.get(Blob, sha) is None:
|
||||||
|
db.add(Blob(sha=sha, content=data, size=len(data), is_text=is_text_path(path)))
|
||||||
|
db.flush()
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
def get_blob(db: Session, sha: str) -> bytes:
|
||||||
|
b = db.get(Blob, sha)
|
||||||
|
if b is None:
|
||||||
|
raise KeyError(sha)
|
||||||
|
return b.content
|
||||||
|
|
||||||
|
|
||||||
|
def audit(db: Session, actor: str, action: str, tenant_id: str | None = None, detail: str = "") -> None:
|
||||||
|
db.add(AuditLog(actor=actor, action=action, tenant_id=tenant_id, detail=detail))
|
||||||
|
|
||||||
|
|
||||||
|
def current_version(db: Session, t: Tenant) -> Version | None:
|
||||||
|
if t.current_version is None:
|
||||||
|
return None
|
||||||
|
return db.scalar(select(Version).where(Version.tenant_id == t.id, Version.number == t.current_version))
|
||||||
|
|
||||||
|
|
||||||
|
def get_version(db: Session, tenant_id: str, number: int) -> Version | None:
|
||||||
|
return db.scalar(select(Version).where(Version.tenant_id == tenant_id, Version.number == number))
|
||||||
|
|
||||||
|
|
||||||
|
def content(db: Session, t: Tenant, source: str = "draft") -> tuple[dict, dict]:
|
||||||
|
"""(config, files) für source = draft | current | v<N>. Draft fällt auf current zurück."""
|
||||||
|
if source == "draft" and t.draft:
|
||||||
|
return deepcopy(t.draft["config"]), dict(t.draft["files"])
|
||||||
|
if source.startswith("v") and source[1:].isdigit():
|
||||||
|
v = get_version(db, t.id, int(source[1:]))
|
||||||
|
else:
|
||||||
|
v = current_version(db, t)
|
||||||
|
if v is None:
|
||||||
|
return {}, {}
|
||||||
|
return deepcopy(v.config), dict(v.files)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_draft(db: Session, t: Tenant) -> dict:
|
||||||
|
"""Arbeitskopie des Entwurfs (nie das am ORM-Objekt hängende Dict mutieren)."""
|
||||||
|
if t.draft:
|
||||||
|
return json.loads(json.dumps(t.draft))
|
||||||
|
cfg, files = content(db, t, "current")
|
||||||
|
return {"config": cfg, "files": files}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_draft(t: Tenant, draft: dict) -> None:
|
||||||
|
t.draft = json.loads(json.dumps(draft))
|
||||||
|
flag_modified(t, "draft")
|
||||||
|
|
||||||
|
|
||||||
|
def set_config(db: Session, t: Tenant, config: dict) -> None:
|
||||||
|
d = ensure_draft(db, t)
|
||||||
|
config = {k: v for k, v in config.items() if k != "deploy"}
|
||||||
|
config["id"] = t.id
|
||||||
|
d["config"] = config
|
||||||
|
_save_draft(t, d)
|
||||||
|
_sync_draft_state(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
def set_file(db: Session, t: Tenant, path: str, data: bytes) -> str:
|
||||||
|
path = clean_path(path)
|
||||||
|
d = ensure_draft(db, t)
|
||||||
|
sha = put_blob(db, data, path)
|
||||||
|
d["files"][path] = sha
|
||||||
|
_save_draft(t, d)
|
||||||
|
_sync_draft_state(db, t)
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(db: Session, t: Tenant, path: str) -> None:
|
||||||
|
d = ensure_draft(db, t)
|
||||||
|
d["files"].pop(path, None)
|
||||||
|
_save_draft(t, d)
|
||||||
|
_sync_draft_state(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_path(path: str) -> str:
|
||||||
|
path = path.strip().lstrip("/")
|
||||||
|
if not path or ".." in path.split("/") or path in RESERVED or "\\" in path:
|
||||||
|
raise ValueError(f"Ungültiger Dateipfad: {path!r}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_draft_state(db: Session, t: Tenant) -> None:
|
||||||
|
"""Entwurf, der identisch zur aktuellen Version ist, wieder löschen."""
|
||||||
|
if t.draft and not diff_summary(db, t, "current", "draft")["changed"]:
|
||||||
|
t.draft = None
|
||||||
|
|
||||||
|
|
||||||
|
def discard_draft(db: Session, t: Tenant) -> None:
|
||||||
|
t.draft = None
|
||||||
|
|
||||||
|
|
||||||
|
def publish(db: Session, t: Tenant, message: str, author: str) -> Version:
|
||||||
|
if not t.draft:
|
||||||
|
raise ValueError("Kein Entwurf zum Veröffentlichen.")
|
||||||
|
number = (t.current_version or 0) + 1
|
||||||
|
last = db.scalar(select(Version.number).where(Version.tenant_id == t.id).order_by(Version.number.desc()))
|
||||||
|
number = max(number, (last or 0) + 1)
|
||||||
|
v = Version(tenant_id=t.id, number=number, message=message.strip() or f"Version {number}", author=author,
|
||||||
|
config=t.draft["config"], files=t.draft["files"])
|
||||||
|
db.add(v)
|
||||||
|
t.current_version = number
|
||||||
|
t.draft = None
|
||||||
|
t.name = v.config.get("name", t.name)
|
||||||
|
audit(db, author, "version.publish", t.id, f"v{number}: {v.message}")
|
||||||
|
db.flush()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def restore_as_draft(db: Session, t: Tenant, number: int) -> None:
|
||||||
|
v = get_version(db, t.id, number)
|
||||||
|
if v is None:
|
||||||
|
raise KeyError(number)
|
||||||
|
t.draft = {"config": deepcopy(v.config), "files": dict(v.files)}
|
||||||
|
_sync_draft_state(db, t)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Diff ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def diff_summary(db: Session, t: Tenant, a: str, b: str, with_text: bool = False) -> dict:
|
||||||
|
return diff_content(db, content(db, t, a), content(db, t, b), with_text)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_content(db: Session, left: tuple[dict, dict], right: tuple[dict, dict], with_text: bool = False) -> dict:
|
||||||
|
ca, fa = left
|
||||||
|
cb, fb = right
|
||||||
|
items = []
|
||||||
|
if json.dumps(ca, sort_keys=True) != json.dumps(cb, sort_keys=True):
|
||||||
|
items.append(_item("tenant.json", "geändert", db, json.dumps(ca, indent=2, ensure_ascii=False, sort_keys=True),
|
||||||
|
json.dumps(cb, indent=2, ensure_ascii=False, sort_keys=True), True, with_text))
|
||||||
|
for path in sorted(set(fa) | set(fb)):
|
||||||
|
sa, sb = fa.get(path), fb.get(path)
|
||||||
|
if sa == sb:
|
||||||
|
continue
|
||||||
|
kind = "neu" if sa is None else "gelöscht" if sb is None else "geändert"
|
||||||
|
text = is_text_path(path)
|
||||||
|
ta = get_blob(db, sa).decode("utf-8", "replace") if (sa and text and with_text) else ""
|
||||||
|
tb = get_blob(db, sb).decode("utf-8", "replace") if (sb and text and with_text) else ""
|
||||||
|
items.append(_item(path, kind, db, ta, tb, text, with_text))
|
||||||
|
return {"changed": bool(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _item(path, kind, db, ta, tb, text, with_text):
|
||||||
|
out = {"path": path, "kind": kind, "text": text}
|
||||||
|
if with_text and text:
|
||||||
|
lines = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "vorher", "nachher", lineterm="", n=2))
|
||||||
|
out["diff"] = lines
|
||||||
|
out["added"] = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||||
|
out["removed"] = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bundle (genau das, was ein Tenant-Container beim Start abholt) ──────────
|
||||||
|
|
||||||
|
def tenant_json_bytes(config: dict) -> bytes:
|
||||||
|
return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()
|
||||||
|
|
||||||
|
|
||||||
|
def materialize(db: Session, t: Tenant, source: str, target) -> dict:
|
||||||
|
"""Schreibt das Tenant-Verzeichnis (tenant.json + Dateien) nach `target`."""
|
||||||
|
cfg, files = content(db, t, source)
|
||||||
|
write_tenant_dir(db, cfg, files, target)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def write_tenant_dir(db: Session, cfg: dict, files: dict, target) -> None:
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
(target / "tenant.json").write_bytes(tenant_json_bytes(cfg))
|
||||||
|
for path, sha in files.items():
|
||||||
|
p = target / path
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_bytes(get_blob(db, sha))
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_tar(db: Session, t: Tenant, source: str = "current") -> tuple[bytes, dict]:
|
||||||
|
cfg, files = content(db, t, source)
|
||||||
|
manifest = {"tenant": t.id, "version": t.current_version if source != "draft" else None,
|
||||||
|
"files": {"tenant.json": hashlib.sha256(tenant_json_bytes(cfg)).hexdigest(), **files}}
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||||
|
def add(name, data):
|
||||||
|
ti = tarfile.TarInfo(name)
|
||||||
|
ti.size = len(data)
|
||||||
|
ti.mtime = 0
|
||||||
|
tar.addfile(ti, io.BytesIO(data))
|
||||||
|
add("tenant.json", tenant_json_bytes(cfg))
|
||||||
|
for path, sha in sorted(files.items()):
|
||||||
|
add(path, get_blob(db, sha))
|
||||||
|
add("MANIFEST.json", json.dumps(manifest, indent=2).encode())
|
||||||
|
return buf.getvalue(), manifest
|
||||||
95
app/services/tenants.py
Normal file
95
app/services/tenants.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Tenant-Lebenszyklus: anlegen, klonen, Secrets, Cut-over-Check."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Deployment, Secret, Tenant, Version
|
||||||
|
from app.services import crypto, defaults, store
|
||||||
|
|
||||||
|
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,38}$")
|
||||||
|
|
||||||
|
|
||||||
|
def create(db: Session, tenant_id: str, name: str, clone_from: str | None, actor: str) -> Tenant:
|
||||||
|
tenant_id = tenant_id.strip().lower()
|
||||||
|
if not SLUG.match(tenant_id):
|
||||||
|
raise ValueError("ID: nur a-z, 0-9 und Bindestrich, 2–39 Zeichen.")
|
||||||
|
if db.get(Tenant, tenant_id):
|
||||||
|
raise ValueError(f"Tenant „{tenant_id}“ existiert bereits.")
|
||||||
|
t = Tenant(id=tenant_id, name=name.strip() or tenant_id, subdomain=tenant_id, domain_status="none", source="admin",
|
||||||
|
oidc={"mode": "managed"})
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
if clone_from:
|
||||||
|
src = db.get(Tenant, clone_from)
|
||||||
|
cfg, files = store.content(db, src, "current")
|
||||||
|
cfg = deepcopy(cfg)
|
||||||
|
cfg.update({"id": tenant_id, "name": t.name, "title": f"{t.name} – Gesprächsprotokoll",
|
||||||
|
"description": f"Gesprächsprotokoll-Tool für {t.name}", "ai_detect_org": t.name, "org_short": t.name})
|
||||||
|
# Personenbezogene/mandantenspezifische Dateien nicht mitklonen
|
||||||
|
files = {p: s for p, s in files.items() if not p.startswith("namensliste")}
|
||||||
|
t.cpu, t.memory = src.cpu, src.memory
|
||||||
|
else:
|
||||||
|
cfg, files = defaults.skeleton_config(tenant_id, t.name), {}
|
||||||
|
db.add(Version(tenant_id=tenant_id, number=1, message="Angelegt" + (f" (Kopie von {clone_from})" if clone_from else ""),
|
||||||
|
author=actor, config=cfg, files=files))
|
||||||
|
t.current_version = 1
|
||||||
|
for key, val in (("SESSION_SECRET", defaults.new_token()), ("ADMIN_BUNDLE_TOKEN", defaults.new_token())):
|
||||||
|
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=crypto.encrypt(val), source="manual"))
|
||||||
|
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
|
||||||
|
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=None, source="manual"))
|
||||||
|
store.audit(db, actor, "tenant.create", tenant_id, f"Kopie von {clone_from}" if clone_from else "leer")
|
||||||
|
db.flush()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def secret_rows(db: Session, tenant_id: str) -> list[dict]:
|
||||||
|
rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))}
|
||||||
|
keys = list(dict.fromkeys(defaults.REQUIRED_SECRETS + [k for k in rows if k not in defaults.REQUIRED_SECRETS]))
|
||||||
|
out = []
|
||||||
|
for k in keys:
|
||||||
|
r = rows.get(k)
|
||||||
|
out.append({
|
||||||
|
"key": k, "set": bool(r and r.value_enc), "source": r.source if r else "",
|
||||||
|
"required": k in defaults.REQUIRED_SECRETS, "help": defaults.SECRET_HELP.get(k, ""),
|
||||||
|
"updated_at": r.updated_at.isoformat() if r and r.updated_at else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_secret(db: Session, tenant_id: str, key: str, value: str | None, actor: str) -> None:
|
||||||
|
if not re.match(r"^[A-Z][A-Z0-9_]{1,60}$", key):
|
||||||
|
raise ValueError("Schlüssel: GROSSBUCHSTABEN_MIT_UNTERSTRICH")
|
||||||
|
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
|
||||||
|
if r is None:
|
||||||
|
r = Secret(tenant_id=tenant_id, key=key)
|
||||||
|
db.add(r)
|
||||||
|
r.value_enc = crypto.encrypt(value) if value else None
|
||||||
|
r.source = "manual"
|
||||||
|
store.audit(db, actor, "secret.set" if value else "secret.clear", tenant_id, key)
|
||||||
|
|
||||||
|
|
||||||
|
def check_bundle_token(db: Session, tenant_id: str, token: str) -> bool:
|
||||||
|
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "ADMIN_BUNDLE_TOKEN"))
|
||||||
|
return bool(r and r.value_enc and token and crypto.decrypt(r.value_enc) == token)
|
||||||
|
|
||||||
|
|
||||||
|
def cutover_checks(db: Session, t: Tenant, validation: dict | None) -> list[dict]:
|
||||||
|
secrets_ok = all(s["set"] for s in secret_rows(db, t.id) if s["required"])
|
||||||
|
imported = db.scalar(select(Version).where(Version.tenant_id == t.id, Version.author == "import")) is not None
|
||||||
|
dry = db.scalar(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "dry-run",
|
||||||
|
Deployment.status == "success").order_by(Deployment.id.desc()))
|
||||||
|
return [
|
||||||
|
{"id": "content", "label": "Inhalt aus Git importiert & Hash-gleich verifiziert" if imported else "Inhalt in der Datenbank",
|
||||||
|
"ok": True},
|
||||||
|
{"id": "validate", "label": "Validierung (Core-Rendering von Web-UI und PDF)", "ok": bool(validation and validation.get("ok"))},
|
||||||
|
{"id": "secrets", "label": "Alle Pflicht-Secrets in der Admin-DB gesetzt", "ok": secrets_ok},
|
||||||
|
{"id": "oidc", "label": "OIDC-Client abgeglichen (Login-API)" if (t.oidc or {}).get("mode", "adopt") != "off" else "OIDC-Verwaltung aus",
|
||||||
|
"ok": (t.oidc or {}).get("mode", "adopt") == "off" or bool(((t.oidc or {}).get("last_sync") or {}).get("ok"))},
|
||||||
|
{"id": "domain", "label": "Domain verifiziert", "ok": t.domain_status == "verified"},
|
||||||
|
{"id": "dryrun", "label": "Dry-Run erfolgreich", "ok": dry is not None},
|
||||||
|
{"id": "published", "label": "Keine unveröffentlichten Änderungen", "ok": not t.draft},
|
||||||
|
]
|
||||||
33
app/settings.py
Normal file
33
app/settings.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Zentrale Konfiguration (Env-getrieben)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if not os.getenv("ONYSCRIBE_SKIP_DOTENV"): # lokale Entwicklung: .env im Projektordner einlesen (Docker/Coolify setzen die Env direkt)
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(ROOT / ".env", override=False) # echte Umgebungsvariablen haben Vorrang
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
DATA = ROOT / ".data"
|
||||||
|
DATA.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA / 'onyscribe-admin.db'}")
|
||||||
|
# Entwicklungs-Fallback: abgeleiteter Key. In Produktion SECRET_KEY setzen!
|
||||||
|
_raw = os.getenv("SECRET_KEY", "").strip()
|
||||||
|
SECRET_KEY = _raw or base64.urlsafe_b64encode(hashlib.sha256(b"onyscribe-admin-dev").digest()).decode()
|
||||||
|
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true"
|
||||||
|
|
||||||
|
# Vendored Snapshot des Plattform-Cores (Preview führt den echten Core aus)
|
||||||
|
PLATFORM_DIR = Path(os.getenv("PLATFORM_DIR", ROOT / "vendor" / "platform"))
|
||||||
|
PREVIEW_CACHE = ROOT / ".preview-cache"
|
||||||
|
PREVIEW_CACHE.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Pfad zur bestehenden transkript-platform (nur für den Importer)
|
||||||
|
LEGACY_PLATFORM = Path(os.getenv("LEGACY_PLATFORM", ROOT.parent / "transkript-platform"))
|
||||||
|
|
||||||
|
APP_NAME = "Onyscribe Admin"
|
||||||
170
app/static/admin.css
Normal file
170
app/static/admin.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
:root{
|
||||||
|
--creme:#f4efe8; --dark:#1a1a1a; --cyan:#00cccc; --cyan-d:#009e9e; --white:#fff; --stone:#eceae4; --mist:#f7f4f0;
|
||||||
|
--border:#ddd8d0; --gray:#888; --mid:#555; --ok:#2a8a2a; --warn:#e0a000; --err:#c0392b;
|
||||||
|
--font:"Liberation Sans",Arial,Helvetica,sans-serif; --mono:ui-monospace,"JetBrains Mono","Liberation Mono",Menlo,monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;height:100%}
|
||||||
|
body{font-family:var(--font);background:var(--creme);color:#2c2c2c;font-size:13px;line-height:1.45;display:flex}
|
||||||
|
a{color:inherit}
|
||||||
|
h1,h2,h3{margin:0;color:var(--dark)}
|
||||||
|
button,input,select,textarea{font:inherit}
|
||||||
|
.muted{color:var(--gray)} .mono{font-family:var(--mono);font-size:12px} .grow{flex:1;min-width:0}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.nowrap{white-space:nowrap}
|
||||||
|
|
||||||
|
/* ── Sidebar ─────────────────────────────────────────────── */
|
||||||
|
.side{width:196px;flex:none;background:var(--dark);color:#d8d4cc;display:flex;flex-direction:column;height:100vh;position:sticky;top:0;border-right:2px solid var(--cyan)}
|
||||||
|
.brand{padding:18px 18px 12px}
|
||||||
|
.brand b{color:#fff;font-size:19px;letter-spacing:-.3px}
|
||||||
|
.brand span{display:block;color:var(--cyan);font-size:9.5px;font-weight:700;letter-spacing:2px;text-transform:uppercase}
|
||||||
|
.nav{padding:6px 8px;display:flex;flex-direction:column;gap:1px}
|
||||||
|
.nav a{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;text-decoration:none;color:#bdb8ae;font-weight:600}
|
||||||
|
.nav a:hover{background:#262626;color:#fff} .nav a.on{background:#262626;color:#fff;box-shadow:inset 3px 0 0 var(--cyan)}
|
||||||
|
.nav svg{width:15px;height:15px;flex:none}
|
||||||
|
.side-foot{margin-top:auto;padding:12px 16px;font-size:11px;color:#8a867e;border-top:1px solid #2c2c2c;display:flex;flex-direction:column;gap:6px}
|
||||||
|
.pill{display:inline-block;border-radius:4px;padding:1px 7px;font-weight:700;letter-spacing:.8px;font-size:9.5px;text-transform:uppercase;width:fit-content}
|
||||||
|
.pill.lock{background:#3a2f12;color:#f0c55a} .pill.live{background:#4a1d17;color:#ff9a8a} .pill.dev{background:#2c2c2c;color:var(--cyan)}
|
||||||
|
.side-foot a{color:#bdb8ae}
|
||||||
|
|
||||||
|
/* ── Seiten-Layout ───────────────────────────────────────── */
|
||||||
|
.main{flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;overflow:auto}
|
||||||
|
.page{padding:22px 28px 50px;max-width:1500px;width:100%}
|
||||||
|
.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:1.8px;color:var(--gray);text-transform:uppercase}
|
||||||
|
.eyebrow::before{content:"— ";color:var(--cyan)}
|
||||||
|
.title{font-size:24px;font-weight:700;letter-spacing:-.4px;margin:2px 0 0}
|
||||||
|
.lead{color:var(--mid);margin:4px 0 0;max-width:760px}
|
||||||
|
.head{display:flex;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
|
||||||
|
/* ── Cards ───────────────────────────────────────────────── */
|
||||||
|
.card{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
|
||||||
|
.card + .card,.card + .grid2,.grid2 + .card{margin-top:12px}
|
||||||
|
.card h3{font-size:13.5px;margin-bottom:8px}
|
||||||
|
.card .sub{color:var(--gray);font-size:12px;margin:-4px 0 10px}
|
||||||
|
.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||||
|
.grid2 > .card{margin:0!important}
|
||||||
|
.kpis{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
|
||||||
|
.kpi{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:7px 13px;display:flex;gap:9px;align-items:baseline;cursor:default}
|
||||||
|
.kpi b{font-size:18px;color:var(--dark)} .kpi span{color:var(--gray);font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||||
|
.kpi.click{cursor:pointer} .kpi.click:hover{border-color:var(--cyan)}
|
||||||
|
|
||||||
|
/* ── Badges / Buttons ────────────────────────────────────── */
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--stone);color:var(--mid);white-space:nowrap;line-height:1.6}
|
||||||
|
.badge.ok{background:#e3f3e3;color:var(--ok)} .badge.warn{background:#fbf0cf;color:#8a6200}
|
||||||
|
.badge.err{background:#f8dcd8;color:var(--err)} .badge.cyan{background:#d6f5f5;color:#007878} .badge.dark{background:var(--dark);color:#fff}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--white);color:var(--dark);border-radius:8px;padding:6px 12px;font-weight:700;cursor:pointer;text-decoration:none;font-size:12.5px;white-space:nowrap}
|
||||||
|
.btn:hover{border-color:var(--dark)} .btn.primary{background:var(--dark);color:#fff;border-color:var(--dark)}
|
||||||
|
.btn.cyan{background:var(--cyan);border-color:var(--cyan);color:#002b2b} .btn.cyan:hover{background:var(--cyan-d);border-color:var(--cyan-d);color:#fff}
|
||||||
|
.btn.danger{color:var(--err)} .btn.danger:hover{border-color:var(--err)} .btn.sm{padding:3px 9px;font-size:11.5px;border-radius:6px}
|
||||||
|
.btn[disabled],.btn.disabled{opacity:.45;pointer-events:none}
|
||||||
|
.seg{display:inline-flex;background:var(--stone);border-radius:8px;padding:2px;gap:1px}
|
||||||
|
.seg button{border:0;background:none;padding:4px 10px;border-radius:6px;font-weight:700;color:var(--mid);cursor:pointer;font-size:12px}
|
||||||
|
.seg button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 2px rgba(0,0,0,.14)}
|
||||||
|
.chipbar{display:flex;gap:5px;flex-wrap:wrap}
|
||||||
|
.fchip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:3px 11px;font-size:12px;font-weight:700;color:var(--mid);cursor:pointer}
|
||||||
|
.fchip.on{background:var(--dark);color:#fff;border-color:var(--dark)}
|
||||||
|
input.search{border:1px solid var(--border);border-radius:8px;padding:6px 10px;min-width:220px;background:#fff}
|
||||||
|
|
||||||
|
/* ── Tabellen ────────────────────────────────────────────── */
|
||||||
|
table.t{width:100%;border-collapse:collapse}
|
||||||
|
table.t th{text-align:left;font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--gray);padding:6px 9px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||||
|
table.t td{padding:7px 9px;border-bottom:1px solid var(--stone);vertical-align:middle}
|
||||||
|
table.t tr:last-child td{border-bottom:0}
|
||||||
|
table.t.hover tbody tr{cursor:pointer} table.t.hover tbody tr:hover td{background:var(--mist)}
|
||||||
|
.logo-box{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--mist);display:grid;place-items:center;flex:none;overflow:hidden}
|
||||||
|
.logo-box img{max-width:28px;max-height:28px}
|
||||||
|
.logo-box.lg{width:120px;height:56px} .logo-box.lg img{max-width:104px;max-height:44px}
|
||||||
|
.swatches{display:inline-flex;gap:2px;vertical-align:middle}
|
||||||
|
.swatches i{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.14);display:block}
|
||||||
|
.dotc{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;background:var(--stone)}
|
||||||
|
.dotc.ok{background:var(--ok)} .dotc.warn{background:var(--warn)} .dotc.err{background:var(--err)} .dotc.cyan{background:var(--cyan)}
|
||||||
|
|
||||||
|
/* ── Tenant-Seite ────────────────────────────────────────── */
|
||||||
|
.thead{padding:12px 20px 10px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--creme);position:sticky;top:0;z-index:6}
|
||||||
|
.thead h1{font-size:17px;letter-spacing:-.2px;line-height:1.2}
|
||||||
|
.crumb{font-size:11px;color:var(--gray)} .crumb a{text-decoration:none} .crumb a:hover{color:var(--dark)}
|
||||||
|
.dirty{display:inline-flex;gap:6px;align-items:center;font-size:11.5px;font-weight:700;color:#8a6200;background:#fbf0cf;border-radius:999px;padding:2px 10px}
|
||||||
|
.dirty::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--warn)}
|
||||||
|
.mid{min-width:0}
|
||||||
|
.tl{display:grid;grid-template-columns:176px minmax(0,1fr);flex:1;min-height:0;align-items:start}
|
||||||
|
.tl.open{grid-template-columns:176px minmax(340px,5fr) minmax(360px,6fr)}
|
||||||
|
.snav{position:sticky;top:53px;padding:12px 8px 20px 14px;display:flex;flex-direction:column;gap:1px;max-height:calc(100vh - 53px);overflow:auto}
|
||||||
|
.snav h6{margin:12px 8px 3px;font-size:9.5px;letter-spacing:1.6px;text-transform:uppercase;color:var(--gray)}
|
||||||
|
.snav h6:first-child{margin-top:0}
|
||||||
|
.snav button{display:flex;align-items:center;gap:7px;border:0;background:none;text-align:left;padding:6px 9px;border-radius:7px;font-weight:600;color:var(--mid);cursor:pointer;width:100%}
|
||||||
|
.snav button:hover{background:var(--stone);color:var(--dark)} .snav button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 3px rgba(0,0,0,.1),inset 3px 0 0 var(--cyan)}
|
||||||
|
.snav .n{margin-left:auto;font-size:10.5px}
|
||||||
|
.pane{padding:14px 18px 60px;min-width:0;container-type:inline-size}
|
||||||
|
@container (max-width:780px){.grid2{grid-template-columns:1fr}.fgrid.c3{grid-template-columns:1fr 1fr}}
|
||||||
|
.panel{position:sticky;top:53px;height:calc(100vh - 53px);padding:10px 20px 14px 4px;display:none;flex-direction:column;gap:8px;min-width:0}
|
||||||
|
.tl.open .panel{display:flex}
|
||||||
|
.pbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||||
|
.pv-frame{flex:1;background:var(--white);border:1px solid var(--border);border-radius:10px;position:relative;overflow:hidden;display:flex;justify-content:center;background-image:radial-gradient(#e6e1d8 1px,transparent 1px);background-size:14px 14px}
|
||||||
|
.pv-frame iframe{border:0;background:#fff;height:100%;width:100%;transition:width .2s;box-shadow:0 0 0 1px var(--border)}
|
||||||
|
.pv-frame.tablet iframe{width:768px;max-width:100%} .pv-frame.mobile iframe{width:390px;max-width:100%}
|
||||||
|
.pv-load{position:absolute;inset:0;background:rgba(244,239,232,.72);display:none;place-items:center;font-weight:700;color:var(--mid)} .pv-load.on{display:grid}
|
||||||
|
.pv-err{position:absolute;inset:0;background:#fff;padding:16px;overflow:auto;display:none;color:var(--err);font-family:var(--mono);font-size:12px;white-space:pre-wrap} .pv-err.on{display:block}
|
||||||
|
.hide{display:none!important}
|
||||||
|
|
||||||
|
/* ── Formulare ───────────────────────────────────────────── */
|
||||||
|
.fgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} .fgrid.c3{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
.fgrid .full{grid-column:1/-1}
|
||||||
|
label.f{display:block;font-size:10.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--gray)}
|
||||||
|
label.f > input,label.f > select,label.f > textarea{display:block;width:100%;margin-top:3px;text-transform:none;letter-spacing:0;font-weight:400;color:#222}
|
||||||
|
input[type=text],input[type=email],input[type=url],input[type=password],input[type=number],select,textarea{border:1px solid var(--border);border-radius:7px;padding:5px 9px;background:#fff;outline:none;font-size:12.5px}
|
||||||
|
input:focus,select:focus,textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(0,204,204,.16)}
|
||||||
|
textarea{width:100%;resize:vertical;line-height:1.5}
|
||||||
|
textarea.code{font-family:var(--mono);font-size:12px;tab-size:4;min-height:300px;background:#fbfaf8;white-space:pre}
|
||||||
|
.colors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 14px}
|
||||||
|
.colorf{display:flex;gap:7px;align-items:center;min-width:0}
|
||||||
|
.colorf input[type=color]{width:24px;height:24px;border:1px solid var(--border);border-radius:5px;padding:0;background:none;cursor:pointer;flex:none}
|
||||||
|
.colorf span{font-size:11px;color:var(--mid);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.colorf input[type=text]{width:74px;padding:2px 5px;font-family:var(--mono);font-size:11.5px}
|
||||||
|
.chk{display:flex;gap:8px;align-items:flex-start;padding:5px 0}.chk input{margin-top:2px;accent-color:var(--dark)}
|
||||||
|
.hint{font-size:11.5px;color:var(--gray)}
|
||||||
|
.banner{border-radius:8px;padding:7px 12px;font-size:12.5px;margin-bottom:10px;border:1px solid}
|
||||||
|
.banner.warn{background:#fdf6df;border-color:#ecd48a;color:#6f5200} .banner.info{background:#e6f8f8;border-color:#a8e6e6;color:#005e5e}
|
||||||
|
.banner.err{background:#fbe6e3;border-color:#efb5ad;color:#8e2418} .banner.ok{background:#e8f5e8;border-color:#b3dcb3;color:#1e661e}
|
||||||
|
details.fold{border:1px solid var(--border);border-radius:9px;background:var(--white);margin-top:10px}
|
||||||
|
details.fold > summary{cursor:pointer;padding:8px 13px;font-weight:700;list-style:none;display:flex;gap:8px;align-items:center}
|
||||||
|
details.fold > summary::before{content:"▸";color:var(--gray);transition:.15s} details.fold[open] > summary::before{transform:rotate(90deg)}
|
||||||
|
details.fold > .in{padding:2px 14px 13px}
|
||||||
|
.kv{display:grid;grid-template-columns:104px 1fr;gap:5px 10px;font-size:12.5px} .kv dt{color:var(--gray);font-weight:700;font-size:10.5px;letter-spacing:.9px;text-transform:uppercase;padding-top:2px} .kv dd{margin:0}
|
||||||
|
.check{display:flex;gap:8px;padding:4px 0;align-items:center;font-size:12.5px}
|
||||||
|
.dot{width:17px;height:17px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;flex:none;background:var(--stone);color:var(--gray)}
|
||||||
|
.dot.ok{background:var(--ok);color:#fff} .dot.no{background:#f2d9d5;color:var(--err)}
|
||||||
|
.console{background:#141414;color:#d6f5e6;font-family:var(--mono);font-size:12px;border-radius:8px;padding:11px 13px;white-space:pre-wrap;min-height:90px;max-height:340px;overflow:auto}
|
||||||
|
.console .sim{color:#7fd8d8}
|
||||||
|
pre.json{background:#141414;color:#e6e6e6;border-radius:8px;padding:11px;font-size:11.5px;overflow:auto;max-height:300px;font-family:var(--mono);margin:6px 0 0}
|
||||||
|
.diff{font-family:var(--mono);font-size:11.5px;background:#fbfaf8;border:1px solid var(--border);border-radius:7px;overflow:auto;max-height:260px;margin:5px 0 10px}
|
||||||
|
.diff div{padding:0 9px;white-space:pre} .diff .a{background:#e3f6e3;color:#1c5a1c} .diff .r{background:#fbe3df;color:#8e2418} .diff .h{color:var(--gray);background:var(--stone)}
|
||||||
|
.empty{padding:22px;text-align:center;color:var(--gray)}
|
||||||
|
.copy{cursor:pointer;border-bottom:1px dashed var(--gray)}
|
||||||
|
|
||||||
|
/* ── Agent-Chat ──────────────────────────────────────────── */
|
||||||
|
.chat{display:flex;flex-direction:column;flex:1;min-height:0}
|
||||||
|
.agpane{position:sticky;top:53px;height:calc(100vh - 53px);padding:12px 12px 14px 18px}
|
||||||
|
.agpane.hide{display:none!important}
|
||||||
|
.msgs{flex:1;overflow:auto;display:flex;flex-direction:column;gap:9px;padding:2px 2px 10px}
|
||||||
|
.bub{max-width:94%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;font-size:12.5px}
|
||||||
|
.bub.user{align-self:flex-end;background:var(--dark);color:#fff;border-bottom-right-radius:3px}
|
||||||
|
.bub.assistant{align-self:flex-start;background:var(--white);border:1px solid var(--border);border-bottom-left-radius:3px}
|
||||||
|
.steps{display:flex;flex-direction:column;gap:3px;margin:0 0 6px}
|
||||||
|
.step{display:flex;gap:6px;align-items:baseline;font-size:11.5px;color:var(--mid);background:var(--mist);border:1px solid var(--stone);border-radius:6px;padding:2px 8px;white-space:normal;overflow-wrap:anywhere}
|
||||||
|
.step b{font-family:var(--mono);color:#007878;flex:none} .step.err{background:#fbe6e3;border-color:#efb5ad} .step.err b{color:var(--err)}
|
||||||
|
.thought{color:var(--gray);font-style:italic;font-size:12px;margin:0 0 5px}
|
||||||
|
.prop{margin-top:8px;border:1px dashed var(--cyan);border-radius:8px;padding:8px 10px;background:#f0fbfb}
|
||||||
|
.chat-in{display:flex;gap:6px;border-top:1px solid var(--border);padding-top:8px}
|
||||||
|
.chat-in textarea{min-height:38px;max-height:120px}
|
||||||
|
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:7px}
|
||||||
|
.chip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:2px 10px;font-size:11.5px;cursor:pointer}.chip:hover{border-color:var(--cyan)}
|
||||||
|
.typing{align-self:flex-start;color:var(--gray);font-size:11.5px}
|
||||||
|
|
||||||
|
/* ── Modal / Toast ───────────────────────────────────────── */
|
||||||
|
.modal-bg{position:fixed;inset:0;background:rgba(20,20,20,.5);display:none;place-items:center;z-index:50;padding:20px}.modal-bg.on{display:grid}
|
||||||
|
.modal{background:var(--creme);border-radius:12px;width:min(780px,100%);max-height:90vh;overflow:auto;padding:20px 22px;border:1px solid var(--border);box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
||||||
|
.modal h2{font-size:17px;margin-bottom:10px}.modal .acts{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
||||||
|
#toast{position:fixed;bottom:18px;right:18px;background:var(--dark);color:#fff;padding:9px 15px;border-radius:9px;font-weight:700;z-index:99;display:none;border-left:4px solid var(--cyan);max-width:420px}#toast.err{border-color:var(--err)}
|
||||||
|
|
||||||
|
@media (max-width:1250px){.grid2{grid-template-columns:1fr}.tl.open{grid-template-columns:150px minmax(300px,1fr) minmax(320px,1fr)}}
|
||||||
|
@media (max-width:800px){.side{width:56px}.side .brand span,.nav a span,.side-foot{display:none}.tl,.tl.open{grid-template-columns:1fr}.snav{position:static;flex-direction:row;flex-wrap:wrap;max-height:none}.snav h6{display:none}}
|
||||||
598
app/static/tenant.js
Normal file
598
app/static/tenant.js
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
/* Tenant-Detailseite: eine Navigation (links) · Formular (Mitte) · Vorschau/Agent (rechts) */
|
||||||
|
let S = null;
|
||||||
|
let SCHEMA = null;
|
||||||
|
const ui = {sec: 'overview', mode: 'edit', open: true, pvKind: 'web', pvSrc: 'draft', pvDev: '', agentSrc: null};
|
||||||
|
const SECTIONS = [
|
||||||
|
['overview', 'Übersicht', null, false],
|
||||||
|
['brand', 'Marke & Farben', 'Gestaltung', true], ['texts', 'Absender & PDF-Texte', 'Gestaltung', true], ['pdf', 'PDF-Layout', 'Gestaltung', true],
|
||||||
|
['prompt', 'Prompt', 'Inhalt', true], ['files', 'Dateien', 'Inhalt', true],
|
||||||
|
['func', 'Funktion & STT', 'Verhalten', true], ['code', 'Hook-Code', 'Verhalten', true], ['adv', 'tenant.json', 'Verhalten', true],
|
||||||
|
['domain', 'Domain & Login', 'Betrieb', false], ['secrets', 'Secrets', 'Betrieb', false], ['deploy', 'Deploy', 'Betrieb', false], ['versions', 'Verlauf', 'Betrieb', false],
|
||||||
|
];
|
||||||
|
const A = p => `/api/tenants/${TID}${p}`;
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
|
||||||
|
function setPath(o, p, v){ const ks = p.split('.'); let c = o; ks.slice(0,-1).forEach(k => c = (c[k] ??= {})); c[ks.at(-1)] = v; }
|
||||||
|
function unsetPath(o, p){ const ks = p.split('.'); const chain = [o]; let c = o; for(const k of ks.slice(0,-1)){ c = c?.[k]; if(c == null) return; chain.push(c); } delete c[ks.at(-1)]; for(let i = ks.length - 2; i >= 0; i--){ if(Object.keys(chain[i+1]).length === 0) delete chain[i][ks[i]]; else break; } }
|
||||||
|
function debounce(fn, ms){ let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
|
||||||
|
function shade(hex, f){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.max(0,Math.min(255,Math.round(x*f)))); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
|
||||||
|
function tint(hex, a){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.round(x+(255-x)*a)); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
|
||||||
|
const copyText = t => navigator.clipboard.writeText(t).then(()=>toast('Kopiert'));
|
||||||
|
const secDef = k => SECTIONS.find(s => s[0] === k);
|
||||||
|
const kindLabel = {'dry-run':'Dry-Run','deploy':'Deploy','offboard-dry':'Offboard (Dry-Run)','offboard':'Offboard'};
|
||||||
|
|
||||||
|
/* ───────── Laden & Rahmen ───────── */
|
||||||
|
async function loadSchema(){ try{ const c = await api('GET', '/api/contract'); SCHEMA = c.schema; }catch(e){ SCHEMA = null; } }
|
||||||
|
async function load(keep){
|
||||||
|
S = await api('GET', A('/state'));
|
||||||
|
renderHead(); renderNav();
|
||||||
|
if(!keep) route();
|
||||||
|
}
|
||||||
|
function route(){
|
||||||
|
const h = (location.hash||'').slice(1);
|
||||||
|
if(secDef(h)) ui.sec = h;
|
||||||
|
renderSection();
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', route);
|
||||||
|
|
||||||
|
function renderHead(){
|
||||||
|
$('#h-name').textContent = S.name;
|
||||||
|
const st = S.status !== 'active' ? `<span class="badge warn">${S.status}</span>` : '';
|
||||||
|
$('#h-badges').innerHTML = `${S.has_draft?'<span class="dirty">Entwurf</span>':''}${st}<span class="badge dark">v${S.current_version}</span>
|
||||||
|
<span class="badge ${S.deployed_version===S.current_version?'ok':'warn'}">deployt v${S.deployed_version??'–'}</span>
|
||||||
|
<span class="badge ${S.source==='admin'?'cyan':''}">Quelle: ${S.source==='admin'?'Admin':'Git'}</span>`;
|
||||||
|
$('#h-actions').innerHTML = S.has_draft
|
||||||
|
? `<button class="btn sm" onclick="discardDraft()">Verwerfen</button><button class="btn sm cyan" onclick="openPublish()">Prüfen & veröffentlichen</button>` : '';
|
||||||
|
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
|
||||||
|
}
|
||||||
|
function renderNav(extra){
|
||||||
|
let g = null, out = '';
|
||||||
|
const nfiles = Object.keys(S.files).length;
|
||||||
|
const badges = {files: nfiles ? `<span class="n muted">${nfiles}</span>` : '', versions: `<span class="n muted">${S.current_version}</span>`,
|
||||||
|
secrets: extra?.missing ? `<span class="n"><span class="dotc err" style="margin:0"></span></span>` : '', code: S.files['overrides.py'] ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : '',
|
||||||
|
deploy: S.deployed_version !== S.current_version ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : ''};
|
||||||
|
for(const [k, l, grp] of SECTIONS){
|
||||||
|
if(grp !== g){ g = grp; if(grp) out += `<h6>${grp}</h6>`; }
|
||||||
|
out += `<button class="${ui.sec===k&&ui.mode==='edit'?'on':''}" onclick="gotoSec('${k}')">${l}${badges[k]||''}</button>`;
|
||||||
|
}
|
||||||
|
$('#snav').innerHTML = out;
|
||||||
|
}
|
||||||
|
function gotoSec(k){ ui.mode = 'edit'; if(location.hash === '#'+k) route(); else location.hash = k; }
|
||||||
|
/* Layout: Mitte = Formular ODER Agent-Chat; die Live-Vorschau rechts bleibt in beiden Fällen sichtbar (Betriebsbereiche ohne Vorschau). */
|
||||||
|
function layout(){
|
||||||
|
ui.open = secDef(ui.sec)[3] || ui.mode === 'agent';
|
||||||
|
$('#tl').classList.toggle('open', ui.open);
|
||||||
|
$('#pane').classList.toggle('hide', ui.mode === 'agent');
|
||||||
|
$('#ag-wrap').classList.toggle('hide', ui.mode !== 'agent');
|
||||||
|
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
|
||||||
|
if(ui.open) refreshPreview();
|
||||||
|
}
|
||||||
|
$('#h-panel').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); });
|
||||||
|
function renderSection(){
|
||||||
|
renderNav(); layout();
|
||||||
|
({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv,
|
||||||
|
domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec]();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Speichern (Entwurf) ───────── */
|
||||||
|
const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; };
|
||||||
|
function afterSave(r){
|
||||||
|
S.has_draft = r.has_draft; renderHead(); saveState(r.has_draft ? 'Entwurf gespeichert' : 'Entspricht der veröffentlichten Version');
|
||||||
|
refreshPreviewSoon();
|
||||||
|
}
|
||||||
|
const saveConfigNow = async () => { try { saveState('Speichere …'); afterSave(await api('PUT', A('/config'), S.config)); } catch(e){ toast(e.message,1); saveState('Fehler'); } };
|
||||||
|
const saveConfig = debounce(saveConfigNow, 500);
|
||||||
|
async function saveFileNow(path, content){ try { saveState('Speichere …'); afterSave(await api('PUT', A('/files/'+path), {content})); } catch(e){ toast(e.message,1); saveState(e.message); } }
|
||||||
|
const saveFile = debounce(saveFileNow, 600);
|
||||||
|
async function deleteFile(path){ afterSave(await api('DELETE', A('/files/'+path))); await load(true); renderSection(); }
|
||||||
|
async function uploadFile(input, path){
|
||||||
|
const f = input.files[0]; if(!f) return;
|
||||||
|
const fd = new FormData(); fd.append('file', f);
|
||||||
|
const r = await fetch(A('/upload?path='+encodeURIComponent(path||f.name)), {method:'POST', body: fd});
|
||||||
|
if(!r.ok){ toast((await r.json()).detail||'Upload fehlgeschlagen',1); return; }
|
||||||
|
await load(true); renderSection(); refreshPreviewSoon(); toast('Hochgeladen'); headLogo(); renderHead();
|
||||||
|
}
|
||||||
|
function headLogo(){ const n = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; $('#h-logo').innerHTML = n?`<img src="${A('/files/'+n)}?source=draft&t=${Date.now()}" alt="">`:''; }
|
||||||
|
async function discardDraft(){
|
||||||
|
if(!confirm('Alle Änderungen im Entwurf verwerfen?')) return;
|
||||||
|
await api('POST', A('/draft/discard')); await load(true); renderSection(); headLogo(); refreshPreview(true); toast('Entwurf verworfen');
|
||||||
|
}
|
||||||
|
const saveInd = () => `<span class="hint" id="save-ind">${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'}</span>`;
|
||||||
|
const title = (t, sub) => `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px" class="grow">${t}${sub?` <span class="muted" style="font-weight:400;font-size:12px">${sub}</span>`:''}</h2>${saveInd()}</div>`;
|
||||||
|
|
||||||
|
/* ───────── Live-Vorschau ───────── */
|
||||||
|
let pvSeq = 0;
|
||||||
|
async function refreshPreview(force){
|
||||||
|
if(!ui.open) return;
|
||||||
|
const seq = ++pvSeq, kind = ui.pvKind;
|
||||||
|
$('#f-web').style.display = kind==='web'?'':'none'; $('#f-pdf').style.display = kind==='pdf'?'':'none';
|
||||||
|
$('#pv-frame').className = 'pv-frame ' + (kind==='web'?ui.pvDev:'');
|
||||||
|
$('#pv-dev').style.display = kind==='web'?'':'none';
|
||||||
|
$('#pv-load').classList.add('on'); $('#pv-err').classList.remove('on');
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const src = ui.pvSrc === 'agent' ? (ui.agentSrc || 'draft') : ui.pvSrc;
|
||||||
|
const r = await fetch(`/preview/${TID}/${kind}?source=${encodeURIComponent(src)}&_=${force?Date.now():''}`);
|
||||||
|
if(seq !== pvSeq) return;
|
||||||
|
if(!r.ok){ $('#pv-err').textContent = await r.text(); $('#pv-err').classList.add('on'); }
|
||||||
|
else if(kind==='web'){ $('#f-web').srcdoc = await r.text(); }
|
||||||
|
else { const b = await r.blob(); $('#f-pdf').src = URL.createObjectURL(b) + '#toolbar=0&navpanes=0'; }
|
||||||
|
$('#pv-info').textContent = `Core ${S.core_version} · ${Math.round(performance.now()-t0)} ms`;
|
||||||
|
} catch(e){ $('#pv-err').textContent = String(e); $('#pv-err').classList.add('on'); }
|
||||||
|
$('#pv-load').classList.remove('on');
|
||||||
|
}
|
||||||
|
const refreshPreviewSoon = debounce(() => refreshPreview(), 900);
|
||||||
|
const refreshPreviewLive = debounce(() => refreshPreview(), 250);
|
||||||
|
function setPvSrc(src){
|
||||||
|
ui.pvSrc = src; $('#pv-agent').classList.toggle('hide', !ui.agentSrc);
|
||||||
|
document.querySelectorAll('#pv-src button').forEach(x => x.classList.toggle('on', x.dataset.s === src));
|
||||||
|
}
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('#pv-kind button, #pv-dev button, #pv-src button'); if(!b) return;
|
||||||
|
const g = b.parentElement.id;
|
||||||
|
b.parentElement.querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
|
||||||
|
if(g==='pv-kind') ui.pvKind = b.dataset.k; if(g==='pv-dev') ui.pvDev = b.dataset.d; if(g==='pv-src') ui.pvSrc = b.dataset.s;
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ───────── Übersicht ───────── */
|
||||||
|
async function sOverview(){
|
||||||
|
const c = S.config, col = c.colors||{};
|
||||||
|
$('#pane').innerHTML = `
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card"><h3>Status</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>URL</dt><dd><a href="https://${esc(S.host)}" target="_blank">${esc(S.host)}</a> <span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}</span></dd>
|
||||||
|
<dt>Version</dt><dd>v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'<span class="badge warn">veraltet</span>':''}</dd>
|
||||||
|
<dt>Quelle</dt><dd>${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}</dd>
|
||||||
|
<dt>Core</dt><dd><span class="mono">:${esc(String(S.core.tag).slice(0,12))}</span> ${S.core.pin?'<span class="badge warn">gepinnt</span>':'<span class="badge">folgt Rollouts</span>'} <span class="hint">· Admin-Vorschau: ${esc(S.core.sha)}</span>
|
||||||
|
<span id="run-state"></span> <button class="btn sm" onclick="probeMe()">Läuft?</button></dd>
|
||||||
|
<dt>STT</dt><dd>${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}</dd>
|
||||||
|
<dt>Dateien</dt><dd>${Object.keys(S.files).map(f=>`<span class="badge">${esc(f)}</span>`).join(' ')||'–'}</dd>
|
||||||
|
<dt>Marke</dt><dd class="swatches" style="gap:3px">${Object.entries(col).map(([k,v])=>`<i title="${esc(S.color_labels[k]||k)} ${esc(v)}" style="width:16px;height:16px;background:${esc(v)}"></i>`).join('')}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="row" style="margin-top:10px"><a class="btn sm" href="/tenants/${TID}/bundle.tar.gz">Bundle (.tar.gz)</a><button class="btn sm" onclick="runValidate()">Validieren</button></div>
|
||||||
|
<div id="val" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Cut-over auf die Admin-Plattform</h3>
|
||||||
|
<p class="sub">Bis alles grün ist, bleibt der Tenant auf <b>Git</b>. Umschalten = <span class="mono">TENANT_SOURCE=admin</span> beim nächsten Deploy.</p>
|
||||||
|
<div id="cut"><div class="muted">Prüfe …</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2" style="margin-top:12px">
|
||||||
|
<div class="card"><h3>Betrieb</h3>
|
||||||
|
<div class="fgrid c3"><label class="f">CPU<input type="number" step="0.25" min="0.1" max="8" id="m-cpu" value="${S.cpu}"></label>
|
||||||
|
<label class="f">RAM<input type="text" id="m-mem" value="${esc(S.memory)}"></label>
|
||||||
|
<label class="f">Status<select id="m-status">${['active','paused','archived'].map(x=>`<option ${S.status===x?'selected':''}>${x}</option>`).join('')}</select></label></div>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveMeta()">Speichern</button><span class="hint">Ressourcen wirken beim nächsten Deploy. „paused/archived“ nimmt den Tenant aus „Alle neu deployen“.</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Notizen</h3>
|
||||||
|
<textarea id="m-notes" rows="4" placeholder="Ansprechpartner, Besonderheiten, Vereinbarungen …">${esc(S.notes||'')}</textarea>
|
||||||
|
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveMeta()">Speichern</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:12px"><h3>Core-Version</h3><p class="sub">Neue Core-Releases rollt die Admin-Plattform zentral aus (Menü „Core“). Ein <b>Pin</b> hält diesen Tenant auf einem festen Tag – er nimmt dann an Rollouts nicht teil.</p>
|
||||||
|
<div class="row"><input type="text" id="m-pin" value="${esc(S.core.pin||'')}" placeholder="kein Pin (folgt dem aktiven Core)" style="width:260px"><button class="btn sm" onclick="savePin()">Pin setzen / aufheben</button></div></div>
|
||||||
|
<details class="fold"><summary>Gefahrenbereich</summary><div class="in">
|
||||||
|
<p class="hint">Offboard entfernt Service, Volume und Ingress aus dem Mittwald-Stack (Dry-Run zeigt vorher, was passiert). Löschen entfernt nur die Daten in dieser Datenbank – und nur für archivierte Tenants.</p>
|
||||||
|
<div class="row"><button class="btn sm" onclick="offboard(true)">Offboard-Dry-Run</button><button class="btn sm danger ${S.live?'':'disabled'}" id="b-off" onclick="offboard(false)">Offboard (live)</button>
|
||||||
|
<button class="btn sm danger" onclick="deleteOidcApp()">OIDC-Client im Login löschen …</button><button class="btn sm danger" onclick="deleteTenant()">Tenant in der DB löschen …</button></div><div class="console hide" id="con-off" style="margin-top:8px"></div></div></details>`;
|
||||||
|
const [cut, mode, sec] = await Promise.all([api('GET', A('/cutover')), api('GET', '/api/deploy/mode'), api('GET', A('/secrets'))]);
|
||||||
|
renderNav({missing: sec.some(s => s.required && !s.set)});
|
||||||
|
$('#b-off')?.classList.toggle('disabled', !mode.live);
|
||||||
|
const ok = cut.checks.every(x=>x.ok);
|
||||||
|
$('#cut').innerHTML = cut.checks.map(x=>`<div class="check"><span class="dot ${x.ok?'ok':'no'}">${x.ok?'✓':'!'}</span><span>${esc(x.label)}</span></div>`).join('') +
|
||||||
|
`<div class="row" style="margin-top:10px">${cut.source==='admin'
|
||||||
|
? `<span class="badge cyan">Quelle: Admin</span><button class="btn sm" onclick="setSource('git')">Zurück auf Git</button>`
|
||||||
|
: `<button class="btn sm cyan" ${ok?'':'disabled'} onclick="setSource('admin')">Auf Admin-Quelle umstellen</button>${ok?'':'<span class="hint">noch nicht alle Bedingungen erfüllt</span>'}`}</div>`;
|
||||||
|
}
|
||||||
|
async function savePin(){ try{ await api('PUT', A('/core-pin'), {pin: $('#m-pin').value}); await load(true); toast('Core-Pin gespeichert – wirksam beim nächsten Deploy'); sOverview(); }catch(e){ toast(e.message,1) } }
|
||||||
|
async function probeMe(){
|
||||||
|
$('#run-state').innerHTML = '<span class="muted">prüfe …</span>';
|
||||||
|
try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `<span class="badge ok">läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''}</span>` : `<span class="badge err">${esc(r.drift.join(' · ')||'nicht erreichbar')}</span>`; }
|
||||||
|
catch(e){ $('#run-state').innerHTML = `<span class="badge err">${esc(e.message)}</span>`; }
|
||||||
|
}
|
||||||
|
async function saveMeta(){ try{ await api('PUT', A('/meta'), {cpu:$('#m-cpu').value, memory:$('#m-mem').value, status:$('#m-status').value, notes:$('#m-notes').value}); await load(true); toast('Gespeichert'); sOverview(); }catch(e){ toast(e.message,1) } }
|
||||||
|
async function setSource(s){ await api('PUT', A('/source'), {source:s}); await load(true); sOverview(); toast('TENANT_SOURCE='+s+' – wirksam beim nächsten Deploy'); }
|
||||||
|
async function runValidate(){ $('#val').innerHTML='<span class="muted">Validiere mit dem echten Core …</span>'; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); }
|
||||||
|
const valHtml = r => `<div class="banner ${r.ok?'ok':'err'}" style="margin:0">${r.ok?'✔ Validierung bestanden':'✘ Fehler'}${(r.errors||[]).map(e=>`<div class="mono" style="white-space:pre-wrap">${esc(e)}</div>`).join('')}${(r.warnings||[]).map(e=>`<div>⚠ ${esc(e)}</div>`).join('')}</div>`;
|
||||||
|
async function offboard(dry){
|
||||||
|
if(!dry && !confirm('Service, Volume und Ingress dieses Tenants wirklich vom Mittwald-Stack entfernen?')) return;
|
||||||
|
try{ const r = await api('POST', A('/offboard'), {dry_run:dry}); followDeploy(r.id, $('#con-off')); }catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
async function deleteOidcApp(){
|
||||||
|
const id = prompt('OIDC-Client dieses Tenants im Login-System LÖSCHEN (nicht umkehrbar, beendet alle Logins der App). Zur Bestätigung die Tenant-ID eingeben:'); if(id===null) return;
|
||||||
|
try{ await api('DELETE', A(`/oidc/app?confirm=${encodeURIComponent(id)}`)); toast('OIDC-Client gelöscht'); await load(true); sOverview(); }catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
async function deleteTenant(){
|
||||||
|
const id = prompt('Alle Daten dieses Tenants in der Datenbank löschen (Versionen, Secrets, Chat). Zur Bestätigung die ID eingeben:'); if(id===null) return;
|
||||||
|
try{ await api('DELETE', `/api/tenants/${TID}?confirm=${encodeURIComponent(id)}`); location.href='/'; }catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Formular-Helfer ───────── */
|
||||||
|
function bindCfg(root){
|
||||||
|
root.querySelectorAll('[data-cfg]').forEach(el => {
|
||||||
|
el.addEventListener('input', () => {
|
||||||
|
const p = el.dataset.cfg;
|
||||||
|
const v = el.type==='checkbox' ? el.checked : el.type==='number' ? (el.value === '' ? '' : Number(el.value)) : el.value;
|
||||||
|
if(v === '' && !['name'].includes(p) && !p.startsWith('office.') && !p.startsWith('colors.')) unsetPath(S.config, p); else setPath(S.config, p, v);
|
||||||
|
if(el.type==='color'){ const t = root.querySelector(`[data-cfg="${p}"][type=text]`); if(t) t.value = v; }
|
||||||
|
if(el.type==='text' && el.dataset.color){ const c = root.querySelector(`[data-cfg="${p}"][type=color]`); if(c && /^#[0-9a-f]{6}$/i.test(v)) c.value = v; }
|
||||||
|
saveConfig();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const fld = (label, path, o={}) => `<label class="f ${o.full?'full':''}">${label}<input type="${o.type||'text'}" data-cfg="${path}" value="${esc(getPath(S.config,path)??'')}" ${o.ph?`placeholder="${esc(o.ph)}"`:''}></label>`;
|
||||||
|
|
||||||
|
/* ───────── Gestaltung ───────── */
|
||||||
|
function sBrand(){
|
||||||
|
const col = S.config.colors||{};
|
||||||
|
const logo = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null;
|
||||||
|
$('#pane').innerHTML = title('Marke & Farben') + `
|
||||||
|
<div class="card"><h3>Name & Titel</h3><div class="fgrid">${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}</div></div>
|
||||||
|
<div class="card"><h3>Logo & Favicon</h3><div class="row" style="align-items:flex-start;gap:20px">
|
||||||
|
<div><div class="logo-box lg">${logo?`<img src="${A('/files/'+logo)}?source=draft&t=${Date.now()}">`:'<span class="muted">kein Logo</span>'}</div>
|
||||||
|
<label class="f" style="margin-top:6px">Logo (SVG/PNG)<input type="file" accept=".svg,.png" onchange="uploadFile(this, this.files[0].name.toLowerCase().endsWith('.svg')?'logo.svg':'logo.png')"></label>
|
||||||
|
<div class="hint">Word-Export braucht zusätzlich <span class="mono">logo.png</span>.</div></div>
|
||||||
|
<div><div class="logo-box lg" style="width:56px">${S.files['favicon.svg']?`<img src="${A('/files/favicon.svg')}?source=draft&t=${Date.now()}">`:'<span class="muted">–</span>'}</div>
|
||||||
|
<label class="f" style="margin-top:6px">Favicon (SVG)<input type="file" accept=".svg" onchange="uploadFile(this,'favicon.svg')"></label></div></div></div>
|
||||||
|
<div class="card"><div class="row" style="margin-bottom:8px"><h3 style="margin:0" class="grow">Farben</h3><button class="btn sm" onclick="deriveFromAccent()" title="dunkel / mittel / hell / extra hell aus dem Akzent berechnen">Töne aus Akzent ableiten</button></div>
|
||||||
|
<div class="colors">${Object.keys(S.color_labels).map(k=>`<div class="colorf"><input type="color" data-cfg="colors.${k}" value="${esc(col[k]||'#000000')}"><span title="${esc(k)}">${esc(S.color_labels[k])}</span><input type="text" data-color="1" data-cfg="colors.${k}" value="${esc(col[k]||'')}"></div>`).join('')}</div></div>`;
|
||||||
|
bindCfg($('#pane'));
|
||||||
|
}
|
||||||
|
function deriveFromAccent(){
|
||||||
|
const a = (S.config.colors||{}).accent; if(!/^#[0-9a-f]{6}$/i.test(a)) return toast('Akzent muss #RRGGBB sein',1);
|
||||||
|
Object.assign(S.config.colors, {accent_dark:shade(a,.72), accent_mid:shade(a,1.12), accent_light:tint(a,.88), accent_xlight:tint(a,.94)});
|
||||||
|
saveConfigNow(); sBrand();
|
||||||
|
}
|
||||||
|
function sTexts(){
|
||||||
|
$('#pane').innerHTML = title('Absender & PDF-Texte') + `
|
||||||
|
<div class="card"><h3>Absender / Kopf- & Fußzeile</h3><p class="sub">Erscheint im PDF und im Word-Export.</p><div class="fgrid">
|
||||||
|
${fld('Name','office.name')}${fld('Zusatz (Rechtsform)','office.subtitle')}${fld('Adresse','office.address',{full:1})}${fld('Telefon','office.tel')}${fld('E-Mail','office.email')}${fld('Website','office.website',{full:1})}</div></div>
|
||||||
|
<div class="card"><h3>PDF-Beschriftungen</h3><div class="fgrid">${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}</div></div>`;
|
||||||
|
bindCfg($('#pane'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text-Datei-Editor mit „Standard des Cores“ */
|
||||||
|
async function fileEditor(path, o){
|
||||||
|
const has = !!S.files[path]; let body = '';
|
||||||
|
try { const r = await fetch(A('/files/'+path)+'?source=draft'); if(r.ok) body = await r.text(); } catch(e){}
|
||||||
|
const w = document.createElement('div'); w.className = 'card';
|
||||||
|
w.innerHTML = `<div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">${esc(o.title)}</h3>
|
||||||
|
${has ? `<span class="badge cyan">eigene Datei</span><button class="btn sm danger" data-act="reset">Auf Core-Standard</button>` : `<span class="badge">Core-Standard</span><button class="btn sm" data-act="custom">Eigene Version anlegen</button>`}</div>
|
||||||
|
${o.sub?`<p class="sub">${o.sub}</p>`:''}
|
||||||
|
<textarea class="code" ${has?'':'readonly'} spellcheck="false" style="min-height:${o.h||300}px;${has?'':'opacity:.7'}">${esc(body)}</textarea>`;
|
||||||
|
const ta = w.querySelector('textarea');
|
||||||
|
ta.addEventListener('input', () => saveFile(path, ta.value));
|
||||||
|
ta.addEventListener('keydown', e => { if(e.key==='Tab'){ e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); ta.dispatchEvent(new Event('input')); } });
|
||||||
|
w.querySelector('[data-act=custom]')?.addEventListener('click', async () => { await saveFileNow(path, body); await load(true); renderSection(); });
|
||||||
|
w.querySelector('[data-act=reset]')?.addEventListener('click', async () => { if(confirm('Eigene Datei löschen und den Core-Standard verwenden?')) await deleteFile(path); });
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
async function sPdf(){
|
||||||
|
$('#pane').innerHTML = title('PDF-Layout') + `<div class="banner info">HTML + CSS (WeasyPrint). Datenfelder bleiben gleich, nur das Aussehen ist frei. Die Vorschau nutzt das Beispielprotokoll unten.</div><div id="e1"></div><div id="e2"></div>
|
||||||
|
<details class="fold"><summary>Beispielprotokoll für die Vorschau</summary><div class="in"><textarea class="code" id="sample" style="min-height:200px">${esc(S.sample)}</textarea>
|
||||||
|
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveSample()">Speichern & neu rendern</button><span class="hint">Format wie vom LLM geliefert; gilt nur für die Vorschau.</span></div></div></details>`;
|
||||||
|
$('#e1').appendChild(await fileEditor('protocol.html', {title:'protocol.html', sub:'Jinja-Template des PDFs.', h:300}));
|
||||||
|
$('#e2').appendChild(await fileEditor('protocol.css', {title:'protocol.css', sub:'Farben kommen als Jinja-Variablen aus der Marke.', h:300}));
|
||||||
|
}
|
||||||
|
async function saveSample(){ await api('PUT', A('/sample'), {sample: $('#sample').value}); S.sample = $('#sample').value; toast('Beispiel gespeichert'); ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); refreshPreview(true); }
|
||||||
|
|
||||||
|
/* ───────── Inhalt ───────── */
|
||||||
|
async function sPrompt(){
|
||||||
|
$('#pane').innerHTML = title('Prompt') + `
|
||||||
|
<div class="card"><h3>Selbstbeschreibung</h3><p class="sub">Ersetzt <span class="mono">@@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@</span> im Standard-Prompt.</p><div class="fgrid">
|
||||||
|
${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}</div></div><div id="ed"></div>
|
||||||
|
<details class="fold" id="ptest"><summary>Prompt testen (echter LLM-Lauf) <span class="badge">kostet Cortecs-Guthaben</span></summary><div class="in">
|
||||||
|
${S.llm_ready ? '' : '<div class="banner warn">Kein Cortecs-Key hinterlegt – unter Einstellungen → Geteilte Secrets setzen.</div>'}
|
||||||
|
<div class="fgrid"><label class="f">Termin-Art (optional)<input type="text" id="pt-art" placeholder="aus Gespräch erkennen"></label><label class="f">Projektname (optional)<input type="text" id="pt-proj"></label>
|
||||||
|
<label class="f full">Beispieltranskript<textarea id="pt-tr" rows="6" class="code" style="min-height:120px">${esc(S.sample_transcript)}</textarea></label></div>
|
||||||
|
<div class="row" style="margin:8px 0"><button class="btn sm cyan" id="pt-go" onclick="promptTest()" ${S.llm_ready?'':'disabled'}>LLM-Lauf mit Entwurfs-Prompt</button><button class="btn sm" onclick="showFinalPrompt()">Finalen Prompt anzeigen</button><span class="hint" id="pt-info"></span></div>
|
||||||
|
<div id="pt-out"></div></div></details>`;
|
||||||
|
bindCfg($('#pane'));
|
||||||
|
$('#ed').appendChild(await fileEditor('prompt.txt', {title:'prompt.txt', sub:'Ohne eigene Datei gilt der Standard-Prompt des Cores. Hook-Code kann den Prompt zusätzlich erweitern (siehe „Finalen Prompt anzeigen“).', h:420}));
|
||||||
|
}
|
||||||
|
async function promptTest(){
|
||||||
|
$('#pt-go').disabled = true; $('#pt-info').textContent = 'LLM läuft … (bis zu 1–2 Minuten)'; $('#pt-out').innerHTML = '';
|
||||||
|
try{
|
||||||
|
const r = await api('POST', A('/prompt-test'), {transcript:$('#pt-tr').value, terminart:$('#pt-art').value, projekt:$('#pt-proj').value});
|
||||||
|
$('#pt-info').textContent = `${r.model} · ${r.input_tokens}→${r.output_tokens} Tokens · Prompt ${r.prompt_chars} Zeichen${r.truncated?' · ⚠ abgeschnitten (max_tokens)':''}`;
|
||||||
|
window._ptText = r.text;
|
||||||
|
$('#pt-out').innerHTML = `<pre class="json" style="white-space:pre-wrap;max-height:340px;background:#fbfaf8;color:#222;border:1px solid var(--border)">${esc(r.text)}</pre><div class="row" style="margin-top:6px"><button class="btn sm" onclick="usePtAsSample()">Als PDF-Beispiel übernehmen & ansehen</button></div>`;
|
||||||
|
}catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
|
||||||
|
$('#pt-go').disabled = !S.llm_ready;
|
||||||
|
}
|
||||||
|
async function usePtAsSample(){ await api('PUT', A('/sample'), {sample: window._ptText}); S.sample = window._ptText; ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); setPvSrc('draft'); refreshPreview(true); toast('Als Beispiel gesetzt'); }
|
||||||
|
async function showFinalPrompt(){
|
||||||
|
try{ const r = await api('GET', A('/final-prompt')); $('#pt-out').innerHTML = `<div class="hint">${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:</div><pre class="json" style="white-space:pre-wrap;max-height:380px">${esc(r.prompt)}</pre>`; }
|
||||||
|
catch(e){ $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
|
||||||
|
}
|
||||||
|
function sFiles(){
|
||||||
|
const rows = Object.entries(S.files).map(([p,m]) => `<tr><td class="mono">${esc(p)} ${m.personal?'<span class="badge warn" title="Für den KI-Agenten gesperrt">personenbezogen</span>':''}</td><td>${(m.size/1024).toFixed(1)} KB</td><td>${m.text?'Text':'Binär'}</td><td style="text-align:right" class="nowrap">${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?`<button class="btn sm" onclick="openTextFile('${esc(p)}')">Bearbeiten</button> `:''}<button class="btn sm danger" onclick="if(confirm('Datei löschen?'))deleteFile('${esc(p)}')">Löschen</button></td></tr>`).join('');
|
||||||
|
$('#pane').innerHTML = title('Dateien') + `<div class="card"><table class="t"><thead><tr><th>Pfad</th><th>Größe</th><th>Typ</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=4 class="empty">Keine Dateien</td></tr>'}</tbody></table>
|
||||||
|
<div class="row" style="margin-top:10px"><label class="btn sm">+ Datei hochladen<input type="file" hidden onchange="uploadFile(this)"></label><span class="hint">z. B. <span class="mono">namensliste.txt</span> – lesbar für Hook-Code im Container. Namenslisten sind für den KI-Agenten automatisch gesperrt.</span></div><div id="tf" style="margin-top:10px"></div></div>`;
|
||||||
|
}
|
||||||
|
async function openTextFile(p){ const w = await fileEditor(p, {title:p, h:260}); $('#tf').innerHTML=''; $('#tf').appendChild(w); }
|
||||||
|
|
||||||
|
/* ───────── Verhalten ───────── */
|
||||||
|
/* Felder, die der Core im Schema deklariert, aber keine eigene Maske haben, erscheinen automatisch hier (neue Core-Optionen ohne Admin-Änderung). */
|
||||||
|
const BESPOKE_PATHS = ['name','title','description','pdf_projekt_label','pdf_footer_note','ai_detect_org','org_short','org_profile'];
|
||||||
|
const isBespoke = p => BESPOKE_PATHS.includes(p) || p.startsWith('colors.') || p.startsWith('office.');
|
||||||
|
function schemaField(f){
|
||||||
|
const raw = getPath(S.config, f.path), cur = raw === undefined ? f.default : raw;
|
||||||
|
const help = f.help ? ` <span class="hint">${esc(f.help)}</span>` : '';
|
||||||
|
if(f.type === 'bool') return `<label class="chk"><input type="checkbox" data-cfg="${f.path}" ${cur?'checked':''}><span><b>${esc(f.label)}</b>${help}</span></label>`;
|
||||||
|
if(f.type === 'enum') return `<label class="f">${esc(f.label)}<select data-cfg="${f.path}"><option value="" ${!raw?'selected':''}>Standard (${esc(f.default ?? 'Core')})</option>${(f.options||[]).map(o=>`<option ${raw===o?'selected':''}>${esc(o)}</option>`).join('')}</select>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
|
||||||
|
if(f.type === 'color') return `<label class="f">${esc(f.label)}<input type="text" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? '')}"></label>`;
|
||||||
|
if(f.type === 'text') return `<label class="f full">${esc(f.label)}<textarea rows="3" data-cfg="${f.path}" placeholder="${esc(f.default ?? '')}">${esc(raw ?? '')}</textarea>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
|
||||||
|
return `<label class="f">${esc(f.label)}<input type="${f.type==='number'?'number':'text'}" step="any" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? 'Standard')}">${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
|
||||||
|
}
|
||||||
|
function sFunc(){
|
||||||
|
if(!SCHEMA) return sFuncLegacy();
|
||||||
|
const groups = {};
|
||||||
|
SCHEMA.fields.filter(f => !isBespoke(f.path)).forEach(f => (groups[f.group] ??= []).push(f));
|
||||||
|
const order = Object.keys(groups);
|
||||||
|
const known = ['Funktion', 'Spracherkennung'];
|
||||||
|
const card = (g) => { const fs = groups[g], basic = fs.filter(f=>!f.advanced), adv = fs.filter(f=>f.advanced);
|
||||||
|
const grid = list => `<div class="${list.every(f=>f.type==='bool')?'':'fgrid'}">${list.map(schemaField).join('')}</div>`;
|
||||||
|
return `<div class="card"><h3>${esc(g)}${known.includes(g)?'':' <span class="badge cyan">neu im Core</span>'}</h3>${basic.length?grid(basic):''}${adv.length?`<details class="fold"><summary>Erweitert</summary><div class="in">${grid(adv)}</div></details>`:''}
|
||||||
|
${g==='Spracherkennung'?'<p class="hint" style="margin-top:6px">Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.</p>':''}</div>`; };
|
||||||
|
$('#pane').innerHTML = title('Funktion & STT') + `<p class="hint" style="margin:-4px 0 8px">Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.</p>` + order.map(card).join('');
|
||||||
|
bindCfg($('#pane'));
|
||||||
|
}
|
||||||
|
function sFuncLegacy(){
|
||||||
|
const stt = S.config.stt||{};
|
||||||
|
const chk = (path, label, hint, def) => { const v = getPath(S.config,path); return `<label class="chk"><input type="checkbox" data-cfg="${path}" ${(v===undefined?def:v)?'checked':''}><span><b>${label}</b> <span class="hint">${hint}</span></span></label>`; };
|
||||||
|
$('#pane').innerHTML = title('Funktion & STT') + `<div class="banner warn">Dieser Core liefert kein Schema (Vertrag) – feste Maske.</div>
|
||||||
|
<div class="card"><h3>Funktionen (Flags)</h3>
|
||||||
|
${chk('flags.ui_kontakt_field','Feld „Ansprechpartner Kunde“','Aus: Feld in der Web-UI ausgeblendet (z. B. Vereine).',true)}
|
||||||
|
${chk('flags.multi_audio','Mehrere Audiodateien (bis 5)','Aus: nur eine Datei pro Protokoll.',true)}</div>
|
||||||
|
<div class="card"><h3>Spracherkennung (STT)</h3><div class="fgrid">
|
||||||
|
<label class="f">Provider<select data-cfg="stt.provider"><option value="" ${!stt.provider?'selected':''}>Standard (mistral)</option><option ${stt.provider==='cortecs'?'selected':''}>cortecs</option><option ${stt.provider==='mistral'?'selected':''}>mistral</option></select></label>
|
||||||
|
${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}</div></div>`;
|
||||||
|
bindCfg($('#pane'));
|
||||||
|
}
|
||||||
|
async function sCode(){
|
||||||
|
const has = !!S.files['overrides.py'];
|
||||||
|
$('#pane').innerHTML = title('Hook-Code') + `<div class="banner warn"><b>Code läuft im Tenant-Container</b> mit dessen Secrets. In der Vorschau läuft er nur in einer Sandbox ohne Secrets (CPU-/Datei-Limits); Veröffentlichen verlangt eine ausdrückliche Freigabe. Erst Flags/Dateien nutzen; Wiederkehrendes gehört in den Core.</div>
|
||||||
|
<div class="card"><h3>Verfügbare Hooks</h3><dl class="kv" style="grid-template-columns:150px 1fr"><dt>build_prompt</dt><dd>Prompt vor dem LLM-Aufruf anpassen</dd><dt>transform_transcript</dt><dd>Transkript nachbearbeiten</dd><dt>pdf_context</dt><dd>Daten fürs PDF-Template ändern</dd><dt>extra_routes</dt><dd>zusätzliche FastAPI-Routen</dd></dl></div><div id="ed"></div>`;
|
||||||
|
if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460}));
|
||||||
|
else $('#ed').innerHTML = `<div class="card empty">Dieser Tenant nutzt keinen Hook-Code.<br><br><button class="btn" onclick="createHook()">overrides.py anlegen</button></div>`;
|
||||||
|
}
|
||||||
|
async function createHook(){ await saveFileNow('overrides.py', `"""Tenant-Hooks für ${S.id}."""\nfrom core.hooks import TenantHooks\n\n\nclass Hooks(TenantHooks):\n def build_prompt(self, base_prompt: str, ctx: dict) -> str:\n return base_prompt\n`); await load(true); renderSection(); }
|
||||||
|
function sAdv(){
|
||||||
|
$('#pane').innerHTML = title('tenant.json') + `<div class="card"><p class="sub">Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der <span class="mono">deploy</span>-Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).</p>
|
||||||
|
<textarea class="code" id="rawjson" style="min-height:460px">${esc(JSON.stringify(S.config,null,2))}</textarea><div class="hint" id="rawerr" style="color:var(--err)"></div></div>`;
|
||||||
|
$('#rawjson').addEventListener('input', e => { try { S.config = JSON.parse(e.target.value); $('#rawerr').textContent=''; saveConfig(); } catch(x){ $('#rawerr').textContent = 'Ungültiges JSON: '+x.message; } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Veröffentlichen ───────── */
|
||||||
|
async function openPublish(){
|
||||||
|
modal('m-pub', true); $('#pub-body').innerHTML = '<div class="muted">Validiere (echter Core) und berechne Änderungen …</div>'; $('#pub-go').disabled = true;
|
||||||
|
const [val, diff] = await Promise.all([api('POST', A('/validate?source=draft')), api('GET', A('/diff?a=current&b=draft'))]);
|
||||||
|
const code = diff.items.some(i => i.path==='overrides.py');
|
||||||
|
$('#pub-body').innerHTML = valHtml(val) + `<h3 style="margin:12px 0 4px">${diff.items.length} geänderte Datei(en)</h3>` + diff.items.map(diffHtml).join('') +
|
||||||
|
(code ? `<label class="chk banner warn"><input type="checkbox" id="codeok"><span><b>Freigabe Hook-Code:</b> Ich habe die Änderungen an <span class="mono">overrides.py</span> geprüft.</span></label>` : '') +
|
||||||
|
`<label class="f" style="margin-top:10px">Commit-Nachricht<input type="text" id="pubmsg" placeholder="Was wurde geändert?"></label>`;
|
||||||
|
const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); };
|
||||||
|
$('#codeok')?.addEventListener('change', upd); upd();
|
||||||
|
}
|
||||||
|
const diffHtml = i => `<div style="margin-top:8px"><div class="row"><b class="mono">${esc(i.path)}</b><span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${i.kind}</span>${i.added!=null?`<span class="hint">+${i.added} −${i.removed}</span>`:''}</div>${i.diff?`<div class="diff">${i.diff.map(l=>`<div class="${l.startsWith('+')&&!l.startsWith('+++')?'a':l.startsWith('-')&&!l.startsWith('---')?'r':l.startsWith('@@')?'h':''}">${esc(l)||' '}</div>`).join('')}</div>`:'<div class="hint">Binärdatei</div>'}</div>`;
|
||||||
|
async function publish(){
|
||||||
|
try { const r = await api('POST', A('/publish'), {message: $('#pubmsg').value}); modal('m-pub', false); await load(true); renderSection(); toast('Version v'+r.version+' veröffentlicht'); }
|
||||||
|
catch(e){ toast(e.message, 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Agent ───────── */
|
||||||
|
const CHIPS = ['Setze die Akzentfarbe auf #0B6E4F','Füge dem Prompt hinzu: Nenne Beträge immer in Euro mit Tausenderpunkt.','Lege ein eigenes PDF-CSS an und mache die Überschriften größer.','Prüfe den Tenant auf Probleme.'];
|
||||||
|
async function initAgent(){
|
||||||
|
$('#ag-chips').innerHTML = CHIPS.map(s=>`<span class="chip" onclick="sendChat(this.textContent)">${esc(s)}</span>`).join('');
|
||||||
|
$('#chat-text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendChat(); } });
|
||||||
|
const st = await api('GET', '/api/llm/status?tenant=' + TID);
|
||||||
|
const src = {shared:'geteiltes Secret', env:'Env/.env', tenant:'Key des Tenants'}[st.source];
|
||||||
|
$('#ag-mode').textContent = st.configured ? `${st.model} · ${st.api}` : 'Demo-Modus – kein Key gefunden';
|
||||||
|
$('#ag-mode').className = 'badge ' + (st.configured ? 'cyan' : 'warn');
|
||||||
|
$('#ag-mode').title = st.configured ? 'Cortecs-Key aus: ' + src : (st.error || 'Key unter Einstellungen → Geteilte Secrets setzen oder Env CORTECS_API_KEY');
|
||||||
|
if(st.error) $('#ag-usage').textContent = '⚠ ' + st.error;
|
||||||
|
const h = await api('GET', A('/chat')); const box = $('#msgs');
|
||||||
|
if(!h.length) box.innerHTML = '<div class="empty">Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.</div>';
|
||||||
|
else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9;
|
||||||
|
}
|
||||||
|
function stepsHtml(steps){ return (steps||[]).map(s=>`<div class="step ${s.error?'err':''}"><b>${esc(s.tool)}</b><span class="mono">${esc(s.arg)}</span> → ${esc(s.result)}</div>`).join(''); }
|
||||||
|
function propHtml(m){
|
||||||
|
if(!m.proposal) return '';
|
||||||
|
const items = (m.proposal.items||[]).map(i=>`<span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${esc(i.path)}</span>`).join(' ');
|
||||||
|
return `<div class="prop"><b>Vorschlag:</b> ${esc(m.proposal.summary)}<div style="margin:5px 0">${items}</div><div class="row">${m.proposal_status==='open'
|
||||||
|
? `<button class="btn sm" onclick="showProposalDiff(${m.id})">Diff ansehen</button><button class="btn sm cyan" onclick="decide(${m.id},'apply')">In Entwurf übernehmen</button><button class="btn sm" onclick="decide(${m.id},'reject')">Verwerfen</button>`
|
||||||
|
: `<span class="badge ${m.proposal_status==='applied'?'ok':''}">${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'}</span>`}</div></div>`;
|
||||||
|
}
|
||||||
|
function msgEl(m){
|
||||||
|
const d = document.createElement('div'); d.className = 'bub '+m.role; d.id = 'm'+(m.id||'');
|
||||||
|
const u = m.usage && m.usage.in ? `<div class="hint" style="margin-top:5px">${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens</div>` : '';
|
||||||
|
d.innerHTML = (m.steps&&m.steps.length?`<div class="steps">${stepsHtml(m.steps)}</div>`:'') + esc(m.content) + propHtml(m) + u; return d;
|
||||||
|
}
|
||||||
|
async function sendChat(text){
|
||||||
|
const ta = $('#chat-text'); text = text || ta.value.trim(); if(!text) return; ta.value = '';
|
||||||
|
ui.mode = 'agent'; layout();
|
||||||
|
const box = $('#msgs'); box.querySelector('.empty')?.remove();
|
||||||
|
box.appendChild(msgEl({role:'user', content:text}));
|
||||||
|
const live = document.createElement('div'); live.className = 'bub assistant'; live.innerHTML = '<div class="steps"></div><div class="typing">Agent arbeitet …</div>'; box.appendChild(live); box.scrollTop = 1e9;
|
||||||
|
$('#chat-send').disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})});
|
||||||
|
if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status);
|
||||||
|
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = '';
|
||||||
|
while(true){
|
||||||
|
const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true});
|
||||||
|
let i; while((i = buf.indexOf('\n\n')) >= 0){
|
||||||
|
const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2);
|
||||||
|
if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); }
|
||||||
|
else if(ev.type==='changed'){ refreshPreviewLive(); }
|
||||||
|
else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev]));
|
||||||
|
else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `<div class="thought">${esc(ev.text)}</div>`);
|
||||||
|
else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text;
|
||||||
|
else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message));
|
||||||
|
if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); }
|
||||||
|
refreshPreview(true); }
|
||||||
|
box.scrollTop = 1e9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e){ live.remove(); toast(e.message, 1); }
|
||||||
|
$('#chat-send').disabled = false;
|
||||||
|
}
|
||||||
|
async function decide(id, d){
|
||||||
|
const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m));
|
||||||
|
ui.agentSrc = null; setPvSrc('draft');
|
||||||
|
if(d==='apply'){ await load(true); renderNav(); headLogo(); if(ui.sec==='overview') sOverview(); toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); }
|
||||||
|
refreshPreview(true);
|
||||||
|
}
|
||||||
|
async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede zum Entwurf.</div>'; modal('m-diff', true); }
|
||||||
|
async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = '<div class="empty">Verlauf geleert.</div>'; }
|
||||||
|
|
||||||
|
/* ───────── Betrieb ───────── */
|
||||||
|
async function sDomain(){
|
||||||
|
const dnsTarget = `${S.subdomain}.${S.base_domain}`;
|
||||||
|
const o = await api('GET', A('/oidc')); window._oidc = o;
|
||||||
|
const logout = `https://${S.host}/`;
|
||||||
|
const modeHelp = {off:'Nichts im Login anfassen (Client wird manuell gepflegt).', adopt:'Bestehenden Client übernehmen: fehlende Redirect-URIs ergänzen, sonst nichts ändern.', managed:'Client vollständig aus dieser Konfiguration verwalten (anlegen, Redirect-URIs, auth/totp, Freigaben).'};
|
||||||
|
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Domain & Login</h2></div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card"><h3>Domain</h3>
|
||||||
|
<div class="fgrid"><label class="f">Subdomain<input type="text" id="d-sub" value="${esc(S.subdomain)}"></label><label class="f">Basis-Domain<input type="text" value="${esc(S.base_domain)}" disabled></label>
|
||||||
|
<label class="f full">Eigene Domain (optional)<input type="text" id="d-cust" value="${esc(S.custom_domain)}" placeholder="protokoll.kunde.de"></label></div>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveDomain()">Speichern</button><button class="btn sm" onclick="checkDomain()">DNS prüfen</button>
|
||||||
|
<span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'verifiziert':S.domain_status==='pending'?'DNS ausstehend':'nicht eingerichtet'}</span></div>
|
||||||
|
<div id="dns-msg" class="hint" style="margin-top:6px"></div>
|
||||||
|
${S.custom_domain?`<table class="t" style="margin-top:10px"><thead><tr><th>Typ</th><th>Name</th><th>Wert</th></tr></thead><tbody><tr><td>CNAME</td><td class="mono">${esc(S.custom_domain)}</td><td class="mono copy" onclick="copyText('${esc(dnsTarget)}')">${esc(dnsTarget)}</td></tr></tbody></table><p class="hint">Alternativ A-Record auf die Projekt-IP aus mStudio. TLS stellt Mittwald automatisch aus.</p>`:''}
|
||||||
|
<p class="hint" style="margin-top:8px">Redirect-URIs im Login ergeben sich aus Subdomain und eigener Domain und werden beim Deploy mit abgeglichen.</p></div>
|
||||||
|
|
||||||
|
<div class="card"><h3>OIDC-Client <span class="muted" style="font-weight:400">· Abgleich über die Login-Developer-API bei jedem Deploy</span></h3>
|
||||||
|
<div class="row" style="margin-bottom:8px">
|
||||||
|
<span class="badge ${o.api_key?'ok':'err'}">API-Key ${o.api_key?'vorhanden':'fehlt'}</span>
|
||||||
|
<span class="badge ${o.writes_allowed?'warn':''}">Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'}</span>
|
||||||
|
<span class="badge ${o.client_id?'ok':'warn'} mono">${o.client_id?esc(o.client_id):'noch kein Client'}</span>
|
||||||
|
<span class="badge ${o.secret_set?'ok':'warn'}">Secret ${o.secret_set?'gesetzt':'fehlt'}</span></div>
|
||||||
|
<div class="fgrid">
|
||||||
|
<label class="f full">Verwaltung<select id="oc-mode">${['off','adopt','managed'].map(m=>`<option value="${m}" ${o.mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
|
||||||
|
<div class="hint full" id="oc-modehelp" style="margin-top:-4px">${modeHelp[o.mode]}</div>
|
||||||
|
<label class="f">Zugriff (auth_mode)<select id="oc-auth">${['required','optional','disabled'].map(m=>`<option ${o.auth_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
|
||||||
|
<label class="f">2FA (totp_mode)<select id="oc-totp">${['disabled','optional','required'].map(m=>`<option ${o.totp_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
|
||||||
|
<label class="f full">Backchannel-Logout-URI (optional, öffentlich, https)<input type="text" id="oc-bc" value="${esc(o.backchannel_logout_uri||'')}"></label>
|
||||||
|
<label class="chk full"><input type="checkbox" id="oc-reg" ${o.allow_registration?'checked':''}><span><b>Selbstregistrierung</b> <span class="hint">Nutzer dürfen sich über diese App selbst registrieren.</span></span></label></div>
|
||||||
|
<div style="margin:4px 0 8px"><div class="hint">Redirect-URIs (Soll):</div>${o.redirect_uris.map(u=>`<div class="mono copy" onclick="copyText('${esc(u)}')">${esc(u)}</div>`).join('')}
|
||||||
|
<div class="hint">Logout-Ziel: <span class="mono">${esc(logout)}</span> (Core: OIDC_LOGOUT_REDIRECT_URI)</div></div>
|
||||||
|
<div class="row"><button class="btn sm" onclick="saveOidc(true)">Speichern</button>
|
||||||
|
<button class="btn sm" onclick="syncOidc(false)" ${o.api_key?'':'disabled'} title="Liest nur – zeigt, was beim Deploy passieren würde">Gegen Login prüfen</button>
|
||||||
|
<button class="btn sm cyan" onclick="syncOidc(true)" ${o.api_key&&o.writes_allowed&&o.mode!=='off'?'':'disabled'} title="${o.writes_allowed?'':'Gesperrt: OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv'}">Jetzt abgleichen</button>
|
||||||
|
<button class="btn sm danger" onclick="syncOidc(true,true)" ${o.api_key&&o.writes_allowed&&o.client_id?'':'disabled'} title="Neues Secret erzeugen; das alte wird sofort ungültig">Secret rotieren</button></div>
|
||||||
|
${o.last_sync?`<div class="hint" style="margin-top:6px">Letzter Abgleich: ${fmt(o.last_sync.ts)} · <span class="${o.last_sync.ok?'':'muted'}">${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')}</span>${o.last_sync.created?' · Client neu angelegt':''}</div>`:''}
|
||||||
|
<div id="oc-out" style="margin-top:8px"></div></div></div>
|
||||||
|
|
||||||
|
<div class="grid2" style="margin-top:12px">
|
||||||
|
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Freigaben</h3>
|
||||||
|
<button class="btn sm" onclick="importMembers()" ${o.api_key&&o.client_id?'':'disabled'} title="Aktuelle Freigaben aus dem Login lesen und hier übernehmen">Aus Login übernehmen</button></div>
|
||||||
|
<p class="sub">Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.</p>
|
||||||
|
<table class="t" id="oc-members"><tbody></tbody></table>
|
||||||
|
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="addMember()">+ Nutzer</button>
|
||||||
|
<label class="f" style="display:flex;align-items:center;gap:6px;margin-left:auto">Abgleich<select id="oc-mm" style="width:auto;margin:0"><option value="add" ${o.members_mode==='add'?'selected':''}>nur ergänzen</option><option value="exact" ${o.members_mode==='exact'?'selected':''}>exakt (zusätzliche entziehen)</option></select></label></div></div>
|
||||||
|
<div class="card"><h3>Manuell eintragen</h3><p class="sub">Falls der Client nicht automatisch verwaltet wird (Verwaltung „off“) oder das Secret aus dem Altbestand übernommen werden soll.</p>
|
||||||
|
<div class="fgrid"><label class="f">Client-ID<input type="text" id="o-id" placeholder="${o.client_id?esc(o.client_id)+' – neu eintragen zum Ändern':'noch nicht gesetzt'}"></label>
|
||||||
|
<label class="f">Client-Secret<input type="password" id="o-sec" placeholder="${o.secret_set?'●●●● gesetzt':'noch nicht gesetzt'}"></label></div>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveManual()">Speichern</button></div></div></div>
|
||||||
|
<div class="card" style="margin-top:12px"><h3>Bundle-Abruf durch den Container</h3><p class="sub">So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = <span class="mono">ADMIN_BUNDLE_TOKEN</span>. Der Core kann das noch nicht – Schnittstelle steht.</p>
|
||||||
|
<pre class="json">curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz</pre></div>`;
|
||||||
|
renderMembers(o.members);
|
||||||
|
$('#oc-mode').addEventListener('change', e => { $('#oc-modehelp').textContent = modeHelp[e.target.value]; });
|
||||||
|
}
|
||||||
|
function renderMembers(ms){
|
||||||
|
window._members = ms.map(m => ({...m}));
|
||||||
|
$('#oc-members tbody').innerHTML = window._members.length ? window._members.map((m,i)=>`<tr><td><input type="text" value="${esc(m.email)}" onchange="_members[${i}].email=this.value.trim().toLowerCase()" style="width:100%"></td>
|
||||||
|
<td style="width:100px"><select onchange="_members[${i}].role=this.value">${['viewer','editor','admin'].map(r=>`<option ${m.role===r?'selected':''}>${r}</option>`).join('')}</select></td>
|
||||||
|
<td style="width:30px"><button class="btn sm danger" onclick="_members.splice(${i},1);renderMembers(_members)">✕</button></td></tr>`).join('') : '<tr><td class="empty">Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“).</td></tr>';
|
||||||
|
}
|
||||||
|
function addMember(){ window._members.push({email:'', role:'viewer'}); renderMembers(window._members); }
|
||||||
|
async function saveOidc(toastOk){
|
||||||
|
try{
|
||||||
|
await api('PUT', A('/oidc'), {mode:$('#oc-mode').value, auth_mode:$('#oc-auth').value, totp_mode:$('#oc-totp').value, allow_registration:$('#oc-reg').checked,
|
||||||
|
backchannel_logout_uri:$('#oc-bc').value, members:window._members.filter(m=>m.email), members_mode:$('#oc-mm').value});
|
||||||
|
if(toastOk) { toast('OIDC-Konfiguration gespeichert'); sDomain(); } return true;
|
||||||
|
}catch(e){ toast(e.message,1); return false; }
|
||||||
|
}
|
||||||
|
async function syncOidc(apply, rotate){
|
||||||
|
if(!await saveOidc(false)) return;
|
||||||
|
if(rotate && !confirm('Secret rotieren? Das alte Secret ist sofort ungültig – der Container braucht danach einen Deploy.')) return;
|
||||||
|
if(apply && !rotate && !confirm('Jetzt im Login-System schreiben (Client anlegen/anpassen, Freigaben setzen)?')) return;
|
||||||
|
$('#oc-out').innerHTML = '<span class="muted">Frage das Login-System …</span>';
|
||||||
|
try{
|
||||||
|
const r = await api('POST', A('/oidc/sync'), {apply, rotate});
|
||||||
|
const acts = r.actions.map(a=>`<div>• ${esc(a.detail)}</div>`).join('') || (r.skipped?`<div>${esc(r.skipped)}</div>`:'<div>✔ Alles abgeglichen – keine Änderungen nötig.</div>');
|
||||||
|
$('#oc-out').innerHTML = `<div class="banner ${r.ok?(r.actions.length&&!apply?'info':'ok'):'err'}" style="margin:0"><b>${apply?'Abgleich':'Prüfung (nur lesen)'}:</b> ${r.ok?'':'✘ '+esc(r.error)}${acts}${r.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</div>`;
|
||||||
|
if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; }
|
||||||
|
}catch(e){ $('#oc-out').innerHTML = `<div class="banner err" style="margin:0">${esc(e.message)}</div>`; }
|
||||||
|
}
|
||||||
|
async function importMembers(){ try{ const o = await api('POST', A('/oidc/import-members')); renderMembers(o.members); toast(o.members.length+' Freigaben übernommen – speichern nicht vergessen'); }catch(e){ toast(e.message,1) } }
|
||||||
|
async function saveManual(){ const id=$('#o-id').value, sc=$('#o-sec').value; if(id) await api('PUT',A('/secrets/OIDC_CLIENT_ID'),{value:id}); if(sc) await api('PUT',A('/secrets/OIDC_CLIENT_SECRET'),{value:sc}); toast('Gespeichert'); sDomain(); }
|
||||||
|
async function saveDomain(){ try{ await api('PUT', A('/domain'), {subdomain:$('#d-sub').value, custom_domain:$('#d-cust').value}); await load(true); sDomain(); toast('Domain gespeichert'); }catch(e){ toast(e.message,1) } }
|
||||||
|
async function checkDomain(){ $('#dns-msg').textContent='Prüfe …'; const r = await api('POST', A('/domain/check')); await load(true); await sDomain(); $('#dns-msg').textContent = r.message; }
|
||||||
|
async function sSecrets(){
|
||||||
|
const sec = await api('GET', A('/secrets')); renderNav({missing: sec.some(s=>s.required&&!s.set)});
|
||||||
|
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Secrets</h2></div><div class="card"><p class="sub">Verschlüsselt in der Datenbank. Werte werden nie angezeigt und gelangen nie in Agent-Kontext oder Bundle – nur beim Deploy als Env an den Container.</p>
|
||||||
|
<table class="t"><thead><tr><th>Schlüssel</th><th>Status</th><th>Bedeutung</th><th></th></tr></thead><tbody>${sec.map(s=>`<tr><td class="mono nowrap">${esc(s.key)}${s.required?' <span class="badge">Pflicht</span>':''}</td>
|
||||||
|
<td>${s.set?'<span class="badge ok">gesetzt</span>':s.source==='sops-import'?'<span class="badge warn">noch in SOPS</span>':'<span class="badge err">fehlt</span>'}</td><td class="muted">${esc(s.help)}</td>
|
||||||
|
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="setSecret('${s.key}')">Wert setzen</button> ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?`<button class="btn sm" onclick="genSecret('${s.key}')">Neu erzeugen</button>`:''}</td></tr>`).join('')}</tbody></table>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="addSecret()">+ Weiteres Secret</button><span class="hint">Bestehende Tenants: <span class="mono">python scripts/import_tenants.py --with-secrets</span> liest SOPS und verschlüsselt neu.</span></div></div>`;
|
||||||
|
}
|
||||||
|
async function setSecret(k){ const v = prompt('Neuer Wert für '+k+':'); if(v===null) return; await api('PUT', A('/secrets/'+k), {value:v}); toast('Gespeichert'); sSecrets(); }
|
||||||
|
async function addSecret(){ const k = prompt('Name (z. B. MISTRAL_API_KEY):'); if(!k) return; await setSecret(k.trim().toUpperCase()); }
|
||||||
|
async function genSecret(k){ if(!confirm(k+' neu erzeugen? Der Container braucht danach einen Deploy.')) return; await api('PUT', A('/secrets/'+k), {generate:true}); toast('Neu erzeugt'); sSecrets(); }
|
||||||
|
|
||||||
|
async function followDeploy(id, con){
|
||||||
|
con.classList.remove('hide'); con.style.display = 'block';
|
||||||
|
const tick = async () => { const d = await api('GET','/api/deployments/'+id); con.innerHTML = esc(d.log).replace(/\[DRY-RUN\]/g,'<span class="sim">[DRY-RUN]</span>'); con.scrollTop = 1e9;
|
||||||
|
if(d.status==='running'||d.status==='queued') setTimeout(tick,500); else { await load(true); if(ui.sec==='deploy') sDeploy(true); } };
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
async function sDeploy(keepConsole){
|
||||||
|
const [plan, hist] = await Promise.all([api('GET', A('/plan')), api('GET', '/api/deployments')]);
|
||||||
|
const mine = hist.filter(d => d.tenant === TID); const m = plan.mode;
|
||||||
|
const old = keepConsole ? $('#con')?.innerHTML : null;
|
||||||
|
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Deploy</h2></div>
|
||||||
|
${m.live?`<div class="banner ${m.tenant_allowed?'err':'warn'}">Live-Modus aktiv · freigegebene Tenants: <b>${esc(m.allowed)}</b> ${m.tenant_allowed?'– dieser Tenant darf live deployen.':'– dieser Tenant ist <b>nicht</b> freigegeben (nur Dry-Run).'}</div>`:'<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (<span class="mono">DEPLOY_LIVE</span> nicht aktiv). Dry-Run läuft mit demselben Code und zeigt, was geschrieben würde.</div>'}
|
||||||
|
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Vorflug-Check <span class="muted" style="font-weight:400">· ist alles bereit für den Live-Deploy? (liest nur)</span></h3><button class="btn sm" onclick="runPreflight()">Prüfen</button></div><div id="pf"><span class="hint">Noch nicht ausgeführt.</span></div></div>
|
||||||
|
<div class="card" style="margin-top:12px"><h3>Geplanter Rollout</h3>
|
||||||
|
<dl class="kv"><dt>Service</dt><dd class="mono">${esc(plan.service)}</dd><dt>Image</dt><dd class="mono">${esc(plan.patch.image)}</dd><dt>Version</dt><dd>v${S.current_version} ${S.has_draft?'<span class="badge warn">Entwurf wird nicht deployt</span>':''}</dd><dt>Ingress</dt><dd class="mono">${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}</dd>
|
||||||
|
<dt>Voraussetzungen</dt><dd><span class="badge ${m.token?'ok':'warn'}">API-Token</span> <span class="badge ${m.project?'ok':'warn'}">Projekt-ID</span> ${S.source==='admin'?`<span class="badge ${m.core_bundle?'ok':'err'}">Core-Bundle</span>`:''}
|
||||||
|
<span class="badge ${plan.oidc.mode==='off'?'':plan.oidc.key?'ok':'warn'}" title="Wird bei jedem Deploy vorher mit der Login-API abgeglichen">OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}</span></dd></dl>
|
||||||
|
${plan.missing_secrets.length?`<div class="banner warn" style="margin-top:8px">Secrets ohne Wert: <span class="mono">${esc(plan.missing_secrets.join(', '))}</span></div>`:''}
|
||||||
|
<details class="fold"><summary>Service-Definition (PATCH-Body, Secrets maskiert)</summary><div class="in"><pre class="json">${esc(JSON.stringify(plan.patch,null,2))}</pre></div></details>
|
||||||
|
<div class="row" style="margin-top:10px"><button class="btn" onclick="startDeploy(true)">Dry-Run</button><button class="btn danger ${m.live?'':'disabled'}" onclick="rollbackTenant()" title="Auf den vorherigen erfolgreich deployten Core-Tag zurück">Zurückrollen</button><button class="btn cyan ${m.live&&m.tenant_allowed?'':'disabled'}" onclick="startDeploy(false)" title="${!m.live?'Gesperrt: DEPLOY_LIVE nicht aktiv':!m.tenant_allowed?'Gesperrt: Tenant nicht in DEPLOY_ALLOWED_TENANTS':''}">Deployen</button></div>
|
||||||
|
<div class="console hide" id="con" style="margin-top:10px"></div></div>
|
||||||
|
<div class="card" style="margin-top:12px"><h3>Verlauf</h3>${mine.length?`<table class="t"><thead><tr><th>Zeit</th><th>Art</th><th>Version</th><th>Status</th></tr></thead><tbody>${mine.map(d=>`<tr><td>${fmt(d.created_at)}</td><td>${kindLabel[d.kind]||d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version}</td><td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':''}">${d.status}</span></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Noch keine Deployments für diesen Tenant.</div>'}</div>`;
|
||||||
|
if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; }
|
||||||
|
}
|
||||||
|
async function runPreflight(){
|
||||||
|
$('#pf').innerHTML = '<span class="muted">Prüfe (liest Mittwald-Stack, Login-API, Rendering) …</span>';
|
||||||
|
try{
|
||||||
|
const r = await api('GET', A('/preflight'));
|
||||||
|
const dot = {ok:'ok', fail:'no', warn:'no', info:''}; const ic = {ok:'✓', fail:'✕', warn:'!', info:'i'};
|
||||||
|
$('#pf').innerHTML = `<div class="banner ${r.ready?'ok':'err'}" style="margin-bottom:6px">${r.ready?'✔ Bereit für den Live-Deploy (keine harten Fehler)':'✘ Noch nicht bereit – bitte die roten Punkte beheben'} · Ziel <span class="mono">${esc(r.host)}</span> · Core <span class="mono">:${esc(String(r.tag).slice(0,12))}</span></div>`+
|
||||||
|
r.items.map(i=>`<div class="check"><span class="dot ${dot[i.level]}" ${i.level==='warn'?'style="background:#fbf0cf;color:#8a6200"':''}>${ic[i.level]}</span><span><b>${esc(i.label)}</b> <span class="muted">${esc(i.detail)}</span>${i.fix?`<div class="hint">→ ${esc(i.fix)}</div>`:''}</span></div>`).join('');
|
||||||
|
}catch(e){ $('#pf').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
|
||||||
|
}
|
||||||
|
async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } }
|
||||||
|
async function startDeploy(dry){
|
||||||
|
if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return;
|
||||||
|
try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Verlauf ───────── */
|
||||||
|
async function sVersions(){
|
||||||
|
const v = await api('GET', A('/versions'));
|
||||||
|
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Verlauf</h2></div><div class="card"><p class="sub">Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als <b>Entwurf</b> an – nichts wird überschrieben.</p>
|
||||||
|
<table class="t"><thead><tr><th>Version</th><th>Nachricht</th><th>Autor</th><th>Zeit</th><th>Dateien</th><th></th></tr></thead><tbody>${v.map(x=>`<tr><td class="nowrap"><b>v${x.number}</b> ${x.current?'<span class="badge dark">aktuell</span>':''} ${x.deployed?'<span class="badge ok">deployt</span>':''}</td><td>${esc(x.message)}</td><td>${esc(x.author)}</td><td class="nowrap">${fmt(x.created_at)}</td><td>${x.files}</td>
|
||||||
|
<td style="text-align:right" class="nowrap">${x.current?'':`<button class="btn sm" onclick="showDiff('v${x.number}','current','v${x.number} → aktuell')">Diff</button> <button class="btn sm" onclick="restoreV(${x.number})">Als Entwurf</button>`}</td></tr>`).join('')}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
async function showDiff(a,b,t){ const d = await api('GET', A(`/diff?a=${a}&b=${b}`)); $('#diff-title').textContent = 'Diff '+t; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede.</div>'; modal('m-diff', true); }
|
||||||
|
async function restoreV(n){ if(!confirm('v'+n+' als Entwurf wiederherstellen? Ein bestehender Entwurf wird ersetzt.')) return; await api('POST', A(`/versions/${n}/restore`)); await load(true); location.hash = 'brand'; renderSection(); toast('v'+n+' liegt jetzt im Entwurf'); }
|
||||||
|
|
||||||
|
(async () => { await loadSchema(); await load(); initAgent(); })();
|
||||||
13
app/templates/audit.html
Normal file
13
app/templates/audit.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Audit-Log · Onyscribe Admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head"><div><div class="eyebrow">Nachvollziehbarkeit</div><h1 class="title">Audit-Log.</h1>
|
||||||
|
<p class="lead">Wer hat wann was geändert, veröffentlicht oder deployt – inklusive Freigaben von Agent-Vorschlägen.</p></div></div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="t"><thead><tr><th>Zeit</th><th>Wer</th><th>Aktion</th><th>Tenant</th><th>Detail</th></tr></thead><tbody>
|
||||||
|
{% for r in rows %}<tr><td>{{ r.ts|dt }}</td><td>{{ r.actor }}</td><td><span class="badge">{{ r.action }}</span></td><td>{{ r.tenant_id or '–' }}</td><td class="muted">{{ r.detail }}</td></tr>
|
||||||
|
{% else %}<tr><td colspan="5" class="empty">Noch keine Einträge.</td></tr>{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
app/templates/base.html
Normal file
44
app/templates/base.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{% block title %}Onyscribe Admin{% endblock %}</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231a1a1a'/%3E%3Cpath d='M20 22l14 10-14 10' fill='none' stroke='%2300cccc' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="/static/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="side">
|
||||||
|
<div class="brand"><b>onyva</b><span>Scribe Admin</span></div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="{{ 'on' if nav=='dashboard' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg><span>Tenants</span></a>
|
||||||
|
<a href="/deployments" class="{{ 'on' if nav=='deployments' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg><span>Deployments</span></a>
|
||||||
|
<a href="/core" class="{{ 'on' if nav=='core' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 22V12M3 7l9 5 9-5"/></svg><span>Core</span></a>
|
||||||
|
<a href="/settings" class="{{ 'on' if nav=='settings' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10l2 2M19 5l-2 2M7 17l-2 2"/></svg><span>Einstellungen</span></a>
|
||||||
|
<a href="/audit" class="{{ 'on' if nav=='audit' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg><span>Audit-Log</span></a>
|
||||||
|
</nav>
|
||||||
|
<div class="side-foot">
|
||||||
|
<span class="pill {{ 'live' if deploy_live() else 'lock' }}" title="Env DEPLOY_LIVE der Admin-Plattform">Deploy: {{ 'live' if deploy_live() else 'gesperrt · Dry-Run' }}</span>
|
||||||
|
{% set u = request.state.user if request is defined else None %}
|
||||||
|
{% if u and u.email and u.email != 'dev@local' %}<span>{{ u.email }}<br><a href="/auth/logout">Abmelden</a></span>{% else %}<span class="pill dev">Login: aus</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">{% block body %}{% endblock %}</div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<script>
|
||||||
|
function toast(msg, err){const t=document.getElementById('toast');t.textContent=msg;t.className=err?'err':'';t.style.display='block';clearTimeout(window._tt);window._tt=setTimeout(()=>t.style.display='none',3200)}
|
||||||
|
async function api(method, url, body){
|
||||||
|
const o={method,headers:{}};
|
||||||
|
if(body!==undefined){o.headers['Content-Type']='application/json';o.body=JSON.stringify(body)}
|
||||||
|
const r=await fetch(url,o);
|
||||||
|
let d=null;try{d=await r.json()}catch(e){}
|
||||||
|
if(!r.ok){throw new Error((d&&(d.detail||d.message))||('Fehler '+r.status))}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
function modal(id,on){document.getElementById(id).classList.toggle('on',on)}
|
||||||
|
function fmt(d){return d?new Date(d).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}):'–'}
|
||||||
|
</script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
90
app/templates/core.html
Normal file
90
app/templates/core.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Core · Onyscribe Admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head"><div><div class="eyebrow">Zusammenspiel mit dem Core</div><h1 class="title">Core-Releases.</h1>
|
||||||
|
<p class="lead">Die Core-CI meldet neue Images hierher. Die Admin-Plattform prüft damit <b>alle</b> Tenants, gibt nach deiner Freigabe aus und rollt Canary-zuerst aus – mit Health-Check pro Tenant und Rollback.</p></div>
|
||||||
|
<button class="btn" onclick="load()">Aktualisieren</button></div>
|
||||||
|
<div id="warn"></div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card"><h3>Core der Admin-Plattform</h3><dl class="kv" id="admin-core"></dl>
|
||||||
|
<p class="hint" style="margin-top:8px">Vorschau, Sandbox und Formulare nutzen genau diesen Core. Im Container ist das Admin-Image <span class="mono">FROM</span> das Core-Image gebaut – Schriften, Python-Pakete und WeasyPrint sind damit identisch zur Produktion.</p></div>
|
||||||
|
<div class="card"><h3>Rollout-Regeln</h3><dl class="kv" id="rules"></dl>
|
||||||
|
<div class="row" style="margin-top:8px"><label class="f" style="flex:1">Canary-Tenants (zuerst, kommagetrennt)<input type="text" id="canary" placeholder="demo"></label><button class="btn sm" onclick="saveCanary()" style="align-self:flex-end">Speichern</button></div>
|
||||||
|
<p class="hint" style="margin-top:6px">Gepinnte Tenants (Tenant → Übersicht → Core-Version) nehmen nicht teil. Der Rollout bricht beim ersten Fehler ab; Echt-Rollouts brauchen <span class="mono">DEPLOY_LIVE=true</span>.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:12px"><h3>Releases</h3><div id="rel"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-bg" id="m-roll"><div class="modal"><h2 id="roll-title">Rollout</h2><p class="hint" id="roll-hint"></p><table class="t" id="roll-table"><tbody></tbody></table>
|
||||||
|
<div class="acts"><button class="btn" onclick="modal('m-roll',false);load()">Schließen</button></div></div></div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% raw %}<script>
|
||||||
|
let D = null, open_ = {}, timer = null;
|
||||||
|
const stLabel = {new:'neu', checked:'geprüft', approved:'freigegeben', rolling:'wird ausgerollt', active:'aktiv', failed:'fehlgeschlagen', rejected:'abgelehnt', superseded:'abgelöst', rolled_back:'zurückgerollt'};
|
||||||
|
const stCls = {active:'ok', approved:'cyan', checked:'', new:'warn', rolling:'cyan', failed:'err', rejected:'err', superseded:'', rolled_back:'warn'};
|
||||||
|
async function load(){
|
||||||
|
D = await api('GET','/api/core/releases');
|
||||||
|
const a = D.admin_core;
|
||||||
|
document.getElementById('admin-core').innerHTML = `<dt>Core-SHA</dt><dd class="mono">${esc(a.sha.slice(0,12))} <span class="muted">(${esc(a.source)})</span></dd><dt>Gebaut</dt><dd>${a.built_at?fmt(a.built_at):'–'}</dd>
|
||||||
|
<dt>Vertrag</dt><dd>v${a.contract} ${a.supported?'<span class="badge ok">unterstützt</span>':'<span class="badge err">nicht unterstützt</span>'}</dd><dt>Schema</dt><dd>${a.fields??'–'} Felder · ${a.env??'–'} Env-Einträge <span class="muted mono">${esc((a.schema_sha||'').slice(0,8))}</span></dd>`;
|
||||||
|
document.getElementById('rules').innerHTML = `<dt>Aktiver Tag</dt><dd class="mono">:${esc(String(D.active_tag).slice(0,12))}</dd><dt>Release-Eingang</dt><dd>${D.intake?'<span class="badge ok">Token gesetzt</span>':'<span class="badge warn">CORE_RELEASE_TOKEN fehlt</span>'} <span class="hint">CI: Job „notify-admin“</span></dd><dt>Rollout</dt><dd>${D.live?'<span class="badge err">live erlaubt</span>':'<span class="badge warn">gesperrt · nur Dry-Run</span>'}</dd>`;
|
||||||
|
document.getElementById('canary').value = D.canary.join(', ');
|
||||||
|
document.getElementById('warn').innerHTML = a.supported ? '' : '<div class="banner err">Dieser Core nutzt einen Vertrag, den diese Admin-Version nicht kennt – Admin aktualisieren, bevor etwas ausgerollt wird.</div>';
|
||||||
|
renderRel();
|
||||||
|
if(D.releases.some(r=>r.compat&&r.compat.running||r.status==='rolling')){ clearTimeout(timer); timer = setTimeout(load, 2500); }
|
||||||
|
}
|
||||||
|
function matrix(r){
|
||||||
|
const c = r.compat; if(!c) return '<div class="hint">Noch nicht geprüft.</div>';
|
||||||
|
const rows = Object.entries(c.tenants||{}).map(([t,v])=>`<tr><td><b>${esc(t)}</b></td><td><span class="badge ${v.status==='ok'?'ok':v.status==='error'?'err':v.status==='changed'?'warn':''}">${{ok:'unverändert',changed:'Rendering geändert',new:'neu (keine Baseline)',error:'Fehler'}[v.status]}</span></td>
|
||||||
|
<td class="muted">${v.changed.length?esc(v.changed.join(', ')):''}${v.errors.map(e=>`<div class="mono" style="color:var(--err)">${esc(e).slice(0,300)}</div>`).join('')}${v.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</td></tr>`).join('');
|
||||||
|
return `<div class="hint" style="margin:4px 0">${c.running?'Prüfung läuft …':'Geprüft '+fmt(c.checked_at)+' mit Core '+esc((c.core_sha||'').slice(0,10))}${c.forced?' · <b>Abweichender Admin-Core (erzwungen)</b>':''} · ${Object.entries(c.summary||{}).filter(([k,v])=>v).map(([k,v])=>v+'× '+k).join(', ')}</div>
|
||||||
|
<table class="t"><tbody>${rows}</tbody></table>`;
|
||||||
|
}
|
||||||
|
function renderRel(){
|
||||||
|
document.getElementById('rel').innerHTML = D.releases.length ? `<table class="t"><thead><tr><th>Release</th><th>Nachricht</th><th>Gemeldet</th><th>Status</th><th>Admin</th><th></th></tr></thead><tbody>`+D.releases.map(r=>{
|
||||||
|
const c = r.compat, sum = c&&c.summary ? c.summary : null;
|
||||||
|
return `<tr><td class="nowrap"><span class="mono">${esc(r.sha.slice(0,10))}</span> ${r.active?'<span class="badge ok">aktiv</span>':''}<div class="hint">${esc(r.ref)}</div></td><td>${esc(r.message)}</td><td class="nowrap">${fmt(r.announced_at)}</td>
|
||||||
|
<td><span class="badge ${stCls[r.status]||''}">${stLabel[r.status]||r.status}</span>${sum?`<div class="hint">${sum.ok||0} ok · ${sum.changed||0} geändert · ${sum.new||0} neu · ${sum.error||0} Fehler</div>`:''}</td>
|
||||||
|
<td>${r.admin_matches?'<span class="badge ok">gleicher Core</span>':'<span class="badge warn" title="Admin muss FROM diesem Core neu gebaut werden">Admin älter/anders</span>'}</td>
|
||||||
|
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="tg(${r.id})">Matrix</button>
|
||||||
|
<button class="btn sm" onclick="act(${r.id},'check')">Prüfen</button>
|
||||||
|
<button class="btn sm" ${['checked','approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'} onclick="approve(${r.id})">Freigeben</button>
|
||||||
|
<button class="btn sm" onclick="rollout(${r.id},true)">Dry-Run</button>
|
||||||
|
<button class="btn sm cyan ${D.live&&['approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'}" onclick="rollout(${r.id},false)">Ausrollen</button>
|
||||||
|
${r.previous_tag?`<button class="btn sm danger ${D.live?'':'disabled'}" onclick="rollback(${r.id})">Zurückrollen</button>`:''}
|
||||||
|
${['new','checked'].includes(r.status)?`<button class="btn sm danger" onclick="act(${r.id},'reject')">Ablehnen</button>`:''}</td></tr>
|
||||||
|
<tr style="display:${open_[r.id]?'table-row':'none'}"><td colspan="6">${matrix(r)}</td></tr>`}).join('')+'</tbody></table>'
|
||||||
|
: '<div class="empty">Noch kein Release gemeldet. Die Core-CI meldet Images per POST /api/core/releases (Job „notify-admin“).</div>';
|
||||||
|
}
|
||||||
|
function tg(id){ open_[id] = !open_[id]; renderRel(); }
|
||||||
|
async function act(id, what){
|
||||||
|
try{ await api('POST', `/api/core/releases/${id}/${what}`, what==='check' ? {force: !D.releases.find(r=>r.id===id).admin_matches && confirm('Die Admin-Plattform läuft nicht auf diesem Core. Trotzdem mit dem aktuellen Admin-Core prüfen (nur Entwicklung)?')} : {}); toast(what==='check'?'Prüfung gestartet':'ok'); open_[id]=true; load(); }
|
||||||
|
catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
async function approve(id){
|
||||||
|
const r = D.releases.find(x=>x.id===id); const changed = r.compat&&r.compat.summary&&r.compat.summary.changed;
|
||||||
|
if(changed && !confirm(changed+' Tenant(s) rendern anders als zuvor (siehe Matrix). Bewusst freigeben?')) return;
|
||||||
|
try{ await api('POST', `/api/core/releases/${id}/approve`, {acknowledge: !!changed}); toast('Freigegeben'); load(); }catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
async function rollout(id, dry){
|
||||||
|
if(!dry && !confirm('Release jetzt Canary-zuerst auf alle (nicht gepinnten) Tenants ausrollen? Bei einem Fehler wird abgebrochen.')) return;
|
||||||
|
let r; try{ r = await api('POST', `/api/core/releases/${id}/rollout`, {dry_run: dry}); }catch(e){ return toast(e.message,1) }
|
||||||
|
followBatch(r.batch, dry ? 'Dry-Run des Rollouts' : 'Rollout läuft');
|
||||||
|
}
|
||||||
|
async function rollback(id){
|
||||||
|
if(!confirm('Alle Tenants auf den Tag vor diesem Rollout zurückrollen?')) return;
|
||||||
|
try{ const r = await api('POST', `/api/core/releases/${id}/rollback`); followBatch(r.batch, 'Rollback läuft'); }catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
function followBatch(batch, title){
|
||||||
|
document.getElementById('roll-title').textContent = title; document.getElementById('roll-hint').textContent = 'Reihenfolge: Canary zuerst, dann der Rest; Abbruch beim ersten Fehler.'; modal('m-roll', true);
|
||||||
|
const tick = async () => {
|
||||||
|
const rows = await api('GET', '/api/deployments?batch=' + batch);
|
||||||
|
document.querySelector('#roll-table tbody').innerHTML = rows.map(d=>{ const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''; return `<tr><td><b>${esc(d.tenant)}</b></td><td class="mono">:${esc(String(d.image).slice(0,10))}</td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`; }).join('');
|
||||||
|
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick, 800);
|
||||||
|
}; tick();
|
||||||
|
}
|
||||||
|
async function saveCanary(){ try{ await api('PUT','/api/settings',{canary_tenants: document.getElementById('canary').value}); toast('Gespeichert'); }catch(e){ toast(e.message,1) } }
|
||||||
|
load();
|
||||||
|
</script>{% endraw %}
|
||||||
|
{% endblock %}
|
||||||
123
app/templates/dashboard.html
Normal file
123
app/templates/dashboard.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Tenants · Onyscribe Admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head">
|
||||||
|
<div><div class="eyebrow">Übersicht</div><h1 class="title">Tenants.</h1></div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" onclick="probe()" title="Fragt /health jedes Tenants ab: läuft der erwartete Core und das erwartete Bundle?">Zustand prüfen</button>
|
||||||
|
<button class="btn" onclick="deployAll(true)" title="Plan für alle Tenants, ohne Schreibzugriff">Dry-Run aller</button>
|
||||||
|
<button class="btn {{ '' if deploy_live() else 'disabled' }}" onclick="deployAll(false)" title="{{ '' if deploy_live() else 'Gesperrt: DEPLOY_LIVE ist nicht aktiv' }}">Alle neu deployen</button>
|
||||||
|
<button class="btn cyan" onclick="modal('m-new',true)">+ Neuer Tenant</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi click" data-f="all"><b>{{ stats.tenants }}</b><span>Tenants</span></div>
|
||||||
|
<div class="kpi click" data-f="admin"><b>{{ stats.admin }}</b><span>Quelle Admin</span></div>
|
||||||
|
<div class="kpi click" data-f="outdated"><b>{{ stats.outdated }}</b><span>Deploy veraltet</span></div>
|
||||||
|
<div class="kpi click" data-f="draft"><b>{{ stats.drafts }}</b><span>Entwürfe</span></div>
|
||||||
|
<div class="kpi click" data-f="secrets"><b>{{ stats.secrets }}</b><span>Secrets offen</span></div>
|
||||||
|
<div class="kpi"><b class="mono" style="font-size:13px">:{{ tag }}</b><span>Core {{ core }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:10px 12px">
|
||||||
|
<div class="row" style="margin:2px 2px 8px">
|
||||||
|
<input class="search" id="q" placeholder="Tenant suchen …" autofocus>
|
||||||
|
<div class="chipbar" id="chips">
|
||||||
|
{% for k,l in [('all','Alle'),('draft','Entwurf'),('outdated','Deploy veraltet'),('secrets','Secrets offen'),('git','Git'),('admin','Admin'),('code','mit Hook-Code'),('paused','pausiert / archiviert')] %}<span class="fchip {{ 'on' if k=='all' }}" data-f="{{ k }}">{{ l }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="t hover" id="tt"><thead><tr><th>Tenant</th><th>Version</th><th>Quelle</th><th>Marke</th><th>Anpassungen</th><th>Ressourcen</th><th>Läuft</th><th>Hinweise</th></tr></thead><tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr onclick="location.href='/tenants/{{ r.t.id }}'" data-q="{{ (r.t.id ~ ' ' ~ r.t.name ~ ' ' ~ r.host)|lower }}"
|
||||||
|
data-flags="{{ 'draft ' if r.t.draft }}{{ 'outdated ' if r.outdated }}{{ 'secrets ' if r.missing_secrets }}{{ r.t.source }} {{ 'code ' if r.has_code }}{{ 'paused ' if r.t.status!='active' }}">
|
||||||
|
<td><div class="row" style="flex-wrap:nowrap"><div class="logo-box">{% if r.logo %}<img src="{{ r.logo }}" alt="">{% endif %}</div>
|
||||||
|
<div><b>{{ r.t.name }}</b>{% if r.t.status!='active' %} <span class="badge warn">{{ r.t.status }}</span>{% endif %}<br><span class="muted">{{ r.host }}</span></div></div></td>
|
||||||
|
<td class="nowrap"><span class="badge dark">v{{ r.t.current_version }}</span>
|
||||||
|
<span class="muted"><span class="dotc {{ 'warn' if r.outdated else 'ok' }}"></span>{{ 'deployt v'~(r.t.deployed_version or '–') if r.outdated else 'deployt' }}</span></td>
|
||||||
|
<td><span class="badge {{ 'cyan' if r.t.source=='admin' else '' }}">{{ 'Admin' if r.t.source=='admin' else 'Git' }}</span></td>
|
||||||
|
<td><span class="swatches">{% for k in ['accent','accent_dark','accent_light','bg','red'] %}<i style="background:{{ r.cfg.get('colors',{}).get(k,'#ddd') }}"></i>{% endfor %}</span></td>
|
||||||
|
<td class="muted">STT {{ r.stt }}{% if r.custom_prompt %} · Prompt{% endif %}{% if r.custom_pdf %} · PDF{% endif %}{% if r.has_code %} · <span style="color:#8a6200;font-weight:700">Code</span>{% endif %}</td>
|
||||||
|
<td class="muted nowrap">{{ r.t.cpu }} CPU · {{ r.t.memory }}</td>
|
||||||
|
<td class="run nowrap" data-t="{{ r.t.id }}"><span class="muted">–</span></td>
|
||||||
|
<td>{% if r.t.draft %}<span class="badge warn">Entwurf</span> {% endif %}{% if r.missing_secrets %}<span class="badge err">{{ r.missing_secrets }} Secret(s)</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table>
|
||||||
|
<div class="empty hide" id="none">Keine Tenants für diesen Filter.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:12px"><h3>Letzte Deployments <a href="/deployments" class="muted" style="font-weight:400;font-size:12px">· alle ansehen</a></h3>
|
||||||
|
{% if last %}<table class="t"><tbody>{% for d in last %}<tr><td class="nowrap">{{ d.created_at|dt }}</td><td><b>{{ d.tenant_id or 'alle' }}</b></td><td>{{ d.kind }}</td><td>v{{ d.version }}</td>
|
||||||
|
<td><span class="badge {{ 'ok' if d.status=='success' else 'err' if d.status in ('failed',) else '' }}">{{ d.status }}</span></td></tr>{% endfor %}</tbody></table>
|
||||||
|
{% else %}<div class="empty">Noch keine Deployments.</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-bg" id="m-new"><div class="modal" style="max-width:520px">
|
||||||
|
<h2>Neuer Tenant</h2>
|
||||||
|
<div class="fgrid">
|
||||||
|
<label class="f">Name<input type="text" id="n-name" placeholder="z. B. Musterklinik GmbH"></label>
|
||||||
|
<label class="f">ID (Slug)<input type="text" id="n-id" placeholder="musterklinik"></label>
|
||||||
|
<label class="f full">Ausgangspunkt
|
||||||
|
<select id="n-clone"><option value="">Leer – Core-Standard (Prompt, PDF-Layout, Farben)</option>
|
||||||
|
{% for r in rows %}<option value="{{ r.t.id }}">Kopie von {{ r.t.name }}</option>{% endfor %}</select></label>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-top:8px">SESSION_SECRET und Bundle-Token werden erzeugt. OIDC-Client und Cost-Key folgen unter „Domain & Login“ / „Secrets“.</p>
|
||||||
|
<div class="acts"><button class="btn" onclick="modal('m-new',false)">Abbrechen</button><button class="btn cyan" onclick="createTenant()">Anlegen</button></div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="modal-bg" id="m-all"><div class="modal">
|
||||||
|
<h2 id="all-title">Rollout</h2><p class="hint" id="all-hint"></p>
|
||||||
|
<table class="t" id="all-table"><tbody></tbody></table>
|
||||||
|
<div class="acts"><button class="btn" onclick="modal('m-all',false);location.reload()">Schließen</button></div>
|
||||||
|
</div></div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let flt='all';
|
||||||
|
function apply(){
|
||||||
|
const q=document.getElementById('q').value.toLowerCase().trim(); let n=0;
|
||||||
|
document.querySelectorAll('#tt tbody tr').forEach(tr=>{
|
||||||
|
const ok=(!q||tr.dataset.q.includes(q))&&(flt==='all'||tr.dataset.flags.split(' ').includes(flt)); tr.style.display=ok?'':'none'; n+=ok;});
|
||||||
|
document.getElementById('none').classList.toggle('hide',n>0);
|
||||||
|
document.querySelectorAll('#chips .fchip').forEach(c=>c.classList.toggle('on',c.dataset.f===flt));
|
||||||
|
}
|
||||||
|
document.getElementById('q').addEventListener('input',apply);
|
||||||
|
document.querySelectorAll('[data-f]').forEach(e=>e.addEventListener('click',()=>{flt=e.dataset.f;apply()}));
|
||||||
|
document.getElementById('n-name').addEventListener('input',e=>{
|
||||||
|
const idEl=document.getElementById('n-id'); if(idEl.dataset.touched) return;
|
||||||
|
idEl.value=e.target.value.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,38);
|
||||||
|
});
|
||||||
|
document.getElementById('n-id').addEventListener('input',e=>e.target.dataset.touched=1);
|
||||||
|
async function probe(){
|
||||||
|
document.querySelectorAll('td.run').forEach(td=>td.innerHTML='<span class="muted">…</span>');
|
||||||
|
try{
|
||||||
|
const r = await api('GET','/api/status?refresh=1');
|
||||||
|
document.querySelectorAll('td.run').forEach(td=>{ const x=r[td.dataset.t]; if(!x){ td.innerHTML='<span class="muted">–</span>'; return; }
|
||||||
|
td.innerHTML = x.reachable && !x.drift.length ? `<span class="badge ok" title="Core ${esc(x.core?.version||'')}">${esc((x.core?.version||'?').slice(0,7))}${x.bundle?.source==='admin'?' · b'+x.bundle.version:''}</span>` : `<span class="badge err" title="${esc(x.drift.join(' · '))}">${esc(x.drift[0]||'Abweichung')}</span>`; });
|
||||||
|
toast('Zustand aktualisiert');
|
||||||
|
}catch(e){ toast(e.message,1) }
|
||||||
|
}
|
||||||
|
async function createTenant(){
|
||||||
|
try{const r=await api('POST','/api/tenants',{id:document.getElementById('n-id').value,name:document.getElementById('n-name').value,clone_from:document.getElementById('n-clone').value||null});location.href='/tenants/'+r.id+'#brand'}
|
||||||
|
catch(e){toast(e.message,1)}
|
||||||
|
}
|
||||||
|
async function deployAll(dry){
|
||||||
|
if(!dry && !confirm('Alle aktiven Tenants nacheinander neu deployen? Beim ersten Fehler wird abgebrochen.')) return;
|
||||||
|
let r; try{r=await api('POST','/api/deploy-all',{dry_run:dry})}catch(e){return toast(e.message,1)}
|
||||||
|
document.getElementById('all-title').textContent=dry?'Dry-Run aller Tenants':'Rollout aller Tenants';
|
||||||
|
document.getElementById('all-hint').textContent=dry?'Es wird nichts geschrieben – nur der Plan geprüft.':'Sequenziell mit Health-Check; Abbruch beim ersten Fehler.';
|
||||||
|
modal('m-all',true);
|
||||||
|
const tick=async()=>{
|
||||||
|
const rows=await api('GET','/api/deployments?batch='+r.batch);
|
||||||
|
document.querySelector('#all-table tbody').innerHTML=rows.map(d=>{
|
||||||
|
const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10);
|
||||||
|
const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':'';
|
||||||
|
return `<tr><td><b>${esc(d.tenant)}</b></td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`}).join('');
|
||||||
|
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick,700);
|
||||||
|
};tick();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
31
app/templates/deployments.html
Normal file
31
app/templates/deployments.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Deployments · Onyscribe Admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head">
|
||||||
|
<div><div class="eyebrow">Rollout</div><h1 class="title">Deployments.</h1>
|
||||||
|
<p class="lead">Differenzielles PATCH auf den Mittwald-Stack (nie PUT), Namensraum <span class="mono">app-</span>, Guard für fremde Container, Abbruch beim ersten Fehler.</p></div>
|
||||||
|
<div class="row"><button class="btn" onclick="run(true)">Dry-Run aller</button><button class="btn cyan {{ '' if deploy_live() else 'disabled' }}" onclick="run(false)">Alle neu deployen</button></div>
|
||||||
|
</div>
|
||||||
|
{% if not deploy_live() %}<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (Env <span class="mono">DEPLOY_LIVE</span> der Admin-Plattform ist nicht <span class="mono">true</span>). Dry-Runs laufen mit demselben Code und zeigen exakt, was geschrieben würde.</div>{% endif %}
|
||||||
|
<div class="card" style="margin-bottom:12px"><div class="row"><b>Core-Image</b><span class="badge dark mono">:{{ tag }}</span>
|
||||||
|
<span class="muted">Image-Tag und Registry unter Einstellungen. Nach dem Core-Umbau holen Tenants ihr Bundle zur Laufzeit – dann genügt ein Neustart statt Image-Build.</span></div></div>
|
||||||
|
<div class="card"><h3>Verlauf</h3><div id="list"></div></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let open_={};
|
||||||
|
async function load(){
|
||||||
|
const rows=await api('GET','/api/deployments');
|
||||||
|
document.getElementById('list').innerHTML=rows.length?`<table class="t"><thead><tr><th>#</th><th>Zeit</th><th>Tenant</th><th>Art</th><th>Version</th><th>Image</th><th>Status</th><th></th></tr></thead><tbody>`+rows.map(d=>`
|
||||||
|
<tr><td>${d.id}</td><td class="nowrap">${fmt(d.created_at)}</td><td><b>${esc(d.tenant||'alle')}</b></td><td>${d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version??'–'}</td><td class="mono">:${esc(d.image)}</td>
|
||||||
|
<td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''}">${d.status}</span></td><td><button class="btn sm" onclick="tg(${d.id})">Log</button></td></tr>
|
||||||
|
<tr style="display:${open_[d.id]?'table-row':'none'}"><td colspan="8"><div class="console">${esc(d.log||'(leer)')}</div></td></tr>`).join('')+'</tbody></table>':'<div class="empty">Noch keine Deployments.</div>';
|
||||||
|
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(load,900);
|
||||||
|
}
|
||||||
|
function tg(id){open_[id]=!open_[id];load()}
|
||||||
|
async function run(dry){ if(!dry&&!confirm('Alle aktiven Tenants nacheinander neu deployen?'))return; try{await api('POST','/api/deploy-all',{dry_run:dry});toast('Rollout gestartet');load()}catch(e){toast(e.message,1)} }
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
133
app/templates/settings.html
Normal file
133
app/templates/settings.html
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Einstellungen · Onyscribe Admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head"><div><div class="eyebrow">Plattform</div><h1 class="title">Einstellungen.</h1>
|
||||||
|
<p class="lead">Geteilte Werte für alle Tenants – bisher Gitea-„Variables“ und <span class="mono">secrets/shared.enc.yaml</span>.</p></div>
|
||||||
|
<button class="btn cyan" onclick="save()">Alles speichern</button></div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card"><h3>Hosting & Deploy</h3>
|
||||||
|
<div class="fgrid">
|
||||||
|
<label class="f">Basis-Domain<input type="text" data-k="base_domain" value="{{ v.base_domain }}"></label>
|
||||||
|
<label class="f">Core-Image-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}"></label>
|
||||||
|
<label class="f">Mittwald PROJECT_ID<input type="text" data-k="mittwald_project_id" value="{{ v.mittwald_project_id }}" placeholder="p-xxxxxx"></label>
|
||||||
|
<label class="f">Mittwald STACK_ID<input type="text" data-k="mittwald_stack_id" value="{{ v.mittwald_stack_id }}" placeholder="s-xxxxxx (optional)"></label>
|
||||||
|
<label class="f">Registry<input type="text" data-k="registry" value="{{ v.registry }}" placeholder="git.example.de/owner"></label>
|
||||||
|
<label class="f">Image-Name<input type="text" data-k="image_name" value="{{ v.image_name }}"></label>
|
||||||
|
<label class="f full">Öffentliche URL der Admin-Plattform (für ADMIN_URL im Container)<input type="text" data-k="admin_public_url" value="{{ v.admin_public_url }}" placeholder="https://admin.example.de"></label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<div class="check"><span class="dot {{ 'ok' if mode.token else 'no' }}">{{ '✓' if mode.token else '!' }}</span><span><span class="mono">MITTWALD_API_TOKEN</span> {{ 'gesetzt (lesende Dry-Run-Aufrufe möglich)' if mode.token else 'nicht gesetzt (Dry-Run ohne Lesezugriff)' }}</span></div>
|
||||||
|
<div class="check"><span class="dot {{ 'ok' if mode.live else 'no' }}">{{ '✓' if mode.live else '–' }}</span><span><span class="mono">DEPLOY_LIVE</span> {{ 'true – echte Deployments erlaubt' if mode.live else 'nicht aktiv – Schreibzugriffe gesperrt' }}</span></div>
|
||||||
|
<div class="check"><span class="dot {{ 'ok' if mode.allowed != 'keiner' else 'no' }}">{{ '✓' if mode.allowed != 'keiner' else '–' }}</span><span><span class="mono">DEPLOY_ALLOWED_TENANTS</span> = <b>{{ mode.allowed }}</b> <span class="hint">– nur diese Tenants werden live angefasst (Mittwald, Login-API)</span></span></div>
|
||||||
|
<label class="chk"><input type="checkbox" data-kb="core_supports_bundle" {{ 'checked' if v.core_supports_bundle=='true' }}><span><b>Core kann Bundle laden</b> <span class="hint">(<span class="mono">TENANT_SOURCE=admin</span>) – erst nach dem Core-Umbau aktivieren. Solange aus, sind Deploys von Tenants mit Quelle „Admin“ gesperrt.</span></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>Login-API (OIDC-Clients automatisch verwalten)</h3>
|
||||||
|
<div class="check"><span class="dot {{ 'ok' if oidc.api_key else 'no' }}">{{ '✓' if oidc.api_key else '!' }}</span><span><span class="mono">ONYVA_LOGIN_API_KEY</span> {{ 'gesetzt' if oidc.api_key else 'nicht gesetzt – Abgleich beim Deploy nur als Hinweis (Dry-Run) bzw. Fehler (live)' }}</span></div>
|
||||||
|
<div class="check"><span class="dot {{ 'ok' if oidc.writes_allowed else 'no' }}">{{ '✓' if oidc.writes_allowed else '–' }}</span><span><span class="mono">OIDC_SYNC_LIVE</span> / <span class="mono">DEPLOY_LIVE</span> {{ 'aktiv – Schreiben im Login erlaubt' if oidc.writes_allowed else 'nicht aktiv – nur Lesen/Plan' }}</span></div>
|
||||||
|
<div class="fgrid" style="margin-top:6px"><label class="f full">API-Basis (leer = Issuer + /api/v1)<input type="text" data-k="onyva_login_api_base" value="{{ v.onyva_login_api_base }}" placeholder="{{ oidc.api_base }}"></label></div>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="testOidc()" {{ '' if oidc.api_key else 'disabled' }}>Verbindung testen</button><span class="hint" id="oidc-info">Liest GET /apps mit dem Key. Der Key liegt als Env der Admin-Plattform (wie ein Admin-Passwort), nie in der DB.</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>Login (OIDC) & Limits</h3>
|
||||||
|
<div class="fgrid">
|
||||||
|
<label class="f">Issuer (Tenant-Container)<input type="text" data-k="oidc_issuer" value="{{ v.oidc_issuer }}"></label>
|
||||||
|
<label class="f">Auth in Tenant-Containern<select data-k="auth_enabled"><option {{ 'selected' if v.auth_enabled=='true' }}>true</option><option {{ 'selected' if v.auth_enabled=='false' }}>false</option></select></label>
|
||||||
|
<label class="f">Cookie secure<select data-k="app_cookie_secure"><option {{ 'selected' if v.app_cookie_secure=='true' }}>true</option><option {{ 'selected' if v.app_cookie_secure=='false' }}>false</option></select></label>
|
||||||
|
<label class="f">Max. Dateigröße (MB)<input type="number" data-k="max_file_size_mb" value="{{ v.max_file_size_mb }}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="banner info" style="margin:10px 0 0">Der Login der Admin-Plattform selbst wird per Env konfiguriert (<span class="mono">AUTH_ENABLED, OIDC_CLIENT_ID/SECRET, OIDC_REDIRECT_URI, ADMIN_ALLOWED_EMAILS</span>) – siehe README.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>LLM & Transkription (Cortecs)</h3>
|
||||||
|
<div class="fgrid">
|
||||||
|
<label class="f full">Cortecs Base-URL<input type="text" data-k="cortecs_base_url" value="{{ v.cortecs_base_url }}"></label>
|
||||||
|
<label class="f">Protokoll-Modell (LLM_MODEL)<input type="text" data-k="llm_model" value="{{ v.llm_model }}"></label>
|
||||||
|
<label class="f">LLM_MAX_TOKENS / TEMPERATURE<div class="row" style="flex-wrap:nowrap"><input type="number" data-k="llm_max_tokens" value="{{ v.llm_max_tokens }}"><input type="text" data-k="llm_temperature" value="{{ v.llm_temperature }}" style="width:70px"></div></label>
|
||||||
|
<label class="f">Mistral-Modell<input type="text" data-k="mistral_transcribe_model" value="{{ v.mistral_transcribe_model }}"></label>
|
||||||
|
<label class="f">Mistral-Timeout (s)<input type="number" data-k="mistral_timeout" value="{{ v.mistral_timeout }}"></label>
|
||||||
|
</div>
|
||||||
|
<h3 style="margin-top:12px">Geteilte Secrets</h3>
|
||||||
|
{% for k in ['CORTECS_API_KEY','SMTP_PASSWORD'] %}
|
||||||
|
<div class="row" style="padding:3px 0"><span class="mono" style="width:150px">{{ k }}</span>
|
||||||
|
<span class="badge {{ 'ok' if shared.get(k) else 'warn' }}">{{ 'gesetzt' if shared.get(k) else 'extern (SOPS) – noch nicht übernommen' }}</span>
|
||||||
|
<button class="btn sm" onclick="setShared('{{ k }}')">Wert setzen</button></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>KI-Agent (Admin-Chat)</h3>
|
||||||
|
<div class="row" style="margin-bottom:8px"><span class="badge {{ 'ok' if llm_ready else 'warn' }}">{{ 'Cortecs-Key vorhanden – echter Agent' if llm_ready else 'kein Key – Demo-Modus' }}</span></div>
|
||||||
|
<div class="fgrid">
|
||||||
|
<label class="f">Agent-Modell<input type="text" data-k="agent_model" id="agent_model" value="{{ v.agent_model }}" list="models"></label>
|
||||||
|
<label class="f">Protokoll<select data-k="agent_api"><option value="messages" {{ 'selected' if v.agent_api=='messages' }}>Anthropic Messages (/v1/messages)</option><option value="chat" {{ 'selected' if v.agent_api=='chat' }}>OpenAI Chat Completions</option></select></label>
|
||||||
|
</div>
|
||||||
|
<datalist id="models"></datalist>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="loadModels()">Modelle von Cortecs laden</button><span class="hint" id="models-info">Liest GET /v1/models mit dem geteilten Key und markiert Modelle mit Tool-Unterstützung.</span></div>
|
||||||
|
<div id="models-list" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>E-Mail (SMTP)</h3>
|
||||||
|
<div class="fgrid"><label class="f">SMTP_HOST<input type="text" data-k="smtp_host" value="{{ v.smtp_host }}"></label><label class="f">SMTP_PORT<input type="number" data-k="smtp_port" value="{{ v.smtp_port }}"></label>
|
||||||
|
<label class="f">SMTP_USER<input type="text" data-k="smtp_user" value="{{ v.smtp_user }}"></label><label class="f">SMTP_FROM<input type="text" data-k="smtp_from" value="{{ v.smtp_from }}"></label></div>
|
||||||
|
<h3 style="margin-top:12px">Kostenerfassung</h3>
|
||||||
|
<div class="fgrid"><label class="f">COST_API_BASE<input type="text" data-k="cost_api_base" value="{{ v.cost_api_base }}"></label><label class="f">COST_TRACKING_ENABLED<input type="text" data-k="cost_tracking_enabled" value="{{ v.cost_tracking_enabled }}" placeholder="true"></label></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="grid-column:1/-1"><h3>Weitere Core-Einstellungen <span class="muted" style="font-weight:400">· aus dem Schema des Cores erzeugt, gelten für alle Tenants</span></h3>
|
||||||
|
<p class="sub">Alles, was der Core als geteilte Einstellung deklariert und oben noch keine eigene Maske hat. Neue Core-Variablen erscheinen hier automatisch und gehen beim Deploy ins Container-Env. Leer = Standard des Cores.</p>
|
||||||
|
<div id="dyn"><span class="muted">Lade …</span></div></div>
|
||||||
|
|
||||||
|
<div class="card"><h3>Rollout</h3>
|
||||||
|
<div class="fgrid"><label class="f">Canary-Tenants (kommagetrennt)<input type="text" data-k="canary_tenants" value="{{ v.canary_tenants }}" placeholder="demo"></label><label class="f">Aktiver Core-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}" disabled></label></div>
|
||||||
|
<p class="hint" style="margin-top:6px">Der aktive Tag ändert sich durch Core-Releases (Menü „Core“).</p></div>
|
||||||
|
|
||||||
|
<div class="card"><h3>Core</h3>
|
||||||
|
<dl class="kv"><dt>Vendored Core</dt><dd class="mono">{{ core }}</dd><dt>Bedeutung</dt><dd>Live-Vorschau und Validierung führen genau diesen Stand aus (<span class="mono">vendor/platform</span>, aktualisieren mit <span class="mono">scripts/sync_core.sh</span>).</dd></dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function save(){
|
||||||
|
const body={};document.querySelectorAll('[data-k]').forEach(e=>body[e.dataset.k]=e.value);
|
||||||
|
document.querySelectorAll('[data-kb]').forEach(e=>body[e.dataset.kb]=e.checked?'true':'false');
|
||||||
|
try{await api('PUT','/api/settings',body);toast('Gespeichert')}catch(e){toast(e.message,1)}
|
||||||
|
}
|
||||||
|
async function setShared(k){
|
||||||
|
const v=prompt('Neuer Wert für '+k+' (wird verschlüsselt gespeichert):'); if(v===null)return;
|
||||||
|
try{await api('PUT','/api/shared-secrets/'+k,{value:v});toast('Gespeichert');location.reload()}catch(e){toast(e.message,1)}
|
||||||
|
}
|
||||||
|
async function testOidc(){
|
||||||
|
const i=document.getElementById('oidc-info'); i.textContent='Teste …';
|
||||||
|
try{const r=await api('POST','/api/oidc/test'); i.textContent='✔ Verbindung ok – '+r.apps+' App(s) im Login'}catch(e){i.textContent='✘ '+e.message}
|
||||||
|
}
|
||||||
|
async function loadDyn(){
|
||||||
|
const box=document.getElementById('dyn');
|
||||||
|
try{
|
||||||
|
const [c, cur] = await Promise.all([api('GET','/api/contract'), api('GET','/api/settings')]);
|
||||||
|
if(!c.schema){ box.innerHTML='<div class="hint">Dieser Core liefert kein Schema – nur die festen Einstellungen oben.</div>'; return; }
|
||||||
|
const have=new Set([...document.querySelectorAll('[data-k]')].map(e=>e.dataset.k));
|
||||||
|
const rest=c.schema.env.filter(e=>e.kind==='setting'&&e.scope==='shared'&&!have.has(e.setting));
|
||||||
|
const groups={}; rest.forEach(e=>(groups[e.group]??=[]).push(e));
|
||||||
|
const field=e=>{const val=cur[e.setting]??''; const ph=e.default??''; const inp=e.type==='bool'?`<select data-k="${esc(e.setting)}"><option value="" ${val===''?'selected':''}>Standard (${esc(ph||'Core')})</option><option ${val==='true'?'selected':''}>true</option><option ${val==='false'?'selected':''}>false</option></select>`:`<input type="text" data-k="${esc(e.setting)}" value="${esc(val)}" placeholder="${esc(ph||'Standard')}">`;
|
||||||
|
return `<label class="f">${esc(e.label)} <span class="mono" style="text-transform:none;letter-spacing:0">${esc(e.name)}</span>${inp}${e.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(e.help)}</span>`:''}</label>`;};
|
||||||
|
box.innerHTML=Object.entries(groups).map(([g,list])=>{const adv=list.filter(e=>e.advanced), basic=list.filter(e=>!e.advanced);
|
||||||
|
return `<h3 style="margin:10px 0 4px;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1.2px">${esc(g)}</h3>${basic.length?`<div class="fgrid">${basic.map(field).join('')}</div>`:''}${adv.length?`<details class="fold"><summary>Erweitert (${adv.length})</summary><div class="in"><div class="fgrid">${adv.map(field).join('')}</div></div></details>`:''}`}).join('')||'<div class="hint">Alle Core-Einstellungen haben oben eine eigene Maske.</div>';
|
||||||
|
}catch(e){ box.innerHTML='<div class="hint">Schema nicht ladbar: '+esc(e.message)+'</div>'; }
|
||||||
|
}
|
||||||
|
loadDyn();
|
||||||
|
async function loadModels(){
|
||||||
|
const info=document.getElementById('models-info'); info.textContent='Lade …';
|
||||||
|
try{
|
||||||
|
const m=await api('GET','/api/llm/models');
|
||||||
|
document.getElementById('models').innerHTML=m.map(x=>`<option value="${esc(x.id)}">`).join('');
|
||||||
|
document.getElementById('models-list').innerHTML=`<div class="chipbar">${m.map(x=>`<span class="fchip" onclick="document.getElementById('agent_model').value='${esc(x.id)}'" title="${x.tools===true?'Tool-Calling: ja':x.tools===false?'Tool-Calling: nein':'Tool-Calling: unbekannt – testen'}">${esc(x.id)} ${x.tools===true?'🔧':x.tools===false?'✖':'?'}</span>`).join('')}</div>`;
|
||||||
|
info.textContent=m.length+' Modelle · 🔧 = Tools laut Cortecs, ? = unbekannt. Klick übernimmt die ID.';
|
||||||
|
}catch(e){info.textContent=e.message}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
51
app/templates/tenant.html
Normal file
51
app/templates/tenant.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ t.name }} · Onyscribe Admin{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="thead">
|
||||||
|
<div class="logo-box" id="h-logo">{% if logo %}<img src="{{ logo }}" alt="">{% endif %}</div>
|
||||||
|
<div class="grow" style="min-width:160px">
|
||||||
|
<div class="crumb"><a href="/">Tenants</a> / <span class="mono">{{ t.id }}</span></div>
|
||||||
|
<h1 id="h-name">{{ t.name }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="row" id="h-badges"></div>
|
||||||
|
<div class="row" id="h-actions"></div>
|
||||||
|
<div class="seg" id="h-panel" title="Mitte: Formular oder KI-Agent – die Vorschau rechts bleibt sichtbar">
|
||||||
|
<button data-p="edit">Bearbeiten</button><button data-p="agent">KI-Agent</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl open" id="tl">
|
||||||
|
<nav class="snav" id="snav"></nav>
|
||||||
|
<div class="mid">
|
||||||
|
<main class="pane" id="pane"><div class="empty">Lade …</div></main>
|
||||||
|
<section class="chat agpane hide" id="ag-wrap">
|
||||||
|
<div class="pbar" style="margin-bottom:6px"><span class="badge" id="ag-mode">Agent</span><span class="hint grow" id="ag-usage"></span><button class="btn sm" title="Verlauf löschen" onclick="clearChat()">Verlauf leeren</button></div>
|
||||||
|
<div class="msgs" id="msgs"></div>
|
||||||
|
<div class="chips" id="ag-chips"></div>
|
||||||
|
<div class="chat-in"><textarea id="chat-text" rows="2" placeholder="Was soll angepasst werden? (Farben, Prompt, PDF-Layout, Flags …) – die Vorschau rechts zeigt jede Änderung sofort."></textarea><button class="btn cyan" id="chat-send" onclick="sendChat()">Senden</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<aside class="panel" id="panel">
|
||||||
|
<div class="pbar">
|
||||||
|
<div class="seg" id="pv-kind"><button data-k="web" class="on">Web-App</button><button data-k="pdf">PDF</button></div>
|
||||||
|
<div class="seg" id="pv-dev"><button data-d="" class="on">Desktop</button><button data-d="tablet">Tablet</button><button data-d="mobile">Mobil</button></div>
|
||||||
|
<div class="seg" id="pv-src"><button data-s="draft" class="on">Entwurf</button><button data-s="current">Veröffentlicht</button><button data-s="agent" id="pv-agent" class="hide">Agent-Stand</button></div>
|
||||||
|
<span class="grow"></span><span class="hint" id="pv-info"></span><button class="btn sm" onclick="refreshPreview(true)" title="Neu rendern">↻</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-frame" id="pv-frame">
|
||||||
|
<iframe id="f-web" sandbox="allow-scripts allow-popups" title="Web-Vorschau"></iframe>
|
||||||
|
<iframe id="f-pdf" style="display:none" title="PDF-Vorschau"></iframe>
|
||||||
|
<div class="pv-load" id="pv-load">Rendert mit dem echten Core …</div><div class="pv-err" id="pv-err"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-bg" id="m-pub"><div class="modal">
|
||||||
|
<h2>Änderungen prüfen & veröffentlichen</h2><div id="pub-body"></div>
|
||||||
|
<div class="acts"><button class="btn" onclick="modal('m-pub',false)">Abbrechen</button><button class="btn cyan" id="pub-go" onclick="publish()">Als neue Version veröffentlichen</button></div>
|
||||||
|
</div></div>
|
||||||
|
<div class="modal-bg" id="m-diff"><div class="modal"><h2 id="diff-title">Diff</h2><div id="diff-body"></div><div class="acts"><button class="btn" onclick="modal('m-diff',false)">Schließen</button></div></div></div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>const TID={{ t.id|tojson }};</script>
|
||||||
|
<script src="/static/tenant.js"></script>
|
||||||
|
{% endblock %}
|
||||||
32
app/templating.py
Normal file
32
app/templating.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.settings import APP_NAME, ROOT
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(ROOT / "app" / "templates"))
|
||||||
|
BERLIN = ZoneInfo("Europe/Berlin")
|
||||||
|
|
||||||
|
|
||||||
|
def dt(value: datetime | None, fmt: str = "%d.%m.%Y %H:%M") -> str:
|
||||||
|
if not value:
|
||||||
|
return "–"
|
||||||
|
if value.tzinfo is None:
|
||||||
|
from datetime import timezone
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(BERLIN).strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.filters["dt"] = dt
|
||||||
|
templates.env.globals["APP_NAME"] = APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _live() -> bool:
|
||||||
|
from app.services import deploy
|
||||||
|
return deploy.live_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.globals["deploy_live"] = _live
|
||||||
6
docker-compose.override.yml
Normal file
6
docker-compose.override.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Nur lokal (docker compose lädt diese Datei automatisch dazu): Port auf dem Host veröffentlichen.
|
||||||
|
# Coolify nutzt sie nicht – dort läuft der Zugriff über den Proxy (Domain auf Service „admin“, Port 8300).
|
||||||
|
services:
|
||||||
|
admin:
|
||||||
|
ports:
|
||||||
|
- "${ADMIN_PORT:-8300}:8300"
|
||||||
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Onyscribe Admin – Admin-Oberfläche + Postgres. Für Coolify (Docker-Compose-Ressource) und lokal.
|
||||||
|
# Pflicht-Env: POSTGRES_PASSWORD, SECRET_KEY
|
||||||
|
# SECRET_KEY erzeugen: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# Backups: Volume `pgdata` (Postgres) sichern – darin liegt ALLES (Tenants, Versionen, verschlüsselte Secrets).
|
||||||
|
# ACHTUNG: SECRET_KEY ebenfalls sichern – ohne ihn sind die Secrets in der DB nicht entschlüsselbar.
|
||||||
|
services:
|
||||||
|
admin:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
# Standard: vendored Core (lokal). Produktion: ADMIN_DOCKERFILE=Dockerfile.fromcore + CORE_IMAGE=<registry>/transkript-platform:<tag>
|
||||||
|
dockerfile: ${ADMIN_DOCKERFILE:-Dockerfile}
|
||||||
|
args:
|
||||||
|
CORE_IMAGE: ${CORE_IMAGE:-scratch}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://onyscribe:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD setzen}@db:5432/onyscribe
|
||||||
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY setzen}
|
||||||
|
PORT: "8300"
|
||||||
|
# Login der Admin-Plattform (login.onyva.dev). Mit AUTH_ENABLED=true sind Client + Zugriffsregel Pflicht.
|
||||||
|
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-https://login.onyva.dev}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
|
||||||
|
OIDC_LOGOUT_REDIRECT_URI: ${OIDC_LOGOUT_REDIRECT_URI:-}
|
||||||
|
APP_COOKIE_SECURE: ${APP_COOKIE_SECURE:-true}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${ADMIN_ALLOWED_EMAILS:-}
|
||||||
|
ADMIN_REQUIRED_ROLE: ${ADMIN_REQUIRED_ROLE:-}
|
||||||
|
# Deploys: ohne DEPLOY_LIVE=true nur Dry-Run. Token nur setzen, wenn lesende Dry-Run-Aufrufe gewünscht sind.
|
||||||
|
DEPLOY_LIVE: ${DEPLOY_LIVE:-false}
|
||||||
|
# Welche Tenants dürfen LIVE angefasst werden (Mittwald + Login-API)? leer = keiner · demo-admin,x = diese · * = alle
|
||||||
|
DEPLOY_ALLOWED_TENANTS: ${DEPLOY_ALLOWED_TENANTS:-}
|
||||||
|
MITTWALD_API_TOKEN: ${MITTWALD_API_TOKEN:-}
|
||||||
|
# Core-CI meldet neue Images hierher (POST /api/core/releases, Bearer-Token)
|
||||||
|
CORE_RELEASE_TOKEN: ${CORE_RELEASE_TOKEN:-}
|
||||||
|
# OIDC-Clients automatisch verwalten (Developer-API von login.onyva.dev). Key = Admin-Passwort-Äquivalent!
|
||||||
|
ONYVA_LOGIN_API_KEY: ${ONYVA_LOGIN_API_KEY:-}
|
||||||
|
OIDC_SYNC_LIVE: ${OIDC_SYNC_LIVE:-false} # true erlaubt Schreiben im Login-System (auch ohne DEPLOY_LIVE)
|
||||||
|
# Optional statt Eintrag unter Einstellungen → Geteilte Secrets
|
||||||
|
CORTECS_API_KEY: ${CORTECS_API_KEY:-}
|
||||||
|
expose:
|
||||||
|
- "8300" # Coolify: Domain auf Service „admin“ legen, Port 8300 (https://<host>:8300). Lokal veröffentlicht docker-compose.override.yml den Port.
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: onyscribe
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD setzen}
|
||||||
|
POSTGRES_DB: onyscribe
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U onyscribe -d onyscribe"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
289
docs/RUNBOOK_PARALLELTEST.md
Normal file
289
docs/RUNBOOK_PARALLELTEST.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# Runbook: Admin-Plattform parallel zum Altbetrieb testen (ohne Merge, nur mit Demo-Tenant)
|
||||||
|
|
||||||
|
Ziel: Die neue Admin-Plattform und den neuen Core **parallel** zu den laufenden Tenants betreiben – mit **einem** neuen Test-Tenant
|
||||||
|
`demo-admin`. Nichts Bestehendes wird angefasst, es wird **nichts gemergt**, und jeder Schritt lässt sich einzeln zurücknehmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. So läuft das Ganze parallel (und warum der Altbetrieb sicher ist)
|
||||||
|
|
||||||
|
```
|
||||||
|
ALTBETRIEB (unverändert) NEUBETRIEB (Test)
|
||||||
|
──────────────────────── ─────────────────
|
||||||
|
Gitea: main / cortecs-llm-migration Gitea: Branch admin-platform-integration
|
||||||
|
└ build-deploy.yml → :latest + deploy-all └ build-branch-image.yml → NUR Image :<sha> (kein :latest, kein Deploy)
|
||||||
|
Mittwald: app-demo, app-rotary, app-sprinz, … Mittwald: app-demo-admin ← neuer, eigener Service + Volume + Ingress
|
||||||
|
Domain: demo.<DOMAIN> … Domain: demo-admin.<DOMAIN>
|
||||||
|
Daten: ins Image gebacken (tenants/) Daten: Bundle von der Admin-Plattform (TENANT_SOURCE=admin)
|
||||||
|
OIDC-Client: von Hand angelegt OIDC-Client: automatisch über die Login-API
|
||||||
|
Admin-Plattform auf Coolify (eigene Domain, eigene Postgres)
|
||||||
|
```
|
||||||
|
|
||||||
|
Schutzmechanismen (alle eingebaut und getestet):
|
||||||
|
|
||||||
|
| Schutz | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Branch-Trennung | `build-deploy.yml` läuft nur auf `main`/`cortecs-llm-migration` (auch nicht per „Run workflow“ vom Test-Branch). Der Test-Branch baut nur ein Image mit Tag = Git-SHA. |
|
||||||
|
| Eigener Tenant | `demo-admin` hat eigenen Service `app-demo-admin`, eigenes Volume `vol-demo-admin`, eigenen Host und eigenen OIDC-Client. `app-demo` bleibt unberührt. |
|
||||||
|
| `DEPLOY_ALLOWED_TENANTS=demo-admin` | Die Admin-Plattform darf **nur** diesen Tenant live anfassen (Mittwald **und** Login-API). Auch „Alle neu deployen“ trifft nur ihn. |
|
||||||
|
| `DEPLOY_LIVE=false` zum Start | Erst nur Dry-Run (liest den Stack, schreibt nichts). |
|
||||||
|
| Differenzielles PATCH + Guard | Es wird nur `app-demo-admin` geschrieben; danach prüft der Admin, dass alle fremden Services noch da sind. |
|
||||||
|
| Default `TENANT_SOURCE=git` im Core | Der neue Core verhält sich ohne Env wie der alte. |
|
||||||
|
|
||||||
|
Rückgängig machen: siehe **Abschnitt 11**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Werte zusammentragen (einmalig)
|
||||||
|
|
||||||
|
| Wert | Woher | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `DOMAIN` | Gitea → Repo `transkriptipt-platform` → Einstellungen → Actions → **Variablen** | Basis-Domain in der Admin-Plattform |
|
||||||
|
| `PROJECT_ID`, `STACK_ID` | dito | Mittwald-Ziel |
|
||||||
|
| `REGISTRY` (z. B. `gitea.onyva.dev/onyva`) | dito | Registry in der Admin-Plattform |
|
||||||
|
| `MITTWALD_API_TOKEN` | Gitea → **Secrets** (du kennst den Wert; derselbe Token) | Env der Admin-Plattform |
|
||||||
|
| Weitere Variablen: `OIDC_ISSUER`, `AUTH_ENABLED`, `APP_COOKIE_SECURE`, `MAX_FILE_SIZE_MB`, `CORTECS_BASE_URL`, `LLM_MODEL`, `LLM_MAX_TOKENS`, `LLM_TEMPERATURE`, `SMTP_HOST/PORT/USER/FROM`, `MISTRAL_*`, `COST_*` | dito | in **Einstellungen** der Admin-Plattform (Abschnitt 6) |
|
||||||
|
| `CORTECS_API_KEY`, `SMTP_PASSWORD` | `cd ~/PycharmProjects/transkript-platform && sops -d --extract '["CORTECS_API_KEY"]' secrets/shared.enc.yaml` (analog `SMTP_PASSWORD`) | Einstellungen → Geteilte Secrets |
|
||||||
|
| `COST_INGEST_KEY` von `demo` | `sops -d --extract '["COST_INGEST_KEY"]' tenants/demo/secrets.enc.yaml` | Secret des Test-Tenants (Abschnitt 7, Punkt 7.4) |
|
||||||
|
| **`<ADMIN_HOST>`** | du wählst: Domain, unter der Coolify die Admin-Plattform ausliefert, z. B. `admin.deine-domain.de` | muss **öffentlich per HTTPS** erreichbar sein (der Tenant-Container holt von dort sein Bundle) |
|
||||||
|
| **`<DEINE_MAIL>`** | deine Login-E-Mail bei login.onyva.dev (Konto muss dort existieren) | Admin-Zugriff + Freigabe im Test-Tenant |
|
||||||
|
|
||||||
|
Werte **nicht** in Chats oder Tickets kopieren (Keys, Secrets).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core-Branch pushen (Image bauen lassen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/PycharmProjects/transkript-platform
|
||||||
|
git branch --show-current # → admin-platform-integration
|
||||||
|
git log --oneline -3 # 13055b8 CI: build-deploy nur von Produktions-Branches …
|
||||||
|
git rev-parse HEAD # → voller SHA; z. B. 13055b811ca0030841f41ce1f10e4f30cba1a7db ← MERKEN (= CORE_SHA)
|
||||||
|
git push -u origin admin-platform-integration
|
||||||
|
```
|
||||||
|
|
||||||
|
Was jetzt in Gitea passiert (Repo → **Actions**):
|
||||||
|
|
||||||
|
| Workflow | Erwartung |
|
||||||
|
|---|---|
|
||||||
|
| `test` | grün (Schema ↔ Code ↔ Tenants, Bundle-Loader) |
|
||||||
|
| `build-branch-image` | grün; Log-Zeile „Image gebaut: `<registry>/transkript-platform:<SHA>`“ |
|
||||||
|
| `build-deploy` | **läuft nicht** (Branch nicht gelistet) – wenn doch: sofort abbrechen und melden |
|
||||||
|
|
||||||
|
Prüfen: Gitea → Repo → **Packages** → `transkript-platform` hat einen neuen Tag = voller SHA. Der Tag `latest` ist **unverändert** (Zeitstempel!).
|
||||||
|
|
||||||
|
> ✅ Ergebnis: `CORE_SHA` (voller 40-stelliger SHA). Lege ihn in deine Notizen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Admin-Plattform ins eigene Repo
|
||||||
|
|
||||||
|
1. Gitea → **Neues Repository**: Organisation `Onyva`, Name `onyscribe-admin`, **privat**, leer (ohne README/.gitignore/Lizenz).
|
||||||
|
2. Lokal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/PycharmProjects/onyscribe-admin
|
||||||
|
git status --short | head # .env und .data dürfen NICHT erscheinen (sind ignoriert)
|
||||||
|
git ls-files | grep -E '(^|/)\.env$|\.db$' || echo "ok: keine Secrets/DB im Index"
|
||||||
|
git add -A
|
||||||
|
git commit -m "Onyscribe Admin: Initialer Stand"
|
||||||
|
git remote add origin https://gitea.onyva.dev/Onyva/onyscribe-admin.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: `seed/tenants.seed.json.gz` enthält die Tenant-Konfigurationen inkl. Zusatzdateien (z. B. `namensliste.txt` – personenbezogen). Das liegt heute schon im Altrepo; Repo privat halten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Login-System: Client + API-Key für die Admin-Plattform
|
||||||
|
|
||||||
|
Im Login-Dashboard (login.onyva.dev), als Admin:
|
||||||
|
|
||||||
|
1. **API-Keys → neuer Key** („onyscribe-admin“, ggf. Ablaufdatum). Klartext `onyva_…` **einmalig** kopieren → später `ONYVA_LOGIN_API_KEY`.
|
||||||
|
2. Client für die Admin-Plattform selbst anlegen (Dashboard **oder** per API):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEY=onyva_… # der Key aus 1.
|
||||||
|
curl -sX POST https://login.onyva.dev/api/v1/apps -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Onyscribe Admin","redirect_uris":["https://<ADMIN_HOST>/auth/callback"],"auth_mode":"required","totp_mode":"disabled"}'
|
||||||
|
# → client_id + client_secret SOFORT sichern (nur einmal sichtbar)
|
||||||
|
curl -sX PUT https://login.onyva.dev/api/v1/apps/<client_id>/memberships -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"user_email":"<DEINE_MAIL>","role":"admin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis: `client_id`, `client_secret` (Admin-Login), `ONYVA_LOGIN_API_KEY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Coolify: Admin-Plattform deployen
|
||||||
|
|
||||||
|
**Neue Ressource → Docker Compose (aus Git)**: Repo `Onyva/onyscribe-admin`, Branch `main`, Compose-Datei `docker-compose.yml`
|
||||||
|
(Zugriff auf das private Repo per Deploy-Key/Token einrichten).
|
||||||
|
|
||||||
|
**Umgebungsvariablen** (Coolify → Ressource → Environment):
|
||||||
|
|
||||||
|
| Variable | Wert | Phase |
|
||||||
|
|---|---|---|
|
||||||
|
| `POSTGRES_PASSWORD` | langes Zufallspasswort | immer |
|
||||||
|
| `SECRET_KEY` | `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` – **sichern! ohne ihn sind die Secrets in der DB unlesbar** | immer |
|
||||||
|
| `AUTH_ENABLED` | `true` | immer |
|
||||||
|
| `OIDC_ISSUER` | `https://login.onyva.dev` | immer |
|
||||||
|
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | aus Abschnitt 4 (Punkt 2) | immer |
|
||||||
|
| `OIDC_REDIRECT_URI` | `https://<ADMIN_HOST>/auth/callback` | immer |
|
||||||
|
| `OIDC_LOGOUT_REDIRECT_URI` | `https://<ADMIN_HOST>/` | immer |
|
||||||
|
| `APP_COOKIE_SECURE` | `true` | immer |
|
||||||
|
| `ADMIN_ALLOWED_EMAILS` | `<DEINE_MAIL>` (weitere kommagetrennt) | immer – ohne Regel wird jeder Login abgelehnt |
|
||||||
|
| `MITTWALD_API_TOKEN` | derselbe Token wie in Gitea | immer |
|
||||||
|
| `ONYVA_LOGIN_API_KEY` | aus Abschnitt 4 (Punkt 1) | immer |
|
||||||
|
| `DEPLOY_ALLOWED_TENANTS` | `demo-admin` | immer |
|
||||||
|
| `DEPLOY_LIVE` | **`false`** | Phase 1 (Dry-Run) → in Abschnitt 8 auf `true` |
|
||||||
|
| `OIDC_SYNC_LIVE` | `false` | optional `true`, um den Login-Abgleich separat vor dem Deploy zu testen |
|
||||||
|
| `CORE_RELEASE_TOKEN` | leer | erst in Abschnitt 10 |
|
||||||
|
| `CORTECS_API_KEY` | leer (wird in der UI als Secret gesetzt) | optional |
|
||||||
|
|
||||||
|
**Domain**: Service `admin` → Domain `https://<ADMIN_HOST>:8300` (Port 8300 = Container-Port). **Persistente Daten**: Volume `pgdata` (Postgres) – Backup einrichten.
|
||||||
|
|
||||||
|
Deploy starten. Der erste Build nutzt das normale `Dockerfile` (mitgelieferter Core-Snapshot, **keine Registry nötig**).
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
|
||||||
|
- [ ] `https://<ADMIN_HOST>/healthz` → `{"ok":true}`
|
||||||
|
- [ ] `https://<ADMIN_HOST>/` → Weiterleitung zu login.onyva.dev → Login → Dashboard mit **7 Tenants** (Seed).
|
||||||
|
- [ ] Seitenleiste unten: „Deploy: gesperrt · Dry-Run“, „<DEINE_MAIL>“.
|
||||||
|
- Probleme: „Kein Zugriff“ → `ADMIN_ALLOWED_EMAILS`; `redirect_uri`-Fehler → Redirect-URI im Login-Client **exakt** wie in `OIDC_REDIRECT_URI`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Admin-Plattform konfigurieren (Menü „Einstellungen“)
|
||||||
|
|
||||||
|
Felder (Spalte „Gitea“ = dieselbe Variable wie bisher), dann oben **„Alles speichern“**:
|
||||||
|
|
||||||
|
| Feld in der Admin-Plattform | Wert / Gitea |
|
||||||
|
|---|---|
|
||||||
|
| Basis-Domain | `DOMAIN` |
|
||||||
|
| Mittwald PROJECT_ID / STACK_ID | `PROJECT_ID` / `STACK_ID` |
|
||||||
|
| Registry | `REGISTRY` |
|
||||||
|
| Image-Name | `transkript-platform` |
|
||||||
|
| **Öffentliche URL der Admin-Plattform** | `https://<ADMIN_HOST>` |
|
||||||
|
| **Core kann Bundle laden** | ✔ aktivieren (betrifft nur Tenants mit Quelle „Admin“, also nur `demo-admin`) |
|
||||||
|
| Login (OIDC) & Limits | `OIDC_ISSUER`, `AUTH_ENABLED` (true), `APP_COOKIE_SECURE` (true), `MAX_FILE_SIZE_MB` |
|
||||||
|
| LLM & Transkription | `CORTECS_BASE_URL`, `LLM_MODEL`, `LLM_MAX_TOKENS`, `LLM_TEMPERATURE`, `MISTRAL_*` |
|
||||||
|
| E-Mail (SMTP) | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_FROM` |
|
||||||
|
| Kostenerfassung | `COST_API_BASE`, `COST_TRACKING_ENABLED` |
|
||||||
|
| „Weitere Core-Einstellungen“ (aus dem Schema) | nur ausfüllen, was du in Gitea auch gesetzt hast (`COST_LLM_*`, `COST_STT_PER_MINUTE`, `STT_*` …); leer = Standard des Cores |
|
||||||
|
| Rollout → Canary-Tenants | `demo-admin` |
|
||||||
|
| Geteilte Secrets → `CORTECS_API_KEY` | **Wert setzen** (aus `sops -d …`) |
|
||||||
|
| Geteilte Secrets → `SMTP_PASSWORD` | **Wert setzen** (nur nötig, wenn der Test E-Mail-Versand prüfen soll) |
|
||||||
|
|
||||||
|
Dann: Karte „Login-API“ → **„Verbindung testen“** → `✔ Verbindung ok – N App(s) im Login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Test-Tenant `demo-admin` anlegen und konfigurieren
|
||||||
|
|
||||||
|
**7.1 Anlegen**: Dashboard → **+ Neuer Tenant** → Name `Onyva Demo (Admin-Test)`, ID `demo-admin`, Ausgangspunkt „Kopie von Onyva Demo“ → Anlegen.
|
||||||
|
(Quelle ist automatisch „Admin“, OIDC-Verwaltung „managed“.)
|
||||||
|
|
||||||
|
**7.2 Spracherkennung**: Tenant → *Funktion & STT* → Provider `cortecs`, Modell `voxtral-mini-transcribe-2602`
|
||||||
|
(dann reicht der geteilte Cortecs-Key; `demo` nutzt sonst Mistral und bräuchte einen eigenen `MISTRAL_API_KEY`).
|
||||||
|
|
||||||
|
**7.3 Optisch unterscheidbar machen**: *Marke & Farben* → Akzent z. B. `#E8590C` → „Töne aus Akzent ableiten“. Oben rechts **Prüfen & veröffentlichen** → Nachricht „Test-Tenant“ → **v2**.
|
||||||
|
|
||||||
|
**7.4 Secrets**: Tenant → *Secrets* → `COST_INGEST_KEY` **Wert setzen** (der Wert von `demo`; Kosten laufen dann unter `demo`, für den Test okay).
|
||||||
|
`SESSION_SECRET` und `ADMIN_BUNDLE_TOKEN` sind schon erzeugt. `OIDC_CLIENT_ID/SECRET` legt der Deploy automatisch an.
|
||||||
|
|
||||||
|
**7.5 Login**: Tenant → *Domain & Login*: Subdomain `demo-admin`; OIDC-Verwaltung `managed`, Zugriff `required`; **Freigaben**: `<DEINE_MAIL>` als `editor` (Nutzer muss im Login existieren) → *Speichern*.
|
||||||
|
**„Gegen Login prüfen“** (nur lesend) → erwartet: „würde App ‚Onyscribe – Onyva Demo (Admin-Test)‘ anlegen (Redirect: https://demo-admin.<DOMAIN>/auth/callback …)“ und „würde <DEINE_MAIL> als editor freigeben“.
|
||||||
|
|
||||||
|
**7.6 Core pinnen**: Tenant → *Übersicht* → Karte „Core-Version“ → Pin = **voller SHA aus Abschnitt 2** → *Pin setzen*.
|
||||||
|
(Der Pin sorgt dafür, dass genau das Branch-Image läuft und der Health-Check die Core-Version prüfen kann.)
|
||||||
|
|
||||||
|
**7.7 Vorflug-Check + Dry-Run**: Tenant → *Deploy* → **Vorflug-Check → Prüfen**.
|
||||||
|
Erwartet in Phase 1: alles grün/gelb, **gelb** nur „DEPLOY_LIVE nicht aktiv“; keine roten Punkte. Typische rote Punkte und Fix stehen direkt dabei
|
||||||
|
(z. B. Registry/PROJECT_ID/Token fehlen, `CORTECS_API_KEY`/`COST_INGEST_KEY` fehlen, „Core kann Bundle laden“ aus, Admin-URL nicht erreichbar).
|
||||||
|
Dann **Dry-Run** → Log lesen: „geschützte bestehende Services (bleiben unangetastet): [app-demo, app-rotary, …]“, `[DRY-RUN] PATCH …` für Volume und Service, kein Schreibzugriff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Live-Deploy des Test-Tenants
|
||||||
|
|
||||||
|
1. Coolify → Admin-Ressource → `DEPLOY_LIVE=true` setzen (`DEPLOY_ALLOWED_TENANTS=demo-admin` bleibt) → neu deployen (Daten bleiben).
|
||||||
|
2. Admin → `demo-admin` → *Deploy* → Vorflug-Check → muss **„Bereit“** zeigen → **Deployen**.
|
||||||
|
|
||||||
|
Was passiert (Log): OIDC-Client im Login anlegen und Secret speichern → Pflicht-Secrets prüfen → Volume + Service per PATCH → Service neu erzeugen → Ingress
|
||||||
|
`demo-admin.<DOMAIN>` → Health-Check (bis ~90 s Wartezeit für Start + Zertifikat).
|
||||||
|
|
||||||
|
Abnahme-Checkliste:
|
||||||
|
|
||||||
|
- [ ] Deploy-Log endet mit `Health-Check … ✔ · Core 13055b811c · Bundle v2` und `✔ Fertig`
|
||||||
|
- [ ] mStudio: Service `app-demo-admin` läuft; **alle anderen Services unverändert** (Image-Tags wie vorher)
|
||||||
|
- [ ] `https://demo-admin.<DOMAIN>/health` → `core.version` = dein SHA, `bundle` = `{"source":"admin","version":2,"stale":false}`
|
||||||
|
- [ ] Seite öffnen → Login → freigegebener Nutzer kommt rein → Oberfläche in der **neuen Farbe**
|
||||||
|
- [ ] Protokoll erzeugen (kleines Transkript hochladen) → Zusammenfassung → PDF/Word → Aussehen = Vorschau in der Admin-Plattform
|
||||||
|
- [ ] Admin → Dashboard → **Zustand prüfen**: `demo-admin` grün. Die Altbetrieb-Tenants zeigen „Core ohne Vertrag (alte Version)“ – **erwartet und harmlos** (sie laufen noch auf dem alten Image)
|
||||||
|
- [ ] Altbetrieb: `demo.<DOMAIN>` und die anderen Kunden funktionieren unverändert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Änderung ohne Core-Änderung testen
|
||||||
|
|
||||||
|
1. Admin → `demo-admin` → *Marke & Farben*: Akzent ändern → **Prüfen & veröffentlichen** (v3) → *Deploy* → **Deployen** (nur Neustart, kein Image-Build).
|
||||||
|
2. Erwartung: Health-Check `Bundle v3 ✔`; Seite neu laden → neue Farbe.
|
||||||
|
3. Agent: *KI-Agent* → „Setze die Akzentfarbe auf #0B6E4F“ → Live-Vorschau beobachten → „In Entwurf übernehmen“ → veröffentlichen → deployen.
|
||||||
|
4. Ausfallsicherheit (nur `demo-admin`!): Coolify → Admin-Ressource **stoppen**; in mStudio den Container `app-demo-admin` **neu starten** → er startet mit gecachtem Bundle:
|
||||||
|
`https://demo-admin.<DOMAIN>/health` → `bundle.source = "cache"`, `stale = true`. Admin wieder starten → nächster Container-Neustart holt wieder `admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. (Optional) Den kompletten Core-Release-Weg testen
|
||||||
|
|
||||||
|
1. Coolify: `CORE_RELEASE_TOKEN` = langes Zufallstoken, Admin neu deployen. Gitea → Repo → Einstellungen → Actions: **Variable** `ADMIN_URL` = `https://<ADMIN_HOST>`, **Secret** `ADMIN_RELEASE_TOKEN` = dasselbe Token.
|
||||||
|
(Wirkt nur auf Workflows des Test-Branches; `main` kennt den `notify-admin`-Job nicht.)
|
||||||
|
2. Im Core-Repo eine Kleinigkeit committen und auf den Branch pushen → `build-branch-image` baut und meldet das Release.
|
||||||
|
3. Admin → Menü **Core**: neues Release, Badge **„Admin älter/anders“** (die Admin-Plattform enthält noch den alten Core-Snapshot).
|
||||||
|
4. Angleichen: `cd ~/PycharmProjects/onyscribe-admin && scripts/sync_core.sh && git add -A && git commit -m "Core-Snapshot aktualisiert" && git push` → Coolify deployt → Badge **„gleicher Core“**.
|
||||||
|
*(Später ersetzt `Dockerfile.fromcore` diesen Schritt: Admin wird direkt auf dem Core-Image gebaut.)*
|
||||||
|
5. **Prüfen** → Matrix mit allen 7 Tenants (beim ersten Mal „neu (keine Baseline)“) → **Freigeben** → **Dry-Run** → *Tenant `demo-admin`: Pin aufheben* → **Ausrollen** → nur `demo-admin` wird live ausgerollt (Allowlist), Health-Check prüft die neue Core-SHA.
|
||||||
|
6. Rollback testen: **Zurückrollen** (stellt den vorherigen Tag wieder her).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Abbruch / Rückbau (jederzeit)
|
||||||
|
|
||||||
|
| Was | Wie |
|
||||||
|
|---|---|
|
||||||
|
| Test-Tenant live entfernen | Admin → `demo-admin` → Übersicht → Gefahrenbereich → **Offboard-Dry-Run** prüfen → **Offboard (live)** (entfernt Service, Volume, Ingress) |
|
||||||
|
| OIDC-Client des Test-Tenants | Gefahrenbereich → **OIDC-Client im Login löschen** |
|
||||||
|
| Admin-Plattform | Coolify-Ressource stoppen/löschen; Login-Client „Onyscribe Admin“ und API-Key im Login löschen |
|
||||||
|
| Branch/Image | Gitea: Branch `admin-platform-integration` löschen, Package-Tag (SHA) löschen |
|
||||||
|
| Alles Live sperren | `DEPLOY_LIVE=false` (und/oder `DEPLOY_ALLOWED_TENANTS` leeren) → Admin neu deployen |
|
||||||
|
|
||||||
|
Der Altbetrieb braucht in keinem dieser Fälle eine Änderung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Fehlersuche
|
||||||
|
|
||||||
|
| Symptom | Ursache / Fix |
|
||||||
|
|---|---|
|
||||||
|
| Vorflug: „Mittwald-Stack lesbar“ rot | Token-Rechte, `PROJECT_ID`/`STACK_ID` in Einstellungen prüfen (dieselben Werte wie Gitea) |
|
||||||
|
| Deploy: „Pflicht-Secrets ohne Wert: …“ | `CORTECS_API_KEY` (Einstellungen → Geteilte Secrets), `COST_INGEST_KEY` (Tenant → Secrets), ggf. `MISTRAL_API_KEY` (nur bei STT-Provider mistral) |
|
||||||
|
| Deploy: „… nicht für Live-Schreibzugriffe freigegeben“ | `DEPLOY_ALLOWED_TENANTS` enthält die Tenant-ID nicht |
|
||||||
|
| Deploy: „Quelle ‚Admin‘ ist noch gesperrt“ | Einstellungen → „Core kann Bundle laden“ aktivieren |
|
||||||
|
| Deploy: „OIDC-Abgleich fehlgeschlagen: API-Key ungültig (401)“ | `ONYVA_LOGIN_API_KEY` falsch/abgelaufen; Key im Login neu erzeugen |
|
||||||
|
| Deploy: „… Freigaben: <mail> existiert im Login nicht“ (Warnung) | Nutzer im Login anlegen, dann *Domain & Login → Jetzt abgleichen* |
|
||||||
|
| Health-Check: „Core X statt Y“ | Pin/Tag stimmt nicht mit dem Image überein oder Image fehlt in der Registry (mStudio-Container-Log) |
|
||||||
|
| Health-Check: „Bundle cache v… (veraltet)“ | Container erreicht die Admin-URL nicht: Domain/HTTPS/Firewall, `admin_public_url`, Bundle-Token |
|
||||||
|
| Container-Log: „Tenant-Bundle … nicht verfügbar“ | wie oben; zusätzlich `ADMIN_URL`/`ADMIN_BUNDLE_TOKEN` im Plan (Deploy → Service-Definition) prüfen |
|
||||||
|
| Login der Tenant-Seite: `redirect_uri`-Fehler | *Domain & Login → Gegen Login prüfen → Jetzt abgleichen* (ergänzt die Redirect-URI) |
|
||||||
|
| Login: `invalid_client` | `OIDC_CLIENT_SECRET` fehlt/ist veraltet → *Jetzt abgleichen* bzw. neu deployen |
|
||||||
|
| Zertifikat/Ingress fehlt | Token braucht Domain/Ingress-Recht (Log: „Ingress … NICHT angelegt“); Service läuft trotzdem, nach Rechteänderung erneut deployen |
|
||||||
|
| Admin „Zustand prüfen“: „nicht erreichbar“ | Tenant noch nicht deployt bzw. DNS/Zertifikat noch nicht fertig |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Danach (nicht Teil dieses Tests)
|
||||||
|
|
||||||
|
Erst wenn der Test-Tenant mehrere Tage sauber läuft: Core-Branch reviewen und mergen (→ `build-deploy` baut dann den neuen Core für alle),
|
||||||
|
Secrets der echten Tenants importieren (`python scripts/import_tenants.py --with-secrets`), pro Tenant die **Cut-over-Checkliste** abarbeiten und
|
||||||
|
`DEPLOY_ALLOWED_TENANTS` schrittweise erweitern. Die echten Tenants stellst du **einzeln** um – Rückweg: Quelle zurück auf „Git“.
|
||||||
8
requirements-extra.txt
Normal file
8
requirements-extra.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Nur die Pakete, die der Core NICHT schon mitbringt (Dockerfile.fromcore baut auf dem Core-Image auf)
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
psycopg[binary]>=3.2
|
||||||
|
pyyaml>=6.0
|
||||||
|
cryptography>=43
|
||||||
|
httpx>=0.27
|
||||||
|
anthropic>=0.40
|
||||||
|
pypdf>=4.0
|
||||||
18
requirements.txt
Normal file
18
requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Admin-Plattform
|
||||||
|
fastapi==0.135.1
|
||||||
|
uvicorn[standard]==0.41.0
|
||||||
|
jinja2>=3.1
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
psycopg[binary]>=3.2
|
||||||
|
python-multipart>=0.0.22
|
||||||
|
pyyaml>=6.0
|
||||||
|
cryptography>=43
|
||||||
|
httpx>=0.27
|
||||||
|
anthropic>=0.40
|
||||||
|
pypdf>=4.0
|
||||||
|
# Abhängigkeiten des vendored Cores (Live-Vorschau führt den echten Core aus)
|
||||||
|
openai==2.46.0
|
||||||
|
python-dotenv>=1.0
|
||||||
|
requests>=2.31
|
||||||
|
weasyprint>=60.0
|
||||||
|
python-docx>=1.1.0
|
||||||
14
scripts/export_seed.py
Normal file
14
scripts/export_seed.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Schreibt den aktuellen DB-Stand (Tenants, Versionen, Dateien; ohne Secret-Werte) nach seed/tenants.seed.json.gz."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.db import session # noqa: E402
|
||||||
|
from app.services import seed # noqa: E402
|
||||||
|
|
||||||
|
out = Path(__file__).resolve().parent.parent / "seed" / "tenants.seed.json.gz"
|
||||||
|
with session() as db:
|
||||||
|
seed.export(db, out)
|
||||||
|
print(f"geschrieben: {out} ({out.stat().st_size/1024:.0f} KB)")
|
||||||
42
scripts/fake_login_server.py
Normal file
42
scripts/fake_login_server.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lokaler Fake von login.onyva.dev /api/v1 – zum Ausprobieren des OIDC-Abgleichs ohne echtes Login-System.
|
||||||
|
|
||||||
|
python scripts/fake_login_server.py # http://127.0.0.1:8399/api/v1, Key: onyva_testkey, Nutzer: anna@x.de, bob@x.de
|
||||||
|
ONYVA_LOGIN_API_KEY=onyva_testkey OIDC_SYNC_LIVE=true uvicorn app.main:app --port 8300
|
||||||
|
→ Einstellungen → „API-Basis“ = http://127.0.0.1:8399/api/v1
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from tests.fakelogin import FakeLogin # noqa: E402
|
||||||
|
|
||||||
|
fake = FakeLogin()
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def _do(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(n)) if n else None
|
||||||
|
status, data, hdrs = fake(self.command, f"http://x{self.path}", {"Authorization": self.headers.get("Authorization", "")}, body)
|
||||||
|
self.send_response(status)
|
||||||
|
for k, v in hdrs.items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
out = json.dumps(data).encode() if data is not None else b""
|
||||||
|
self.send_header("Content-Length", str(len(out)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(out)
|
||||||
|
print(f"{self.command} {self.path} → {status}", flush=True)
|
||||||
|
|
||||||
|
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _do
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Fake-Login auf http://127.0.0.1:8399/api/v1 (Key onyva_testkey)", flush=True)
|
||||||
|
HTTPServer(("127.0.0.1", 8399), H).serve_forever()
|
||||||
27
scripts/import_tenants.py
Normal file
27
scripts/import_tenants.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Importiert die Tenants aus ../transkript-platform in die Admin-DB.
|
||||||
|
|
||||||
|
python scripts/import_tenants.py # nur Schlüsselnamen der Secrets
|
||||||
|
python scripts/import_tenants.py --with-secrets # Secrets via `sops -d` entschlüsseln und verschlüsselt speichern
|
||||||
|
python scripts/import_tenants.py --force # neue Version auch bei identischem Inhalt
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.db import init_db, session # noqa: E402
|
||||||
|
from app.services.importer import import_all # noqa: E402
|
||||||
|
from app.settings import LEGACY_PLATFORM # noqa: E402
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--root", default=str(LEGACY_PLATFORM))
|
||||||
|
ap.add_argument("--with-secrets", action="store_true")
|
||||||
|
ap.add_argument("--force", action="store_true")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
with session() as db:
|
||||||
|
for line in import_all(db, Path(a.root), a.with_secrets, a.force):
|
||||||
|
print(line)
|
||||||
13
scripts/sync_core.sh
Executable file
13
scripts/sync_core.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Aktualisiert den vendored Core-Snapshot (nur für lokale Entwicklung ohne Core-Image) aus der transkript-platform.
|
||||||
|
# Standard: aktueller Stand des Quell-Repos. Im Container/Coolify wird stattdessen FROM <core-image> gebaut (Dockerfile.fromcore).
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="${1:-$(cd "$(dirname "$0")/../.." && pwd)/transkript-platform}"
|
||||||
|
DST="$(cd "$(dirname "$0")/.." && pwd)/vendor/platform"
|
||||||
|
rm -rf "$DST/core" && mkdir -p "$DST"
|
||||||
|
cp -r "$SRC/core" "$DST/core" && cp "$SRC/main.py" "$SRC/requirements.txt" "$DST/"
|
||||||
|
find "$DST" -name __pycache__ -prune -exec rm -rf {} +
|
||||||
|
SHA="$(git -C "$SRC" rev-parse HEAD)"; BRANCH="$(git -C "$SRC" rev-parse --abbrev-ref HEAD)"
|
||||||
|
echo "$SHA $BRANCH" > "$DST/CORE_VERSION"
|
||||||
|
printf '{"sha": "%s", "built_at": "%s"}\n' "$SHA" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DST/core/BUILD_INFO.json"
|
||||||
|
echo "Core synchronisiert: ${SHA:0:10} ($BRANCH)"
|
||||||
39
scripts/verify_import.py
Normal file
39
scripts/verify_import.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Beweis für den Cut-over: Bundle aus der DB == Git-Ordner (Hash-Vergleich, ohne deploy-Block)."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.db import session # noqa: E402
|
||||||
|
from app.models import Tenant # noqa: E402
|
||||||
|
from app.services import store # noqa: E402
|
||||||
|
from app.settings import LEGACY_PLATFORM # noqa: E402
|
||||||
|
from sqlalchemy import select # noqa: E402
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]) if len(sys.argv) > 1 else LEGACY_PLATFORM
|
||||||
|
bad = 0
|
||||||
|
with session() as db:
|
||||||
|
for t in db.scalars(select(Tenant).order_by(Tenant.id)):
|
||||||
|
tdir = root / "tenants" / t.id
|
||||||
|
cfg, files = store.content(db, t, "current")
|
||||||
|
git_cfg = json.loads((tdir / "tenant.json").read_text(encoding="utf-8"))
|
||||||
|
git_cfg.pop("deploy", None)
|
||||||
|
problems = []
|
||||||
|
if json.dumps(git_cfg, sort_keys=True) != json.dumps(cfg, sort_keys=True):
|
||||||
|
problems.append("tenant.json weicht ab")
|
||||||
|
for path, sha in files.items():
|
||||||
|
p = tdir / path
|
||||||
|
if not p.exists():
|
||||||
|
problems.append(f"{path}: fehlt in Git")
|
||||||
|
elif hashlib.sha256(p.read_bytes()).hexdigest() != sha:
|
||||||
|
problems.append(f"{path}: Hash weicht ab")
|
||||||
|
for p in tdir.rglob("*"):
|
||||||
|
rel = p.relative_to(tdir).as_posix()
|
||||||
|
if p.is_file() and rel not in files and rel not in ("tenant.json", "secrets.enc.yaml") and "__pycache__" not in rel:
|
||||||
|
problems.append(f"{rel}: fehlt in DB")
|
||||||
|
print(("✔" if not problems else "✘"), t.id, "; ".join(problems))
|
||||||
|
bad += bool(problems)
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
BIN
seed/tenants.seed.json.gz
Normal file
BIN
seed/tenants.seed.json.gz
Normal file
Binary file not shown.
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Test-Isolation: Tests dürfen NIE echte Keys/Dienste aus der lokalen .env oder der Shell erreichen."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
os.environ["ONYSCRIBE_SKIP_DOTENV"] = "1" # .env nicht einlesen
|
||||||
|
for var in ("CORTECS_API_KEY", "ONYVA_LOGIN_API_KEY", "MITTWALD_API_TOKEN", "MITTWALD_API_BASE", "DEPLOY_LIVE", "OIDC_SYNC_LIVE",
|
||||||
|
"AUTH_ENABLED", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_LOGOUT_REDIRECT_URI", "OIDC_ISSUER",
|
||||||
|
"ADMIN_ALLOWED_EMAILS", "DEPLOY_ALLOWED_TENANTS", "CORE_RELEASE_TOKEN", "ADMIN_REQUIRED_ROLE", "SECRET_KEY", "APP_COOKIE_SECURE"):
|
||||||
|
os.environ.pop(var, None)
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{tempfile.mkdtemp()}/test.db"
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def fresh_db():
|
||||||
|
"""Jedes Testmodul startet mit leerer Datenbank (die Module teilen sich sonst Zustand über die Temp-DB)."""
|
||||||
|
from app.db import Base, engine, init_db
|
||||||
|
from app import models # noqa: F401
|
||||||
|
Base.metadata.drop_all(engine)
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
59
tests/fakelogin.py
Normal file
59
tests/fakelogin.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md) für Tests und lokale Entwicklung."""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLogin:
|
||||||
|
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md)."""
|
||||||
|
|
||||||
|
def __init__(self, users=("anna@x.de", "bob@x.de")):
|
||||||
|
self.apps, self.members, self.users, self.calls, self.n, self.rate_limit_once = {}, {}, set(users), [], 0, False
|
||||||
|
|
||||||
|
def __call__(self, method, url, headers, body):
|
||||||
|
path = url.split("/api/v1")[1]
|
||||||
|
self.calls.append((method, path, body))
|
||||||
|
if headers.get("Authorization") != "Bearer onyva_testkey":
|
||||||
|
return 401, {"detail": "invalid"}, {}
|
||||||
|
if self.rate_limit_once:
|
||||||
|
self.rate_limit_once = False
|
||||||
|
return 429, {"detail": "slow down"}, {"Retry-After": "2"}
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if parts == ["apps"]:
|
||||||
|
if method == "GET":
|
||||||
|
return 200, {"apps": list(self.apps.values())}, {}
|
||||||
|
self.n += 1
|
||||||
|
cid = f"app_{self.n:04d}"
|
||||||
|
app = {"client_id": cid, "name": body["name"], "slug": body["name"].lower(), "redirect_uris": body["redirect_uris"],
|
||||||
|
"auth_mode": body.get("auth_mode", "required"), "totp_mode": body.get("totp_mode", "disabled"),
|
||||||
|
"allow_registration": body.get("allow_registration", False), "backchannel_logout_uri": body.get("backchannel_logout_uri")}
|
||||||
|
self.apps[cid], self.members[cid] = app, {}
|
||||||
|
return 201, {**app, "client_secret": f"secret-{self.n}"}, {}
|
||||||
|
cid = parts[1]
|
||||||
|
if cid not in self.apps:
|
||||||
|
return 404, {"detail": "not found"}, {}
|
||||||
|
if len(parts) == 2:
|
||||||
|
if method == "GET":
|
||||||
|
return 200, self.apps[cid], {}
|
||||||
|
if method == "PATCH":
|
||||||
|
self.apps[cid].update(body)
|
||||||
|
return 200, self.apps[cid], {}
|
||||||
|
if method == "DELETE":
|
||||||
|
del self.apps[cid]
|
||||||
|
return 204, None, {}
|
||||||
|
if parts[2] == "rotate-secret":
|
||||||
|
self.n += 1
|
||||||
|
return 200, {"client_id": cid, "client_secret": f"rotated-{self.n}"}, {}
|
||||||
|
if parts[2] == "memberships":
|
||||||
|
if method == "GET":
|
||||||
|
return 200, {"memberships": [{"email": e, "display_name": e, "role": r} for e, r in self.members[cid].items()]}, {}
|
||||||
|
if method == "PUT":
|
||||||
|
if body["user_email"] not in self.users:
|
||||||
|
return 404, {"detail": "user"}, {}
|
||||||
|
self.members[cid][body["user_email"]] = body.get("role", "viewer")
|
||||||
|
return 200, body, {}
|
||||||
|
if method == "DELETE":
|
||||||
|
self.members[cid].pop(parts[3], None)
|
||||||
|
return 204, None, {}
|
||||||
|
return 404, {}, {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def writes(self):
|
||||||
|
return [c for c in self.calls if c[0] != "GET"]
|
||||||
443
tests/test_core_contract.py
Normal file
443
tests/test_core_contract.py
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
"""Zusammenspiel Admin ↔ Core: Vertrag/Schema, schema-getriebene Env, Release-Eingang, Kompatibilitätsprüfung, Rollout, Rollback, Zustand."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.db import session
|
||||||
|
from app.main import app
|
||||||
|
from app.models import CoreRelease, Tenant
|
||||||
|
from app.services import contract, deploy, releases, status as status_svc
|
||||||
|
from app.services.importer import import_all
|
||||||
|
from app.services.mittwald import DeployError
|
||||||
|
from app.settings import LEGACY_PLATFORM, ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with session() as db:
|
||||||
|
import_all(db, Path(LEGACY_PLATFORM))
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vertrag / Schema ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_contract_is_loaded_and_supported():
|
||||||
|
assert contract.available() and contract.supported() and contract.contract_version() == 1
|
||||||
|
assert len(contract.schema()["fields"]) >= 30 and contract.build_info()["sha"] not in ("", "unbekannt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_imported_tenants_conform_to_schema(client):
|
||||||
|
for tid in ("demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"):
|
||||||
|
cfg = client.get(f"/api/tenants/{tid}/state").json()["config"]
|
||||||
|
assert contract.check_tenant_json(cfg) == [], tid
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_knows_every_color_the_core_declares():
|
||||||
|
from app.services.defaults import COLOR_LABELS, DEFAULT_COLORS
|
||||||
|
core_colors = {p.split(".", 1)[1] for p in contract.field_paths() if p.startswith("colors.")}
|
||||||
|
assert core_colors == set(COLOR_LABELS) == set(DEFAULT_COLORS), "Farben im Core-Schema weichen von den Admin-Defaults ab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bespoke_form_paths_exist_in_schema():
|
||||||
|
js = (ROOT / "app" / "static" / "tenant.js").read_text()
|
||||||
|
bespoke = re.search(r"BESPOKE_PATHS = \[(.*?)\]", js, re.S).group(1)
|
||||||
|
for p in re.findall(r"'([a-z_.]+)'", bespoke):
|
||||||
|
assert p in contract.field_paths(), f"{p} wird von einer festen Admin-Maske genutzt, fehlt aber im Core-Schema"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_warnings_flow_into_validate(client):
|
||||||
|
cfg = client.get("/api/tenants/demo/state").json()["config"]
|
||||||
|
client.put("/api/tenants/demo/config", json={**cfg, "flags": {"ui_kontakt_feld": False}}) # Tippfehler
|
||||||
|
v = client.post("/api/tenants/demo/validate").json()
|
||||||
|
client.post("/api/tenants/demo/draft/discard")
|
||||||
|
assert any("flags.ui_kontakt_feld" in w for w in v["warnings"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_endpoint_and_settings_get(client):
|
||||||
|
c = client.get("/api/contract").json()
|
||||||
|
assert c["available"] and c["supported"] and c["schema"]["env"]
|
||||||
|
assert client.get("/api/settings").json()["base_domain"] == "onyscribe.de"
|
||||||
|
assert client.get("/core").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── schema-getriebene Container-Env ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_env_is_built_from_core_schema_and_picks_up_new_variables(client, monkeypatch):
|
||||||
|
client.put("/api/settings", json={"llm_model": "claude-x", "smtp_host": "smtp.test"})
|
||||||
|
with session() as db:
|
||||||
|
env = deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)
|
||||||
|
assert env["LLM_MODEL"] == "claude-x" and env["SMTP_HOST"] == "smtp.test" and env["TENANT"] == "sprinz" and "STT_PROVIDER" not in env
|
||||||
|
# Der Core deklariert eine neue geteilte Variable → sie landet ohne Admin-Änderung im Env
|
||||||
|
spec = contract.env_spec() + [{"name": "NEW_FEATURE_LEVEL", "kind": "setting", "scope": "shared", "setting": "new_feature_level", "group": "X", "label": "X"}]
|
||||||
|
monkeypatch.setattr(contract, "env_spec", lambda: spec)
|
||||||
|
client.put("/api/settings", json={"new_feature_level": "3"})
|
||||||
|
with session() as db:
|
||||||
|
assert deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)["NEW_FEATURE_LEVEL"] == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_secrets_follow_schema_conditions():
|
||||||
|
assert "MISTRAL_API_KEY" not in contract.required_secrets({"stt": {"provider": "cortecs"}})
|
||||||
|
assert "MISTRAL_API_KEY" in contract.required_secrets({"stt": {"provider": "mistral"}})
|
||||||
|
assert "MISTRAL_API_KEY" in contract.required_secrets({})
|
||||||
|
assert {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY", "CORTECS_API_KEY"} <= set(contract.required_secrets({}))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Health-Verifikation & Zustandsabgleich ──────────────────────────────────
|
||||||
|
|
||||||
|
def health_body(sha="abcdef1234567", bundle=None, status="ok"):
|
||||||
|
return {"status": status, "tenant": "t", "core": {"version": sha, "contract": 1, "hook_api": 1}, "bundle": bundle or {"source": "git", "version": None, "stale": False, "error": None}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_health_accepts_correct_and_rejects_wrong_versions():
|
||||||
|
logs = []
|
||||||
|
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body()), tries=1)
|
||||||
|
assert any("✔" in l for l in logs)
|
||||||
|
with pytest.raises(DeployError, match="Core 1234567890 statt abcdef1"):
|
||||||
|
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body("1234567890abc")), tries=2, delay=0, sleep=lambda s: None)
|
||||||
|
with pytest.raises(DeployError, match="Bundle cache v2 \\(veraltet\\) statt admin v3"):
|
||||||
|
deploy.verify_health("h", None, 3, logs.append, get=lambda u: (200, health_body(bundle={"source": "cache", "version": 2, "stale": True})), tries=1)
|
||||||
|
with pytest.raises(DeployError, match="nicht erreichbar|Connect"):
|
||||||
|
def boom(u):
|
||||||
|
raise OSError("Connect refused")
|
||||||
|
deploy.verify_health("h", None, None, logs.append, get=boom, tries=2, delay=0, sleep=lambda s: None)
|
||||||
|
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, {"status": "ok"}), tries=1) # Image vor dem Vertrag → nur Hinweis
|
||||||
|
assert any("Image vor dem Vertrag" in l for l in logs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_probe_detects_drift():
|
||||||
|
ok = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("abcdef12349999", {"source": "admin", "version": 5, "stale": False})))
|
||||||
|
assert ok["reachable"] and ok["drift"] == []
|
||||||
|
bad = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("0000000aaaa", {"source": "baked", "version": None, "stale": True})))
|
||||||
|
assert len(bad["drift"]) == 2
|
||||||
|
old = status_svc.probe("h", "abcdef1234", None, get=lambda u: (200, {"status": "ok"}))
|
||||||
|
assert "Core ohne Vertrag" in old["drift"][0]
|
||||||
|
down = status_svc.probe("h", "x", None, get=lambda u: (_ for _ in ()).throw(OSError("timeout")))
|
||||||
|
assert not down["reachable"] and down["drift"] == ["nicht erreichbar"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Release-Eingang ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SHA_ADMIN = None
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_sha():
|
||||||
|
return contract.sha()
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_intake_requires_token_and_is_idempotent(client, monkeypatch):
|
||||||
|
body = {"sha": _admin_sha(), "tag": _admin_sha(), "ref": "main", "image": "reg/x", "message": "Neuer Core"}
|
||||||
|
monkeypatch.delenv("CORE_RELEASE_TOKEN", raising=False)
|
||||||
|
assert client.post("/api/core/releases", json=body).status_code == 503
|
||||||
|
monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim")
|
||||||
|
assert client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer falsch"}).status_code == 401
|
||||||
|
assert client.post("/api/core/releases", json={**body, "sha": "zzz"}, headers={"Authorization": "Bearer geheim"}).status_code == 400
|
||||||
|
r = client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()
|
||||||
|
assert r["created"] and r["admin_matches"] and r["status"] == "new"
|
||||||
|
assert not client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()["created"]
|
||||||
|
other = client.post("/api/core/releases", json={**body, "sha": "f" * 40, "tag": "f" * 40, "message": "Fremder Core"}, headers={"Authorization": "Bearer geheim"}).json()
|
||||||
|
assert not other["admin_matches"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_list_needs_session_when_auth_enabled(monkeypatch):
|
||||||
|
from app import auth
|
||||||
|
monkeypatch.setattr(auth, "AUTH_ENABLED", True)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
assert c.get("/api/core/releases").status_code == 401
|
||||||
|
monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim")
|
||||||
|
assert c.post("/api/core/releases", json={"sha": "a" * 40}, headers={"Authorization": "Bearer geheim"}).status_code == 200 # CI braucht keine Session
|
||||||
|
assert c.get("/api/tenants/demo/state").status_code == 401
|
||||||
|
monkeypatch.setattr(auth, "AUTH_ENABLED", False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kompatibilitätsprüfung & Freigabe ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _rid(sha):
|
||||||
|
with session() as db:
|
||||||
|
return db.query(CoreRelease).filter_by(sha=sha).one().id
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_requires_matching_admin_core(client):
|
||||||
|
rid = _rid("f" * 40)
|
||||||
|
r = client.post(f"/api/core/releases/{rid}/check", json={})
|
||||||
|
assert r.status_code == 400 and "Admin" in r.json()["detail"] and "neu bauen" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_matrix_baseline_changes_and_approval_rules(client):
|
||||||
|
rid = _rid(_admin_sha())
|
||||||
|
releases.check(rid, wait=True)
|
||||||
|
with session() as db:
|
||||||
|
c = db.get(CoreRelease, rid).compat
|
||||||
|
assert set(c["tenants"]) >= {"demo", "rotary-wil", "sprinz"} and c["summary"]["error"] == 0
|
||||||
|
assert all(v["status"] == "new" for v in c["tenants"].values()) # noch keine Baseline
|
||||||
|
assert c["tenants"]["rotary-wil"]["fp"]["pdf_pages"] >= 1
|
||||||
|
# Freigabe ohne Änderungen möglich
|
||||||
|
assert client.post(f"/api/core/releases/{rid}/approve", json={}).status_code == 200
|
||||||
|
# Baseline setzen (wie nach einem erfolgreichen Rollout) und Core-Änderung simulieren
|
||||||
|
with session() as db:
|
||||||
|
for tid, v in c["tenants"].items():
|
||||||
|
t = db.get(Tenant, tid)
|
||||||
|
t.render_baseline = {"version": t.current_version, "core": "alt", "fp": {**v["fp"], "pdf_text": "0" * 64}}
|
||||||
|
releases.check(rid, wait=True)
|
||||||
|
with session() as db:
|
||||||
|
c2 = db.get(CoreRelease, rid).compat
|
||||||
|
assert c2["tenants"]["sprinz"]["status"] == "changed" and "PDF-Text" in c2["tenants"]["sprinz"]["changed"]
|
||||||
|
r = client.post(f"/api/core/releases/{rid}/approve", json={})
|
||||||
|
assert r.status_code == 400 and "bestätigen" in r.json()["detail"]
|
||||||
|
assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200
|
||||||
|
# unveränderte Baseline → ok
|
||||||
|
with session() as db:
|
||||||
|
for tid, v in c2["tenants"].items():
|
||||||
|
t = db.get(Tenant, tid)
|
||||||
|
t.render_baseline = {"version": t.current_version, "core": "x", "fp": v["fp"]}
|
||||||
|
releases.check(rid, wait=True)
|
||||||
|
with session() as db:
|
||||||
|
assert db.get(CoreRelease, rid).compat["summary"]["ok"] >= 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_tenant_blocks_approval(client):
|
||||||
|
rid = _rid(_admin_sha())
|
||||||
|
client.put("/api/tenants/revier/files/protocol.html", json={"content": "{% for %}"})
|
||||||
|
client.post("/api/tenants/revier/publish", json={"message": "kaputt"})
|
||||||
|
releases.check(rid, wait=True)
|
||||||
|
with session() as db:
|
||||||
|
c = db.get(CoreRelease, rid).compat
|
||||||
|
assert c["tenants"]["revier"]["status"] == "error" and c["summary"]["error"] == 1
|
||||||
|
r = client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True})
|
||||||
|
assert r.status_code == 400 and "revier" in r.json()["detail"]
|
||||||
|
client.post("/api/tenants/revier/versions/1/restore") # reparieren
|
||||||
|
client.post("/api/tenants/revier/publish", json={"message": "repariert"})
|
||||||
|
releases.check(rid, wait=True)
|
||||||
|
assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rollout / Rollback ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ready(client, tid):
|
||||||
|
"""Tenant mit allen Pflicht-Secrets, Quelle git, OIDC-Verwaltung aus – bereit für einen (gefakten) Live-Deploy."""
|
||||||
|
from app.services import tenants as tsvc
|
||||||
|
client.post("/api/tenants", json={"id": tid, "name": f"Tenant {tid}"}) # existiert er schon, bleibt er und wird nur reaktiviert
|
||||||
|
client.put(f"/api/tenants/{tid}/meta", json={"status": "active"})
|
||||||
|
client.put(f"/api/tenants/{tid}/core-pin", json={"pin": ""})
|
||||||
|
client.put(f"/api/tenants/{tid}/source", json={"source": "git"})
|
||||||
|
client.put(f"/api/tenants/{tid}/oidc", json={"mode": "off"})
|
||||||
|
with session() as db:
|
||||||
|
for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
|
||||||
|
tsvc.set_secret(db, tid, k, "x", "t")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMW:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls, self.images = [], {}
|
||||||
|
|
||||||
|
def __call__(self, method, url, headers, body):
|
||||||
|
path = url.split("/v2")[1]
|
||||||
|
self.calls.append((method, path))
|
||||||
|
if method == "GET" and path.endswith("/stacks/s-1/"):
|
||||||
|
return 200, {"services": {"legacy": {"id": "svc-0"}, **{n: {"id": f"id-{n}"} for n in self.images}}}, ""
|
||||||
|
if method == "GET" and "ingresses" in path:
|
||||||
|
return 200, [], ""
|
||||||
|
if method == "GET" and "/projects/p-1/" in path:
|
||||||
|
return 200, {"id": "uuid"}, ""
|
||||||
|
if method == "PATCH":
|
||||||
|
for name, svc in ((body or {}).get("services") or {}).items():
|
||||||
|
if svc:
|
||||||
|
self.images[name] = svc["image"]
|
||||||
|
return 200, {}, ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def live_env(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("DEPLOY_LIVE", "true")
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
|
||||||
|
monkeypatch.setenv("MITTWALD_API_TOKEN", "tok")
|
||||||
|
client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o", "core_image_tag": "alt1234", "canary_tenants": "ro2"})
|
||||||
|
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
|
||||||
|
with session() as db: # alle bestehenden Tenants aus dem Rollout nehmen (andere Tests laufen weiter gegen sie)
|
||||||
|
paused = [t.id for t in db.query(Tenant).filter(Tenant.status == "active")]
|
||||||
|
for t in db.query(Tenant):
|
||||||
|
t.status = "paused"
|
||||||
|
yield
|
||||||
|
with session() as db:
|
||||||
|
for t in db.query(Tenant):
|
||||||
|
t.status = "active" if t.id in paused else t.status
|
||||||
|
for t in db.query(Tenant).filter(Tenant.id.like("ro%")):
|
||||||
|
t.status = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_release(client, sha="c" * 40):
|
||||||
|
with session() as db:
|
||||||
|
r, _ = releases.announce(db, sha, sha, "main", "reg.test/o", "Rollout-Test")
|
||||||
|
r.status = "approved"
|
||||||
|
r.compat = {"tenants": {}, "summary": {}, "core_sha": sha, "running": False}
|
||||||
|
rid = r.id
|
||||||
|
return rid
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_rollout_changes_nothing(client, live_env, monkeypatch):
|
||||||
|
monkeypatch.delenv("DEPLOY_LIVE")
|
||||||
|
_ready(client, "ro1")
|
||||||
|
rid = _approved_release(client, "d" * 40)
|
||||||
|
mw = FakeMW()
|
||||||
|
batch, ids = releases.rollout(rid, "t", dry_run=True, http=mw, wait=True)
|
||||||
|
assert not [c for c in mw.calls if c[0] != "GET"] # Dry-Run liest höchstens
|
||||||
|
rows = client.get(f"/api/deployments?batch={batch}").json()
|
||||||
|
assert rows and all(r["status"] == "success" and r["kind"] == "dry-run" and r["image"] == "d" * 40 for r in rows)
|
||||||
|
with session() as db:
|
||||||
|
assert db.get(CoreRelease, rid).status == "approved"
|
||||||
|
assert deploy.setting(db, "core_image_tag") == "alt1234"
|
||||||
|
with pytest.raises(releases.ReleaseError, match="gesperrt"):
|
||||||
|
releases.rollout(rid, "t", dry_run=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_rollout_canary_first_pin_baseline_and_rollback(client, live_env):
|
||||||
|
for t in ("ro1", "ro2", "ro3"):
|
||||||
|
_ready(client, t)
|
||||||
|
client.put("/api/tenants/ro3/core-pin", json={"pin": "fest99"})
|
||||||
|
rid = _approved_release(client, "e" * 40)
|
||||||
|
with session() as db:
|
||||||
|
db.get(CoreRelease, rid).compat = {"tenants": {"ro1": {"fp": {"x": 1}}, "ro2": {"fp": {"x": 2}}}, "summary": {}, "core_sha": "e" * 40, "running": False}
|
||||||
|
mw, seen = FakeMW(), []
|
||||||
|
health = lambda host, tag, bundle, log: seen.append((host, tag)) # noqa: E731
|
||||||
|
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True)
|
||||||
|
rows = client.get(f"/api/deployments?batch={batch}").json()
|
||||||
|
assert [r["tenant"] for r in rows][0] == "ro2" and {r["tenant"] for r in rows} == {"ro1", "ro2"} # Canary zuerst, ro3 gepinnt → nicht dabei
|
||||||
|
assert all(r["status"] == "success" and not r["simulated"] for r in rows)
|
||||||
|
assert mw.images == {"app-ro1": f"reg.test/o/transkript-platform:{'e' * 40}", "app-ro2": f"reg.test/o/transkript-platform:{'e' * 40}"}
|
||||||
|
assert [t for _, t in seen] == ["e" * 40, "e" * 40]
|
||||||
|
with session() as db:
|
||||||
|
r = db.get(CoreRelease, rid)
|
||||||
|
assert r.status == "active" and r.previous_tag == "alt1234" and deploy.setting(db, "core_image_tag") == "e" * 40
|
||||||
|
assert db.get(Tenant, "ro1").render_baseline["fp"] == {"x": 1} and db.get(Tenant, "ro1").deployed_image == "e" * 40
|
||||||
|
assert deploy.image_ref(db, db.get(Tenant, "ro3")).endswith(":fest99") # Pin gewinnt
|
||||||
|
# Rollback aller Tenants auf den vorherigen Tag
|
||||||
|
mw2 = FakeMW()
|
||||||
|
releases.rollback_release(rid, "t", http=mw2, health=health, wait=True)
|
||||||
|
assert set(mw2.images.values()) == {"reg.test/o/transkript-platform:alt1234"}
|
||||||
|
with session() as db:
|
||||||
|
assert db.get(CoreRelease, rid).status == "rolled_back" and deploy.setting(db, "core_image_tag") == "alt1234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_canary_aborts_rollout_and_marks_release_failed(client, live_env):
|
||||||
|
for t in ("ro1", "ro2"):
|
||||||
|
_ready(client, t)
|
||||||
|
rid = _approved_release(client, "9" * 40)
|
||||||
|
mw = FakeMW()
|
||||||
|
|
||||||
|
def health(host, tag, bundle, log):
|
||||||
|
if host.startswith("ro2."):
|
||||||
|
raise DeployError("Health-Check fehlgeschlagen: Core falsch")
|
||||||
|
|
||||||
|
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True)
|
||||||
|
rows = {r["tenant"]: r for r in client.get(f"/api/deployments?batch={batch}").json()}
|
||||||
|
assert rows["ro2"]["status"] == "failed" and rows["ro1"]["status"] == "cancelled" and "übersprungen" in rows["ro1"]["log"]
|
||||||
|
assert "app-ro1" not in mw.images # nichts ausgerollt
|
||||||
|
with session() as db:
|
||||||
|
assert db.get(CoreRelease, rid).status == "failed" and deploy.setting(db, "core_image_tag") == "alt1234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_rollback_uses_previous_successful_live_tag(client, live_env, monkeypatch):
|
||||||
|
from app.services.mittwald import Mittwald
|
||||||
|
_ready(client, "ro4")
|
||||||
|
mw, health = FakeMW(), (lambda *a: None)
|
||||||
|
monkeypatch.setattr(deploy, "_client", lambda live, log, http=None: Mittwald("tok", "https://api.test/v2", live, log, mw))
|
||||||
|
monkeypatch.setattr(deploy, "verify_health", lambda *a, **k: None)
|
||||||
|
deploy.start("ro4", "deploy", "t", http=mw, core_tag="1111111", health=health, wait=True)
|
||||||
|
deploy.start("ro4", "deploy", "t", http=mw, core_tag="2222222", health=health, wait=True)
|
||||||
|
with session() as db:
|
||||||
|
assert db.get(Tenant, "ro4").deployed_image == "2222222" and releases.tenant_rollback_tag(db, db.get(Tenant, "ro4")) == "1111111"
|
||||||
|
r = client.post("/api/tenants/ro4/rollback").json()
|
||||||
|
assert r["tag"] == "1111111"
|
||||||
|
time.sleep(1.5)
|
||||||
|
assert mw.images["app-ro4"].endswith(":1111111")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollout_blocked_for_unapproved_and_unsupported(client, live_env):
|
||||||
|
with session() as db:
|
||||||
|
r, _ = releases.announce(db, "8" * 40, "8" * 40, "main", "", "neu")
|
||||||
|
rid = r.id
|
||||||
|
with pytest.raises(releases.ReleaseError, match="erst prüfen und freigeben"):
|
||||||
|
releases.rollout(rid, "t", dry_run=False)
|
||||||
|
with pytest.raises(releases.ReleaseError):
|
||||||
|
releases.approve(session().__enter__(), rid, "t")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schreib-Freigabe pro Tenant (DEPLOY_ALLOWED_TENANTS) ───────────────────
|
||||||
|
|
||||||
|
def test_allowlist_parsing(monkeypatch):
|
||||||
|
from app.services import guard
|
||||||
|
monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS", raising=False)
|
||||||
|
assert guard.allowed_tenants() == set() and not guard.live_allowed("demo") and guard.describe() == "keiner"
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", " demo-admin , x ")
|
||||||
|
assert guard.live_allowed("demo-admin") and guard.live_allowed("x") and not guard.live_allowed("demo")
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
|
||||||
|
assert guard.allowed_tenants() is None and guard.live_allowed("irgendwer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_deploy_oidc_and_rollout_respect_allowlist(client, live_env, monkeypatch):
|
||||||
|
for t in ("ro1", "ro2"):
|
||||||
|
_ready(client, t)
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "ro2")
|
||||||
|
# API: nicht freigegebener Tenant → 403, freigegebener → ok
|
||||||
|
assert client.post("/api/tenants/ro1/deploy", json={"dry_run": False}).status_code == 403
|
||||||
|
assert client.post("/api/tenants/ro1/offboard", json={"dry_run": False}).status_code == 403
|
||||||
|
assert client.post("/api/tenants/ro1/rollback").status_code == 403
|
||||||
|
assert client.post("/api/tenants/ro1/oidc/sync", json={"apply": True}).status_code == 403
|
||||||
|
dry = client.post("/api/tenants/ro1/deploy", json={"dry_run": True}) # Dry-Run immer erlaubt
|
||||||
|
assert dry.status_code == 200
|
||||||
|
for _ in range(40):
|
||||||
|
if client.get(f"/api/deployments/{dry.json()['id']}").json()["status"] != "running":
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
assert client.get("/api/deploy/mode").json()["allowed"] == "ro2"
|
||||||
|
# Engine blockiert auch direkt
|
||||||
|
d = client.get(f"/api/deployments/{deploy.start('ro1', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
|
||||||
|
assert d["status"] == "failed" and "nicht freigegeben" in d["log"]
|
||||||
|
# Rollout: nur ro2 wird live ausgerollt, ro1 bleibt unberührt
|
||||||
|
rid = _approved_release(client, "7" * 40)
|
||||||
|
mw = FakeMW()
|
||||||
|
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=lambda *a: None, wait=True)
|
||||||
|
assert [r["tenant"] for r in client.get(f"/api/deployments?batch={batch}").json()] == ["ro2"] and list(mw.images) == ["app-ro2"]
|
||||||
|
# niemand freigegeben → Rollout wird abgelehnt
|
||||||
|
monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS")
|
||||||
|
with pytest.raises(releases.ReleaseError, match="Kein Tenant für Live-Rollouts freigegeben"):
|
||||||
|
releases.rollout(rid, "t", dry_run=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vorflug-Check ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_preflight_reports_problems_and_readiness(client, live_env, monkeypatch):
|
||||||
|
from tests.test_flow import FakeLogin # noqa: F401
|
||||||
|
_ready(client, "pf1")
|
||||||
|
client.put("/api/settings", json={"admin_public_url": "", "core_supports_bundle": "false", "base_domain": "onyscribe.de"})
|
||||||
|
client.put("/api/tenants/pf1/source", json={"source": "admin"})
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "")
|
||||||
|
mw = FakeMW()
|
||||||
|
from app.services import preflight
|
||||||
|
r = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200)
|
||||||
|
lv = {i["id"]: i["level"] for i in r["items"]}
|
||||||
|
assert not r["ready"] and lv["bundle"] == "fail" and lv["adminurl"] == "fail" and lv["allow"] == "warn" and lv["stack"] == "ok" and lv["tag"] == "warn"
|
||||||
|
assert "legacy" in next(i for i in r["items"] if i["id"] == "stack")["detail"] # bestehende Services werden als „bleiben unangetastet“ gezeigt
|
||||||
|
assert [c for c in mw.calls if c[0] != "GET"] == [] # schreibt nie
|
||||||
|
# alles in Ordnung bringen
|
||||||
|
client.put("/api/settings", json={"admin_public_url": "https://admin.example.de", "core_supports_bundle": "true"})
|
||||||
|
client.put("/api/tenants/pf1/core-pin", json={"pin": "a" * 40})
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "pf1")
|
||||||
|
r2 = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200)
|
||||||
|
assert r2["ready"], [i for i in r2["items"] if i["level"] == "fail"]
|
||||||
|
bad = preflight.run("pf1", mw_http=mw, self_get=lambda u: 502)
|
||||||
|
assert not bad["ready"] and any(i["id"] == "adminreach" and i["level"] == "fail" for i in bad["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_deploy_requires_admin_public_url_for_admin_source(client, live_env):
|
||||||
|
_ready(client, "pf2")
|
||||||
|
client.put("/api/tenants/pf2/source", json={"source": "admin"})
|
||||||
|
client.put("/api/settings", json={"core_supports_bundle": "true", "admin_public_url": ""})
|
||||||
|
d = client.get(f"/api/deployments/{deploy.start('pf2', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
|
||||||
|
assert d["status"] == "failed" and "Öffentliche URL der Admin-Plattform" in d["log"]
|
||||||
|
assert client.get("/api/tenants/pf2/preflight").status_code == 200
|
||||||
529
tests/test_flow.py
Normal file
529
tests/test_flow.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
"""End-to-End-Test des Kernablaufs gegen eine temporäre SQLite-DB (echter Core-Render in der Sandbox)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tarfile
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_tmp = tempfile.mkdtemp()
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp}/t.db"
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.db import session # noqa: E402
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
from app.services.importer import import_all # noqa: E402
|
||||||
|
from app.settings import LEGACY_PLATFORM # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with session() as db:
|
||||||
|
import_all(db, Path(LEGACY_PLATFORM))
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_all_tenants(client):
|
||||||
|
for tid in ["demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"]:
|
||||||
|
s = client.get(f"/api/tenants/{tid}/state").json()
|
||||||
|
assert s["current_version"] == 1 and s["config"]["id"] == tid and "deploy" not in s["config"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_previews_render_with_real_core(client):
|
||||||
|
for tid in ("rotary-wil", "sprinz"):
|
||||||
|
assert client.get(f"/preview/{tid}/pdf").content[:5] == b"%PDF-"
|
||||||
|
html = client.get(f"/preview/{tid}/web").text
|
||||||
|
assert "@@" not in html and "<html" in html.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_publish_cycle(client):
|
||||||
|
assert client.put("/api/tenants/demo/config", json={**client.get("/api/tenants/demo/state").json()["config"],
|
||||||
|
"colors": {**client.get("/api/tenants/demo/state").json()["config"]["colors"], "accent": "#112233"}}).json()["has_draft"]
|
||||||
|
d = client.get("/api/tenants/demo/diff").json()
|
||||||
|
assert d["changed"] and d["items"][0]["path"] == "tenant.json"
|
||||||
|
assert "#112233" in client.get("/preview/demo/web").text
|
||||||
|
assert "#112233" not in client.get("/preview/demo/web?source=current").text # veröffentlicht unverändert
|
||||||
|
assert client.post("/api/tenants/demo/validate").json()["ok"]
|
||||||
|
v = client.post("/api/tenants/demo/publish", json={"message": "Akzent"}).json()["version"]
|
||||||
|
assert v == 2
|
||||||
|
assert not client.get("/api/tenants/demo/state").json()["has_draft"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_python_is_rejected(client):
|
||||||
|
r = client.put("/api/tenants/demo/files/overrides.py", json={"content": "def x(:\n"})
|
||||||
|
assert r.status_code == 400 and "Syntax" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_template_fails_validation(client):
|
||||||
|
client.put("/api/tenants/sprinz/files/protocol.html", json={"content": "{% for %}"})
|
||||||
|
v = client.post("/api/tenants/sprinz/validate").json()
|
||||||
|
assert not v["ok"]
|
||||||
|
client.post("/api/tenants/sprinz/draft/discard")
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_and_discard(client):
|
||||||
|
client.post("/api/tenants/demo/versions/1/restore")
|
||||||
|
assert client.get("/api/tenants/demo/state").json()["has_draft"]
|
||||||
|
client.post("/api/tenants/demo/draft/discard")
|
||||||
|
assert not client.get("/api/tenants/demo/state").json()["has_draft"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_tenant_clone_and_bundle(client):
|
||||||
|
r = client.post("/api/tenants", json={"id": "musterklinik", "name": "Musterklinik", "clone_from": "sportklinik"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.post("/api/tenants", json={"id": "musterklinik", "name": "x"}).status_code == 400
|
||||||
|
# Bundle nur mit Token
|
||||||
|
assert client.get("/api/tenants/musterklinik/bundle").status_code == 401
|
||||||
|
from app.models import Secret
|
||||||
|
from app.services import crypto
|
||||||
|
from sqlalchemy import select
|
||||||
|
with session() as db:
|
||||||
|
tok = crypto.decrypt(db.scalar(select(Secret).where(Secret.tenant_id == "musterklinik", Secret.key == "ADMIN_BUNDLE_TOKEN")).value_enc)
|
||||||
|
b = client.get("/api/tenants/musterklinik/bundle", headers={"Authorization": f"Bearer {tok}"})
|
||||||
|
assert b.status_code == 200
|
||||||
|
names = tarfile.open(fileobj=io.BytesIO(b.content), mode="r:gz").getnames()
|
||||||
|
assert "tenant.json" in names and "MANIFEST.json" in names and "logo.png" in names
|
||||||
|
|
||||||
|
|
||||||
|
def _wait(client, dep, tries=60):
|
||||||
|
for _ in range(tries):
|
||||||
|
d = client.get(f"/api/deployments/{dep}").json()
|
||||||
|
if d["status"] not in ("running", "queued"):
|
||||||
|
return d
|
||||||
|
time.sleep(0.4)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_deploy_and_cutover(client):
|
||||||
|
dep = client.post("/api/tenants/sprinz/deploy", json={"dry_run": True}).json()["id"]
|
||||||
|
d = _wait(client, dep)
|
||||||
|
assert d["status"] == "success" and "Dry-Run beendet" in d["log"] and "[DRY-RUN] PATCH" in d["log"]
|
||||||
|
assert "••••" in d["log"] or "SESSION_SECRET" not in d["log"] # Secrets maskiert
|
||||||
|
checks = {c["id"]: c["ok"] for c in client.get("/api/tenants/sprinz/cutover").json()["checks"]}
|
||||||
|
assert checks["dryrun"] and checks["validate"] and not checks["secrets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_deploy_is_locked(client):
|
||||||
|
r = client.post("/api/tenants/sprinz/deploy", json={"dry_run": False})
|
||||||
|
assert r.status_code == 403 and "DEPLOY_LIVE" in r.json()["detail"]
|
||||||
|
assert client.post("/api/deploy-all", json={"dry_run": False}).status_code == 403
|
||||||
|
assert client.post("/api/tenants/sprinz/offboard", json={"dry_run": False}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_all_dry_run(client):
|
||||||
|
r = client.post("/api/deploy-all", json={"dry_run": True}).json()
|
||||||
|
for _ in range(120):
|
||||||
|
rows = client.get(f"/api/deployments?batch={r['batch']}").json()
|
||||||
|
if all(x["status"] not in ("running", "queued") for x in rows):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
assert len(rows) >= 7 and all(x["status"] == "success" for x in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mittwald_client_live_sequence_with_guard():
|
||||||
|
"""Gegen einen Fake-HTTP: richtige Reihenfolge, PATCH statt PUT, Guard gegen verschwundene Services."""
|
||||||
|
from app.services.mittwald import DeployError, Mittwald
|
||||||
|
calls, lines = [], []
|
||||||
|
stack = {"services": {"legacy": {"id": "svc-0"}, "app-x": {"id": "svc-1"}}}
|
||||||
|
|
||||||
|
def http(method, url, headers, body):
|
||||||
|
calls.append((method, url.split("/v2")[1], body))
|
||||||
|
if method == "GET" and url.endswith("/stacks/s-1/"):
|
||||||
|
return 200, stack, ""
|
||||||
|
if method == "GET" and "ingresses" in url:
|
||||||
|
return 200, [], ""
|
||||||
|
if method == "GET" and "/projects/p-1/" in url:
|
||||||
|
return 200, {"id": "uuid-1"}, ""
|
||||||
|
return 200, {}, ""
|
||||||
|
|
||||||
|
mw = Mittwald("tok", "https://api.test/v2", True, lines.append, http)
|
||||||
|
mw.deploy_service(stack_id="s-1", service="app-x", volume="vol-x", definition={"image": "i"}, container_port="8276",
|
||||||
|
project_id="p-1", hostname="x.example.de")
|
||||||
|
verbs = [(m, p) for m, p, _ in calls]
|
||||||
|
assert ("PUT", "/stacks/s-1/") not in verbs
|
||||||
|
assert verbs.index(("PATCH", "/stacks/s-1/")) < verbs.index(("POST", "/stacks/s-1/services/svc-1/actions/recreate/"))
|
||||||
|
assert any(p == "/ingresses" and m == "POST" for m, p, _ in calls)
|
||||||
|
assert any("Guard" in l for l in lines)
|
||||||
|
# Guard schlägt an, wenn ein fremder Service verschwindet
|
||||||
|
state = {"n": 0}
|
||||||
|
|
||||||
|
def http2(method, url, headers, body):
|
||||||
|
if method == "GET" and url.endswith("/stacks/s-1/"):
|
||||||
|
state["n"] += 1
|
||||||
|
return 200, ({"services": {"legacy": {}}} if state["n"] == 1 else {"services": {"app-x": {"id": "svc-1"}}}), ""
|
||||||
|
return 200, {}, ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
Mittwald("tok", "https://api.test/v2", True, lambda m: None, http2).deploy_service(
|
||||||
|
stack_id="s-1", service="app-x", volume="vol-x", definition={}, container_port="8276", project_id="p-1", hostname=None)
|
||||||
|
raise AssertionError("Guard hat nicht angeschlagen")
|
||||||
|
except DeployError as exc:
|
||||||
|
assert "verschwunden" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_client_never_writes():
|
||||||
|
from app.services.mittwald import Mittwald
|
||||||
|
calls = []
|
||||||
|
mw = Mittwald("tok", "https://api.test/v2", False, lambda m: None, lambda *a: calls.append(a) or (200, {}, ""))
|
||||||
|
mw.deploy_service(stack_id="s", service="app-x", volume="v", definition={"environment": {"X_SECRET": "geheim"}}, container_port="1", project_id="p", hostname="h.de")
|
||||||
|
assert all(c[0] == "GET" for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(resp):
|
||||||
|
return [json.loads(l[6:]) for l in resp.text.split("\n\n") if l.startswith("data: ")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_mock_stream_and_apply(client):
|
||||||
|
r = client.post("/api/tenants/revier/chat", json={"message": "Setze die Akzentfarbe auf #0B6E4F"})
|
||||||
|
ev = _sse(r)
|
||||||
|
assert [e["type"] for e in ev][0] == "mode" and ev[0]["backend"] == "mock"
|
||||||
|
assert any(e["type"] == "step" and e["tool"] == "validate" for e in ev)
|
||||||
|
msg = ev[-1]["message"]
|
||||||
|
assert msg["proposal_status"] == "open"
|
||||||
|
assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] != "#0B6E4F" # Entwurf unberührt
|
||||||
|
d = client.get(f"/api/tenants/revier/chat/{msg['id']}/diff").json()
|
||||||
|
assert d["changed"] and "#0B6E4F" in json.dumps(d)
|
||||||
|
client.post(f"/api/tenants/revier/chat/{msg['id']}/apply")
|
||||||
|
assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] == "#0B6E4F"
|
||||||
|
client.post("/api/tenants/revier/draft/discard")
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_tools_with_scripted_backend(client):
|
||||||
|
"""Echter Loop + echte Tools + Core-Rendering, nur das LLM ist geskriptet."""
|
||||||
|
from app.agent import loop
|
||||||
|
from app.agent.backends import Turn
|
||||||
|
|
||||||
|
class Scripted:
|
||||||
|
name, model = "scripted", "x"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.i = 0
|
||||||
|
|
||||||
|
def step(self, system, messages, tools):
|
||||||
|
self.i += 1
|
||||||
|
if self.i == 1:
|
||||||
|
return Turn([{"type": "tool_use", "id": "1", "name": "read_file", "input": {"path": "namensliste.txt"}},
|
||||||
|
{"type": "tool_use", "id": "2", "name": "write_file", "input": {"path": "namensliste.txt", "content": "x"}},
|
||||||
|
{"type": "tool_use", "id": "3", "name": "write_file", "input": {"path": "protocol.css", "content": "body{color:red}"}},
|
||||||
|
{"type": "tool_use", "id": "4", "name": "write_file", "input": {"path": "overrides.py", "content": "def x(:"}},
|
||||||
|
{"type": "tool_use", "id": "5", "name": "render_pdf", "input": {}}])
|
||||||
|
return Turn([{"type": "text", "text": "Fertig."}])
|
||||||
|
|
||||||
|
with session() as db:
|
||||||
|
from app.models import Tenant
|
||||||
|
t = db.get(Tenant, "rotary-wil")
|
||||||
|
ev = list(loop.run(db, t, "teste", backend=Scripted()))
|
||||||
|
steps = [e for e in ev if e["type"] == "step"]
|
||||||
|
assert "gesperrt" in steps[0]["result"] # Namensliste für den Agenten unlesbar
|
||||||
|
assert steps[1]["error"] and "personenbezogen" in steps[1]["result"] # … und unveränderbar
|
||||||
|
assert not steps[2]["error"]
|
||||||
|
assert steps[3]["error"] # Syntaxfehler in overrides.py abgefangen
|
||||||
|
assert "Seite" in steps[4]["result"]
|
||||||
|
prop = [e for e in ev if e["type"] == "proposal"][0]["proposal"]
|
||||||
|
assert [i["path"] for i in prop["items"]] == ["protocol.css"]
|
||||||
|
with session() as db: # Entwurf bleibt unberührt, bis freigegeben
|
||||||
|
assert not db.get(Tenant, "rotary-wil").draft
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_test_endpoint(client, monkeypatch):
|
||||||
|
from app.services import llm
|
||||||
|
monkeypatch.setattr(llm, "summarize", lambda db, system, user, model=None, tenant_id=None: {"text": "===TERMINART===\nKick-off\n===/TERMINART===", "model": "m", "input_tokens": 10, "output_tokens": 5, "truncated": False})
|
||||||
|
r = client.post("/api/tenants/rotary-wil/prompt-test", json={}).json()
|
||||||
|
assert r["text"].startswith("===TERMINART") and r["prompt_chars"] > 1000
|
||||||
|
# Namensregister landet über den build_prompt-Hook im finalen Prompt
|
||||||
|
from app.services import preview
|
||||||
|
with session() as db:
|
||||||
|
from app.models import Tenant
|
||||||
|
fin = preview.run(db, db.get(Tenant, "rotary-wil"), "draft", "prompt", args={"title": "x"})["prompt"]
|
||||||
|
assert "Namensregister des Clubs" in fin
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_test_without_key_is_clean_error(client, monkeypatch):
|
||||||
|
monkeypatch.delenv("CORTECS_API_KEY", raising=False)
|
||||||
|
r = client.post("/api/tenants/sprinz/prompt-test", json={})
|
||||||
|
assert r.status_code == 400 and "CORTECS_API_KEY" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_archive_delete(client):
|
||||||
|
client.post("/api/tenants", json={"id": "wegdamit", "name": "Weg damit"})
|
||||||
|
assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 99}).status_code == 400
|
||||||
|
assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 1, "memory": "1G", "notes": "n"}).status_code == 200
|
||||||
|
assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 400 # erst archivieren
|
||||||
|
client.put("/api/tenants/wegdamit/meta", json={"status": "archived"})
|
||||||
|
assert client.delete("/api/tenants/wegdamit?confirm=falsch").status_code == 400
|
||||||
|
assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 200
|
||||||
|
assert client.get("/api/tenants/wegdamit/state").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_login_flow_and_access_rules(monkeypatch):
|
||||||
|
from app import auth
|
||||||
|
monkeypatch.setattr(auth, "AUTH_ENABLED", True)
|
||||||
|
monkeypatch.setenv("OIDC_CLIENT_ID", "cid")
|
||||||
|
monkeypatch.setenv("OIDC_CLIENT_SECRET", "sec")
|
||||||
|
import base64 as b64
|
||||||
|
idt = lambda claims: "h." + b64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + ".s" # noqa: E731
|
||||||
|
with TestClient(app, follow_redirects=False) as c:
|
||||||
|
assert c.get("/").status_code == 302 and c.get("/").headers["location"] == "/auth/login"
|
||||||
|
assert c.get("/api/tenants/demo/state").status_code == 401
|
||||||
|
assert c.get("/healthz").status_code == 200
|
||||||
|
r = c.get("/auth/login")
|
||||||
|
assert r.status_code == 302 and "code_challenge_method=S256" in r.headers["location"]
|
||||||
|
state = dict(p.split("=") for p in r.headers["location"].split("?")[1].split("&"))["state"]
|
||||||
|
# Default-Deny: keine Zugriffsregel konfiguriert
|
||||||
|
monkeypatch.setattr(auth, "exchange_code", lambda code, cv: {"id_token": idt({"sub": "1", "email": "chef@onyva.de", "app_roles": ["admin"]})})
|
||||||
|
assert c.get(f"/auth/callback?code=x&state={state}").status_code == 403
|
||||||
|
# Mit Allowlist
|
||||||
|
monkeypatch.setenv("ADMIN_ALLOWED_EMAILS", "chef@onyva.de")
|
||||||
|
state2 = dict(p.split("=") for p in c.get("/auth/login").headers["location"].split("?")[1].split("&"))["state"]
|
||||||
|
ok = c.get(f"/auth/callback?code=x&state={state2}")
|
||||||
|
assert ok.status_code == 303
|
||||||
|
assert c.get("/api/tenants/demo/state").status_code == 200
|
||||||
|
assert c.get("/auth/me").json()["user"]["email"] == "chef@onyva.de"
|
||||||
|
# falscher State → neu starten, keine Session
|
||||||
|
with TestClient(app, follow_redirects=False) as c2:
|
||||||
|
c2.get("/auth/login")
|
||||||
|
assert c2.get("/auth/callback?code=x&state=falsch").headers["location"] == "/auth/login"
|
||||||
|
# Bundle bleibt tokenbasiert erreichbar (nicht über Cookie)
|
||||||
|
assert c2.get("/api/tenants/demo/bundle").status_code == 401
|
||||||
|
monkeypatch.setattr(auth, "AUTH_ENABLED", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_backend_talks_to_cortecs_endpoint():
|
||||||
|
"""Anthropic-SDK gegen Cortecs: richtige URL (/v1/messages), Bearer-Auth, Tool-Schema, Antwort-Parsing."""
|
||||||
|
import anthropic
|
||||||
|
try:
|
||||||
|
import httpx2 as httpx # neuere Anthropic-SDKs bringen einen eigenen HTTP-Client mit
|
||||||
|
except ImportError:
|
||||||
|
import httpx
|
||||||
|
from app.agent.backends import MessagesBackend
|
||||||
|
from app.agent.tools import TOOLS
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request):
|
||||||
|
seen["url"], seen["auth"], seen["body"] = str(req.url), req.headers.get("authorization"), json.loads(req.content)
|
||||||
|
return httpx.Response(200, json={"id": "m", "type": "message", "role": "assistant", "model": "x", "stop_reason": "tool_use",
|
||||||
|
"content": [{"type": "text", "text": "ok"}, {"type": "tool_use", "id": "t1", "name": "validate", "input": {}}],
|
||||||
|
"usage": {"input_tokens": 11, "output_tokens": 7}})
|
||||||
|
|
||||||
|
b = MessagesBackend("KEY", "https://api.cortecs.ai/v1", "claude-4-6-sonnet")
|
||||||
|
b.client = anthropic.Anthropic(api_key="KEY", auth_token="KEY", base_url="https://api.cortecs.ai", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
||||||
|
turn = b.step("sys", [{"role": "user", "content": "hi"}], TOOLS)
|
||||||
|
assert seen["url"] == "https://api.cortecs.ai/v1/messages" and seen["auth"] == "Bearer KEY"
|
||||||
|
assert seen["body"]["model"] == "claude-4-6-sonnet" and {t["name"] for t in seen["body"]["tools"]} >= {"edit_file", "validate"}
|
||||||
|
assert turn.blocks[1] == {"type": "tool_use", "id": "t1", "name": "validate", "input": {}} and turn.usage == {"in": 11, "out": 7}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_backend_message_conversion():
|
||||||
|
from app.agent.backends import ChatBackend
|
||||||
|
conv = ChatBackend._convert("sys", [
|
||||||
|
{"role": "user", "content": "los"},
|
||||||
|
{"role": "assistant", "content": [{"type": "text", "text": "ich lese"}, {"type": "tool_use", "id": "a", "name": "read_file", "input": {"path": "x"}}]},
|
||||||
|
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "inhalt"}]}])
|
||||||
|
assert [m["role"] for m in conv] == ["system", "user", "assistant", "tool"]
|
||||||
|
assert conv[2]["tool_calls"][0]["function"]["name"] == "read_file" and conv[3]["tool_call_id"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_masks_secrets_but_not_tokens_limit(client):
|
||||||
|
p = client.get("/api/tenants/sprinz/plan").json()["patch"]["environment"]
|
||||||
|
assert p["LLM_MAX_TOKENS"] == "16000" and p["TENANT"] == "sprinz" and "TENANT_SOURCE" not in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_prompt_and_files_privacy(client):
|
||||||
|
r = client.get("/api/tenants/rotary-wil/final-prompt").json()
|
||||||
|
assert "Namensregister des Clubs" in r["prompt"]
|
||||||
|
st = client.get("/api/tenants/rotary-wil/state").json()
|
||||||
|
assert st["files"]["namensliste.txt"]["personal"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── OIDC-Abgleich gegen die Developer-API (Fake-Login-Server) ───────────────
|
||||||
|
|
||||||
|
from tests.fakelogin import FakeLogin # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def login(monkeypatch):
|
||||||
|
from app.services import onyva_login
|
||||||
|
fake = FakeLogin()
|
||||||
|
monkeypatch.setenv("ONYVA_LOGIN_API_KEY", "onyva_testkey")
|
||||||
|
monkeypatch.setenv("OIDC_SYNC_LIVE", "true")
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
|
||||||
|
monkeypatch.setattr(onyva_login, "client_from_env",
|
||||||
|
lambda db, http=None: onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=lambda s: None))
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_value(tenant, key):
|
||||||
|
from app.models import Secret
|
||||||
|
from app.services import crypto
|
||||||
|
from sqlalchemy import select
|
||||||
|
with session() as db:
|
||||||
|
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant, Secret.key == key))
|
||||||
|
return crypto.decrypt(r.value_enc) if r and r.value_enc else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_creates_app_stores_secret_and_is_idempotent(client, login):
|
||||||
|
client.post("/api/tenants", json={"id": "klinik1", "name": "Klinik Eins"})
|
||||||
|
client.put("/api/tenants/klinik1/oidc", json={"members": [{"email": "anna@x.de", "role": "editor"}, {"email": "ghost@x.de", "role": "viewer"}]})
|
||||||
|
plan = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": False}).json()
|
||||||
|
assert plan["ok"] and [a["kind"] for a in plan["actions"]][0] == "create" and not login.writes # Dry-Run schreibt nichts
|
||||||
|
rep = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json()
|
||||||
|
assert rep["created"] and rep["client_id"] == "app_0001"
|
||||||
|
app = login.apps["app_0001"]
|
||||||
|
assert app["redirect_uris"] == ["https://klinik1.onyscribe.de/auth/callback"] and app["auth_mode"] == "required"
|
||||||
|
assert _secret_value("klinik1", "OIDC_CLIENT_ID") == "app_0001" and _secret_value("klinik1", "OIDC_CLIENT_SECRET") == "secret-1"
|
||||||
|
assert login.members["app_0001"] == {"anna@x.de": "editor"} and any("ghost@x.de" in w for w in rep["warnings"])
|
||||||
|
login.calls.clear()
|
||||||
|
again = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json()
|
||||||
|
assert [a["kind"] for a in again["actions"]] == ["member_put"] and not any(c[0] in ("POST",) for c in login.writes) # nur der (weiter fehlende) Geist-Nutzer
|
||||||
|
assert client.get("/api/tenants/klinik1/oidc").json()["last_sync"]["ok"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_adopt_only_adds_missing_redirect(client, login):
|
||||||
|
from app.services import tenants as tsvc
|
||||||
|
login.apps["app_legacy"] = {"client_id": "app_legacy", "name": "Alt", "redirect_uris": ["https://wil-rotary.onyscribe.de/auth/callback", "https://alt.example/cb"],
|
||||||
|
"auth_mode": "optional", "totp_mode": "required", "allow_registration": True, "backchannel_logout_uri": None}
|
||||||
|
with session() as db:
|
||||||
|
tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_ID", "app_legacy", "t")
|
||||||
|
tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_SECRET", "legacy-secret", "t")
|
||||||
|
client.put("/api/tenants/rotary-wil/meta", json={})
|
||||||
|
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"})
|
||||||
|
rep = client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True}).json()
|
||||||
|
assert rep["ok"] and not rep["created"] and not rep["warnings"]
|
||||||
|
assert login.apps["app_legacy"]["redirect_uris"][-1:] == ["https://alt.example/cb"] and login.apps["app_legacy"]["auth_mode"] == "optional" # nichts Fremdes verändert
|
||||||
|
assert _secret_value("rotary-wil", "OIDC_CLIENT_SECRET") == "legacy-secret"
|
||||||
|
# Subdomain ändern → adopt ergänzt nur die neue URI
|
||||||
|
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "neu"})
|
||||||
|
client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True})
|
||||||
|
assert "https://neu.onyscribe.de/auth/callback" in login.apps["app_legacy"]["redirect_uris"] and len(login.apps["app_legacy"]["redirect_uris"]) == 3
|
||||||
|
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_managed_corrects_drift_and_exact_members(client, login):
|
||||||
|
client.post("/api/tenants", json={"id": "klinik2", "name": "Klinik Zwei"})
|
||||||
|
client.put("/api/tenants/klinik2/oidc", json={"members": [{"email": "anna@x.de", "role": "viewer"}], "members_mode": "exact", "totp_mode": "optional"})
|
||||||
|
client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True})
|
||||||
|
cid = _secret_value("klinik2", "OIDC_CLIENT_ID")
|
||||||
|
assert login.apps[cid]["totp_mode"] == "optional"
|
||||||
|
login.apps[cid].update(auth_mode="disabled", redirect_uris=["https://evil.example/cb"]) # Drift
|
||||||
|
login.members[cid]["bob@x.de"] = "admin" # fremde Freigabe
|
||||||
|
rep = client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True}).json()
|
||||||
|
assert login.apps[cid]["auth_mode"] == "required" and login.apps[cid]["redirect_uris"] == ["https://klinik2.onyscribe.de/auth/callback"]
|
||||||
|
assert "bob@x.de" not in login.members[cid] and {a["kind"] for a in rep["actions"]} >= {"patch", "member_delete"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_stale_client_id_recreates_and_rotate(client, login):
|
||||||
|
from app.services import tenants as tsvc
|
||||||
|
client.post("/api/tenants", json={"id": "klinik3", "name": "Klinik Drei"})
|
||||||
|
with session() as db:
|
||||||
|
tsvc.set_secret(db, "klinik3", "OIDC_CLIENT_ID", "app_gone", "t")
|
||||||
|
rep = client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True}).json()
|
||||||
|
assert rep["created"] and any("existiert im Login nicht mehr" in w for w in rep["warnings"])
|
||||||
|
assert _secret_value("klinik3", "OIDC_CLIENT_ID") != "app_gone"
|
||||||
|
old = _secret_value("klinik3", "OIDC_CLIENT_SECRET")
|
||||||
|
client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True, "rotate": True})
|
||||||
|
assert _secret_value("klinik3", "OIDC_CLIENT_SECRET") not in (None, old)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_write_gate_and_missing_key(client, monkeypatch):
|
||||||
|
monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False)
|
||||||
|
monkeypatch.delenv("DEPLOY_LIVE", raising=False)
|
||||||
|
assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403
|
||||||
|
monkeypatch.delenv("ONYVA_LOGIN_API_KEY", raising=False)
|
||||||
|
r = client.post("/api/tenants/sprinz/oidc/sync", json={"apply": False}).json()
|
||||||
|
assert r["ok"] and "ONYVA_LOGIN_API_KEY" in r["skipped"]
|
||||||
|
assert client.delete("/api/tenants/sprinz/oidc/app?confirm=sprinz").status_code == 403
|
||||||
|
assert client.put("/api/tenants/sprinz/oidc", json={"mode": "kaputt"}).status_code == 400
|
||||||
|
assert client.put("/api/tenants/sprinz/oidc", json={"members": [{"email": "keine-mail"}]}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_rate_limit_retry_and_bad_key():
|
||||||
|
from app.services import onyva_login
|
||||||
|
fake = FakeLogin()
|
||||||
|
waits = []
|
||||||
|
c = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=waits.append)
|
||||||
|
fake.rate_limit_once = True
|
||||||
|
assert c.list_apps() == [] and waits == [2]
|
||||||
|
try:
|
||||||
|
onyva_login.OnyvaLogin("https://login.test/api/v1", "falsch", fake, sleep=lambda s: None).list_apps()
|
||||||
|
raise AssertionError
|
||||||
|
except onyva_login.OnyvaError as exc:
|
||||||
|
assert "401" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_import_members_and_delete_app(client, login):
|
||||||
|
client.post("/api/tenants", json={"id": "klinik4", "name": "Klinik Vier"})
|
||||||
|
client.post("/api/tenants/klinik4/oidc/sync", json={"apply": True})
|
||||||
|
cid = _secret_value("klinik4", "OIDC_CLIENT_ID")
|
||||||
|
login.members[cid]["bob@x.de"] = "admin"
|
||||||
|
got = client.post("/api/tenants/klinik4/oidc/import-members").json()
|
||||||
|
assert got["members"] == [{"email": "bob@x.de", "role": "admin"}]
|
||||||
|
assert client.delete("/api/tenants/klinik4/oidc/app?confirm=nein").status_code == 400
|
||||||
|
assert client.delete("/api/tenants/klinik4/oidc/app?confirm=klinik4").status_code == 200
|
||||||
|
assert cid not in login.apps and _secret_value("klinik4", "OIDC_CLIENT_ID") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_runs_oidc_sync_before_service_patch(client, login, monkeypatch):
|
||||||
|
"""Live-Deploy (alles gefakt): OIDC-Client wird VOR dem Service-PATCH angelegt und landet in dessen Env; Fehler bricht ab."""
|
||||||
|
from app.services import deploy
|
||||||
|
client.post("/api/tenants", json={"id": "klinik5", "name": "Klinik Fünf"})
|
||||||
|
client.put("/api/tenants/klinik5/source", json={"source": "git"})
|
||||||
|
client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o"})
|
||||||
|
monkeypatch.setenv("DEPLOY_LIVE", "true")
|
||||||
|
monkeypatch.setenv("MITTWALD_API_TOKEN", "tok")
|
||||||
|
# Pflicht-Secrets laut Core-Schema: erst fehlen sie → Deploy wird VOR dem Service-Eingriff blockiert
|
||||||
|
blocked = deploy.start("klinik5", "deploy", "test", http=lambda *a: (200, {}, ""), oidc_client=None, wait=True, health=lambda *a: None)
|
||||||
|
d0 = client.get(f"/api/deployments/{blocked}").json()
|
||||||
|
assert d0["status"] == "failed" and ("COST_INGEST_KEY" in d0["log"] or "OIDC-Abgleich" in d0["log"] or "ONYVA_LOGIN_API_KEY" in d0["log"])
|
||||||
|
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
|
||||||
|
client.put("/api/tenants/klinik5/secrets/COST_INGEST_KEY", json={"value": "ik"})
|
||||||
|
order = []
|
||||||
|
stack = {"services": {"legacy": {"id": "svc-0"}, "app-klinik5": {"id": "svc-5"}}}
|
||||||
|
|
||||||
|
def mw_http(method, url, headers, body):
|
||||||
|
order.append(("mittwald", method, url.split("/v2")[1], body))
|
||||||
|
if method == "GET" and url.endswith("/stacks/s-1/"):
|
||||||
|
return 200, stack, ""
|
||||||
|
if method == "GET" and "ingresses" in url:
|
||||||
|
return 200, [], ""
|
||||||
|
if method == "GET" and "/projects/p-1/" in url:
|
||||||
|
return 200, {"id": "uuid-1"}, ""
|
||||||
|
return 200, {}, ""
|
||||||
|
|
||||||
|
class Spy(FakeLogin):
|
||||||
|
def __call__(self, method, url, headers, body):
|
||||||
|
order.append(("login", method, url.split("/api/v1")[1], body))
|
||||||
|
return super().__call__(method, url, headers, body)
|
||||||
|
|
||||||
|
spy = Spy()
|
||||||
|
from app.services import onyva_login
|
||||||
|
oc = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", spy, sleep=lambda s: None)
|
||||||
|
dep = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=oc, wait=True, health=lambda *a: None)
|
||||||
|
d = client.get(f"/api/deployments/{dep}").json()
|
||||||
|
assert d["status"] == "success", d["log"]
|
||||||
|
first_login_write = next(i for i, o in enumerate(order) if o[0] == "login" and o[1] == "POST")
|
||||||
|
first_patch = next(i for i, o in enumerate(order) if o[0] == "mittwald" and o[1] == "PATCH")
|
||||||
|
assert first_login_write < first_patch
|
||||||
|
env = next(o[3]["services"]["app-klinik5"]["environment"] for o in order if o[0] == "mittwald" and o[1] == "PATCH" and o[3].get("services"))
|
||||||
|
assert env["OIDC_CLIENT_ID"] == "app_0001" and env["OIDC_CLIENT_SECRET"] == "secret-1" and env["OIDC_REDIRECT_URI"].startswith("https://klinik5.")
|
||||||
|
# Login-API kaputt → Deploy bricht VOR dem Service-PATCH ab
|
||||||
|
order.clear()
|
||||||
|
client.put("/api/tenants/klinik5/oidc", json={"auth_mode": "optional"})
|
||||||
|
bad = onyva_login.OnyvaLogin("https://login.test/api/v1", "falscher-key", spy, sleep=lambda s: None)
|
||||||
|
dep2 = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=bad, wait=True, health=lambda *a: None)
|
||||||
|
d2 = client.get(f"/api/deployments/{dep2}").json()
|
||||||
|
assert d2["status"] == "failed" and "OIDC-Abgleich fehlgeschlagen" in d2["log"]
|
||||||
|
assert not any(o[0] == "mittwald" and o[1] == "PATCH" for o in order)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_deploy_shows_oidc_plan_without_writes(client, login, monkeypatch):
|
||||||
|
monkeypatch.delenv("DEPLOY_LIVE", raising=False)
|
||||||
|
monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False)
|
||||||
|
from app.services import deploy
|
||||||
|
client.post("/api/tenants", json={"id": "klinik6", "name": "Klinik Sechs"})
|
||||||
|
dep = deploy.start("klinik6", "dry-run", "test", wait=True)
|
||||||
|
d = client.get(f"/api/deployments/{dep}").json()
|
||||||
|
assert d["status"] == "success" and "würde App" in d["log"] and not login.writes
|
||||||
1
vendor/platform/CORE_VERSION
vendored
Normal file
1
vendor/platform/CORE_VERSION
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration
|
||||||
1
vendor/platform/core/BUILD_INFO.json
vendored
Normal file
1
vendor/platform/core/BUILD_INFO.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"}
|
||||||
0
vendor/platform/core/__init__.py
vendored
Normal file
0
vendor/platform/core/__init__.py
vendored
Normal file
1637
vendor/platform/core/app.py
vendored
Normal file
1637
vendor/platform/core/app.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
178
vendor/platform/core/bundle.py
vendored
Normal file
178
vendor/platform/core/bundle.py
vendored
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tenant-Bundle-Loader: holt die Tenant-Daten zur Laufzeit von der Admin-Plattform (TENANT_SOURCE=admin).
|
||||||
|
|
||||||
|
git (Standard, wie bisher) Tenant-Ordner aus dem Image: tenants/<id>/
|
||||||
|
admin Bundle von {ADMIN_URL}/api/tenants/<id>/bundle (Bearer ADMIN_BUNDLE_TOKEN), geprüft und lokal gecacht
|
||||||
|
|
||||||
|
Ausfallsicherheit bei admin: Admin nicht erreichbar → letzter gecachter Stand (/data/bundle) → eingebackener Tenant
|
||||||
|
(nur solange BUNDLE_FALLBACK_BAKED nicht „false“ ist) → sonst Fehler beim Start. Die Admin-Plattform ist damit nur
|
||||||
|
Kontrollebene, nicht Teil des Laufzeitpfads.
|
||||||
|
|
||||||
|
Sicherheit: Hashes aller Dateien werden gegen MANIFEST.json geprüft, nur normale Dateien, keine Pfad-Tricks, Größenlimits.
|
||||||
|
Format siehe docs/CONTRACT.md. Nur Standardbibliothek – wird beim Import von core.config genutzt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
SUPPORTED_BUNDLE_FORMAT = 1
|
||||||
|
MAX_FILES = 200
|
||||||
|
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
||||||
|
KEEP_VERSIONS = 2
|
||||||
|
|
||||||
|
|
||||||
|
class BundleError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(v: str | None, default: bool) -> bool:
|
||||||
|
return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _http_fetch(url: str, token: str, etag: str | None, timeout: float = 20.0):
|
||||||
|
"""→ (status, headers, body). 304 bei unverändertem ETag. Wirft OSError bei Netzwerkfehlern."""
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", **({"If-None-Match": etag} if etag else {})})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.status, dict(r.headers), r.read(MAX_TOTAL_BYTES + 1)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, dict(exc.headers or {}), b""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bundle(data: bytes, tenant_id: str) -> tuple[dict, dict[str, bytes]]:
|
||||||
|
"""Prüft ein Bundle und gibt (manifest, {pfad: inhalt}) zurück. Wirft BundleError bei jeder Auffälligkeit."""
|
||||||
|
if len(data) > MAX_TOTAL_BYTES:
|
||||||
|
raise BundleError("Bundle zu groß")
|
||||||
|
files: dict[str, bytes] = {}
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
||||||
|
members = tar.getmembers()
|
||||||
|
if len(members) > MAX_FILES:
|
||||||
|
raise BundleError(f"Bundle enthält mehr als {MAX_FILES} Dateien")
|
||||||
|
for m in members:
|
||||||
|
if not m.isreg():
|
||||||
|
raise BundleError(f"Nicht erlaubter Eintrag im Bundle: {m.name!r}")
|
||||||
|
p = PurePosixPath(m.name)
|
||||||
|
if p.is_absolute() or ".." in p.parts or not p.parts or "\\" in m.name:
|
||||||
|
raise BundleError(f"Unzulässiger Pfad im Bundle: {m.name!r}")
|
||||||
|
total += m.size
|
||||||
|
if total > MAX_TOTAL_BYTES:
|
||||||
|
raise BundleError("Bundle entpackt zu groß")
|
||||||
|
files[str(p)] = tar.extractfile(m).read()
|
||||||
|
except (tarfile.TarError, EOFError, OSError) as exc:
|
||||||
|
raise BundleError(f"Bundle nicht lesbar: {exc}") from exc
|
||||||
|
if "MANIFEST.json" not in files or "tenant.json" not in files:
|
||||||
|
raise BundleError("MANIFEST.json oder tenant.json fehlt im Bundle")
|
||||||
|
try:
|
||||||
|
manifest = json.loads(files.pop("MANIFEST.json"))
|
||||||
|
cfg = json.loads(files["tenant.json"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BundleError(f"MANIFEST.json/tenant.json ungültig: {exc}") from exc
|
||||||
|
if int(manifest.get("bundle_format", 1)) > SUPPORTED_BUNDLE_FORMAT:
|
||||||
|
raise BundleError(f"Bundle-Format {manifest.get('bundle_format')} wird von diesem Core nicht unterstützt")
|
||||||
|
if manifest.get("tenant") != tenant_id or cfg.get("id") != tenant_id:
|
||||||
|
raise BundleError(f"Bundle gehört nicht zu Tenant {tenant_id!r}")
|
||||||
|
expected = manifest.get("files") or {}
|
||||||
|
if set(expected) != set(files):
|
||||||
|
raise BundleError("Dateiliste weicht vom MANIFEST ab")
|
||||||
|
for path, sha in expected.items():
|
||||||
|
if hashlib.sha256(files[path]).hexdigest() != sha:
|
||||||
|
raise BundleError(f"Hash-Abweichung: {path}")
|
||||||
|
return manifest, files
|
||||||
|
|
||||||
|
|
||||||
|
def _install(cache: Path, version: int, manifest: dict, files: dict[str, bytes], etag: str | None) -> Path:
|
||||||
|
"""Schreibt das Bundle in ein neues Versionsverzeichnis und schaltet `current` atomar um."""
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = cache / f"v{version}-{hashlib.sha256(json.dumps(manifest.get('files', {}), sort_keys=True).encode()).hexdigest()[:8]}"
|
||||||
|
if not target.exists():
|
||||||
|
tmp = cache / f".tmp-{os.getpid()}-{int(time.time() * 1000)}"
|
||||||
|
for path, content in files.items():
|
||||||
|
dest = tmp / path
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(content)
|
||||||
|
(tmp / ".bundle.json").write_text(json.dumps({"version": version, "etag": etag, "fetched_at": time.time()}))
|
||||||
|
os.replace(tmp, target)
|
||||||
|
link = cache / "current"
|
||||||
|
new_link = cache / f".current-{os.getpid()}"
|
||||||
|
if new_link.is_symlink() or new_link.exists():
|
||||||
|
new_link.unlink()
|
||||||
|
os.symlink(target.name, new_link)
|
||||||
|
os.replace(new_link, link)
|
||||||
|
keep = {target.name}
|
||||||
|
for d in sorted((p for p in cache.glob("v*") if p.is_dir()), key=lambda p: p.stat().st_mtime, reverse=True)[:KEEP_VERSIONS]:
|
||||||
|
keep.add(d.name)
|
||||||
|
for d in cache.glob("v*"):
|
||||||
|
if d.is_dir() and d.name not in keep:
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(cache: Path) -> tuple[Path, dict] | None:
|
||||||
|
link = cache / "current"
|
||||||
|
if link.is_symlink() and (link / "tenant.json").exists():
|
||||||
|
try:
|
||||||
|
meta = json.loads((link / ".bundle.json").read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
meta = {}
|
||||||
|
return link, meta
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(tenant_id: str, tenants_dir: Path, env: dict | None = None, fetch=_http_fetch) -> tuple[Path, dict]:
|
||||||
|
"""Liefert (Tenant-Verzeichnis, Info). Info: {source: git|admin|cache|baked, version, stale, error, fetched_at}."""
|
||||||
|
env = os.environ if env is None else env
|
||||||
|
source = (env.get("TENANT_SOURCE") or "git").strip().lower()
|
||||||
|
baked = tenants_dir / tenant_id
|
||||||
|
if source == "git":
|
||||||
|
return baked, {"source": "git", "version": None, "stale": False, "error": None, "fetched_at": None}
|
||||||
|
if source != "admin":
|
||||||
|
raise BundleError(f"Unbekannte TENANT_SOURCE {source!r} (erlaubt: git, admin)")
|
||||||
|
|
||||||
|
base = (env.get("ADMIN_URL") or "").rstrip("/")
|
||||||
|
token = env.get("ADMIN_BUNDLE_TOKEN") or ""
|
||||||
|
cache = Path(env.get("BUNDLE_CACHE_DIR") or Path(env.get("DATA_DIR") or "/data") / "bundle") / tenant_id
|
||||||
|
have = _cached(cache)
|
||||||
|
error: str | None = None
|
||||||
|
if not base or not token:
|
||||||
|
error = "ADMIN_URL oder ADMIN_BUNDLE_TOKEN nicht gesetzt"
|
||||||
|
else:
|
||||||
|
for attempt in range(3): # kurze Wiederholung: Container und Admin starten oft gleichzeitig
|
||||||
|
try:
|
||||||
|
status, headers, body = fetch(f"{base}/api/tenants/{tenant_id}/bundle", token, (have[1].get("etag") if have else None))
|
||||||
|
except OSError as exc:
|
||||||
|
error, status = f"Admin nicht erreichbar: {exc}", None
|
||||||
|
else:
|
||||||
|
if status == 200:
|
||||||
|
try:
|
||||||
|
manifest, files = parse_bundle(body, tenant_id)
|
||||||
|
version = int(manifest.get("version") or headers.get("X-Bundle-Version") or 0)
|
||||||
|
etag = {k.lower(): v for k, v in headers.items()}.get("etag")
|
||||||
|
d = _install(cache, version, manifest, files, etag)
|
||||||
|
return d, {"source": "admin", "version": version, "stale": False, "error": None, "fetched_at": time.time()}
|
||||||
|
except BundleError as exc:
|
||||||
|
error = f"Ungültiges Bundle: {exc}"
|
||||||
|
break # kein Retry: Inhalt ist kaputt, nicht das Netz
|
||||||
|
elif status == 304 and have:
|
||||||
|
return have[0], {"source": "admin", "version": have[1].get("version"), "stale": False, "error": None, "fetched_at": have[1].get("fetched_at")}
|
||||||
|
elif status in (401, 403, 404):
|
||||||
|
error = f"Admin verweigert das Bundle (HTTP {status})"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
error = f"Admin-Antwort HTTP {status}"
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1.5 * (attempt + 1))
|
||||||
|
if have:
|
||||||
|
return have[0], {"source": "cache", "version": have[1].get("version"), "stale": True, "error": error, "fetched_at": have[1].get("fetched_at")}
|
||||||
|
if _truthy(env.get("BUNDLE_FALLBACK_BAKED"), True) and (baked / "tenant.json").exists():
|
||||||
|
return baked, {"source": "baked", "version": None, "stale": True, "error": error, "fetched_at": None}
|
||||||
|
raise BundleError(f"Tenant-Bundle für {tenant_id!r} nicht verfügbar und kein Cache/Rückfall vorhanden: {error}")
|
||||||
154
vendor/platform/core/config.py
vendored
Normal file
154
vendor/platform/core/config.py
vendored
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Tenant configuration loading.
|
||||||
|
|
||||||
|
One process serves exactly one tenant, selected via the TENANT env var
|
||||||
|
(default "default"). The tenant folder lives in tenants/<id>/ and contains:
|
||||||
|
|
||||||
|
tenant.json branding, office info, feature flags, STT provider (data, not code)
|
||||||
|
incl. org_short/org_profile for the shared default prompt
|
||||||
|
logo.svg|png brand logo (web topbar + PDF header)
|
||||||
|
prompt.txt summary prompt (optional — falls back to core default)
|
||||||
|
protocol.html PDF layout template (optional — falls back to core default)
|
||||||
|
protocol.css PDF styling (optional — falls back to core default)
|
||||||
|
overrides.py code hooks (optional — see core/hooks.py)
|
||||||
|
|
||||||
|
Everything that differs per customer is *data* here. The core code never
|
||||||
|
changes per customer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core import bundle
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
# TENANTS_DIR (Env) nur für Tests/Entwicklung; Standard: eingebackene Tenants im Image
|
||||||
|
TENANTS_DIR = Path(os.getenv("TENANTS_DIR") or ROOT / "tenants")
|
||||||
|
CORE_DEFAULTS = ROOT / "core" / "defaults"
|
||||||
|
CORE_TEMPLATES = ROOT / "core" / "templates"
|
||||||
|
|
||||||
|
# Default theme — used for any colour a tenant does not override.
|
||||||
|
DEFAULT_COLORS = {
|
||||||
|
"accent": "#327F24",
|
||||||
|
"accent_dark": "#235A19",
|
||||||
|
"accent_mid": "#3E9130",
|
||||||
|
"accent_light": "#EAF4E7",
|
||||||
|
"accent_xlight": "#F3FAF1",
|
||||||
|
"bg": "#F1F5F0",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surface2": "#F6FAF5",
|
||||||
|
"border": "#DCE8D9",
|
||||||
|
"border2": "#BDD1B8",
|
||||||
|
"text": "#444444",
|
||||||
|
"text_2": "#666666",
|
||||||
|
"text_3": "#9AA89A",
|
||||||
|
"red": "#DC2626",
|
||||||
|
"progress_to": "#8bbf43",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TenantConfig:
|
||||||
|
id: str
|
||||||
|
dir: Path
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
colors: dict
|
||||||
|
office: dict
|
||||||
|
flags: dict
|
||||||
|
ai_detect_org: str
|
||||||
|
org_short: str = ""
|
||||||
|
org_profile: str = ""
|
||||||
|
stt: dict = field(default_factory=dict)
|
||||||
|
raw: dict = field(default_factory=dict)
|
||||||
|
# Herkunft der Tenant-Daten (siehe core/bundle.py): {source: git|admin|cache|baked, version, stale, error}
|
||||||
|
bundle: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
# ── resolved file paths (tenant override → core default) ──
|
||||||
|
def _pick(self, tenant_name: str, default_path: Path) -> Path:
|
||||||
|
candidate = self.dir / tenant_name
|
||||||
|
return candidate if candidate.exists() else default_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prompt_path(self) -> Path:
|
||||||
|
return self._pick("prompt.txt", CORE_DEFAULTS / "prompt.txt")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pdf_template_path(self) -> Path:
|
||||||
|
return self._pick("protocol.html", CORE_TEMPLATES / "protocol.html")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pdf_css_path(self) -> Path:
|
||||||
|
return self._pick("protocol.css", CORE_TEMPLATES / "protocol.css")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logo_path(self) -> Path | None:
|
||||||
|
for name in ("logo.svg", "logo.png"):
|
||||||
|
p = self.dir / name
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def docx_logo_path(self) -> Path | None:
|
||||||
|
"""Logo for the Word header — Word needs a raster image, no SVG.
|
||||||
|
|
||||||
|
A tenant may ship both (logo.svg for web/PDF, logo.png next to it);
|
||||||
|
this prefers the raster file where logo_path prefers the vector one.
|
||||||
|
"""
|
||||||
|
for name in ("logo.png", "logo.jpg", "logo.jpeg", "logo.svg"):
|
||||||
|
p = self.dir / name
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def favicon_path(self) -> Path | None:
|
||||||
|
p = self.dir / "favicon.svg"
|
||||||
|
return p if p.exists() else None
|
||||||
|
|
||||||
|
def flag(self, key: str, default=None):
|
||||||
|
return self.flags.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def load_tenant(tenant_id: str | None = None) -> TenantConfig:
|
||||||
|
tenant_id = (tenant_id or os.getenv("TENANT", "default")).strip()
|
||||||
|
tenant_dir, bundle_info = bundle.resolve(tenant_id, TENANTS_DIR)
|
||||||
|
config_file = tenant_dir / "tenant.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tenant '{tenant_id}' nicht gefunden: {config_file} fehlt. "
|
||||||
|
f"Verfügbar: {[p.name for p in TENANTS_DIR.iterdir() if p.is_dir()] if TENANTS_DIR.exists() else []}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = json.loads(config_file.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
colors = {**DEFAULT_COLORS, **(raw.get("colors") or {})}
|
||||||
|
name = raw.get("name", tenant_id)
|
||||||
|
|
||||||
|
return TenantConfig(
|
||||||
|
id=tenant_id,
|
||||||
|
dir=tenant_dir,
|
||||||
|
name=name,
|
||||||
|
title=raw.get("title", f"{name} – Gesprächsprotokoll"),
|
||||||
|
description=raw.get("description", f"Gesprächsprotokoll-Tool für {name}"),
|
||||||
|
colors=colors,
|
||||||
|
office=raw.get("office") or {},
|
||||||
|
flags=raw.get("flags") or {},
|
||||||
|
ai_detect_org=raw.get("ai_detect_org", name),
|
||||||
|
# How the tenant refers to itself inside the protocol text ("revier",
|
||||||
|
# "Onyva") and one optional sentence describing what it does — both are
|
||||||
|
# substituted into the shared default prompt.
|
||||||
|
org_short=raw.get("org_short", name),
|
||||||
|
org_profile=raw.get("org_profile", ""),
|
||||||
|
stt=raw.get("stt") or {},
|
||||||
|
raw=raw,
|
||||||
|
bundle=bundle_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton for the running process (TENANT is fixed per instance).
|
||||||
|
CFG: TenantConfig = load_tenant()
|
||||||
111
vendor/platform/core/contract.py
vendored
Normal file
111
vendor/platform/core/contract.py
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Vertrag zwischen Core und Admin-Plattform (siehe docs/CONTRACT.md).
|
||||||
|
|
||||||
|
Der Core veröffentlicht bei jedem Release: Image, Manifest, Schema (`core/schema/tenant_schema.json`), Bundle-Format und
|
||||||
|
`/health`. Die Admin-Plattform liest das – sie hält keine eigene Kopie der Tenant-Optionen.
|
||||||
|
|
||||||
|
python -m core.contract # Manifest als JSON auf stdout
|
||||||
|
python -m core.contract --check # Schema gegen den Code prüfen (Exit-Code ≠ 0 bei Abweichung)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONTRACT_VERSION = 1 # Version dieses Vertrags (Schema-Format, Bundle-Format, Health-Felder)
|
||||||
|
HOOK_API = 1 # Version der Hook-Schnittstelle (core/hooks.py); overrides.py kann `HOOK_API = 1` deklarieren
|
||||||
|
BUNDLE_FORMAT = 1 # Format des Tenant-Bundles (tar.gz mit tenant.json + Dateien + MANIFEST.json)
|
||||||
|
|
||||||
|
CORE_DIR = Path(__file__).resolve().parent
|
||||||
|
ROOT = CORE_DIR.parent
|
||||||
|
SCHEMA_PATH = CORE_DIR / "schema" / "tenant_schema.json"
|
||||||
|
BUILD_INFO_PATH = CORE_DIR / "BUILD_INFO.json" # schreibt das Dockerfile; lokal fehlt die Datei
|
||||||
|
|
||||||
|
# Env-Variablen, die der Code liest, die aber bewusst nicht im Schema stehen (interne/Legacy/Plattformvariablen)
|
||||||
|
INTERNAL_ENV = {"TENANTS_DIR", "TEMP_DIR", "LOCALAPPDATA", "APP_SESSION_SECRET", "CORE_SHA", "CORE_BUILT_AT", "PYTHONPATH", "PATH", "HOME"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_info() -> dict:
|
||||||
|
"""{sha, built_at, source}: BUILD_INFO.json (Image) > Env CORE_SHA > git > 'dev'."""
|
||||||
|
try:
|
||||||
|
data = json.loads(BUILD_INFO_PATH.read_text(encoding="utf-8"))
|
||||||
|
if data.get("sha"):
|
||||||
|
return {"sha": data["sha"], "built_at": data.get("built_at"), "source": "image"}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
if os.getenv("CORE_SHA"):
|
||||||
|
return {"sha": os.environ["CORE_SHA"], "built_at": os.getenv("CORE_BUILT_AT"), "source": "env"}
|
||||||
|
try:
|
||||||
|
sha = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
|
||||||
|
if sha:
|
||||||
|
return {"sha": sha, "built_at": None, "source": "git"}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {"sha": "dev", "built_at": None, "source": "dev"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_schema() -> dict:
|
||||||
|
return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest() -> dict:
|
||||||
|
schema = load_schema()
|
||||||
|
return {
|
||||||
|
"contract": CONTRACT_VERSION, "hook_api": HOOK_API, "bundle_format": BUNDLE_FORMAT,
|
||||||
|
"core": build_info(),
|
||||||
|
"requirements_sha256": _sha256(ROOT / "requirements.txt"),
|
||||||
|
"schema_sha256": _sha256(SCHEMA_PATH),
|
||||||
|
"fields": len(schema["fields"]), "env": len(schema["env"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schema_paths(schema: dict | None = None) -> set[str]:
|
||||||
|
return {f["path"] for f in (schema or load_schema())["fields"]}
|
||||||
|
|
||||||
|
|
||||||
|
def check_tenant_json(raw: dict, schema: dict | None = None) -> list[str]:
|
||||||
|
"""Meldet unbekannte Schlüssel und falsche Typen in einer tenant.json (Tippfehler fallen sonst still durch)."""
|
||||||
|
schema = schema or load_schema()
|
||||||
|
fields = {f["path"]: f for f in schema["fields"]}
|
||||||
|
ignored = set(schema.get("ignored_keys", []))
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
def walk(prefix: str, obj: dict) -> None:
|
||||||
|
for k, v in obj.items():
|
||||||
|
path = f"{prefix}{k}"
|
||||||
|
if not prefix and k in ignored or (not prefix and k == "id"):
|
||||||
|
continue
|
||||||
|
if path in fields:
|
||||||
|
t = fields[path]["type"]
|
||||||
|
ok = {"bool": isinstance(v, bool), "number": isinstance(v, (int, float)) and not isinstance(v, bool),
|
||||||
|
"enum": v in (fields[path].get("options") or []) or v in (None, ""),
|
||||||
|
"color": isinstance(v, str) and len(v) in (4, 7) and v.startswith("#")}.get(t, isinstance(v, str) or v is None)
|
||||||
|
if not ok:
|
||||||
|
problems.append(f"{path}: ungültiger Wert {v!r} (erwartet {t})")
|
||||||
|
elif isinstance(v, dict) and any(p.startswith(path + ".") for p in fields):
|
||||||
|
walk(path + ".", v)
|
||||||
|
else:
|
||||||
|
problems.append(f"{path}: unbekannte Option (nicht im Schema)")
|
||||||
|
|
||||||
|
walk("", raw)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--check" in sys.argv:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from tests.test_contract import drift_report # noqa: E402
|
||||||
|
rep = drift_report()
|
||||||
|
print("\n".join(rep) or "Schema und Code stimmen überein.")
|
||||||
|
sys.exit(1 if rep else 0)
|
||||||
|
print(json.dumps(build_manifest(), indent=2, ensure_ascii=False))
|
||||||
146
vendor/platform/core/cost.py
vendored
Normal file
146
vendor/platform/core/cost.py
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Cost-/Usage-Tracking client for the central Onyva Dev Login.
|
||||||
|
|
||||||
|
Reports finished EUR amounts for LLM calls and audio STT to the central
|
||||||
|
``/api/usage`` endpoint (see ``docs/cost.md``). Design goals:
|
||||||
|
|
||||||
|
* **Buffered + batched** — events are collected in memory and flushed by a
|
||||||
|
background thread, not one HTTP request per event.
|
||||||
|
* **Never blocks the user request** — ``track_usage`` only appends to a buffer;
|
||||||
|
the network call happens on the daemon thread. All errors are swallowed
|
||||||
|
(tracking is best effort and must never break the app flow).
|
||||||
|
* **Idempotent** — callers pass a stable ``idempotency_key`` so retries / repeated
|
||||||
|
status polls never double-count.
|
||||||
|
|
||||||
|
The ingest key (``usk_…``) comes from a **per-tenant Gitea secret**
|
||||||
|
``COST_INGEST_KEY_<ID>`` and reaches the container as ``COST_INGEST_KEY`` — kept
|
||||||
|
separate from the existing ``TENANT_SECRETS_<ID>`` blob (see ``infra/deploy.py``).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ── config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Same base URL as the auth server. Defaults to OIDC_ISSUER, then the public login.
|
||||||
|
COST_API_BASE = os.getenv(
|
||||||
|
"COST_API_BASE",
|
||||||
|
os.getenv("OIDC_ISSUER", "https://login.onyva.dev"),
|
||||||
|
).strip().rstrip("/")
|
||||||
|
|
||||||
|
# Per-tenant ingest key (usk_…). Empty → tracking is inert (see _enabled()).
|
||||||
|
COST_INGEST_KEY = os.getenv("COST_INGEST_KEY", "").strip()
|
||||||
|
|
||||||
|
COST_TRACKING_ENABLED = os.getenv("COST_TRACKING_ENABLED", "true").lower() == "true"
|
||||||
|
|
||||||
|
_FLUSH_INTERVAL = float(os.getenv("COST_FLUSH_INTERVAL", "5"))
|
||||||
|
_HTTP_TIMEOUT = float(os.getenv("COST_HTTP_TIMEOUT", "5"))
|
||||||
|
|
||||||
|
_buffer: list[dict] = []
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_worker_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
# No key → nothing to authenticate with → stay completely inert (also covers
|
||||||
|
# local dev where the key is simply unset).
|
||||||
|
return COST_TRACKING_ENABLED and bool(COST_INGEST_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
# ── pricing (EUR) ─────────────────────────────────────────────────────────────
|
||||||
|
# The app reports finished EUR amounts; the server stores them 1:1. Rates are
|
||||||
|
# overridable via env so they can be tuned without a redeploy.
|
||||||
|
#
|
||||||
|
# Defaults:
|
||||||
|
# • LLM — claude-4-6-sonnet via Cortecs, billed by Cortecs directly in EUR:
|
||||||
|
# €2.87 / €14.31 per 1M tokens (list price, 17 Jul 2026). No USD
|
||||||
|
# conversion applies — unlike Bedrock, Cortecs quotes EUR natively.
|
||||||
|
# • STT — Mistral Voxtral Mini Transcribe: $0.003/min → €0.002625/min
|
||||||
|
# (converted at 1 USD ≈ €0.875, 24 Jun 2026). Läuft ein Tenant über
|
||||||
|
# Cortecs statt direkt über Mistral, liefert core/app.py den dortigen
|
||||||
|
# EUR-Tarif als `per_minute` mit (siehe _STT_PROVIDERS).
|
||||||
|
# Re-tune via the COST_* env vars when tariffs move, or if LLM_MODEL is changed
|
||||||
|
# to a different tier (Cortecs publishes per-model EUR rates on GET /v1/models).
|
||||||
|
|
||||||
|
def llm_cost_eur(model: str, input_tokens: int, output_tokens: int) -> float:
|
||||||
|
"""EUR cost of one LLM call from token counts. Rates are €/1M tokens."""
|
||||||
|
in_per_mtok = float(os.getenv("COST_LLM_INPUT_PER_MTOK", "2.87"))
|
||||||
|
out_per_mtok = float(os.getenv("COST_LLM_OUTPUT_PER_MTOK", "14.31"))
|
||||||
|
return (input_tokens / 1_000_000) * in_per_mtok + (output_tokens / 1_000_000) * out_per_mtok
|
||||||
|
|
||||||
|
|
||||||
|
def stt_cost_eur(minutes: float, per_minute: float | None = None) -> float:
|
||||||
|
"""EUR cost of an STT call from audio duration. Rate is €/minute.
|
||||||
|
|
||||||
|
The caller passes the rate of the tenant's STT provider (Mistral direkt vs.
|
||||||
|
Cortecs sind unterschiedlich teuer); COST_STT_PER_MINUTE überschreibt sie."""
|
||||||
|
default = "0.002625" if per_minute is None else str(per_minute)
|
||||||
|
rate = float(os.getenv("COST_STT_PER_MINUTE") or default)
|
||||||
|
return max(minutes, 0.0) * rate
|
||||||
|
|
||||||
|
|
||||||
|
# ── public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def track_usage(
|
||||||
|
cost_point: str,
|
||||||
|
amount_eur: float,
|
||||||
|
account: str | None = None,
|
||||||
|
quantity: float | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Non-blocking: append a usage event to the buffer. No-op when disabled."""
|
||||||
|
if not _enabled():
|
||||||
|
return
|
||||||
|
ev: dict = {"cost_point": cost_point, "amount": round(float(amount_eur), 4)}
|
||||||
|
if account:
|
||||||
|
ev["account"] = account
|
||||||
|
if quantity is not None:
|
||||||
|
ev["quantity"] = quantity
|
||||||
|
if idempotency_key:
|
||||||
|
ev["idempotency_key"] = idempotency_key
|
||||||
|
if metadata:
|
||||||
|
ev["metadata"] = metadata
|
||||||
|
with _lock:
|
||||||
|
_buffer.append(ev)
|
||||||
|
|
||||||
|
|
||||||
|
def flush_now() -> None:
|
||||||
|
"""Send everything currently buffered. Safe to call from shutdown."""
|
||||||
|
if not _enabled():
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
if not _buffer:
|
||||||
|
return
|
||||||
|
batch, _buffer[:] = _buffer[:], []
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f"{COST_API_BASE}/api/usage",
|
||||||
|
headers={"Authorization": f"Bearer {COST_INGEST_KEY}"},
|
||||||
|
json={"events": batch},
|
||||||
|
timeout=_HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
except Exception as exc: # best effort — never propagate
|
||||||
|
# Re-buffer so a transient outage doesn't silently drop events.
|
||||||
|
with _lock:
|
||||||
|
_buffer[:0] = batch
|
||||||
|
print(f"[cost] flush failed ({len(batch)} events re-buffered): {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _loop() -> None:
|
||||||
|
while True:
|
||||||
|
time.sleep(_FLUSH_INTERVAL)
|
||||||
|
flush_now()
|
||||||
|
|
||||||
|
|
||||||
|
def start_worker() -> None:
|
||||||
|
"""Start the background flush thread once. Inert when tracking is disabled."""
|
||||||
|
global _worker_started
|
||||||
|
if _worker_started or not _enabled():
|
||||||
|
return
|
||||||
|
_worker_started = True
|
||||||
|
threading.Thread(target=_loop, daemon=True, name="cost-flush").start()
|
||||||
|
print(f"[cost] tracking enabled → {COST_API_BASE}/api/usage")
|
||||||
142
vendor/platform/core/defaults/prompt.txt
vendored
Normal file
142
vendor/platform/core/defaults/prompt.txt
vendored
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
Du bist der offizielle Protokollführer von @@ORG@@. @@ORG_PROFILE@@
|
||||||
|
|
||||||
|
Deine Aufgabe ist es, aus einem rohen Gesprächsprotokoll eines Kundentermins ein druckreifes Ergebnisprotokoll im Stil von @@ORG_SHORT@@ zu erstellen. Das Ergebnis ist ein internes wie externes Arbeitsdokument, das nach dem Termin an Kunde und Team geht und als verbindliche Grundlage für die weitere Zusammenarbeit dient.
|
||||||
|
|
||||||
|
Zentrale Zielsetzung:
|
||||||
|
Erzeuge keine wortgetreue Wiedergabe des Gesprächs, sondern ein klar gegliedertes Ergebnisprotokoll. Im Mittelpunkt stehen Entscheidungen, Action Items mit Verantwortlichen und Fristen sowie offene Punkte. Diskussionen werden verdichtet, Smalltalk und Wiederholungen entfallen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ABSOLUTE REGELN
|
||||||
|
|
||||||
|
1. Erfinde nichts. Füge keine Namen, Termine, Budgets, Tools, Plattformen, Domains, KPIs oder Zusagen hinzu, die nicht im Rohprotokoll enthalten oder daraus eindeutig ableitbar sind.
|
||||||
|
2. Bleibe streng quellentreu. Verdichte, ordne und glätte sprachlich, aber ändere keine inhaltliche Aussage.
|
||||||
|
3. Schreibe vollständig auf Deutsch.
|
||||||
|
4. Verwende niemals Markdown. Also keine Sternchen, Doppelsternchen, Rauten, Backticks oder eckigen Klammern (außer in der genau vorgegebenen Action-Item-Notation, siehe unten).
|
||||||
|
5. Gib ausschließlich den finalen Dokumenttext aus. Keine Vorbemerkung, keine Erklärung, keine Meta-Hinweise, keine Code-Blöcke.
|
||||||
|
6. Sachlicher, professioneller Ton. Sie-Anrede an den Kunden, soweit Anrede überhaupt vorkommt. Keine Werbesprache, keine Füllphrasen, keine Bewertungen.
|
||||||
|
7. Eigennamen, Zahlen, Geldbeträge, Datumsangaben, URLs und Tool-Bezeichnungen exakt bewahren.
|
||||||
|
8. Wenn ein Detail im Gespräch unklar bleibt, nimm es als offenen Punkt auf, statt etwas zu ergänzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
EINGABE
|
||||||
|
|
||||||
|
Du erhältst vom Nutzer vor dem Gesprächsprotokoll drei Angaben:
|
||||||
|
|
||||||
|
Termin-Art: entweder eine konkrete Bezeichnung (z.B. "Kick-off", "Jour fixe", "Konzept-Review", "Status-Update", "Workshop", "Briefing", "Abnahme", "Strategie-Meeting") oder leer bzw. "(aus Gespräch erkennen)". Ist die Termin-Art vorgegeben, übernimm sie unverändert. Ist sie nicht vorgegeben, leite sie aus dem Gespräch ab. Gelingt das nicht eindeutig, nutze "Kundengespräch".
|
||||||
|
|
||||||
|
Projektname: entweder eine Bezeichnung des Projekts oder leer bzw. "(keiner)". Ist ein Projektname angegeben, führe ihn im Projekt-Block auf.
|
||||||
|
|
||||||
|
Meeting-Titel: ein freier Titel, der dir Orientierung geben kann, aber nicht ins Dokument gehört.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
AUSGABEFORMAT
|
||||||
|
|
||||||
|
Die Ausgabe besteht aus zwei Teilen:
|
||||||
|
|
||||||
|
TEIL 1 — METADATEN-BLÖCKE (immer am Anfang, in genau dieser Reihenfolge, mit den exakten Markern)
|
||||||
|
|
||||||
|
===PROJEKT===
|
||||||
|
{Kunde / Unternehmen, z.B. "Müller Bau GmbH"}
|
||||||
|
{ggf. Projektname, z.B. "Relaunch Website 2026", nur wenn vorhanden}
|
||||||
|
===/PROJEKT===
|
||||||
|
|
||||||
|
===KONTAKT===
|
||||||
|
{Vorname Nachname - Funktion, alles auf einer Zeile, z.B. "Markus Müller - Geschäftsführer"}
|
||||||
|
{ggf. weitere Teilnehmer auf Kundenseite, je eine Zeile, gleiches Format "Vorname Nachname - Funktion"}
|
||||||
|
===/KONTAKT===
|
||||||
|
|
||||||
|
Wenn die Funktion nicht bekannt ist, schreibe nur den Namen ohne Trennstrich. Setze keine Anrede ("Herr"/"Frau") vor den Namen — Vor- und Nachname genügen.
|
||||||
|
|
||||||
|
Wenn im Gespräch kein Ansprechpartner identifizierbar ist, gib den Block trotzdem aus, aber mit einer einzigen Zeile dazwischen:
|
||||||
|
===KONTAKT===
|
||||||
|
(keiner)
|
||||||
|
===/KONTAKT===
|
||||||
|
|
||||||
|
===TERMINART===
|
||||||
|
{Die verwendete Termin-Art, exakt so wie in der Eingabe vorgegeben oder aus dem Gespräch erkannt}
|
||||||
|
===/TERMINART===
|
||||||
|
|
||||||
|
TEIL 2 — INHALTSABSCHNITTE (immer in dieser Reihenfolge, jeder Abschnitt mit seiner genauen Überschrift auf einer eigenen Zeile, gefolgt von einer Leerzeile und dem Abschnittsinhalt)
|
||||||
|
|
||||||
|
Anlass & Kontext
|
||||||
|
|
||||||
|
Besprochene Themen
|
||||||
|
|
||||||
|
Entscheidungen
|
||||||
|
|
||||||
|
Action Items
|
||||||
|
|
||||||
|
Offene Punkte
|
||||||
|
|
||||||
|
Nächste Schritte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
INHALT DER EINZELNEN ABSCHNITTE
|
||||||
|
|
||||||
|
Abschnitt "Anlass & Kontext"
|
||||||
|
|
||||||
|
Knapper Absatz von 2 bis 4 Sätzen, der benennt:
|
||||||
|
- Worum es in diesem Termin ging (z.B. Kick-off, Status, Abnahme)
|
||||||
|
- Welcher Projekt- oder Phasenstand vorliegt (nur wenn aus Gespräch erkennbar)
|
||||||
|
- Welche übergeordnete Zielsetzung im Raum steht
|
||||||
|
Keine Aufzählung, sondern Fließtext. Smalltalk und Begrüßung gehören nicht hierhin.
|
||||||
|
|
||||||
|
Abschnitt "Besprochene Themen"
|
||||||
|
|
||||||
|
Bullet-Liste der inhaltlich besprochenen Themen. Jeder Punkt ist ein eigener Bullet, der mit "• " (Aufzählungspunkt + Leerzeichen) beginnt. Pro Thema 1 bis 3 Sätze, die das Wesentliche der Diskussion verdichten — also Position des Kunden, Hinweise von @@ORG_SHORT@@, Argumente, Optionen. Verzichte auf Wortlautwiedergabe und auf reine Wiederholung. Reihenfolge: thematisch sinnvoll, nicht zwingend chronologisch. Wenn mehrere Sub-Punkte zusammengehören, halte sie in einem Bullet.
|
||||||
|
|
||||||
|
Beispiel für Bullet-Stil:
|
||||||
|
• Startseiten-Hero: Kunde wünscht stärkeren Fokus auf USPs. @@ORG_SHORT@@ schlägt zweispaltiges Layout mit Slider rechts vor; Entscheidung wird nach Wireframe getroffen.
|
||||||
|
|
||||||
|
Abschnitt "Entscheidungen"
|
||||||
|
|
||||||
|
Bullet-Liste der im Termin verbindlich getroffenen Entscheidungen. Jeder Punkt beginnt mit "• ". Nur Beschlüsse aufnehmen, die im Gespräch ausdrücklich getroffen oder bestätigt wurden — keine Vermutungen, keine Empfehlungen ohne Zustimmung. Wenn keine Entscheidungen getroffen wurden, gib einen einzigen Satz aus: "In diesem Termin wurden keine verbindlichen Entscheidungen getroffen."
|
||||||
|
|
||||||
|
Abschnitt "Action Items"
|
||||||
|
|
||||||
|
Bullet-Liste der vereinbarten Aufgaben. Jeder Punkt beginnt mit "• " und folgt diesem festen Schema:
|
||||||
|
|
||||||
|
• [Verantwortlicher · Frist] Aufgabenbeschreibung im Imperativ
|
||||||
|
|
||||||
|
Erläuterungen:
|
||||||
|
- Verantwortlicher ist entweder "Kunde", "@@ORG_SHORT@@" oder ein konkreter Personenname, sofern im Gespräch genannt. Wenn unklar, nutze "offen".
|
||||||
|
- Frist ist ein konkretes Datum (TT.MM.JJJJ), eine Kalenderwoche ("KW 19"), eine relative Angabe ("bis nächste Woche") oder "offen". Bewahre die Schreibweise wie im Gespräch.
|
||||||
|
- Aufgabenbeschreibung ist ein konkreter Auftrag, kein Konzept. Schreibe "Logo-Dateien liefern" statt "Wir kümmern uns um die Logos".
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
• [Kunde · 02.05.2026] Logo-Dateien und Brand-Guidelines im Vektorformat liefern
|
||||||
|
• [@@ORG_SHORT@@ · KW 19] Wireframes für Startseite und zwei Unterseiten erstellen
|
||||||
|
• [Markus Müller · offen] Freigabe der Domain-Liste
|
||||||
|
|
||||||
|
Wenn keine Action Items vereinbart wurden, gib einen einzigen Satz aus: "In diesem Termin wurden keine Action Items vereinbart."
|
||||||
|
|
||||||
|
Abschnitt "Offene Punkte"
|
||||||
|
|
||||||
|
Bullet-Liste aller Themen, zu denen Klärungsbedarf besteht oder die explizit vertagt wurden. Jeder Punkt beginnt mit "• ". Halte den Punkt so konkret, dass beim Folgetermin direkt daran weitergearbeitet werden kann. Wenn keine offenen Punkte vorliegen, gib einen einzigen Satz aus: "Es bestehen keine offenen Punkte."
|
||||||
|
|
||||||
|
Abschnitt "Nächste Schritte"
|
||||||
|
|
||||||
|
Knapper Absatz oder bis zu drei Bullet-Punkte. Inhalt: Folgetermin (Datum, falls vereinbart), nächster Meilenstein, anstehende Phase. Wenn nichts Konkretes vereinbart wurde, gib einen einzigen Satz aus: "Ein Folgetermin wurde nicht vereinbart."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
FORMATREGELN
|
||||||
|
|
||||||
|
- Jede Abschnittsüberschrift steht allein in einer Zeile, exakt in der oben genannten Schreibweise.
|
||||||
|
- Nach jeder Überschrift folgt eine Leerzeile, dann der Abschnittsinhalt.
|
||||||
|
- Fließtext als sauberer, gut lesbarer Absatz.
|
||||||
|
- Bullet-Points beginnen mit "• " (Aufzählungspunkt + Leerzeichen). Keine "-", keine "*", keine Nummerierung.
|
||||||
|
- Eckige Klammern sind ausschließlich in der Action-Item-Notation [Verantwortlicher · Frist] erlaubt, sonst nirgendwo.
|
||||||
|
- Keine Tabellen, keine HTML-Ausgabe, kein Markdown.
|
||||||
|
- Keine doppelten Leerzeilen zwischen Absätzen innerhalb eines Abschnitts (höchstens eine Leerzeile).
|
||||||
|
- Zwischen zwei Abschnitten steht genau eine Leerzeile vor der nächsten Überschrift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
QUALITÄTSMASSSTAB
|
||||||
|
|
||||||
|
Das Ergebnis soll so wirken, als wäre es von einem erfahrenen Projektleiter von @@ORG_SHORT@@ direkt nach dem Termin redaktionell erstellt worden. Der Leser versteht innerhalb von 30 Sekunden: Worum ging es, was wurde entschieden, wer macht was bis wann, was ist offen. Keine Floskeln, keine Wiederholungen, keine erfundenen Details. Action Items sind als verbindliche Aufträge formuliert, nicht als Absichtserklärungen.
|
||||||
238
vendor/platform/core/docx_export.py
vendored
Normal file
238
vendor/platform/core/docx_export.py
vendored
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Renders a Word (.docx) protocol from the shared PDF context.
|
||||||
|
|
||||||
|
The *content* is 100 % identical to the PDF export: both consume the structured
|
||||||
|
dict produced by ``core.pdf.build_pdf_context`` (tenant-independent). This module
|
||||||
|
supplies the Word *layout*, kept as close as reasonably possible to the PDF
|
||||||
|
(``core/templates/protocol.html`` + ``protocol.css``):
|
||||||
|
|
||||||
|
* title + logo live in the page header (not a big body title),
|
||||||
|
* office/footer note + page numbers live in the page footer,
|
||||||
|
* section headings are filled accent bars,
|
||||||
|
* the "Erstellt durch" metadata column is right-aligned,
|
||||||
|
* body font approximates the PDF's Noto Sans / Arial stack.
|
||||||
|
|
||||||
|
Because it reuses the shared context, the Word download works for every tenant
|
||||||
|
automatically, with no per-tenant flag or template.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_hex(value: str, fallback: str) -> str:
|
||||||
|
v = str(value or "").lstrip("#")
|
||||||
|
if len(v) == 3:
|
||||||
|
v = "".join(c * 2 for c in v)
|
||||||
|
if len(v) != 6:
|
||||||
|
v = fallback.lstrip("#")
|
||||||
|
try:
|
||||||
|
int(v, 16)
|
||||||
|
except ValueError:
|
||||||
|
v = fallback.lstrip("#")
|
||||||
|
return v.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def render_docx(context: dict, cfg) -> bytes:
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.shared import Mm, Pt, RGBColor
|
||||||
|
|
||||||
|
colors = context.get("colors") or {}
|
||||||
|
|
||||||
|
def hx(key: str, fallback: str) -> str:
|
||||||
|
return _norm_hex(colors.get(key, fallback), fallback)
|
||||||
|
|
||||||
|
accent_hex = hx("accent", "327F24")
|
||||||
|
accent_dark_hex = hx("accent_dark", accent_hex)
|
||||||
|
accent_light_hex = hx("accent_light", "E8F0E5")
|
||||||
|
text2_hex = hx("text_2", "5A5A68")
|
||||||
|
text3_hex = hx("text_3", "8E8E9E")
|
||||||
|
|
||||||
|
def rgb(h: str) -> "RGBColor":
|
||||||
|
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||||
|
|
||||||
|
accent, accent_dark = rgb(accent_hex), rgb(accent_dark_hex)
|
||||||
|
text2, text3 = rgb(text2_hex), rgb(text3_hex)
|
||||||
|
|
||||||
|
BODY_FONT = "Arial" # PDF stack is Noto Sans → DejaVu Sans → Arial
|
||||||
|
CONTENT_W = Mm(166) # A4 (210) − 22mm left − 22mm right
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# ── page geometry (matches protocol.css @page margins) ──
|
||||||
|
sec = doc.sections[0]
|
||||||
|
sec.top_margin, sec.bottom_margin = Mm(26), Mm(24)
|
||||||
|
sec.left_margin, sec.right_margin = Mm(22), Mm(22)
|
||||||
|
|
||||||
|
# ── base font ──
|
||||||
|
normal = doc.styles["Normal"]
|
||||||
|
normal.font.name = BODY_FONT
|
||||||
|
normal.font.size = Pt(9.8)
|
||||||
|
normal.paragraph_format.space_after = Pt(0)
|
||||||
|
normal.paragraph_format.space_before = Pt(0)
|
||||||
|
|
||||||
|
office = context.get("office") or {}
|
||||||
|
terminart = context.get("terminart", "")
|
||||||
|
|
||||||
|
def shade(paragraph, fill_hex: str) -> None:
|
||||||
|
shd = OxmlElement("w:shd")
|
||||||
|
shd.set(qn("w:val"), "clear")
|
||||||
|
shd.set(qn("w:color"), "auto")
|
||||||
|
shd.set(qn("w:fill"), fill_hex)
|
||||||
|
paragraph._p.get_or_add_pPr().append(shd)
|
||||||
|
|
||||||
|
def field(paragraph, instr: str, size: float, italic: bool, color_hex: str) -> None:
|
||||||
|
fld = OxmlElement("w:fldSimple")
|
||||||
|
fld.set(qn("w:instr"), instr)
|
||||||
|
r = OxmlElement("w:r")
|
||||||
|
rpr = OxmlElement("w:rPr")
|
||||||
|
sz = OxmlElement("w:sz"); sz.set(qn("w:val"), str(int(size * 2))); rpr.append(sz)
|
||||||
|
if italic:
|
||||||
|
rpr.append(OxmlElement("w:i"))
|
||||||
|
col = OxmlElement("w:color"); col.set(qn("w:val"), color_hex); rpr.append(col)
|
||||||
|
r.append(rpr)
|
||||||
|
t = OxmlElement("w:t"); t.text = "1"; r.append(t)
|
||||||
|
fld.append(r)
|
||||||
|
paragraph._p.append(fld)
|
||||||
|
|
||||||
|
def right_tab_only(paragraph) -> None:
|
||||||
|
"""Give the paragraph a single right tab stop at the content edge.
|
||||||
|
|
||||||
|
Word *merges* a paragraph's tab stops with those of its style, and the
|
||||||
|
built-in Header/Footer styles carry a centre tab at half the US-Letter
|
||||||
|
text width. Without clearing it the first "\\t" stops mid-page (the logo
|
||||||
|
would look centred), so every inherited stop gets an explicit clear.
|
||||||
|
"""
|
||||||
|
pf = paragraph.paragraph_format
|
||||||
|
for ts in list(paragraph.style.paragraph_format.tab_stops):
|
||||||
|
pf.tab_stops.add_tab_stop(ts.position, WD_TAB_ALIGNMENT.CLEAR)
|
||||||
|
pf.tab_stops.add_tab_stop(CONTENT_W, WD_TAB_ALIGNMENT.RIGHT)
|
||||||
|
|
||||||
|
# ── page header: "<Terminart> | Gesprächsprotokoll" + logo right ──
|
||||||
|
hp = sec.header.paragraphs[0]
|
||||||
|
right_tab_only(hp)
|
||||||
|
hr = hp.add_run(f"{terminart} | Gesprächsprotokoll")
|
||||||
|
hr.italic = True
|
||||||
|
hr.font.size = Pt(9)
|
||||||
|
hr.font.color.rgb = text2
|
||||||
|
logo_path = getattr(cfg, "docx_logo_path", None) or getattr(cfg, "logo_path", None)
|
||||||
|
if logo_path and logo_path.suffix.lower() in (".png", ".jpg", ".jpeg"):
|
||||||
|
try:
|
||||||
|
hp.add_run("\t")
|
||||||
|
hp.add_run().add_picture(str(logo_path), height=Mm(11))
|
||||||
|
except Exception:
|
||||||
|
pass # a broken logo must never fail the whole export
|
||||||
|
|
||||||
|
# ── page footer: office / note (left, multi-line) + "Seite X / Y" (right) ──
|
||||||
|
fp = sec.footer.paragraphs[0]
|
||||||
|
right_tab_only(fp)
|
||||||
|
f1 = fp.add_run(
|
||||||
|
f"Gesprächsprotokoll · {terminart} · {context.get('date_de', '')} · "
|
||||||
|
f"{context.get('project_display', '')}"
|
||||||
|
)
|
||||||
|
f1.font.size = Pt(7); f1.font.color.rgb = text2
|
||||||
|
fp.add_run("\t")
|
||||||
|
field(fp, "PAGE", 7.5, True, text3_hex)
|
||||||
|
fpr = fp.add_run(" / "); fpr.font.size = Pt(7.5); fpr.italic = True; fpr.font.color.rgb = text3
|
||||||
|
field(fp, "NUMPAGES", 7.5, True, text3_hex)
|
||||||
|
|
||||||
|
erstellt = "Erstellt durch: " + (office.get("name") or "")
|
||||||
|
if office.get("subtitle"):
|
||||||
|
erstellt += f" {office['subtitle']}"
|
||||||
|
if office.get("tel"):
|
||||||
|
erstellt += f" • {office['tel']}"
|
||||||
|
if office.get("email"):
|
||||||
|
erstellt += f" • {office['email']}"
|
||||||
|
f2 = sec.footer.add_paragraph().add_run(erstellt)
|
||||||
|
f2.font.size = Pt(7); f2.font.color.rgb = text2
|
||||||
|
if context.get("footer_note"):
|
||||||
|
f3 = sec.footer.add_paragraph().add_run(context["footer_note"])
|
||||||
|
f3.font.size = Pt(7); f3.italic = True; f3.font.color.rgb = text2
|
||||||
|
|
||||||
|
if context.get("doc_title"):
|
||||||
|
tp = doc.add_paragraph()
|
||||||
|
tp.paragraph_format.space_after = Pt(6)
|
||||||
|
tr = tp.add_run(context["doc_title"])
|
||||||
|
tr.bold = True
|
||||||
|
tr.font.size = Pt(16)
|
||||||
|
tr.font.color.rgb = accent_dark
|
||||||
|
|
||||||
|
# ── metadata block: two columns (Projekt / Erstellt durch, right-aligned) ──
|
||||||
|
table = doc.add_table(rows=1, cols=2)
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.LEFT
|
||||||
|
table.allow_autofit = False
|
||||||
|
left, right = table.rows[0].cells
|
||||||
|
left.width = right.width = Mm(83)
|
||||||
|
|
||||||
|
def cell_line(cell, text, *, size, color=None, bold=False, italic=False,
|
||||||
|
align=None, first=False, space_before=0.0):
|
||||||
|
if first and not cell.paragraphs[0].runs:
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
else:
|
||||||
|
p = cell.add_paragraph()
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
p.paragraph_format.space_before = Pt(space_before)
|
||||||
|
if align is not None:
|
||||||
|
p.alignment = align
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.font.size = Pt(size)
|
||||||
|
run.bold, run.italic = bold, italic
|
||||||
|
if color is not None:
|
||||||
|
run.font.color.rgb = color
|
||||||
|
|
||||||
|
# left column
|
||||||
|
cell_line(left, context.get("projekt_label", "Projekt"), size=11, color=accent, italic=True, first=True)
|
||||||
|
for line in context.get("projekt") or []:
|
||||||
|
cell_line(left, line, size=10.2)
|
||||||
|
if context.get("kontakt"):
|
||||||
|
cell_line(left, "Ansprechpartner Kunde", size=9.5, color=accent, bold=True, space_before=3)
|
||||||
|
for line in context["kontakt"]:
|
||||||
|
cell_line(left, line, size=10.2)
|
||||||
|
|
||||||
|
# right column (right-aligned)
|
||||||
|
R = WD_ALIGN_PARAGRAPH.RIGHT
|
||||||
|
cell_line(right, "Erstellt durch", size=11, color=accent, italic=True, align=R, first=True)
|
||||||
|
if office.get("name"):
|
||||||
|
name = office["name"] + (f" {office['subtitle']}" if office.get("subtitle") else "")
|
||||||
|
cell_line(right, name, size=10.2, align=R)
|
||||||
|
for key in ("address", "tel", "email", "website"):
|
||||||
|
for line in str(office.get(key) or "").splitlines(): # multi-line address
|
||||||
|
cell_line(right, line, size=10.2, align=R)
|
||||||
|
|
||||||
|
doc.add_paragraph().paragraph_format.space_after = Pt(6) # gap before sections
|
||||||
|
|
||||||
|
# ── content sections: filled accent bar + indented body ──
|
||||||
|
for section in context.get("sections") or []:
|
||||||
|
bar = doc.add_paragraph()
|
||||||
|
bpf = bar.paragraph_format
|
||||||
|
bpf.space_before = Pt(10)
|
||||||
|
bpf.space_after = Pt(4)
|
||||||
|
bpf.left_indent = Mm(1.5)
|
||||||
|
bpf.right_indent = Mm(1.5)
|
||||||
|
bpf.keep_with_next = True
|
||||||
|
bpf.line_spacing = 1.3
|
||||||
|
shade(bar, accent_light_hex)
|
||||||
|
b_run = bar.add_run(section.get("heading", ""))
|
||||||
|
b_run.bold = True
|
||||||
|
b_run.font.size = Pt(10)
|
||||||
|
b_run.font.color.rgb = accent_dark
|
||||||
|
|
||||||
|
for block in section.get("blocks") or []:
|
||||||
|
if block.get("type") == "paragraph":
|
||||||
|
p = doc.add_paragraph(block.get("text", ""))
|
||||||
|
p.paragraph_format.left_indent = Mm(6)
|
||||||
|
p.paragraph_format.space_after = Pt(5)
|
||||||
|
p.paragraph_format.line_spacing = 1.4
|
||||||
|
elif block.get("type") == "list":
|
||||||
|
for item in block.get("items") or []:
|
||||||
|
p = doc.add_paragraph(item, style="List Bullet")
|
||||||
|
p.paragraph_format.left_indent = Mm(10)
|
||||||
|
p.paragraph_format.space_after = Pt(2)
|
||||||
|
p.paragraph_format.line_spacing = 1.4
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
doc.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
64
vendor/platform/core/hooks.py
vendored
Normal file
64
vendor/platform/core/hooks.py
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Per-tenant code hooks.
|
||||||
|
|
||||||
|
Most customers differ only in *data* (colours, logo, prompt, PDF template) and
|
||||||
|
need no code here. For the few that need genuine behaviour changes, drop an
|
||||||
|
`overrides.py` into their tenant folder defining a `Hooks(TenantHooks)` subclass
|
||||||
|
that overrides only the methods it needs. Core stays untouched, so core updates
|
||||||
|
keep flowing.
|
||||||
|
|
||||||
|
Example tenants/acme/overrides.py:
|
||||||
|
|
||||||
|
from core.hooks import TenantHooks
|
||||||
|
|
||||||
|
class Hooks(TenantHooks):
|
||||||
|
def build_prompt(self, base, ctx):
|
||||||
|
return base + "\\nBevorzuge Fachterminologie aus der Orthopädie."
|
||||||
|
|
||||||
|
def extra_routes(self, app):
|
||||||
|
@app.get("/health/acme")
|
||||||
|
def _h():
|
||||||
|
return {"ok": True}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.contract import HOOK_API
|
||||||
|
|
||||||
|
|
||||||
|
class TenantHooks:
|
||||||
|
"""Default (no-op) behaviour. Override selectively per tenant."""
|
||||||
|
|
||||||
|
def build_prompt(self, base_prompt: str, ctx: dict) -> str:
|
||||||
|
"""Adjust the system prompt sent to the LLM. ctx carries the form fields."""
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
def transform_transcript(self, transcript: str, ctx: dict) -> str:
|
||||||
|
"""Post-process the transcript before summarisation/delivery."""
|
||||||
|
return transcript
|
||||||
|
|
||||||
|
def pdf_context(self, context: dict) -> dict:
|
||||||
|
"""Last chance to enrich/alter the data passed to the PDF template."""
|
||||||
|
return context
|
||||||
|
|
||||||
|
def extra_routes(self, app) -> None:
|
||||||
|
"""Register tenant-specific FastAPI routes."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_hooks(tenant_dir: Path) -> TenantHooks:
|
||||||
|
override = tenant_dir / "overrides.py"
|
||||||
|
if not override.exists():
|
||||||
|
return TenantHooks()
|
||||||
|
spec = importlib.util.spec_from_file_location(f"tenant_overrides_{tenant_dir.name}", override)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
declared = getattr(module, "HOOK_API", HOOK_API) # fehlt die Deklaration, gilt die aktuelle Version
|
||||||
|
if declared != HOOK_API:
|
||||||
|
raise RuntimeError(f"{override}: HOOK_API={declared} wird von diesem Core nicht unterstützt (Core: {HOOK_API})")
|
||||||
|
hooks_cls = getattr(module, "Hooks", None)
|
||||||
|
if hooks_cls is None:
|
||||||
|
return TenantHooks()
|
||||||
|
return hooks_cls()
|
||||||
4
vendor/platform/core/pdf/__init__.py
vendored
Normal file
4
vendor/platform/core/pdf/__init__.py
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from core.pdf.context import build_pdf_context
|
||||||
|
from core.pdf.render import render_pdf
|
||||||
|
|
||||||
|
__all__ = ["build_pdf_context", "render_pdf"]
|
||||||
92
vendor/platform/core/pdf/context.py
vendored
Normal file
92
vendor/platform/core/pdf/context.py
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Builds the structured data the PDF template renders.
|
||||||
|
|
||||||
|
This is the *content* half of the PDF — purely data, identical logic for every
|
||||||
|
tenant. The *layout* half lives in the tenant's (or core's) protocol.html +
|
||||||
|
protocol.css. Core guarantees the shape of this dict; templates may ignore any
|
||||||
|
field they don't use, so adding fields here never breaks an old template.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.protocol import (
|
||||||
|
group_section_blocks,
|
||||||
|
normalize_protocol_text,
|
||||||
|
parse_address_lines,
|
||||||
|
parse_protocol_sections,
|
||||||
|
project_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _logo_data_uri(logo_path: Path | None) -> str | None:
|
||||||
|
if not logo_path or not logo_path.exists():
|
||||||
|
return None
|
||||||
|
mime = "image/svg+xml" if logo_path.suffix.lower() == ".svg" else "image/png"
|
||||||
|
data = base64.b64encode(logo_path.read_bytes()).decode("ascii")
|
||||||
|
return f"data:{mime};base64,{data}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_pdf_context(
|
||||||
|
cfg,
|
||||||
|
title: str,
|
||||||
|
summary: str,
|
||||||
|
insurance_type: str = "",
|
||||||
|
customer_2: str = "",
|
||||||
|
customer_name: str = "",
|
||||||
|
customer_address: str = "",
|
||||||
|
) -> dict:
|
||||||
|
clean = normalize_protocol_text(summary)
|
||||||
|
parsed = parse_protocol_sections(clean)
|
||||||
|
|
||||||
|
terminart = parsed["terminart"] or insurance_type.strip() or "Kundengespräch"
|
||||||
|
|
||||||
|
projekt: list[str] = []
|
||||||
|
if parsed["projekt"]:
|
||||||
|
projekt = parsed["projekt"]
|
||||||
|
else:
|
||||||
|
if customer_name.strip():
|
||||||
|
projekt.append(customer_name.strip())
|
||||||
|
if customer_2.strip():
|
||||||
|
projekt.append(customer_2.strip())
|
||||||
|
if not projekt:
|
||||||
|
projekt = ["Projekt"]
|
||||||
|
|
||||||
|
if parsed["kontakt"]:
|
||||||
|
kontakt = parsed["kontakt"]
|
||||||
|
elif customer_address.strip():
|
||||||
|
kontakt = parse_address_lines(customer_address)
|
||||||
|
else:
|
||||||
|
kontakt = []
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
{"heading": s["heading"], "blocks": group_section_blocks(s)}
|
||||||
|
for s in parsed["sections"]
|
||||||
|
]
|
||||||
|
|
||||||
|
project_display = project_label(projekt)
|
||||||
|
date_de = datetime.now().strftime("%d.%m.%Y")
|
||||||
|
office = cfg.office
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant": {"name": cfg.name, "id": cfg.id},
|
||||||
|
"colors": cfg.colors,
|
||||||
|
"logo_data_uri": _logo_data_uri(cfg.logo_path),
|
||||||
|
"title": title,
|
||||||
|
"terminart": terminart,
|
||||||
|
"projekt": projekt,
|
||||||
|
"projekt_label": cfg.raw.get("pdf_projekt_label", "Projekt"),
|
||||||
|
"project_display": project_display,
|
||||||
|
"kontakt": kontakt,
|
||||||
|
"office": office,
|
||||||
|
"sections": sections,
|
||||||
|
"date_de": date_de,
|
||||||
|
# {year} keeps a copyright line in the footer note current without a
|
||||||
|
# yearly config edit.
|
||||||
|
"footer_note": cfg.raw.get(
|
||||||
|
"pdf_footer_note",
|
||||||
|
"Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich "
|
||||||
|
"für die genannten Empfänger bestimmt.",
|
||||||
|
).replace("{year}", str(datetime.now().year)),
|
||||||
|
}
|
||||||
33
vendor/platform/core/pdf/render.py
vendored
Normal file
33
vendor/platform/core/pdf/render.py
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Renders the PDF from an HTML+CSS template via WeasyPrint.
|
||||||
|
|
||||||
|
The template + stylesheet are resolved per tenant (tenant override → core
|
||||||
|
default) in core/config.py. A designer can fully restyle the PDF by editing
|
||||||
|
those two text files — no Python involved.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
from core.config import CORE_TEMPLATES
|
||||||
|
|
||||||
|
|
||||||
|
def render_pdf(context: dict, cfg) -> bytes:
|
||||||
|
from weasyprint import CSS, HTML # imported lazily: only needed for PDF
|
||||||
|
|
||||||
|
template_path = cfg.pdf_template_path
|
||||||
|
css_path = cfg.pdf_css_path
|
||||||
|
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader([str(template_path.parent), str(CORE_TEMPLATES)]),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
)
|
||||||
|
template = env.get_template(template_path.name)
|
||||||
|
html_str = template.render(**context)
|
||||||
|
|
||||||
|
# CSS is rendered through Jinja too, so colour tokens can be injected.
|
||||||
|
css_env = Environment(loader=FileSystemLoader([str(css_path.parent)]))
|
||||||
|
css_str = css_env.get_template(css_path.name).render(**context)
|
||||||
|
|
||||||
|
base_url = str(template_path.parent)
|
||||||
|
doc = HTML(string=html_str, base_url=base_url)
|
||||||
|
return doc.write_pdf(stylesheets=[CSS(string=css_str)])
|
||||||
160
vendor/platform/core/protocol.py
vendored
Normal file
160
vendor/platform/core/protocol.py
vendored
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Parsing/normalisation of the Claude protocol output.
|
||||||
|
|
||||||
|
Tenant-independent: turns the raw LLM text into a structured shape that both
|
||||||
|
the PDF template and the e-mail rendering consume.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
SECTION_HEADINGS = (
|
||||||
|
"Anlass & Kontext",
|
||||||
|
"Besprochene Themen",
|
||||||
|
"Entscheidungen",
|
||||||
|
"Action Items",
|
||||||
|
"Offene Punkte",
|
||||||
|
"Nächste Schritte",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_protocol_text(text: str) -> str:
|
||||||
|
normalized_lines = []
|
||||||
|
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = raw_line.rstrip()
|
||||||
|
if line.startswith("===") and line.endswith("==="):
|
||||||
|
normalized_lines.append(line.strip())
|
||||||
|
continue
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
normalized_lines.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleaned = re.sub(r"^\s{0,3}#{1,6}\s*", "", stripped)
|
||||||
|
cleaned = cleaned.replace("**", "").replace("__", "")
|
||||||
|
cleaned = re.sub(r"^\s*[-*]\s+", "• ", cleaned)
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
normalized_lines.append(cleaned)
|
||||||
|
|
||||||
|
text = "\n".join(normalized_lines)
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_protocol_sections(text: str) -> dict:
|
||||||
|
"""Zerlegt den Claude-Output in Metadaten-Blöcke und Abschnitte."""
|
||||||
|
lines = text.replace("\r\n", "\n").split("\n")
|
||||||
|
|
||||||
|
def collect_block(marker: str) -> list[str] | None:
|
||||||
|
open_tag = f"==={marker}==="
|
||||||
|
close_tag = f"===/{marker}==="
|
||||||
|
try:
|
||||||
|
start = lines.index(open_tag)
|
||||||
|
end = lines.index(close_tag, start + 1)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
block = [ln.strip() for ln in lines[start + 1:end] if ln.strip()]
|
||||||
|
return block
|
||||||
|
|
||||||
|
projekt = collect_block("PROJEKT") or []
|
||||||
|
kontakt = collect_block("KONTAKT") or []
|
||||||
|
terminart_block = collect_block("TERMINART") or []
|
||||||
|
terminart = terminart_block[0] if terminart_block else ""
|
||||||
|
|
||||||
|
if len(kontakt) == 1 and kontakt[0].lower().strip("() ") in {"keiner", "keine", "none", ""}:
|
||||||
|
kontakt = []
|
||||||
|
|
||||||
|
body_lines: list[str] = []
|
||||||
|
skip = False
|
||||||
|
for ln in lines:
|
||||||
|
stripped = ln.strip()
|
||||||
|
if stripped.startswith("===") and stripped.endswith("==="):
|
||||||
|
if stripped.startswith("===/"):
|
||||||
|
skip = False
|
||||||
|
continue
|
||||||
|
skip = True
|
||||||
|
continue
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
|
body_lines.append(ln)
|
||||||
|
|
||||||
|
sections: list[dict] = []
|
||||||
|
current = None
|
||||||
|
for raw in body_lines:
|
||||||
|
line = raw.strip()
|
||||||
|
if line in SECTION_HEADINGS:
|
||||||
|
if current is not None:
|
||||||
|
sections.append(current)
|
||||||
|
current = {"heading": line, "lines": []}
|
||||||
|
continue
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
if line.startswith("• "):
|
||||||
|
current["lines"].append(("bullet", line[2:].strip()))
|
||||||
|
elif line:
|
||||||
|
current["lines"].append(("paragraph", line))
|
||||||
|
else:
|
||||||
|
current["lines"].append(("blank", ""))
|
||||||
|
|
||||||
|
if current is not None:
|
||||||
|
sections.append(current)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"projekt": projekt,
|
||||||
|
"kontakt": kontakt,
|
||||||
|
"terminart": terminart,
|
||||||
|
"sections": sections,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_address_lines(address: str) -> list[str]:
|
||||||
|
"""Splits a free-form address string into [street, PLZ city] lines."""
|
||||||
|
address = address.strip()
|
||||||
|
if not address:
|
||||||
|
return []
|
||||||
|
if "\n" in address:
|
||||||
|
return [l.strip() for l in address.splitlines() if l.strip()]
|
||||||
|
if "," in address:
|
||||||
|
return [p.strip() for p in address.split(",") if p.strip()]
|
||||||
|
m = re.search(r"(\b\d{5}\b.*)", address)
|
||||||
|
if m:
|
||||||
|
before = address[: m.start()].strip().rstrip(",").strip()
|
||||||
|
plz_city = m.group(1).strip()
|
||||||
|
return ([before] if before else []) + [plz_city]
|
||||||
|
return [address]
|
||||||
|
|
||||||
|
|
||||||
|
def project_label(projekt_lines: list[str]) -> str:
|
||||||
|
"""Erste nicht-leere Zeile des PROJEKT-Blocks (für Dateinamen/Subject)."""
|
||||||
|
for line in projekt_lines:
|
||||||
|
if line and line.strip():
|
||||||
|
return line.strip()
|
||||||
|
return "Projekt"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(title: str) -> str:
|
||||||
|
safe = re.sub(r"[^\w\-äöüÄÖÜß ]", "", title).strip()
|
||||||
|
safe = re.sub(r"\s+", "_", safe)
|
||||||
|
return safe or "Dokument"
|
||||||
|
|
||||||
|
|
||||||
|
def group_section_blocks(section: dict) -> list[dict]:
|
||||||
|
"""Turn the flat (kind, text) line list into HTML-friendly blocks:
|
||||||
|
consecutive bullets become one {'type':'list','items':[...]}, paragraphs
|
||||||
|
become {'type':'paragraph','text':...}."""
|
||||||
|
blocks: list[dict] = []
|
||||||
|
bullets: list[str] = []
|
||||||
|
|
||||||
|
def flush_bullets():
|
||||||
|
if bullets:
|
||||||
|
blocks.append({"type": "list", "items": list(bullets)})
|
||||||
|
bullets.clear()
|
||||||
|
|
||||||
|
for kind, text in section.get("lines", []):
|
||||||
|
if kind == "bullet":
|
||||||
|
bullets.append(text)
|
||||||
|
elif kind == "paragraph":
|
||||||
|
flush_bullets()
|
||||||
|
blocks.append({"type": "paragraph", "text": text})
|
||||||
|
# blank lines are visual only — ignored in the structured shape
|
||||||
|
flush_bullets()
|
||||||
|
return blocks
|
||||||
100
vendor/platform/core/retry.py
vendored
Normal file
100
vendor/platform/core/retry.py
vendored
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Lightweight retry helpers for transient provider failures.
|
||||||
|
|
||||||
|
The transcription (Mistral) and e-mail (SMTP) call sites had no retry at all, so
|
||||||
|
a brief provider outage aborted the whole job. These helpers add exponential
|
||||||
|
backoff with jitter for *transient* failures only — network errors, timeouts and
|
||||||
|
5xx/429 — while failing fast on permanent errors (bad request, auth, payload),
|
||||||
|
where retrying cannot help.
|
||||||
|
|
||||||
|
The OpenAI SDK used for Cortecs already retries internally (configured via
|
||||||
|
LLM_MAX_RETRIES at the client); this module covers the two raw call sites.
|
||||||
|
|
||||||
|
All knobs are env-tunable so they can be adjusted without a redeploy:
|
||||||
|
RETRY_MAX_ATTEMPTS total attempts incl. the first (default 4)
|
||||||
|
RETRY_BASE_DELAY base backoff in seconds (default 1.0)
|
||||||
|
RETRY_MAX_DELAY cap per wait in seconds (default 30.0)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
RETRY_MAX_ATTEMPTS = int(os.getenv("RETRY_MAX_ATTEMPTS", "4"))
|
||||||
|
RETRY_BASE_DELAY = float(os.getenv("RETRY_BASE_DELAY", "1.0"))
|
||||||
|
RETRY_MAX_DELAY = float(os.getenv("RETRY_MAX_DELAY", "30.0"))
|
||||||
|
|
||||||
|
# Transient HTTP statuses — worth retrying.
|
||||||
|
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
||||||
|
# Network-level failures before any HTTP response arrives.
|
||||||
|
RETRYABLE_HTTP_EXC = (requests.exceptions.ConnectionError, requests.exceptions.Timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _backoff_delay(attempt: int, retry_after: float | None) -> float:
|
||||||
|
"""Seconds to wait before the next attempt (0-indexed)."""
|
||||||
|
if retry_after is not None:
|
||||||
|
return min(retry_after, RETRY_MAX_DELAY)
|
||||||
|
# exponential backoff with full jitter
|
||||||
|
ceiling = min(RETRY_BASE_DELAY * (2 ** attempt), RETRY_MAX_DELAY)
|
||||||
|
return random.uniform(0, ceiling)
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_seconds(resp: requests.Response) -> float | None:
|
||||||
|
val = resp.headers.get("Retry-After")
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val) # delta-seconds form
|
||||||
|
except ValueError:
|
||||||
|
return None # HTTP-date form — fall back to backoff
|
||||||
|
|
||||||
|
|
||||||
|
def request_with_retry(do_request, *, label: str = "request") -> requests.Response:
|
||||||
|
"""Call ``do_request()`` (a zero-arg callable returning a ``requests.Response``)
|
||||||
|
with retries on transient network errors and 5xx/429.
|
||||||
|
|
||||||
|
The caller still inspects the returned response for non-retryable ``>= 400``
|
||||||
|
statuses. ``do_request`` must rebuild any per-attempt state (e.g. re-open the
|
||||||
|
upload file) so each retry sends a complete request.
|
||||||
|
"""
|
||||||
|
for attempt in range(RETRY_MAX_ATTEMPTS):
|
||||||
|
last = attempt == RETRY_MAX_ATTEMPTS - 1
|
||||||
|
try:
|
||||||
|
resp = do_request()
|
||||||
|
except RETRYABLE_HTTP_EXC as exc:
|
||||||
|
if last:
|
||||||
|
raise
|
||||||
|
delay = _backoff_delay(attempt, None)
|
||||||
|
print(f"[retry] {label}: {type(exc).__name__} "
|
||||||
|
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
if resp.status_code in RETRYABLE_STATUS and not last:
|
||||||
|
delay = _backoff_delay(attempt, _retry_after_seconds(resp))
|
||||||
|
print(f"[retry] {label}: HTTP {resp.status_code} "
|
||||||
|
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
|
||||||
|
try:
|
||||||
|
resp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
return resp
|
||||||
|
# Unreachable: the loop either returns a response or raises on the last attempt.
|
||||||
|
raise RuntimeError(f"{label}: retry loop ended without a result")
|
||||||
|
|
||||||
|
|
||||||
|
def call_with_retry(fn, *, retry_on: tuple[type[BaseException], ...], label: str = "call"):
|
||||||
|
"""Retry an arbitrary callable on the given (transient) exception types."""
|
||||||
|
for attempt in range(RETRY_MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except retry_on as exc:
|
||||||
|
if attempt == RETRY_MAX_ATTEMPTS - 1:
|
||||||
|
raise
|
||||||
|
delay = _backoff_delay(attempt, None)
|
||||||
|
print(f"[retry] {label}: {type(exc).__name__} "
|
||||||
|
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
|
||||||
|
time.sleep(delay)
|
||||||
928
vendor/platform/core/schema/tenant_schema.json
vendored
Normal file
928
vendor/platform/core/schema/tenant_schema.json
vendored
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
{
|
||||||
|
"contract": 1,
|
||||||
|
"description": "Tenant-Schema des Onyscribe-Cores. Quelle der Wahrheit für Admin-Plattform (Formulare, Validierung, Deploy-Env).",
|
||||||
|
"ignored_keys": [
|
||||||
|
"deploy"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"path": "name",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Marke",
|
||||||
|
"label": "Name",
|
||||||
|
"help": "Anzeigename des Kunden (Web-UI, E-Mail, PDF-Fallback).",
|
||||||
|
"default": null,
|
||||||
|
"since": 1,
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "title",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Marke",
|
||||||
|
"label": "Seitentitel (Browser-Tab)",
|
||||||
|
"help": "Standard: „<Name> – Gesprächsprotokoll“.",
|
||||||
|
"default": null,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "description",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Marke",
|
||||||
|
"label": "Beschreibung",
|
||||||
|
"help": "Meta-Beschreibung der Web-UI. Standard: „Gesprächsprotokoll-Tool für <Name>“.",
|
||||||
|
"default": null,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.accent",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Akzent",
|
||||||
|
"help": "",
|
||||||
|
"default": "#327F24",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.accent_dark",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Akzent dunkel",
|
||||||
|
"help": "",
|
||||||
|
"default": "#235A19",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.accent_mid",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Akzent mittel",
|
||||||
|
"help": "",
|
||||||
|
"default": "#3E9130",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.accent_light",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Akzent hell",
|
||||||
|
"help": "",
|
||||||
|
"default": "#EAF4E7",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.accent_xlight",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Akzent extra hell",
|
||||||
|
"help": "",
|
||||||
|
"default": "#F3FAF1",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.bg",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Seitenhintergrund",
|
||||||
|
"help": "",
|
||||||
|
"default": "#F1F5F0",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.surface",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Karten",
|
||||||
|
"help": "",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.surface2",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Karten 2",
|
||||||
|
"help": "",
|
||||||
|
"default": "#F6FAF5",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.border",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Rahmen",
|
||||||
|
"help": "",
|
||||||
|
"default": "#DCE8D9",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.border2",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Rahmen 2",
|
||||||
|
"help": "",
|
||||||
|
"default": "#BDD1B8",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.text",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Text",
|
||||||
|
"help": "",
|
||||||
|
"default": "#444444",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.text_2",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Text sekundär",
|
||||||
|
"help": "",
|
||||||
|
"default": "#666666",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.text_3",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Text tertiär",
|
||||||
|
"help": "",
|
||||||
|
"default": "#9AA89A",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.red",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Rot (Hinweis)",
|
||||||
|
"help": "",
|
||||||
|
"default": "#DC2626",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "colors.progress_to",
|
||||||
|
"type": "color",
|
||||||
|
"group": "Farben",
|
||||||
|
"label": "Fortschritt (Verlauf-Ende)",
|
||||||
|
"help": "",
|
||||||
|
"default": "#8bbf43",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "office.name",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Absender",
|
||||||
|
"label": "Name",
|
||||||
|
"help": "Kopf-/Fußzeile von PDF, Word und E-Mail.",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "office.subtitle",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Absender",
|
||||||
|
"label": "Zusatz (Rechtsform)",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "office.address",
|
||||||
|
"type": "text",
|
||||||
|
"group": "Absender",
|
||||||
|
"label": "Adresse",
|
||||||
|
"help": "Mehrzeilig möglich.",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "office.tel",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Absender",
|
||||||
|
"label": "Telefon",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "office.email",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Absender",
|
||||||
|
"label": "E-Mail",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "office.website",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Absender",
|
||||||
|
"label": "Website",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pdf_projekt_label",
|
||||||
|
"type": "string",
|
||||||
|
"group": "PDF",
|
||||||
|
"label": "Bezeichnung „Projekt“",
|
||||||
|
"help": "Beschriftung des Projekt-Feldes im PDF (z. B. „Thema“).",
|
||||||
|
"default": "Projekt",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pdf_footer_note",
|
||||||
|
"type": "text",
|
||||||
|
"group": "PDF",
|
||||||
|
"label": "Fußzeilen-Hinweis",
|
||||||
|
"help": "Platzhalter {year} = aktuelles Jahr.",
|
||||||
|
"default": "Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich für die genannten Empfänger bestimmt.",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ai_detect_org",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Prompt-Identität",
|
||||||
|
"label": "Organisation (voll)",
|
||||||
|
"help": "Ersetzt @@ORG@@ im Standard-Prompt. Standard: Name.",
|
||||||
|
"default": null,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "org_short",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Prompt-Identität",
|
||||||
|
"label": "Kurzname im Protokolltext",
|
||||||
|
"help": "Ersetzt @@ORG_SHORT@@. Standard: Name.",
|
||||||
|
"default": null,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "org_profile",
|
||||||
|
"type": "text",
|
||||||
|
"group": "Prompt-Identität",
|
||||||
|
"label": "Profil (ein Satz)",
|
||||||
|
"help": "Ersetzt @@ORG_PROFILE@@ – was die Organisation macht.",
|
||||||
|
"default": "",
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "flags.ui_kontakt_field",
|
||||||
|
"type": "bool",
|
||||||
|
"group": "Funktion",
|
||||||
|
"label": "Feld „Ansprechpartner Kunde“",
|
||||||
|
"help": "Aus: Feld in der Web-UI ausgeblendet (z. B. bei Vereinen).",
|
||||||
|
"default": true,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "flags.multi_audio",
|
||||||
|
"type": "bool",
|
||||||
|
"group": "Funktion",
|
||||||
|
"label": "Mehrere Audiodateien (bis 5)",
|
||||||
|
"help": "Aus: nur eine Datei pro Protokoll.",
|
||||||
|
"default": true,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "stt.provider",
|
||||||
|
"type": "enum",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"label": "Provider",
|
||||||
|
"help": "Env STT_PROVIDER überschreibt. Bei „cortecs“ genügt der geteilte CORTECS_API_KEY.",
|
||||||
|
"default": "mistral",
|
||||||
|
"since": 1,
|
||||||
|
"options": [
|
||||||
|
"mistral",
|
||||||
|
"cortecs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "stt.model",
|
||||||
|
"type": "string",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"label": "Modell",
|
||||||
|
"help": "Env STT_MODEL überschreibt. Standard je Provider.",
|
||||||
|
"default": null,
|
||||||
|
"since": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "stt.cost_per_minute",
|
||||||
|
"type": "number",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"label": "Kosten pro Minute (EUR)",
|
||||||
|
"help": "Nur für die Kostenerfassung; Standard je Provider.",
|
||||||
|
"default": null,
|
||||||
|
"since": 1,
|
||||||
|
"advanced": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "prompt.txt",
|
||||||
|
"kind": "text",
|
||||||
|
"label": "Prompt",
|
||||||
|
"default": "defaults/prompt.txt",
|
||||||
|
"help": "Platzhalter @@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@. Ohne Datei gilt der Standard."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "protocol.html",
|
||||||
|
"kind": "text",
|
||||||
|
"label": "PDF-Layout (HTML/Jinja)",
|
||||||
|
"default": "templates/protocol.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "protocol.css",
|
||||||
|
"kind": "text",
|
||||||
|
"label": "PDF-Styling (CSS/Jinja)",
|
||||||
|
"default": "templates/protocol.css"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "overrides.py",
|
||||||
|
"kind": "code",
|
||||||
|
"label": "Hook-Code",
|
||||||
|
"help": "Klasse Hooks(TenantHooks); Hooks siehe „hooks“. Optional HOOK_API = 1."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "logo.svg",
|
||||||
|
"kind": "image",
|
||||||
|
"label": "Logo (SVG)",
|
||||||
|
"help": "Web-UI und PDF."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "logo.png",
|
||||||
|
"kind": "image",
|
||||||
|
"label": "Logo (PNG)",
|
||||||
|
"help": "Word-Export braucht ein Rasterbild."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "favicon.svg",
|
||||||
|
"kind": "image",
|
||||||
|
"label": "Favicon (SVG)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra_files": true,
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"name": "build_prompt",
|
||||||
|
"help": "Prompt vor dem LLM-Aufruf anpassen (base_prompt, ctx)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transform_transcript",
|
||||||
|
"help": "Transkript nachbearbeiten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pdf_context",
|
||||||
|
"help": "Daten fürs PDF-Template ändern."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "extra_routes",
|
||||||
|
"help": "Zusätzliche FastAPI-Routen registrieren."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prompt_placeholders": [
|
||||||
|
"@@ORG@@",
|
||||||
|
"@@ORG_SHORT@@",
|
||||||
|
"@@ORG_PROFILE@@"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "OIDC_ISSUER",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "OIDC-Issuer",
|
||||||
|
"help": "Login-System der Tenant-Container.",
|
||||||
|
"default": "https://login.onyva.dev",
|
||||||
|
"group": "Login",
|
||||||
|
"setting": "oidc_issuer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AUTH_ENABLED",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Login aktiv",
|
||||||
|
"help": "Aus nur für lokale Entwicklung.",
|
||||||
|
"default": "true",
|
||||||
|
"group": "Login",
|
||||||
|
"setting": "auth_enabled",
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "APP_COOKIE_SECURE",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Cookies „secure“",
|
||||||
|
"help": "Hinter HTTPS immer true.",
|
||||||
|
"default": "false",
|
||||||
|
"group": "Login",
|
||||||
|
"setting": "app_cookie_secure",
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MAX_FILE_SIZE_MB",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Max. Dateigröße (MB)",
|
||||||
|
"help": "",
|
||||||
|
"default": "300",
|
||||||
|
"group": "Limits",
|
||||||
|
"setting": "max_file_size_mb",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "STT_MAX_AUDIO_MINUTES",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Max. Audiolänge (Minuten)",
|
||||||
|
"help": "Grenze von Voxtral Mini Transcribe V2: 180.",
|
||||||
|
"default": "180",
|
||||||
|
"group": "Limits",
|
||||||
|
"setting": "stt_max_audio_minutes",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM_MODEL",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Protokoll-Modell",
|
||||||
|
"help": "Modell-ID bei Cortecs.",
|
||||||
|
"default": "claude-4-6-sonnet",
|
||||||
|
"group": "LLM",
|
||||||
|
"setting": "llm_model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM_MAX_TOKENS",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Max. Ausgabe-Tokens",
|
||||||
|
"help": "",
|
||||||
|
"default": "16000",
|
||||||
|
"group": "LLM",
|
||||||
|
"setting": "llm_max_tokens",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM_TEMPERATURE",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Temperatur",
|
||||||
|
"help": "",
|
||||||
|
"default": "0.2",
|
||||||
|
"group": "LLM",
|
||||||
|
"setting": "llm_temperature",
|
||||||
|
"type": "float"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM_MAX_RETRIES",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Wiederholungen (SDK)",
|
||||||
|
"help": "",
|
||||||
|
"default": "4",
|
||||||
|
"group": "LLM",
|
||||||
|
"setting": "llm_max_retries",
|
||||||
|
"type": "int",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM_TIMEOUT",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Timeout (s)",
|
||||||
|
"help": "",
|
||||||
|
"default": "600",
|
||||||
|
"group": "LLM",
|
||||||
|
"setting": "llm_timeout",
|
||||||
|
"type": "int",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CORTECS_BASE_URL",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Cortecs-Basis-URL",
|
||||||
|
"help": "",
|
||||||
|
"default": "https://api.cortecs.ai/v1",
|
||||||
|
"group": "LLM",
|
||||||
|
"setting": "cortecs_base_url"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "STT_PROVIDER",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "STT-Provider (global)",
|
||||||
|
"help": "Überschreibt tenant.json; leer = je Tenant.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "stt_provider",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "STT_MODEL",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "STT-Modell (global)",
|
||||||
|
"help": "Überschreibt tenant.json; leer = je Tenant.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "stt_model",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "STT_DIARIZE",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Sprechererkennung",
|
||||||
|
"help": "",
|
||||||
|
"default": "true",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "stt_diarize",
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "STT_TIMEOUT",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "STT-Timeout (s)",
|
||||||
|
"help": "Wächst zusätzlich mit der Audiolänge.",
|
||||||
|
"default": "600",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "stt_timeout",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MISTRAL_TRANSCRIBE_MODEL",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Mistral-Modell (Legacy)",
|
||||||
|
"help": "Wirkt nur für Tenants mit Provider mistral.",
|
||||||
|
"default": "voxtral-mini-latest",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "mistral_transcribe_model",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MISTRAL_DIARIZE",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Mistral Diarize (Legacy)",
|
||||||
|
"help": "",
|
||||||
|
"default": "true",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "mistral_diarize",
|
||||||
|
"type": "bool",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MISTRAL_TIMEOUT",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Mistral-Timeout (Legacy)",
|
||||||
|
"help": "",
|
||||||
|
"default": "600",
|
||||||
|
"group": "Spracherkennung",
|
||||||
|
"setting": "mistral_timeout",
|
||||||
|
"type": "int",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SMTP_HOST",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "SMTP-Host",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"group": "E-Mail",
|
||||||
|
"setting": "smtp_host"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SMTP_PORT",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "SMTP-Port",
|
||||||
|
"help": "",
|
||||||
|
"default": "587",
|
||||||
|
"group": "E-Mail",
|
||||||
|
"setting": "smtp_port",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SMTP_USER",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "SMTP-Benutzer",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"group": "E-Mail",
|
||||||
|
"setting": "smtp_user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SMTP_FROM",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Absendername",
|
||||||
|
"help": "",
|
||||||
|
"default": "",
|
||||||
|
"group": "E-Mail",
|
||||||
|
"setting": "smtp_from"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_API_BASE",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Cost-API-Basis",
|
||||||
|
"help": "Standard: <OIDC_ISSUER>/api/cost.",
|
||||||
|
"default": "",
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_api_base"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_TRACKING_ENABLED",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Kostenerfassung aktiv",
|
||||||
|
"help": "",
|
||||||
|
"default": "true",
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_tracking_enabled",
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_LLM_INPUT_PER_MTOK",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "LLM-Preis Eingabe (EUR / Mio. Tokens)",
|
||||||
|
"help": "",
|
||||||
|
"default": "2.87",
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_llm_input_per_mtok",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_LLM_OUTPUT_PER_MTOK",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "LLM-Preis Ausgabe (EUR / Mio. Tokens)",
|
||||||
|
"help": "",
|
||||||
|
"default": "14.31",
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_llm_output_per_mtok",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_STT_PER_MINUTE",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "STT-Preis (EUR / Minute, global)",
|
||||||
|
"help": "Nicht global setzen, wenn Tenants verschiedene Provider nutzen.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_stt_per_minute",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_FLUSH_INTERVAL",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Cost-Flush-Intervall (s)",
|
||||||
|
"help": "",
|
||||||
|
"default": "5",
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_flush_interval",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_HTTP_TIMEOUT",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Cost-HTTP-Timeout (s)",
|
||||||
|
"help": "",
|
||||||
|
"default": "5",
|
||||||
|
"group": "Kosten",
|
||||||
|
"setting": "cost_http_timeout",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RETRY_MAX_ATTEMPTS",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Retry: Versuche",
|
||||||
|
"help": "",
|
||||||
|
"default": "4",
|
||||||
|
"group": "Robustheit",
|
||||||
|
"setting": "retry_max_attempts",
|
||||||
|
"type": "int",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RETRY_BASE_DELAY",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Retry: Basis-Wartezeit (s)",
|
||||||
|
"help": "",
|
||||||
|
"default": "1.0",
|
||||||
|
"group": "Robustheit",
|
||||||
|
"setting": "retry_base_delay",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RETRY_MAX_DELAY",
|
||||||
|
"kind": "setting",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Retry: maximale Wartezeit (s)",
|
||||||
|
"help": "",
|
||||||
|
"default": "30.0",
|
||||||
|
"group": "Robustheit",
|
||||||
|
"setting": "retry_max_delay",
|
||||||
|
"type": "float",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CORTECS_API_KEY",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "Cortecs-API-Key",
|
||||||
|
"help": "LLM und (bei Provider cortecs) STT.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "cortecs_api_key",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SMTP_PASSWORD",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "shared",
|
||||||
|
"label": "SMTP-Passwort",
|
||||||
|
"help": "",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "smtp_password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MISTRAL_API_KEY",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Mistral-API-Key",
|
||||||
|
"help": "Nur bei STT-Provider mistral.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "mistral_api_key",
|
||||||
|
"required_if": "stt.provider!=cortecs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_CLIENT_ID",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "OIDC-Client-ID",
|
||||||
|
"help": "Wird vom Admin über die Login-API verwaltet.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "oidc_client_id",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_CLIENT_SECRET",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "OIDC-Client-Secret",
|
||||||
|
"help": "",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "oidc_client_secret",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SESSION_SECRET",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Session-Secret",
|
||||||
|
"help": "Signiert Session-Cookies.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "session_secret",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COST_INGEST_KEY",
|
||||||
|
"kind": "secret",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Cost-Ingest-Key",
|
||||||
|
"help": "Schlüssel für die Kostenerfassung.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Secrets",
|
||||||
|
"setting": "cost_ingest_key",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TENANT",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Tenant-ID",
|
||||||
|
"help": "= tenant.id",
|
||||||
|
"default": null,
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "tenant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATA_DIR",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Datenverzeichnis",
|
||||||
|
"help": "Volume des Containers.",
|
||||||
|
"default": "/data",
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "data_dir"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PORT",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Port",
|
||||||
|
"help": "",
|
||||||
|
"default": "8276",
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "port"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_REDIRECT_URI",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "OIDC-Redirect-URI",
|
||||||
|
"help": "= https://<host>/auth/callback",
|
||||||
|
"default": null,
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "oidc_redirect_uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_LOGOUT_REDIRECT_URI",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "OIDC-Logout-Ziel",
|
||||||
|
"help": "= https://<host>/",
|
||||||
|
"default": null,
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "oidc_logout_redirect_uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TENANT_SOURCE",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Quelle der Tenant-Daten",
|
||||||
|
"help": "git = ins Image gebacken (Legacy) · admin = Bundle der Admin-Plattform.",
|
||||||
|
"default": "git",
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "tenant_source"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ADMIN_URL",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "URL der Admin-Plattform",
|
||||||
|
"help": "Nur bei TENANT_SOURCE=admin.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "admin_url"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ADMIN_BUNDLE_TOKEN",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Bundle-Token",
|
||||||
|
"help": "Pro Tenant; nur bei TENANT_SOURCE=admin.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "admin_bundle_token",
|
||||||
|
"secret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BUNDLE_CACHE_DIR",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Bundle-Cache",
|
||||||
|
"help": "Standard: <DATA_DIR>/bundle.",
|
||||||
|
"default": null,
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "bundle_cache_dir",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BUNDLE_FALLBACK_BAKED",
|
||||||
|
"kind": "runtime",
|
||||||
|
"scope": "tenant",
|
||||||
|
"label": "Rückfall auf eingebackenen Tenant",
|
||||||
|
"help": "Nur Migrationszeit; danach false.",
|
||||||
|
"default": "true",
|
||||||
|
"group": "Laufzeit",
|
||||||
|
"setting": "bundle_fallback_baked",
|
||||||
|
"type": "bool",
|
||||||
|
"advanced": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2505
vendor/platform/core/templates/index.template.html
vendored
Normal file
2505
vendor/platform/core/templates/index.template.html
vendored
Normal file
File diff suppressed because it is too large
Load Diff
112
vendor/platform/core/templates/protocol.css
vendored
Normal file
112
vendor/platform/core/templates/protocol.css
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* Default PDF stylesheet. Rendered through Jinja, so colour tokens like
|
||||||
|
colors.accent (in double curly braces) from the tenant config are available.
|
||||||
|
Copy this file into a tenant folder and edit freely to give a customer a
|
||||||
|
completely individual PDF design. */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 26mm 22mm 24mm 22mm;
|
||||||
|
|
||||||
|
@top-left {
|
||||||
|
content: element(runHdrText);
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
@top-right {
|
||||||
|
content: element(runHdrLogo);
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
@bottom-left {
|
||||||
|
content: element(runFtr);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: "Seite " counter(page, decimal-leading-zero) " / " counter(pages, decimal-leading-zero);
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
font-style: italic;
|
||||||
|
color: {{ colors.text_3 }};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Noto Sans", "DejaVu Sans", Arial, sans-serif;
|
||||||
|
font-size: 9.8pt;
|
||||||
|
color: #000000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── running header / footer ── */
|
||||||
|
.run-hdr-text {
|
||||||
|
position: running(runHdrText);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: {{ colors.text_2 }};
|
||||||
|
}
|
||||||
|
.run-hdr-logo {
|
||||||
|
position: running(runHdrLogo);
|
||||||
|
height: 12mm;
|
||||||
|
}
|
||||||
|
.run-ftr {
|
||||||
|
position: running(runFtr);
|
||||||
|
font-size: 7pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: {{ colors.text_2 }};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── optional document title (tenant sets doc_title) ── */
|
||||||
|
.doc-title {
|
||||||
|
color: {{ colors.accent_dark }};
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 3mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── metadata block ── */
|
||||||
|
.meta {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 2mm 0 6mm 0;
|
||||||
|
}
|
||||||
|
.meta-left { width: 50%; vertical-align: top; padding: 0; }
|
||||||
|
.meta-right { width: 50%; vertical-align: top; text-align: right; padding: 0; }
|
||||||
|
.meta-label {
|
||||||
|
color: {{ colors.accent }};
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 11pt;
|
||||||
|
margin-bottom: 1.8mm;
|
||||||
|
}
|
||||||
|
.meta-sublabel {
|
||||||
|
color: {{ colors.accent }};
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
margin: 3mm 0 1mm 0;
|
||||||
|
}
|
||||||
|
.meta-val {
|
||||||
|
font-size: 10.2pt;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── content sections ── */
|
||||||
|
.proto-section { margin-bottom: 3.5mm; }
|
||||||
|
.section-bar {
|
||||||
|
background: {{ colors.accent_light }};
|
||||||
|
color: {{ colors.accent_dark }};
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1.8mm 2.5mm;
|
||||||
|
margin: 0 0 2.5mm 0;
|
||||||
|
break-after: avoid;
|
||||||
|
}
|
||||||
|
.para {
|
||||||
|
margin: 0 0 1.8mm 6mm;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.bullets {
|
||||||
|
margin: 0 0 1.8mm 0;
|
||||||
|
padding-left: 10mm;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.bullets li {
|
||||||
|
margin: 0 0 0.8mm 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
59
vendor/platform/core/templates/protocol.html
vendored
Normal file
59
vendor/platform/core/templates/protocol.html
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Gesprächsprotokoll {{ terminart }} – {{ project_display }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{# ── running header/footer: pulled into the @page margin boxes by the CSS ── #}
|
||||||
|
<div class="run-hdr-text">{{ terminart }} | Gesprächsprotokoll</div>
|
||||||
|
{% if logo_data_uri %}<img class="run-hdr-logo" src="{{ logo_data_uri }}" alt="{{ tenant.name }}">{% endif %}
|
||||||
|
<div class="run-ftr">
|
||||||
|
<div>Gesprächsprotokoll · {{ terminart }} · {{ date_de }} · {{ project_display }}</div>
|
||||||
|
<div>Erstellt durch: {{ office.name }}{% if office.subtitle %} {{ office.subtitle }}{% endif %}{% if office.tel %} • {{ office.tel }}{% endif %}{% if office.email %} • {{ office.email }}{% endif %}</div>
|
||||||
|
<div>{{ footer_note }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if doc_title %}<h1 class="doc-title">{{ doc_title }}</h1>{% endif %}
|
||||||
|
|
||||||
|
{# ── metadata: two columns (Projekt / Erstellt durch) ── #}
|
||||||
|
<table class="meta">
|
||||||
|
<tr>
|
||||||
|
<td class="meta-left">
|
||||||
|
<div class="meta-label">{{ projekt_label }}</div>
|
||||||
|
{% for line in projekt %}<div class="meta-val">{{ line }}</div>{% endfor %}
|
||||||
|
{% if kontakt %}
|
||||||
|
<div class="meta-sublabel">Ansprechpartner Kunde</div>
|
||||||
|
{% for line in kontakt %}<div class="meta-val">{{ line }}</div>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="meta-right">
|
||||||
|
<div class="meta-label">Erstellt durch</div>
|
||||||
|
{% if office.name %}<div class="meta-val">{{ office.name }}{% if office.subtitle %} {{ office.subtitle }}{% endif %}</div>{% endif %}
|
||||||
|
{% for line in (office.address or "").splitlines() %}<div class="meta-val">{{ line }}</div>{% endfor %}
|
||||||
|
{% if office.tel %}<div class="meta-val">{{ office.tel }}</div>{% endif %}
|
||||||
|
{% if office.email %}<div class="meta-val">{{ office.email }}</div>{% endif %}
|
||||||
|
{% if office.website %}<div class="meta-val">{{ office.website }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{# ── content sections ── #}
|
||||||
|
{% for section in sections %}
|
||||||
|
<section class="proto-section">
|
||||||
|
<h2 class="section-bar">{{ section.heading }}</h2>
|
||||||
|
{% for block in section.blocks %}
|
||||||
|
{% if block.type == 'paragraph' %}
|
||||||
|
<p class="para">{{ block.text }}</p>
|
||||||
|
{% elif block.type == 'list' %}
|
||||||
|
<ul class="bullets">
|
||||||
|
{% for item in block['items'] %}<li>{{ item }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
vendor/platform/main.py
vendored
Normal file
12
vendor/platform/main.py
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Entry point. The tenant is selected via the TENANT env var (one instance
|
||||||
|
per customer). Run e.g.:
|
||||||
|
|
||||||
|
TENANT=sportklinik python main.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.getenv("PORT", "8276"))
|
||||||
|
uvicorn.run("core.app:app", host="0.0.0.0", port=port, reload=True)
|
||||||
13
vendor/platform/requirements.txt
vendored
Normal file
13
vendor/platform/requirements.txt
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# System-Abhängigkeiten:
|
||||||
|
# ffmpeg (Audio-Konvertierung; nur für Azure-STT nötig)
|
||||||
|
# libpango / libcairo etc. (WeasyPrint PDF-Rendering)
|
||||||
|
# Debian/Ubuntu: apt-get install ffmpeg libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-core
|
||||||
|
fastapi==0.135.1
|
||||||
|
openai==2.46.0
|
||||||
|
python-multipart==0.0.22
|
||||||
|
uvicorn==0.41.0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
requests>=2.31.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
weasyprint>=60.0
|
||||||
|
python-docx>=1.1.0
|
||||||
Reference in New Issue
Block a user