""" Letters API - Elternbrief-Verwaltung für BreakPilot. Bietet Endpoints für: - Speichern und Laden von Elternbriefen - PDF-Export von Briefen - Versenden per Email - GFK-Integration für Textverbesserung Arbeitet zusammen mit: - services/pdf_service.py für PDF-Generierung - llm_gateway/services/communication_service.py für GFK-Verbesserungen """ import logging import os import uuid from datetime import datetime from typing import Optional, List, Dict, Any from enum import Enum from fastapi import APIRouter, HTTPException, Response, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field import httpx import io # PDF service requires WeasyPrint with system libraries - make optional for CI try: from services.pdf_service import generate_letter_pdf, SchoolInfo _pdf_available = True except (ImportError, OSError): generate_letter_pdf = None # type: ignore SchoolInfo = None # type: ignore _pdf_available = False logger = logging.getLogger(__name__) router = APIRouter(prefix="/letters", tags=["letters"]) # ============================================================================= # Enums # ============================================================================= class LetterType(str, Enum): """Typen von Elternbriefen.""" GENERAL = "general" # Allgemeine Information HALBJAHR = "halbjahr" # Halbjahresinformation FEHLZEITEN = "fehlzeiten" # Fehlzeiten-Mitteilung ELTERNABEND = "elternabend" # Einladung Elternabend LOB = "lob" # Positives Feedback CUSTOM = "custom" # Benutzerdefiniert class LetterTone(str, Enum): """Tonalität der Briefe.""" FORMAL = "formal" PROFESSIONAL = "professional" WARM = "warm" CONCERNED = "concerned" APPRECIATIVE = "appreciative" class LetterStatus(str, Enum): """Status eines Briefes.""" DRAFT = "draft" SENT = "sent" ARCHIVED = "archived" # ============================================================================= # Pydantic Models # ============================================================================= class SchoolInfoModel(BaseModel): """Schulinformationen für Briefkopf.""" name: str address: str phone: str email: str website: Optional[str] = None principal: Optional[str] = None logo_path: Optional[str] = None class LegalReferenceModel(BaseModel): """Rechtliche Referenz.""" law: str paragraph: str title: str summary: Optional[str] = None relevance: Optional[str] = None class LetterCreateRequest(BaseModel): """Request zum Erstellen eines neuen Briefes.""" recipient_name: str = Field(..., description="Name des Empfängers (z.B. 'Familie Müller')") recipient_address: str = Field(..., description="Adresse des Empfängers") student_name: str = Field(..., description="Name des Schülers") student_class: str = Field(..., description="Klasse des Schülers") subject: str = Field(..., description="Betreff des Briefes") content: str = Field(..., description="Inhalt des Briefes") letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes") tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalität des Briefes") teacher_name: str = Field(..., description="Name des Lehrers") teacher_title: Optional[str] = Field(None, description="Titel des Lehrers (z.B. 'Klassenlehrerin')") school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen für Briefkopf") legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen") gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien") class LetterUpdateRequest(BaseModel): """Request zum Aktualisieren eines Briefes.""" recipient_name: Optional[str] = None recipient_address: Optional[str] = None student_name: Optional[str] = None student_class: Optional[str] = None subject: Optional[str] = None content: Optional[str] = None letter_type: Optional[LetterType] = None tone: Optional[LetterTone] = None teacher_name: Optional[str] = None teacher_title: Optional[str] = None school_info: Optional[SchoolInfoModel] = None legal_references: Optional[List[LegalReferenceModel]] = None gfk_principles_applied: Optional[List[str]] = None status: Optional[LetterStatus] = None class LetterResponse(BaseModel): """Response mit Briefdaten.""" id: str recipient_name: str recipient_address: str student_name: str student_class: str subject: str content: str letter_type: LetterType tone: LetterTone teacher_name: str teacher_title: Optional[str] school_info: Optional[SchoolInfoModel] legal_references: Optional[List[LegalReferenceModel]] gfk_principles_applied: Optional[List[str]] gfk_score: Optional[float] status: LetterStatus pdf_path: Optional[str] dsms_cid: Optional[str] sent_at: Optional[datetime] created_at: datetime updated_at: datetime class LetterListResponse(BaseModel): """Response mit Liste von Briefen.""" letters: List[LetterResponse] total: int page: int page_size: int class ExportPDFRequest(BaseModel): """Request zum PDF-Export.""" letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes") letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten") class ImproveRequest(BaseModel): """Request zur GFK-Verbesserung.""" content: str = Field(..., description="Text zur Verbesserung") communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation") tone: Optional[str] = Field("professional", description="Gewünschte Tonalität") class ImproveResponse(BaseModel): """Response mit verbessertem Text.""" improved_content: str changes: List[str] gfk_score: float gfk_principles_applied: List[str] class SendEmailRequest(BaseModel): """Request zum Email-Versand.""" letter_id: str recipient_email: str cc_emails: Optional[List[str]] = None include_pdf: bool = True class SendEmailResponse(BaseModel): """Response nach Email-Versand.""" success: bool message: str sent_at: Optional[datetime] # ============================================================================= # In-Memory Storage (Prototyp - später durch DB ersetzen) # ============================================================================= _letters_store: Dict[str, Dict[str, Any]] = {} def _get_letter(letter_id: str) -> Dict[str, Any]: """Holt Brief aus dem Store.""" if letter_id not in _letters_store: raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") return _letters_store[letter_id] def _save_letter(letter_data: Dict[str, Any]) -> str: """Speichert Brief und gibt ID zurück.""" letter_id = letter_data.get("id") or str(uuid.uuid4()) letter_data["id"] = letter_id letter_data["updated_at"] = datetime.now() if "created_at" not in letter_data: letter_data["created_at"] = datetime.now() _letters_store[letter_id] = letter_data return letter_id # ============================================================================= # API Endpoints # ============================================================================= @router.post("/", response_model=LetterResponse) async def create_letter(request: LetterCreateRequest): """ Erstellt einen neuen Elternbrief. Der Brief wird als Entwurf gespeichert und kann später bearbeitet, als PDF exportiert oder per Email versendet werden. """ logger.info(f"Creating new letter for student: {request.student_name}") letter_data = { "recipient_name": request.recipient_name, "recipient_address": request.recipient_address, "student_name": request.student_name, "student_class": request.student_class, "subject": request.subject, "content": request.content, "letter_type": request.letter_type, "tone": request.tone, "teacher_name": request.teacher_name, "teacher_title": request.teacher_title, "school_info": request.school_info.model_dump() if request.school_info else None, "legal_references": [ref.model_dump() for ref in request.legal_references] if request.legal_references else None, "gfk_principles_applied": request.gfk_principles_applied, "gfk_score": None, "status": LetterStatus.DRAFT, "pdf_path": None, "dsms_cid": None, "sent_at": None, } letter_id = _save_letter(letter_data) letter_data["id"] = letter_id logger.info(f"Letter created with ID: {letter_id}") return LetterResponse(**letter_data) # NOTE: Static routes must come BEFORE dynamic routes like /{letter_id} @router.get("/types") async def get_letter_types(): """ Gibt alle verfügbaren Brieftypen zurück. """ return { "types": [ {"value": t.value, "label": _get_type_label(t)} for t in LetterType ] } @router.get("/tones") async def get_letter_tones(): """ Gibt alle verfügbaren Tonalitäten zurück. """ return { "tones": [ {"value": t.value, "label": _get_tone_label(t)} for t in LetterTone ] } @router.get("/{letter_id}", response_model=LetterResponse) async def get_letter(letter_id: str): """ Lädt einen gespeicherten Brief. """ logger.info(f"Getting letter: {letter_id}") letter_data = _get_letter(letter_id) return LetterResponse(**letter_data) @router.get("/", response_model=LetterListResponse) async def list_letters( student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"), class_name: Optional[str] = Query(None, description="Filter nach Klasse"), letter_type: Optional[LetterType] = Query(None, description="Filter nach Brief-Typ"), status: Optional[LetterStatus] = Query(None, description="Filter nach Status"), page: int = Query(1, ge=1, description="Seitennummer"), page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite") ): """ Listet alle gespeicherten Briefe mit optionalen Filtern. """ logger.info("Listing letters with filters") # Filter anwenden filtered_letters = list(_letters_store.values()) if class_name: filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name] if letter_type: filtered_letters = [l for l in filtered_letters if l.get("letter_type") == letter_type] if status: filtered_letters = [l for l in filtered_letters if l.get("status") == status] # Sortieren nach Erstelldatum (neueste zuerst) filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) # Paginierung total = len(filtered_letters) start = (page - 1) * page_size end = start + page_size paginated_letters = filtered_letters[start:end] return LetterListResponse( letters=[LetterResponse(**l) for l in paginated_letters], total=total, page=page, page_size=page_size ) @router.put("/{letter_id}", response_model=LetterResponse) async def update_letter(letter_id: str, request: LetterUpdateRequest): """ Aktualisiert einen bestehenden Brief. """ logger.info(f"Updating letter: {letter_id}") letter_data = _get_letter(letter_id) # Nur übergebene Felder aktualisieren update_data = request.model_dump(exclude_unset=True) for key, value in update_data.items(): if value is not None: if key == "school_info" and value: letter_data[key] = value if isinstance(value, dict) else value.model_dump() elif key == "legal_references" and value: letter_data[key] = [ref if isinstance(ref, dict) else ref.model_dump() for ref in value] else: letter_data[key] = value _save_letter(letter_data) return LetterResponse(**letter_data) @router.delete("/{letter_id}") async def delete_letter(letter_id: str): """ Löscht einen Brief. """ logger.info(f"Deleting letter: {letter_id}") if letter_id not in _letters_store: raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") del _letters_store[letter_id] return {"message": f"Brief {letter_id} wurde gelöscht"} @router.post("/export-pdf") async def export_letter_pdf(request: ExportPDFRequest): """ Exportiert einen Brief als PDF. Kann entweder einen gespeicherten Brief (per letter_id) oder direkte Briefdaten (per letter_data) als PDF exportieren. Gibt das PDF als Download zurück. """ logger.info("Exporting letter as PDF") # Briefdaten ermitteln if request.letter_id: letter_data = _get_letter(request.letter_id) elif request.letter_data: letter_data = request.letter_data.model_dump() else: raise HTTPException( status_code=400, detail="Entweder letter_id oder letter_data muss angegeben werden" ) # Datum hinzufügen falls nicht vorhanden if "date" not in letter_data: letter_data["date"] = datetime.now().strftime("%d.%m.%Y") # PDF generieren try: pdf_bytes = generate_letter_pdf(letter_data) except Exception as e: logger.error(f"Error generating PDF: {e}") raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}") # Dateiname erstellen student_name = letter_data.get("student_name", "Brief").replace(" ", "_") date_str = datetime.now().strftime("%Y%m%d") filename = f"Elternbrief_{student_name}_{date_str}.pdf" # PDF als Download zurückgeben return Response( content=pdf_bytes, media_type="application/pdf", headers={ "Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes)) } ) @router.post("/{letter_id}/export-pdf") async def export_saved_letter_pdf(letter_id: str): """ Exportiert einen gespeicherten Brief als PDF (Kurzform). """ return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id)) @router.post("/improve", response_model=ImproveResponse) async def improve_letter_content(request: ImproveRequest): """ Verbessert den Briefinhalt nach GFK-Prinzipien. Nutzt die Communication Service API für KI-gestützte Verbesserungen. """ logger.info("Improving letter content with GFK principles") # Communication Service URL (läuft im gleichen Backend) comm_service_url = os.getenv( "COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication" ) try: async with httpx.AsyncClient() as client: # Validierung des aktuellen Textes validate_response = await client.post( f"{comm_service_url}/validate", json={"text": request.content}, timeout=30.0 ) if validate_response.status_code != 200: logger.warning(f"Validation service returned {validate_response.status_code}") # Fallback: Original-Text zurückgeben return ImproveResponse( improved_content=request.content, changes=["Verbesserungsservice nicht verfügbar"], gfk_score=0.5, gfk_principles_applied=[] ) validation_data = validate_response.json() # Falls Text schon gut ist, keine Änderungen if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8: return ImproveResponse( improved_content=request.content, changes=["Text entspricht bereits GFK-Standards"], gfk_score=validation_data.get("gfk_score", 0.8), gfk_principles_applied=validation_data.get("positive_elements", []) ) # Verbesserungsvorschläge als Änderungen changes = validation_data.get("suggestions", []) gfk_score = validation_data.get("gfk_score", 0.5) gfk_principles = validation_data.get("positive_elements", []) # TODO: Hier könnte ein LLM den Text basierend auf den Vorschlägen verbessern # Für jetzt geben wir den Original-Text mit den Verbesserungsvorschlägen zurück return ImproveResponse( improved_content=request.content, changes=changes, gfk_score=gfk_score, gfk_principles_applied=gfk_principles ) except httpx.TimeoutException: logger.error("Timeout while calling communication service") return ImproveResponse( improved_content=request.content, changes=["Zeitüberschreitung beim Verbesserungsservice"], gfk_score=0.5, gfk_principles_applied=[] ) except Exception as e: logger.error(f"Error improving content: {e}") return ImproveResponse( improved_content=request.content, changes=[f"Fehler: {str(e)}"], gfk_score=0.5, gfk_principles_applied=[] ) @router.post("/{letter_id}/send", response_model=SendEmailResponse) async def send_letter_email(letter_id: str, request: SendEmailRequest): """ Versendet einen Brief per Email. Der Brief wird als PDF angehängt (wenn include_pdf=True) und der Status wird auf 'sent' gesetzt. """ logger.info(f"Sending letter {letter_id} to {request.recipient_email}") # Brief laden letter_data = _get_letter(letter_id) # Email-Service URL (Mailpit oder SMTP) email_service_url = os.getenv( "EMAIL_SERVICE_URL", "http://localhost:8025/api/v1/send" # Mailpit default ) try: # PDF generieren falls gewünscht pdf_attachment = None if request.include_pdf: letter_data["date"] = datetime.now().strftime("%d.%m.%Y") pdf_bytes = generate_letter_pdf(letter_data) pdf_attachment = { "filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf", "content": pdf_bytes.hex(), # Hex-encoded für JSON "content_type": "application/pdf" } # Email senden (vereinfachte Implementierung) # In der Praxis würde hier ein richtiger Email-Service aufgerufen async with httpx.AsyncClient() as client: email_data = { "to": request.recipient_email, "cc": request.cc_emails or [], "subject": letter_data.get("subject", "Elternbrief"), "body": letter_data.get("content", ""), "attachments": [pdf_attachment] if pdf_attachment else [] } # Für Prototyp: Nur loggen, nicht wirklich senden logger.info(f"Would send email: {email_data['subject']} to {email_data['to']}") # Status aktualisieren letter_data["status"] = LetterStatus.SENT letter_data["sent_at"] = datetime.now() _save_letter(letter_data) return SendEmailResponse( success=True, message=f"Brief wurde an {request.recipient_email} gesendet", sent_at=datetime.now() ) except Exception as e: logger.error(f"Error sending email: {e}") return SendEmailResponse( success=False, message=f"Fehler beim Versenden: {str(e)}", sent_at=None ) @router.get("/student/{student_id}", response_model=LetterListResponse) async def get_letters_for_student( student_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100) ): """ Lädt alle Briefe für einen bestimmten Schüler. """ logger.info(f"Getting letters for student: {student_id}") # In einem echten System würde hier nach student_id gefiltert # Für Prototyp filtern wir nach student_name filtered_letters = [ l for l in _letters_store.values() if student_id.lower() in l.get("student_name", "").lower() ] # Sortieren und Paginierung filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) total = len(filtered_letters) start = (page - 1) * page_size end = start + page_size paginated_letters = filtered_letters[start:end] return LetterListResponse( letters=[LetterResponse(**l) for l in paginated_letters], total=total, page=page, page_size=page_size ) # ============================================================================= # Helper Functions # ============================================================================= def _get_type_label(letter_type: LetterType) -> str: """Gibt menschenlesbare Labels für Brieftypen zurück.""" labels = { LetterType.GENERAL: "Allgemeine Information", LetterType.HALBJAHR: "Halbjahresinformation", LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung", LetterType.ELTERNABEND: "Einladung Elternabend", LetterType.LOB: "Positives Feedback", LetterType.CUSTOM: "Benutzerdefiniert", } return labels.get(letter_type, letter_type.value) def _get_tone_label(tone: LetterTone) -> str: """Gibt menschenlesbare Labels für Tonalitäten zurück.""" labels = { LetterTone.FORMAL: "Sehr förmlich", LetterTone.PROFESSIONAL: "Professionell-freundlich", LetterTone.WARM: "Warmherzig", LetterTone.CONCERNED: "Besorgt", LetterTone.APPRECIATIVE: "Wertschätzend", } return labels.get(tone, tone.value)