Files
breakpilot-lehrer/backend-lehrer/letters_api.py
Benjamin Admin 451365a312 [split-required] Split remaining 500-680 LOC files (final batch)
website (17 pages + 3 components):
- multiplayer/wizard, middleware/wizard+test-wizard, communication
- builds/wizard, staff-search, voice, sbom/wizard
- foerderantrag, mail/tasks, tools/communication, sbom
- compliance/evidence, uni-crawler, brandbook (already done)
- CollectionsTab, IngestionTab, RiskHeatmap

backend-lehrer (5 files):
- letters_api (641 → 2), certificates_api (636 → 2)
- alerts_agent/db/models (636 → 3)
- llm_gateway/communication_service (614 → 2)
- game/database already done in prior batch

klausur-service (2 files):
- hybrid_vocab_extractor (664 → 2)
- klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2)

voice-service (3 files):
- bqas/rag_judge (618 → 3), runner (529 → 2)
- enhanced_task_orchestrator (519 → 2)

studio-v2 (6 files):
- korrektur/[klausurId] (578 → 4), fairness (569 → 2)
- AlertsWizard (552 → 2), OnboardingWizard (513 → 2)
- korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:56:45 +02:00

347 lines
13 KiB
Python

"""
Letters API - Elternbrief-Verwaltung fuer BreakPilot.
Bietet Endpoints fuer:
- Speichern und Laden von Elternbriefen
- PDF-Export von Briefen
- Versenden per Email
- GFK-Integration fuer Textverbesserung
Split into:
- letters_models.py: Enums, Pydantic models, helper functions
- letters_api.py (this file): API endpoints and in-memory store
"""
import logging
import os
import uuid
from datetime import datetime
from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Response, Query
import httpx
# 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
from letters_models import (
LetterType,
LetterTone,
LetterStatus,
LetterCreateRequest,
LetterUpdateRequest,
LetterResponse,
LetterListResponse,
ExportPDFRequest,
ImproveRequest,
ImproveResponse,
SendEmailRequest,
SendEmailResponse,
get_type_label as _get_type_label,
get_tone_label as _get_tone_label,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/letters", tags=["letters"])
# =============================================================================
# In-Memory Storage (Prototyp - spaeter 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 zurueck."""
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."""
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 verfuegbaren Brieftypen zurueck."""
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in LetterType]}
@router.get("/tones")
async def get_letter_tones():
"""Gibt alle verfuegbaren Tonalitaeten zurueck."""
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):
"""Laedt 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),
class_name: Optional[str] = Query(None),
letter_type: Optional[LetterType] = Query(None),
status: Optional[LetterStatus] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""Listet alle gespeicherten Briefe mit optionalen Filtern."""
logger.info("Listing letters with filters")
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]
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
total = len(filtered_letters)
start = (page - 1) * page_size
paginated_letters = filtered_letters[start:start + page_size]
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)
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):
"""Loescht 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 geloescht"}
@router.post("/export-pdf")
async def export_letter_pdf(request: ExportPDFRequest):
"""Exportiert einen Brief als PDF."""
logger.info("Exporting letter as PDF")
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")
if "date" not in letter_data:
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
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)}")
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"
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."""
logger.info("Improving letter content with GFK principles")
comm_service_url = os.getenv("COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication")
try:
async with httpx.AsyncClient() as client:
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}")
return ImproveResponse(
improved_content=request.content,
changes=["Verbesserungsservice nicht verfuegbar"],
gfk_score=0.5, gfk_principles_applied=[]
)
validation_data = validate_response.json()
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", [])
)
return ImproveResponse(
improved_content=request.content,
changes=validation_data.get("suggestions", []),
gfk_score=validation_data.get("gfk_score", 0.5),
gfk_principles_applied=validation_data.get("positive_elements", [])
)
except httpx.TimeoutException:
logger.error("Timeout while calling communication service")
return ImproveResponse(
improved_content=request.content,
changes=["Zeitueberschreitung 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."""
logger.info(f"Sending letter {letter_id} to {request.recipient_email}")
letter_data = _get_letter(letter_id)
try:
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(),
"content_type": "application/pdf"
}
async with httpx.AsyncClient() as client:
logger.info(f"Would send email: {letter_data.get('subject')} to {request.recipient_email}")
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)
):
"""Laedt alle Briefe fuer einen bestimmten Schueler."""
logger.info(f"Getting letters for student: {student_id}")
filtered_letters = [
l for l in _letters_store.values()
if student_id.lower() in l.get("student_name", "").lower()
]
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
total = len(filtered_letters)
start = (page - 1) * page_size
paginated_letters = filtered_letters[start:start + page_size]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
total=total, page=page, page_size=page_size
)