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>
108 lines
5.4 KiB
Python
108 lines
5.4 KiB
Python
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"})
|