Versionen je Thema

Ein Thema hat Versionen storage/<Thema>/v<N>/ mit eigenem Lauf, Buch und
Leseposition; bestehende Ablagen wandern beim Start nach v1/. Intern ist
„Thema/vN“ der Schlüssel aller Zustandsfunktionen. API: ?v=N an allen
Themen-Endpunkten, POST /version, DELETE ?v=N. Oberfläche: Versionsliste
unter dem Thema (Klick auf Thema → Lauf der neuesten, auf Version → Buch),
Knopf „Neue Version“ mit gleichen Eingaben. make loeschen kennt V=<Nr>.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
team3
2026-10-05 17:01:40 +02:00
parent 8d28680a12
commit 1b6899638b
10 changed files with 229 additions and 107 deletions

View File

@@ -16,6 +16,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
@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)
@@ -35,19 +36,34 @@ class Position(BaseModel):
lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index
def _pruefen(name: str) -> dict:
if not state.name_ok(name) or not (t := state.thema(name)):
def _key(name: str, v: int | None = None) -> str:
"""Themenname + optionale Version → Schlüssel „Thema/vN“ (ohne v: neueste)."""
if not state.name_ok(name) or not state.thema(name):
raise HTTPException(404, "Thema unbekannt")
return t
key = state.schluessel(name, v)
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)}
@app.get("/api/topics")
async def topics():
aus = []
for t in state.themen():
st = state.zustand(t["name"])
aus.append({"name": t["name"], "titel": t["titel"], "status": st["status"],
"laeuft": pipeline.laeuft(t["name"]), "stufe": st["stufe"]})
vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])]
if not vs:
continue
neueste = vs[-1]
aus.append({"name": t["name"], "titel": t["titel"], "status": neueste["status"],
"laeuft": neueste["laeuft"], "stufe": neueste["stufe"], "versionen": vs})
return aus
@@ -62,77 +78,87 @@ async def topic_anlegen(body: ThemaNeu):
raise HTTPException(409, str(e))
@app.post("/api/topics/{name}/version")
async def version_anlegen(name: str):
"""Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger."""
_key(name)
key = state.version_anlegen(name)
return {"v": int(key.rsplit("/v", 1)[1])}
@app.delete("/api/topics/{name}")
async def topic_loeschen(name: str):
_pruefen(name)
await pipeline.pausieren(name)
state.loeschen(name)
async def topic_loeschen(name: str, v: int | None = None):
if v is None:
_key(name)
for n in state.versionen(name):
await pipeline.pausieren(f"{name}/v{n}")
state.loeschen(name)
else:
key = _key(name, v)
await pipeline.pausieren(key)
state.version_loeschen(key)
return {"ok": True}
@app.post("/api/topics/{name}/start")
async def start(name: str):
_pruefen(name)
if state.zustand(name)["status"] == "fertig":
async def start(name: str, v: int | None = None):
key = _key(name, v)
if state.zustand(key)["status"] == "fertig":
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
pipeline.starten(name)
pipeline.starten(key)
return {"ok": True}
@app.post("/api/topics/{name}/pause")
async def pause(name: str):
_pruefen(name)
await pipeline.pausieren(name)
async def pause(name: str, v: int | None = None):
await pipeline.pausieren(_key(name, v))
return {"ok": True}
@app.post("/api/topics/{name}/reset")
async def reset(name: str):
_pruefen(name)
await pipeline.pausieren(name)
state.zuruecksetzen(name)
async def reset(name: str, v: int | None = None):
key = _key(name, v)
await pipeline.pausieren(key)
state.zuruecksetzen(key)
return {"ok": True}
@app.get("/api/topics/{name}/state")
async def zustand(name: str):
t = _pruefen(name)
st = state.zustand(name)
return {"name": name, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
"deutung": st.get("deutung") or state.deutung(name), "ereignisse": st["ereignisse"][-100:]}
async def zustand(name: str, v: int | None = None):
key = _key(name, v)
t = state.thema(name)
st = state.zustand(key)
return {"name": name, "version": int(key.rsplit("/v", 1)[1]), "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
@app.get("/api/topics/{name}/position")
async def position_lesen(name: str):
_pruefen(name)
return state.position(name)
async def position_lesen(name: str, v: int | None = None):
return state.position(_key(name, v))
@app.put("/api/topics/{name}/position")
async def position_schreiben(name: str, body: Position):
_pruefen(name)
return state.position_setzen(name, {k: v for k, v in body.model_dump().items() if v is not None})
async def position_schreiben(name: str, body: Position, v: int | None = None):
return state.position_setzen(_key(name, v), {k: val for k, val in body.model_dump().items() if val is not None})
@app.get("/api/topics/{name}/guide")
async def guide(name: str):
_pruefen(name)
return montage.guide(name)
async def guide(name: str, v: int | None = None):
return montage.guide(_key(name, v))
@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse)
async def guide_md(name: str):
_pruefen(name)
return montage.markdown(name)
async def guide_md(name: str, v: int | None = None):
return montage.markdown(_key(name, v))
@app.get("/api/topics/{name}/buch.pdf")
async def buch_pdf(name: str):
_pruefen(name)
p = state.verzeichnis(name) / "buch" / "buch.pdf"
async def buch_pdf(name: str, v: int | None = None):
key = _key(name, v)
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"{name}.pdf")
return FileResponse(p, media_type="application/pdf", filename=f"{name} v{key.rsplit('/v', 1)[1]}.pdf")
if config.FRONTEND_DIST.exists():