""" 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 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)