Files
breakpilot-lehrer/backend-lehrer/services/pdf_service.py
Benjamin Admin b6983ab1dc [split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 23:35:37 +02:00

290 lines
13 KiB
Python

"""
PDF Service - Zentrale PDF-Generierung für BreakPilot.
Shared Service für:
- Letters (Elternbriefe)
- Zeugnisse (Schulzeugnisse)
- Correction (Korrektur-Übersichten)
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
Split structure:
- pdf_models.py: Data classes (SchoolInfo, LetterData, CertificateData, etc.)
- pdf_templates.py: Inline HTML templates (letter, certificate, correction)
- pdf_service.py: Core PDFService class + convenience functions (this file)
"""
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
from .pdf_models import (
SchoolInfo, LetterData, CertificateData, StudentInfo, CorrectionData,
)
from .pdf_templates import (
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 für BreakPilot.
Unterstützt:
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
- Korrektur-Übersichten für Klausuren
"""
def __init__(self, templates_dir: Optional[Path] = None):
self.templates_dir = templates_dir or TEMPLATES_DIR
self.templates_dir.mkdir(parents=True, exist_ok=True)
self.jinja_env = Environment(
loader=FileSystemLoader(str(self.templates_dir)),
autoescape=select_autoescape(['html', 'xml']),
trim_blocks=True,
lstrip_blocks=True
)
self.jinja_env.filters['date_format'] = self._date_format
self.jinja_env.filters['grade_color'] = self._grade_color
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 für 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 zurück."""
grade_colors = {
"1": "#27ae60", "2": "#2ecc71", "3": "#f1c40f",
"4": "#e67e22", "5": "#e74c3c", "6": "#c0392b",
"A": "#27ae60", "B": "#2ecc71", "C": "#f1c40f", "D": "#e74c3c",
}
return grade_colors.get(str(grade), "#333333")
def _get_base_css(self) -> str:
"""Gibt Basis-CSS für alle PDFs zurück."""
return """
@page {
size: A4;
margin: 2cm 2.5cm;
@top-right {
content: counter(page) " / " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
font-size: 11pt; line-height: 1.5; color: #333;
}
h1, h2, h3 { font-weight: bold; margin-top: 1em; margin-bottom: 0.5em; }
h1 { font-size: 16pt; } h2 { font-size: 14pt; } h3 { font-size: 12pt; }
.header { border-bottom: 2px solid #2c3e50; padding-bottom: 15px; margin-bottom: 20px; }
.school-name { font-size: 18pt; font-weight: bold; color: #2c3e50; }
.school-info { font-size: 9pt; color: #666; }
.letter-date { text-align: right; margin-bottom: 20px; }
.recipient { margin-bottom: 30px; }
.subject { font-weight: bold; margin-bottom: 20px; }
.content { text-align: justify; margin-bottom: 30px; }
.signature { margin-top: 40px; }
.legal-references { font-size: 9pt; color: #666; border-top: 1px solid #ddd; margin-top: 30px; padding-top: 10px; }
.gfk-badge { display: inline-block; background: #e8f5e9; color: #27ae60; font-size: 8pt; padding: 2px 8px; border-radius: 10px; margin-right: 5px; }
.certificate-header { text-align: center; margin-bottom: 30px; }
.certificate-title { font-size: 20pt; font-weight: bold; margin-bottom: 10px; }
.student-info { margin-bottom: 20px; padding: 15px; background: #f9f9f9; border-radius: 5px; }
.grades-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.grades-table th, .grades-table td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
.grades-table th { background: #2c3e50; color: white; }
.grades-table tr:nth-child(even) { background: #f9f9f9; }
.grade-cell { text-align: center; font-weight: bold; font-size: 12pt; }
.attendance-box { background: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
.signatures-row { display: flex; justify-content: space-between; margin-top: 50px; }
.signature-block { text-align: center; width: 40%; }
.signature-line { border-top: 1px solid #333; margin-top: 40px; padding-top: 5px; }
.exam-header { background: #2c3e50; color: white; padding: 15px; margin-bottom: 20px; }
.result-box { background: #e8f5e9; padding: 20px; text-align: center; margin-bottom: 20px; border-radius: 5px; }
.result-grade { font-size: 36pt; font-weight: bold; }
.result-points { font-size: 14pt; color: #666; }
.corrections-list { margin-bottom: 20px; }
.correction-item { border: 1px solid #ddd; padding: 15px; margin-bottom: 10px; border-radius: 5px; }
.correction-question { font-weight: bold; margin-bottom: 5px; }
.correction-feedback { background: #fff8e1; padding: 10px; margin-top: 10px; border-left: 3px solid #ffc107; font-size: 10pt; }
.stats-table { width: 100%; margin-top: 20px; }
.stats-table td { padding: 5px 10px; }
"""
def generate_letter_pdf(self, data: LetterData) -> bytes:
"""Generiert PDF für Elternbrief."""
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 für Schulzeugnis."""
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 für Korrektur-Übersicht."""
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 zurück (inline falls Datei nicht existiert)."""
template_path = self.templates_dir / "letter.html"
if template_path.exists():
return self.jinja_env.get_template("letter.html")
return self.jinja_env.from_string(get_letter_template_html())
def _get_certificate_template(self):
"""Gibt Certificate-Template zurück."""
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 zurück."""
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 zurück."""
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."""
service = get_pdf_service()
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."""
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."""
service = get_pdf_service()
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", ""))
)
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)