Restructure: Move 43 files into 8 domain packages (backend-lehrer)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 27s
CI / test-go-edu-search (push) Successful in 40s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 28s
CI / test-nodejs-website (push) Successful in 20s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 27s
CI / test-go-edu-search (push) Successful in 40s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 28s
CI / test-nodejs-website (push) Successful in 20s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
346
backend-lehrer/letters/api.py
Normal file
346
backend-lehrer/letters/api.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
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 .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
|
||||
)
|
||||
Reference in New Issue
Block a user