feat: PDF-Export zusätzlich zu DOCX
Mac-Worker:
- pdf_export.py mit reportlab (pure Python, kein externes Tool)
- Nutzt dieselbe profile.docx-Konfig wie der DOCX-Renderer →
strukturell identisch, nur andere Optik
- POST /api/export/pdf
- requirements: reportlab==4.2.5
LXC:
- Job.pdf_path neu (Migration: ALTER TABLE job ADD COLUMN)
- Pipeline-Step nach DOCX: PDF wird ebenfalls erzeugt
(Progress: 90 DOCX → 95 PDF → 100 done)
- /api/jobs/{id}/download/pdf
- /api/jobs/{id}/retry löscht PDF mit, damit es neu erzeugt wird
- Frontend: zusätzlicher Download-Button "PDF" (vorne im Array)
- Tabelle: Download-Spalte 320px, 3-spaltiges Grid (5 Buttons → 3+2)
- Cards (Mobile) bleiben 2-spaltig (5 Buttons → 3 Zeilen)
This commit is contained in:
@@ -108,7 +108,7 @@ async def retry_job(
|
||||
raise HTTPException(400, "Job ist bereits abgeschlossen")
|
||||
|
||||
# Post-Transkriptions-Artefakte löschen, damit sie neu erzeugt werden.
|
||||
for attr in ("summary_path", "protocol_path", "docx_path"):
|
||||
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
|
||||
p = getattr(job, attr)
|
||||
if p:
|
||||
try:
|
||||
@@ -133,6 +133,7 @@ def download(job_id: int, kind: str, session: Session = Depends(get_session), us
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
mapping = {
|
||||
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
|
||||
"pdf": (job.pdf_path, "application/pdf", "protocol.pdf"),
|
||||
"transcript": (job.transcript_path, "application/json", "transcript.json"),
|
||||
"summary": (job.summary_path, "application/json", "summary.json"),
|
||||
"protocol": (job.protocol_path, "application/json", "protocol.json"),
|
||||
@@ -162,6 +163,7 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
|
||||
"summary": bool(j.summary_path),
|
||||
"protocol": bool(j.protocol_path),
|
||||
"docx": bool(j.docx_path),
|
||||
"pdf": bool(j.pdf_path),
|
||||
},
|
||||
}
|
||||
if user.is_admin:
|
||||
|
||||
@@ -55,3 +55,12 @@ class MacClient:
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
async def export_pdf(self, data: dict, profile: str | None = None) -> bytes:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
r = await c.post(
|
||||
f"{self.base_url}/api/export/pdf",
|
||||
json={"data": data, "profile": profile},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
@@ -40,6 +40,12 @@ def run_migrations() -> None:
|
||||
log.info("Migration: füge user.default_profile hinzu (default 'meeting')")
|
||||
conn.execute(text("ALTER TABLE user ADD COLUMN default_profile VARCHAR DEFAULT 'meeting'"))
|
||||
|
||||
# 4) Job.pdf_path nachrüsten (PDF-Export zusätzlich zu DOCX).
|
||||
cols = _existing_columns(conn, "job")
|
||||
if "pdf_path" not in cols:
|
||||
log.info("Migration: füge job.pdf_path hinzu")
|
||||
conn.execute(text("ALTER TABLE job ADD COLUMN pdf_path VARCHAR DEFAULT ''"))
|
||||
|
||||
|
||||
def assign_orphan_jobs_to(user_id: int) -> int:
|
||||
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
|
||||
|
||||
@@ -27,5 +27,6 @@ class Job(SQLModel, table=True):
|
||||
summary_path: str = ""
|
||||
protocol_path: str = ""
|
||||
docx_path: str = ""
|
||||
pdf_path: str = ""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -52,6 +52,7 @@ async def run_pipeline(job_id: int) -> None:
|
||||
existing_sum_path = _existing(job.summary_path)
|
||||
existing_pro_path = _existing(job.protocol_path)
|
||||
existing_dx_path = _existing(job.docx_path)
|
||||
existing_pdf_path = _existing(job.pdf_path)
|
||||
|
||||
job_dir = settings.result_dir / str(job_id)
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -103,14 +104,25 @@ async def run_pipeline(job_id: int) -> None:
|
||||
# 4) DOCX
|
||||
if existing_dx_path:
|
||||
log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name)
|
||||
_update(job_id, status=JobStatus.DONE, progress=100, error="")
|
||||
else:
|
||||
_update(job_id, status=JobStatus.EXPORTING, progress=90)
|
||||
log.info("[job %s] export docx", job_id)
|
||||
docx_bytes = await client.export_docx(protocol, profile=profile)
|
||||
docx_path = job_dir / "protocol.docx"
|
||||
docx_path.write_bytes(docx_bytes)
|
||||
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100, error="")
|
||||
_update(job_id, docx_path=str(docx_path), progress=95)
|
||||
|
||||
# 5) PDF
|
||||
if existing_pdf_path:
|
||||
log.info("[job %s] reuse pdf %s", job_id, existing_pdf_path.name)
|
||||
_update(job_id, status=JobStatus.DONE, progress=100, error="")
|
||||
else:
|
||||
_update(job_id, status=JobStatus.EXPORTING, progress=96)
|
||||
log.info("[job %s] export pdf", job_id)
|
||||
pdf_bytes = await client.export_pdf(protocol, profile=profile)
|
||||
pdf_path = job_dir / "protocol.pdf"
|
||||
pdf_path.write_bytes(pdf_bytes)
|
||||
_update(job_id, pdf_path=str(pdf_path), status=JobStatus.DONE, progress=100, error="")
|
||||
log.info("[job %s] done", job_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("[job %s] failed", job_id)
|
||||
|
||||
@@ -57,7 +57,8 @@ let mode = "login"; // "login" | "setup"
|
||||
let availableProfiles = []; // [{ name, display_name, language }, ...]
|
||||
|
||||
const DOWNLOADS = [
|
||||
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
|
||||
{ kind: "pdf", label: "PDF", title: "Protokoll als PDF" },
|
||||
{ kind: "docx", label: "DOCX", title: "Protokoll als Word-Dokument" },
|
||||
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
|
||||
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
||||
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
|
||||
|
||||
@@ -99,9 +99,9 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
|
||||
/* Spaltenbreiten — Status bleibt schmal, Downloads bekommen Platz */
|
||||
.col-id { width: 56px; }
|
||||
.col-status { width: 110px; }
|
||||
.col-progress { width: 130px; }
|
||||
.col-updated { width: 130px; }
|
||||
.col-dl { width: 280px; }
|
||||
.col-progress { width: 120px; }
|
||||
.col-updated { width: 120px; }
|
||||
.col-dl { width: 320px; }
|
||||
/* col-title nimmt den Rest */
|
||||
|
||||
.col-title { word-break: break-word; }
|
||||
@@ -132,10 +132,10 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Download-Buttons — sauberes 2×2-Grid, alle gleich breit */
|
||||
/* Download-Buttons — 3-spaltiges Grid (5 Buttons → 2 Zeilen 3+2) */
|
||||
.dl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-dl {
|
||||
|
||||
Reference in New Issue
Block a user