""" 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. """ import logging import os from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional, List from dataclasses import dataclass from jinja2 import Environment, FileSystemLoader, select_autoescape from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration logger = logging.getLogger(__name__) # Template directory TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf" @dataclass class SchoolInfo: """Schulinformationen für Header.""" name: str address: str phone: str email: str logo_path: Optional[str] = None website: Optional[str] = None principal: Optional[str] = None @dataclass class LetterData: """Daten für Elternbrief-PDF.""" recipient_name: str recipient_address: str student_name: str student_class: str subject: str content: str date: str teacher_name: str teacher_title: Optional[str] = None school_info: Optional[SchoolInfo] = None letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob tone: str = "professional" legal_references: Optional[List[Dict[str, str]]] = None gfk_principles_applied: Optional[List[str]] = None @dataclass class CertificateData: """Daten für Zeugnis-PDF.""" student_name: str student_birthdate: str student_class: str school_year: str certificate_type: str # halbjahr, jahres, abschluss subjects: List[Dict[str, Any]] # [{name, grade, note}] attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused} remarks: Optional[str] = None class_teacher: str = "" principal: str = "" school_info: Optional[SchoolInfo] = None issue_date: str = "" social_behavior: Optional[str] = None # A, B, C, D work_behavior: Optional[str] = None # A, B, C, D @dataclass class StudentInfo: """Schülerinformationen für Korrektur-PDFs.""" student_id: str name: str class_name: str @dataclass class CorrectionData: """Daten für Korrektur-Übersicht PDF.""" student: StudentInfo exam_title: str subject: str date: str max_points: int achieved_points: int grade: str percentage: float corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}] teacher_notes: str = "" ai_feedback: str = "" grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl} class_average: Optional[float] = None 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): """ 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 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", # Grün "2": "#2ecc71", # Hellgrün "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 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; } /* Zeugnis-Styles */ .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; } /* Korrektur-Styles */ .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. 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 für 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 für Korrektur-Übersicht. 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 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") # Inline-Template als Fallback return self.jinja_env.from_string(self._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(self._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(self._get_correction_template_html()) @staticmethod def _get_letter_template_html() -> str: """Inline HTML-Template für Elternbriefe.""" return """ {{ data.subject }}
{% if data.school_info %}
{{ data.school_info.name }}
{{ data.school_info.address }}
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }} {% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
{% else %}
Schule
{% endif %}
{{ data.date }}
{{ data.recipient_name }}
{{ data.recipient_address | replace('\\n', '
') | safe }}
Betreff: {{ data.subject }}
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
{{ data.content | replace('\\n', '
') | safe }}
{% if data.gfk_principles_applied %}
{% for principle in data.gfk_principles_applied %} ✓ {{ principle }} {% endfor %}
{% endif %}

Mit freundlichen Grüßen

{{ data.teacher_name }} {% if data.teacher_title %}
{{ data.teacher_title }}{% endif %}

{% if data.legal_references %} {% endif %}
Erstellt mit BreakPilot | {{ generated_at }}
""" @staticmethod def _get_certificate_template_html() -> str: """Inline HTML-Template für Zeugnisse.""" return """ Zeugnis - {{ data.student_name }}
{% if data.school_info %}
{{ data.school_info.name }}
{% endif %}
{% if data.certificate_type == 'halbjahr' %} Halbjahreszeugnis {% elif data.certificate_type == 'jahres' %} Jahreszeugnis {% else %} Abschlusszeugnis {% endif %}
Schuljahr {{ data.school_year }}
Name: {{ data.student_name }} Geburtsdatum: {{ data.student_birthdate }}
Klasse: {{ data.student_class }}  

Leistungen

{% for subject in data.subjects %} {% endfor %}
Fach Note Punkte
{{ subject.name }} {{ subject.grade }} {{ subject.points | default('-') }}
{% if data.social_behavior or data.work_behavior %}

Verhalten

{% if data.social_behavior %} {% endif %} {% if data.work_behavior %} {% endif %}
Sozialverhalten {{ data.social_behavior }}
Arbeitsverhalten {{ data.work_behavior }}
{% endif %}
Versäumte Tage: {{ data.attendance.days_absent | default(0) }} (davon entschuldigt: {{ data.attendance.days_excused | default(0) }}, unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
{% if data.remarks %}
Bemerkungen:
{{ data.remarks }}
{% endif %}
Ausgestellt am: {{ data.issue_date }}
{{ data.class_teacher }}
Klassenlehrer/in
{{ data.principal }}
Schulleiter/in
Siegel der Schule
""" @staticmethod def _get_correction_template_html() -> str: """Inline HTML-Template für Korrektur-Übersichten.""" return """ Korrektur - {{ data.exam_title }}

{{ data.exam_title }}

{{ data.subject }} | {{ data.date }}
{{ data.student.name }} | Klasse {{ data.student.class_name }}
Note: {{ data.grade }}
{{ data.achieved_points }} von {{ data.max_points }} Punkten ({{ data.percentage | round(1) }}%)

Detaillierte Auswertung

{% for item in data.corrections %}
{{ item.question }}
{% if item.answer %}
Antwort: {{ item.answer }}
{% endif %}
Punkte: {{ item.points }}
{% if item.feedback %}
{{ item.feedback }}
{% endif %}
{% endfor %}
{% if data.teacher_notes %}
Lehrerkommentar:
{{ data.teacher_notes }}
{% endif %} {% if data.ai_feedback %}
KI-Feedback:
{{ data.ai_feedback }}
{% endif %} {% if data.class_average or data.grade_distribution %}

Klassenstatistik

{% if data.class_average %} {% endif %} {% if data.grade_distribution %} {% endif %}
Klassendurchschnitt: {{ data.class_average }}
Notenverteilung: {% for grade, count in data.grade_distribution.items() %} Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %} {% endfor %}
{% endif %}

Datum: {{ data.date }}

Erstellt mit BreakPilot | {{ generated_at }}
""" # 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. 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)