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>
347 lines
13 KiB
Python
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
|
|
)
|