Files
Onyscribe-Admin/app/routers/pages.py
RubenRWU 7d8c226da2 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>
2026-10-15 15:30:26 +02:00

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"})