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 = "" class Position(BaseModel): abschnitt: str | None = None lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index 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") 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(): 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 @app.post("/api/topics") async def topic_anlegen(body: ThemaNeu): name = body.name.strip() if not state.name_ok(name): raise HTTPException(400, "Ungültiger Name") try: return state.anlegen(name, body.hinweise) except ValueError as e: 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, 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, 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(key) return {"ok": True} @app.post("/api/topics/{name}/pause") 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, 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, 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, v: int | None = None): return state.position(_key(name, v)) @app.put("/api/topics/{name}/position") 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, 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, v: int | None = None): return montage.markdown(_key(name, v)) @app.get("/api/topics/{name}/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} v{key.rsplit('/v', 1)[1]}.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): return FileResponse(config.FRONTEND_DIST / "index.html")