Files
breakpilot-core/backend-core/services/pdf_service.py
Benjamin Admin 92c86ec6ba [split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:09:30 +02:00

349 lines
11 KiB
Python

"""
PDF Service - Zentrale PDF-Generierung fuer BreakPilot.
Shared Service fuer:
- Letters (Elternbriefe)
- Zeugnisse (Schulzeugnisse)
- Correction (Korrektur-Uebersichten)
Verwendet WeasyPrint fuer PDF-Rendering und Jinja2 fuer Templates.
Datenmodelle: services/pdf_models.py
HTML-Templates: services/pdf_templates.py
"""
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
# Re-export models for backward compatibility
from .pdf_models import (
SchoolInfo,
LetterData,
CertificateData,
StudentInfo,
CorrectionData,
)
from .pdf_templates import (
get_base_css,
get_letter_template_html,
get_certificate_template_html,
get_correction_template_html,
)
logger = logging.getLogger(__name__)
# Template directory
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
class PDFService:
"""
Zentrale PDF-Generierung fuer BreakPilot.
Unterstuetzt:
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
- Korrektur-Uebersichten fuer Klausuren
"""
def __init__(self, templates_dir: Optional[Path] = None):
"""
Initialisiert den PDF-Service.
Args:
templates_dir: Optionaler Pfad zu Templates (Standard: backend/templates/pdf)
"""
self.templates_dir = templates_dir or TEMPLATES_DIR
# Ensure templates directory exists
self.templates_dir.mkdir(parents=True, exist_ok=True)
# Initialize Jinja2 environment
self.jinja_env = Environment(
loader=FileSystemLoader(str(self.templates_dir)),
autoescape=select_autoescape(['html', 'xml']),
trim_blocks=True,
lstrip_blocks=True
)
# Add custom filters
self.jinja_env.filters['date_format'] = self._date_format
self.jinja_env.filters['grade_color'] = self._grade_color
# Font configuration for WeasyPrint
self.font_config = FontConfiguration()
logger.info(f"PDFService initialized with templates from {self.templates_dir}")
@staticmethod
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
"""Formatiert Datum fuer deutsche Darstellung."""
if not value:
return ""
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt.strftime(format_str)
except (ValueError, AttributeError):
return value
@staticmethod
def _grade_color(grade: str) -> str:
"""Gibt Farbe basierend auf Note zurueck."""
grade_colors = {
"1": "#27ae60", # Gruen
"2": "#2ecc71", # Hellgruen
"3": "#f1c40f", # Gelb
"4": "#e67e22", # Orange
"5": "#e74c3c", # Rot
"6": "#c0392b", # Dunkelrot
"A": "#27ae60",
"B": "#2ecc71",
"C": "#f1c40f",
"D": "#e74c3c",
}
return grade_colors.get(str(grade), "#333333")
def _get_base_css(self) -> str:
"""Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates)."""
return get_base_css()
def generate_letter_pdf(self, data: LetterData) -> bytes:
"""
Generiert PDF fuer Elternbrief.
Args:
data: LetterData mit allen Briefinformationen
Returns:
PDF als bytes
"""
logger.info(f"Generating letter PDF for student: {data.student_name}")
template = self._get_letter_template()
html_content = template.render(
data=data,
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
)
css = CSS(string=self._get_base_css(), font_config=self.font_config)
pdf_bytes = HTML(string=html_content).write_pdf(
stylesheets=[css],
font_config=self.font_config
)
logger.info(f"Letter PDF generated: {len(pdf_bytes)} bytes")
return pdf_bytes
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
"""
Generiert PDF fuer Schulzeugnis.
Args:
data: CertificateData mit allen Zeugnisinformationen
Returns:
PDF als bytes
"""
logger.info(f"Generating certificate PDF for: {data.student_name}")
template = self._get_certificate_template()
html_content = template.render(
data=data,
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
)
css = CSS(string=self._get_base_css(), font_config=self.font_config)
pdf_bytes = HTML(string=html_content).write_pdf(
stylesheets=[css],
font_config=self.font_config
)
logger.info(f"Certificate PDF generated: {len(pdf_bytes)} bytes")
return pdf_bytes
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
"""
Generiert PDF fuer Korrektur-Uebersicht.
Args:
data: CorrectionData mit allen Korrekturinformationen
Returns:
PDF als bytes
"""
logger.info(f"Generating correction PDF for: {data.student.name}")
template = self._get_correction_template()
html_content = template.render(
data=data,
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
)
css = CSS(string=self._get_base_css(), font_config=self.font_config)
pdf_bytes = HTML(string=html_content).write_pdf(
stylesheets=[css],
font_config=self.font_config
)
logger.info(f"Correction PDF generated: {len(pdf_bytes)} bytes")
return pdf_bytes
def _get_letter_template(self):
"""Gibt Letter-Template zurueck (inline falls Datei nicht existiert)."""
template_path = self.templates_dir / "letter.html"
if template_path.exists():
return self.jinja_env.get_template("letter.html")
# Inline-Template als Fallback
return self.jinja_env.from_string(get_letter_template_html())
def _get_certificate_template(self):
"""Gibt Certificate-Template zurueck."""
template_path = self.templates_dir / "certificate.html"
if template_path.exists():
return self.jinja_env.get_template("certificate.html")
return self.jinja_env.from_string(get_certificate_template_html())
def _get_correction_template(self):
"""Gibt Correction-Template zurueck."""
template_path = self.templates_dir / "correction.html"
if template_path.exists():
return self.jinja_env.get_template("correction.html")
return self.jinja_env.from_string(get_correction_template_html())
# Convenience functions for direct usage
_pdf_service: Optional[PDFService] = None
def get_pdf_service() -> PDFService:
"""Gibt Singleton-Instanz des PDF-Service zurueck."""
global _pdf_service
if _pdf_service is None:
_pdf_service = PDFService()
return _pdf_service
def generate_letter_pdf(data: Dict[str, Any]) -> bytes:
"""
Convenience function zum Generieren eines Elternbrief-PDFs.
Args:
data: Dict mit allen Briefdaten
Returns:
PDF als bytes
"""
service = get_pdf_service()
# Convert dict to LetterData
school_info = None
if data.get("school_info"):
school_info = SchoolInfo(**data["school_info"])
letter_data = LetterData(
recipient_name=data.get("recipient_name", ""),
recipient_address=data.get("recipient_address", ""),
student_name=data.get("student_name", ""),
student_class=data.get("student_class", ""),
subject=data.get("subject", ""),
content=data.get("content", ""),
date=data.get("date", datetime.now().strftime("%d.%m.%Y")),
teacher_name=data.get("teacher_name", ""),
teacher_title=data.get("teacher_title"),
school_info=school_info,
letter_type=data.get("letter_type", "general"),
tone=data.get("tone", "professional"),
legal_references=data.get("legal_references"),
gfk_principles_applied=data.get("gfk_principles_applied")
)
return service.generate_letter_pdf(letter_data)
def generate_certificate_pdf(data: Dict[str, Any]) -> bytes:
"""
Convenience function zum Generieren eines Zeugnis-PDFs.
Args:
data: Dict mit allen Zeugnisdaten
Returns:
PDF als bytes
"""
service = get_pdf_service()
school_info = None
if data.get("school_info"):
school_info = SchoolInfo(**data["school_info"])
cert_data = CertificateData(
student_name=data.get("student_name", ""),
student_birthdate=data.get("student_birthdate", ""),
student_class=data.get("student_class", ""),
school_year=data.get("school_year", ""),
certificate_type=data.get("certificate_type", "halbjahr"),
subjects=data.get("subjects", []),
attendance=data.get("attendance", {"days_absent": 0, "days_excused": 0, "days_unexcused": 0}),
remarks=data.get("remarks"),
class_teacher=data.get("class_teacher", ""),
principal=data.get("principal", ""),
school_info=school_info,
issue_date=data.get("issue_date", datetime.now().strftime("%d.%m.%Y")),
social_behavior=data.get("social_behavior"),
work_behavior=data.get("work_behavior")
)
return service.generate_certificate_pdf(cert_data)
def generate_correction_pdf(data: Dict[str, Any]) -> bytes:
"""
Convenience function zum Generieren eines Korrektur-PDFs.
Args:
data: Dict mit allen Korrekturdaten
Returns:
PDF als bytes
"""
service = get_pdf_service()
# Create StudentInfo from dict
student = StudentInfo(
student_id=data.get("student_id", "unknown"),
name=data.get("student_name", data.get("name", "")),
class_name=data.get("student_class", data.get("class_name", ""))
)
# Calculate percentage if not provided
max_points = data.get("max_points", data.get("total_points", 0))
achieved_points = data.get("achieved_points", 0)
percentage = data.get("percentage", (achieved_points / max_points * 100) if max_points > 0 else 0.0)
correction_data = CorrectionData(
student=student,
exam_title=data.get("exam_title", ""),
subject=data.get("subject", ""),
date=data.get("date", data.get("exam_date", "")),
max_points=max_points,
achieved_points=achieved_points,
grade=data.get("grade", ""),
percentage=percentage,
corrections=data.get("corrections", []),
teacher_notes=data.get("teacher_notes", data.get("teacher_comment", "")),
ai_feedback=data.get("ai_feedback", ""),
grade_distribution=data.get("grade_distribution"),
class_average=data.get("class_average")
)
return service.generate_correction_pdf(correction_data)