- rbac_api.py: import get_current_user from auth.dependencies directly - keycloak_auth.py: remove re-export of dependencies module symbols - pdf_service.py, file_processor.py: remove misleading compat comments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
348 lines
11 KiB
Python
348 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
|
|
|
|
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)
|