Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
4
vendor/platform/core/pdf/__init__.py
vendored
Normal file
4
vendor/platform/core/pdf/__init__.py
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
from core.pdf.context import build_pdf_context
|
||||
from core.pdf.render import render_pdf
|
||||
|
||||
__all__ = ["build_pdf_context", "render_pdf"]
|
||||
92
vendor/platform/core/pdf/context.py
vendored
Normal file
92
vendor/platform/core/pdf/context.py
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Builds the structured data the PDF template renders.
|
||||
|
||||
This is the *content* half of the PDF — purely data, identical logic for every
|
||||
tenant. The *layout* half lives in the tenant's (or core's) protocol.html +
|
||||
protocol.css. Core guarantees the shape of this dict; templates may ignore any
|
||||
field they don't use, so adding fields here never breaks an old template.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from core.protocol import (
|
||||
group_section_blocks,
|
||||
normalize_protocol_text,
|
||||
parse_address_lines,
|
||||
parse_protocol_sections,
|
||||
project_label,
|
||||
)
|
||||
|
||||
|
||||
def _logo_data_uri(logo_path: Path | None) -> str | None:
|
||||
if not logo_path or not logo_path.exists():
|
||||
return None
|
||||
mime = "image/svg+xml" if logo_path.suffix.lower() == ".svg" else "image/png"
|
||||
data = base64.b64encode(logo_path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{data}"
|
||||
|
||||
|
||||
def build_pdf_context(
|
||||
cfg,
|
||||
title: str,
|
||||
summary: str,
|
||||
insurance_type: str = "",
|
||||
customer_2: str = "",
|
||||
customer_name: str = "",
|
||||
customer_address: str = "",
|
||||
) -> dict:
|
||||
clean = normalize_protocol_text(summary)
|
||||
parsed = parse_protocol_sections(clean)
|
||||
|
||||
terminart = parsed["terminart"] or insurance_type.strip() or "Kundengespräch"
|
||||
|
||||
projekt: list[str] = []
|
||||
if parsed["projekt"]:
|
||||
projekt = parsed["projekt"]
|
||||
else:
|
||||
if customer_name.strip():
|
||||
projekt.append(customer_name.strip())
|
||||
if customer_2.strip():
|
||||
projekt.append(customer_2.strip())
|
||||
if not projekt:
|
||||
projekt = ["Projekt"]
|
||||
|
||||
if parsed["kontakt"]:
|
||||
kontakt = parsed["kontakt"]
|
||||
elif customer_address.strip():
|
||||
kontakt = parse_address_lines(customer_address)
|
||||
else:
|
||||
kontakt = []
|
||||
|
||||
sections = [
|
||||
{"heading": s["heading"], "blocks": group_section_blocks(s)}
|
||||
for s in parsed["sections"]
|
||||
]
|
||||
|
||||
project_display = project_label(projekt)
|
||||
date_de = datetime.now().strftime("%d.%m.%Y")
|
||||
office = cfg.office
|
||||
|
||||
return {
|
||||
"tenant": {"name": cfg.name, "id": cfg.id},
|
||||
"colors": cfg.colors,
|
||||
"logo_data_uri": _logo_data_uri(cfg.logo_path),
|
||||
"title": title,
|
||||
"terminart": terminart,
|
||||
"projekt": projekt,
|
||||
"projekt_label": cfg.raw.get("pdf_projekt_label", "Projekt"),
|
||||
"project_display": project_display,
|
||||
"kontakt": kontakt,
|
||||
"office": office,
|
||||
"sections": sections,
|
||||
"date_de": date_de,
|
||||
# {year} keeps a copyright line in the footer note current without a
|
||||
# yearly config edit.
|
||||
"footer_note": cfg.raw.get(
|
||||
"pdf_footer_note",
|
||||
"Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich "
|
||||
"für die genannten Empfänger bestimmt.",
|
||||
).replace("{year}", str(datetime.now().year)),
|
||||
}
|
||||
33
vendor/platform/core/pdf/render.py
vendored
Normal file
33
vendor/platform/core/pdf/render.py
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Renders the PDF from an HTML+CSS template via WeasyPrint.
|
||||
|
||||
The template + stylesheet are resolved per tenant (tenant override → core
|
||||
default) in core/config.py. A designer can fully restyle the PDF by editing
|
||||
those two text files — no Python involved.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from core.config import CORE_TEMPLATES
|
||||
|
||||
|
||||
def render_pdf(context: dict, cfg) -> bytes:
|
||||
from weasyprint import CSS, HTML # imported lazily: only needed for PDF
|
||||
|
||||
template_path = cfg.pdf_template_path
|
||||
css_path = cfg.pdf_css_path
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader([str(template_path.parent), str(CORE_TEMPLATES)]),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template(template_path.name)
|
||||
html_str = template.render(**context)
|
||||
|
||||
# CSS is rendered through Jinja too, so colour tokens can be injected.
|
||||
css_env = Environment(loader=FileSystemLoader([str(css_path.parent)]))
|
||||
css_str = css_env.get_template(css_path.name).render(**context)
|
||||
|
||||
base_url = str(template_path.parent)
|
||||
doc = HTML(string=html_str, base_url=base_url)
|
||||
return doc.write_pdf(stylesheets=[CSS(string=css_str)])
|
||||
Reference in New Issue
Block a user