Files
buchwerk/backend/main.py
team3 e06be9d50a Stopp nach der Gliederung als Häkchen neben „Buch erzeugen“
Das Häkchen steht im Lauf neben dem Startknopf und bei „Neue Version“
neben „Buch erzeugen“, nicht mehr im Anlegen-Formular. Es wird mit dem
Start gespeichert und ist sichtbar, solange die Gliederung aussteht.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 16:02:57 +02:00

214 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from pydantic import BaseModel
from . import config, montage, pipeline, state
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
@asynccontextmanager
async def lifespan(app: FastAPI):
config.STORAGE.mkdir(exist_ok=True)
state.migrieren()
yield
for name in list(pipeline._laeufe):
await pipeline.pausieren(name)
app = FastAPI(title="buchwerk", lifespan=lifespan)
app.add_middleware(GZipMiddleware, minimum_size=1000) # Buchdaten sind 1–2 MB JSON
class ThemaNeu(BaseModel):
name: str
hinweise: str = ""
spezialisierung: str = ""
class VersionNeu(BaseModel):
hinweise: str | None = None # None: wie die bisherige Version
stopp_gliederung: bool = False
class Start(BaseModel):
stopp_gliederung: bool | None = None # None: wie gespeichert
class Position(BaseModel):
abschnitt: str | None = None
lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index
zuletzt: str | None = None # 'buch' oder 'lesen': welche Ansicht zuletzt gelesen wurde
def _thema(slug: str) -> dict:
name = state.name_von_slug(slug)
t = state.thema(name) if name else None
if not t:
raise HTTPException(404, "Thema unbekannt")
return t
def _key(slug: str, n: int) -> str:
"""Slug + Versionsnummer → interner Schlüssel „Thema/vN“."""
t = _thema(slug)
key = state.schluessel(t["name"], n)
if not key:
raise HTTPException(404, "Version unbekannt")
return key
def _version_info(name: str, n: int) -> dict:
key = f"{name}/v{n}"
st = state.zustand(key)
angelegt = st.get("angelegt") or (state.thema(name) or {}).get("angelegt", "") # migrierte v1 hat kein eigenes Datum
return {"v": n, "status": st["status"], "laeuft": pipeline.laeuft(key), "stufe": st["stufe"],
"angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)}
def _thema_info(t: dict) -> dict | None:
vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])]
if not vs:
return None
neueste = vs[-1]
return {"slug": t["slug"], "name": t["name"], "thema": t.get("thema") or t["name"], "spezialisierung": t.get("spezialisierung", ""),
"titel": t["titel"], "hinweise": t.get("hinweise", ""),
"angelegt": t.get("angelegt", ""), "status": neueste["status"], "laeuft": neueste["laeuft"],
"stufe": neueste["stufe"], "versionen": vs}
@app.get("/api/themen")
async def themen():
return [i for t in state.themen() if (i := _thema_info(t))]
@app.post("/api/themen")
async def thema_anlegen(body: ThemaNeu):
name = body.name.strip()
if not state.name_ok(name) or not state.name_ok(state.bezeichnung(name, body.spezialisierung.strip())):
raise HTTPException(400, "Ungültiger Name")
try:
return _thema_info(state.anlegen(name, body.hinweise, body.spezialisierung))
except ValueError as e:
raise HTTPException(409, str(e))
@app.get("/api/themen/{slug}")
async def thema(slug: str):
return _thema_info(_thema(slug))
@app.delete("/api/themen/{slug}")
async def thema_loeschen(slug: str):
t = _thema(slug)
for n in state.versionen(t["name"]):
await pipeline.pausieren(f"{t['name']}/v{n}")
state.loeschen(t["name"])
return {"ok": True}
@app.post("/api/themen/{slug}/versionen")
async def version_anlegen(slug: str, body: VersionNeu | None = None):
"""Neue Version: frischer Lauf ohne Wissen über die Vorgänger; Hinweise wahlweise geändert."""
b = body or VersionNeu()
key = state.version_anlegen(_thema(slug)["name"], b.hinweise, b.stopp_gliederung)
return {"v": int(key.rsplit("/v", 1)[1])}
@app.get("/api/themen/{slug}/v{n}")
async def zustand(slug: str, n: int):
key = _key(slug, n)
t = _thema(slug)
st = state.zustand(key)
return {"slug": slug, "name": t["name"], "thema": t.get("thema") or t["name"], "version": n, "titel": t["titel"], **st,
"hinweise": state.hinweise_von(key), "spezialisierung": state.spezialisierung_von(key),
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
@app.delete("/api/themen/{slug}/v{n}")
async def version_loeschen(slug: str, n: int):
key = _key(slug, n)
await pipeline.pausieren(key)
state.version_loeschen(key)
return {"ok": True}
@app.post("/api/themen/{slug}/v{n}/start")
async def start(slug: str, n: int, body: Start | None = None):
key = _key(slug, n)
st = state.zustand(key)
if st["status"] == "fertig":
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
if body and body.stopp_gliederung is not None:
st["stopp_gliederung"] = body.stopp_gliederung
pipeline.starten(key)
return {"ok": True}
@app.post("/api/themen/{slug}/v{n}/pause")
async def pause(slug: str, n: int):
await pipeline.pausieren(_key(slug, n))
return {"ok": True}
@app.post("/api/themen/{slug}/v{n}/reset")
async def reset(slug: str, n: int):
key = _key(slug, n)
await pipeline.pausieren(key)
state.zuruecksetzen(key)
return {"ok": True}
@app.get("/api/themen/{slug}/v{n}/position")
async def position_lesen(slug: str, n: int):
return state.position(_key(slug, n))
@app.put("/api/themen/{slug}/v{n}/position")
async def position_schreiben(slug: str, n: int, body: Position):
return state.position_setzen(_key(slug, n), {k: val for k, val in body.model_dump().items() if val is not None})
@app.get("/api/themen/{slug}/v{n}/buch")
async def buch(slug: str, n: int):
return montage.guide(_key(slug, n))
@app.get("/api/themen/{slug}/v{n}/buch.md", response_class=PlainTextResponse)
async def buch_md(slug: str, n: int):
return montage.markdown(_key(slug, n))
@app.get("/api/themen/{slug}/v{n}/kern.md", response_class=PlainTextResponse)
async def kern_md(slug: str, n: int):
return montage.kern(_key(slug, n))
@app.get("/api/themen/{slug}/v{n}/buch.pdf")
async def buch_pdf(slug: str, n: int):
key = _key(slug, n)
p = state.verzeichnis(key) / "buch" / "buch.pdf"
if not p.exists():
raise HTTPException(404, "PDF noch nicht gebaut")
return FileResponse(p, media_type="application/pdf", filename=f"{slug}-v{n}.pdf")
if config.FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=config.FRONTEND_DIST / "assets"), name="assets")
@app.get("/{pfad:path}")
async def spa(pfad: str):
if pfad.startswith("api/"):
raise HTTPException(404, "Unbekannter API-Pfad")
# Statische Dateien des Frontends (Manifest, Icons) direkt ausliefern, alles andere ist die App
datei = (config.FRONTEND_DIST / pfad).resolve()
if pfad and datei.is_file() and config.FRONTEND_DIST.resolve() in datei.parents:
return FileResponse(datei)
return FileResponse(config.FRONTEND_DIST / "index.html")