Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 1m15s
All checks were successful
Build & Deploy / deploy (push) Successful in 1m15s
This commit is contained in:
139
app.py
Normal file
139
app.py
Normal 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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user