Initiale Ablage der umsortierten Skrift-Programme

Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 12:21:08 +02:00
commit 0341c4eaea
90 changed files with 40401 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
"""
Signatur-Plotter-Service (FastAPI).
Zustandsloser Service: nimmt ein PDF mit unsichtbaren Marker-Token, bis zu drei
Signatur-SVGs und eine Job-Definition entgegen und erzeugt A4-Plot-SVGs bzw. ein
Vorschau-PDF. Keine Datenbank, keine Persistenz, keine Plotter-Kalibrierung.
"""
import io
import json
import zipfile
from typing import List
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import ValidationError
from core import build_preview_pdf, place_all
from models import SignatureJob
app = FastAPI(title="Signatur-Plotter-Service", version="1.0.0")
# CORS offen halten, damit das Upload-Frontend (anderer Port/Origin) den Service erreicht.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
MAX_SVGS = 3
# ── Eingaben aufbereiten ────────────────────────────────────────────────
def _parse_jobs(job_raw: str) -> List[SignatureJob]:
"""Parst das Job-Definition-JSON-Formfeld zu einer Liste von SignatureJob."""
try:
data = json.loads(job_raw)
except json.JSONDecodeError as e:
raise HTTPException(400, f"Job-Definition ist kein gültiges JSON: {e}")
# Sowohl eine reine Liste als auch ein Wrapper-Objekt akzeptieren.
if isinstance(data, dict):
data = data.get("signaturen") or data.get("jobs") or [data]
if not isinstance(data, list) or not data:
raise HTTPException(400, "Job-Definition muss eine nicht-leere Liste sein.")
jobs: List[SignatureJob] = []
errors: List[str] = []
for i, entry in enumerate(data, start=1):
try:
jobs.append(SignatureJob(**entry))
except ValidationError as e:
for err in e.errors():
loc = ".".join(str(x) for x in err["loc"])
errors.append(f"Eintrag {i} ({loc}): {err['msg']}")
except TypeError:
errors.append(f"Eintrag {i}: ungültiges Format.")
if errors:
# 422 bei Validierungsfehlern der Job-Definition.
raise HTTPException(422, detail=errors)
# Doppelte Token in der Job-Definition selbst sind ein Eingabefehler.
seen = {}
for j in jobs:
seen[j.token] = seen.get(j.token, 0) + 1
dups = [t for t, c in seen.items() if c > 1]
if dups:
raise HTTPException(422, detail=[f"Token '{t}' mehrfach in der Job-Definition." for t in dups])
return jobs
async def _read_svgs(svgs: List[UploadFile]) -> dict:
"""Liest die hochgeladenen SVGs in ein {dateiname: bytes}-Dict. Max. 3."""
if len(svgs) > MAX_SVGS:
raise HTTPException(400, f"Maximal {MAX_SVGS} SVG-Dateien erlaubt (es waren {len(svgs)}).")
out = {}
for f in svgs:
out[f.filename] = await f.read()
return out
async def _prepare(pdf: UploadFile, svgs: List[UploadFile], job: str):
"""Gemeinsame Aufbereitung für /render und /preview."""
jobs = _parse_jobs(job)
svg_map = await _read_svgs(svgs)
pdf_bytes = await pdf.read()
if not pdf_bytes:
raise HTTPException(400, "PDF-Datei ist leer.")
pages, errors = place_all(pdf_bytes, svg_map, jobs)
if errors:
# 422: fachliche Validierung (Token nicht gefunden, SVG fehlt, keine viewBox …)
raise HTTPException(422, detail=errors)
return pdf_bytes, pages
# ── Endpunkte ───────────────────────────────────────────────────────────
@app.get("/")
def health():
return {"service": "Signatur-Plotter-Service", "status": "ok"}
@app.post("/render")
async def render(
pdf: UploadFile = File(...),
svgs: List[UploadFile] = File(default=[]),
job: str = Form(...),
):
"""Erzeugt pro betroffener Seite eine A4-SVG und liefert alle als ZIP."""
_, pages = await _prepare(pdf, svgs, job)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for p in pages:
zf.writestr(p.filename, p.a4_svg)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="signaturen.zip"'},
)
@app.post("/preview")
async def preview(
pdf: UploadFile = File(...),
svgs: List[UploadFile] = File(default=[]),
job: str = Form(...),
):
"""Erzeugt ein Vorschau-PDF mit allen betroffenen Seiten und Signatur-Overlay."""
pdf_bytes, pages = await _prepare(pdf, svgs, job)
out_pdf = build_preview_pdf(pdf_bytes, pages)
return StreamingResponse(
io.BytesIO(out_pdf),
media_type="application/pdf",
headers={"Content-Disposition": 'inline; filename="vorschau.pdf"'},
)