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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 22:32:45 +02:00
parent 165c493d1e
commit dde45b29db
93 changed files with 9469 additions and 9290 deletions

View File

@@ -0,0 +1 @@
# letters — Elternbriefe and Zeugnisse (certificates).

View 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
)

View File

@@ -0,0 +1,340 @@
"""
Certificates API - Zeugnisverwaltung fuer BreakPilot.
Split into:
- certificates_models.py: Enums, Pydantic models, helper functions
- certificates_api.py (this file): API endpoints and in-memory store
"""
import logging
import uuid
from datetime import datetime
from typing import Optional, Dict, List, Any
from fastapi import APIRouter, HTTPException, Response, Query
# PDF service requires WeasyPrint with system libraries - make optional for CI
try:
from services.pdf_service import generate_certificate_pdf, SchoolInfo
_pdf_available = True
except (ImportError, OSError):
generate_certificate_pdf = None # type: ignore
SchoolInfo = None # type: ignore
_pdf_available = False
from .certificates_models import (
CertificateType,
CertificateStatus,
BehaviorGrade,
CertificateCreateRequest,
CertificateUpdateRequest,
CertificateResponse,
CertificateListResponse,
GradeStatistics,
get_type_label as _get_type_label,
calculate_average as _calculate_average,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/certificates", tags=["certificates"])
# =============================================================================
# In-Memory Storage (Prototyp - spaeter durch DB ersetzen)
# =============================================================================
_certificates_store: Dict[str, Dict[str, Any]] = {}
def _get_certificate(cert_id: str) -> Dict[str, Any]:
"""Holt Zeugnis aus dem Store."""
if cert_id not in _certificates_store:
raise HTTPException(status_code=404, detail=f"Zeugnis mit ID {cert_id} nicht gefunden")
return _certificates_store[cert_id]
def _save_certificate(cert_data: Dict[str, Any]) -> str:
"""Speichert Zeugnis und gibt ID zurueck."""
cert_id = cert_data.get("id") or str(uuid.uuid4())
cert_data["id"] = cert_id
cert_data["updated_at"] = datetime.now()
if "created_at" not in cert_data:
cert_data["created_at"] = datetime.now()
_certificates_store[cert_id] = cert_data
return cert_id
# =============================================================================
# API Endpoints
# =============================================================================
@router.post("/", response_model=CertificateResponse)
async def create_certificate(request: CertificateCreateRequest):
"""Erstellt ein neues Zeugnis."""
logger.info(f"Creating new certificate for student: {request.student_name}")
subjects_list = [s.model_dump() for s in request.subjects]
cert_data = {
"student_id": request.student_id,
"student_name": request.student_name,
"student_birthdate": request.student_birthdate,
"student_class": request.student_class,
"school_year": request.school_year,
"certificate_type": request.certificate_type,
"subjects": subjects_list,
"attendance": request.attendance.model_dump(),
"remarks": request.remarks,
"class_teacher": request.class_teacher,
"principal": request.principal,
"school_info": request.school_info.model_dump() if request.school_info else None,
"issue_date": request.issue_date or datetime.now().strftime("%d.%m.%Y"),
"social_behavior": request.social_behavior,
"work_behavior": request.work_behavior,
"status": CertificateStatus.DRAFT,
"average_grade": _calculate_average(subjects_list),
"pdf_path": None,
"dsms_cid": None,
}
cert_id = _save_certificate(cert_data)
cert_data["id"] = cert_id
logger.info(f"Certificate created with ID: {cert_id}")
return CertificateResponse(**cert_data)
# IMPORTANT: Static routes must be defined BEFORE dynamic /{cert_id} route
@router.get("/types")
async def get_certificate_types():
"""Gibt alle verfuegbaren Zeugnistypen zurueck."""
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in CertificateType]}
@router.get("/behavior-grades")
async def get_behavior_grades():
"""Gibt alle verfuegbaren Verhaltensnoten zurueck."""
labels = {
BehaviorGrade.A: "A - Sehr gut", BehaviorGrade.B: "B - Gut",
BehaviorGrade.C: "C - Befriedigend", BehaviorGrade.D: "D - Verbesserungswuerdig"
}
return {"grades": [{"value": g.value, "label": labels[g]} for g in BehaviorGrade]}
@router.get("/{cert_id}", response_model=CertificateResponse)
async def get_certificate(cert_id: str):
"""Laedt ein gespeichertes Zeugnis."""
logger.info(f"Getting certificate: {cert_id}")
return CertificateResponse(**_get_certificate(cert_id))
@router.get("/", response_model=CertificateListResponse)
async def list_certificates(
student_id: Optional[str] = Query(None),
class_name: Optional[str] = Query(None),
school_year: Optional[str] = Query(None),
certificate_type: Optional[CertificateType] = Query(None),
status: Optional[CertificateStatus] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""Listet alle gespeicherten Zeugnisse mit optionalen Filtern."""
logger.info("Listing certificates with filters")
filtered_certs = list(_certificates_store.values())
if student_id:
filtered_certs = [c for c in filtered_certs if c.get("student_id") == student_id]
if class_name:
filtered_certs = [c for c in filtered_certs if c.get("student_class") == class_name]
if school_year:
filtered_certs = [c for c in filtered_certs if c.get("school_year") == school_year]
if certificate_type:
filtered_certs = [c for c in filtered_certs if c.get("certificate_type") == certificate_type]
if status:
filtered_certs = [c for c in filtered_certs if c.get("status") == status]
filtered_certs.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
total = len(filtered_certs)
start = (page - 1) * page_size
paginated_certs = filtered_certs[start:start + page_size]
return CertificateListResponse(
certificates=[CertificateResponse(**c) for c in paginated_certs],
total=total, page=page, page_size=page_size
)
@router.put("/{cert_id}", response_model=CertificateResponse)
async def update_certificate(cert_id: str, request: CertificateUpdateRequest):
"""Aktualisiert ein bestehendes Zeugnis."""
logger.info(f"Updating certificate: {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") in [CertificateStatus.ISSUED, CertificateStatus.ARCHIVED]:
raise HTTPException(status_code=400, detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden")
update_data = request.model_dump(exclude_unset=True)
for key, value in update_data.items():
if value is not None:
if key == "subjects":
cert_data[key] = [s if isinstance(s, dict) else s.model_dump() for s in value]
cert_data["average_grade"] = _calculate_average(cert_data["subjects"])
elif key == "attendance":
cert_data[key] = value if isinstance(value, dict) else value.model_dump()
else:
cert_data[key] = value
_save_certificate(cert_data)
return CertificateResponse(**cert_data)
@router.delete("/{cert_id}")
async def delete_certificate(cert_id: str):
"""Loescht ein Zeugnis. Nur Entwuerfe koennen geloescht werden."""
logger.info(f"Deleting certificate: {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.DRAFT:
raise HTTPException(status_code=400, detail="Nur Zeugnis-Entwuerfe koennen geloescht werden")
del _certificates_store[cert_id]
return {"message": f"Zeugnis {cert_id} wurde geloescht"}
@router.post("/{cert_id}/export-pdf")
async def export_certificate_pdf(cert_id: str):
"""Exportiert ein Zeugnis als PDF."""
logger.info(f"Exporting certificate {cert_id} as PDF")
cert_data = _get_certificate(cert_id)
try:
pdf_bytes = generate_certificate_pdf(cert_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 = cert_data.get("student_name", "Zeugnis").replace(" ", "_")
school_year = cert_data.get("school_year", "").replace("/", "-")
cert_type = cert_data.get("certificate_type", "zeugnis")
filename = f"Zeugnis_{student_name}_{cert_type}_{school_year}.pdf"
from urllib.parse import quote
filename_ascii = filename.encode('ascii', 'replace').decode('ascii')
filename_encoded = quote(filename, safe='')
return Response(
content=pdf_bytes, media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
"Content-Length": str(len(pdf_bytes))
}
)
@router.post("/{cert_id}/submit-review")
async def submit_for_review(cert_id: str):
"""Reicht Zeugnis zur Pruefung ein."""
logger.info(f"Submitting certificate {cert_id} for review")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.DRAFT:
raise HTTPException(status_code=400, detail="Nur Entwuerfe koennen zur Pruefung eingereicht werden")
if not cert_data.get("subjects"):
raise HTTPException(status_code=400, detail="Keine Fachnoten eingetragen")
cert_data["status"] = CertificateStatus.REVIEW
_save_certificate(cert_data)
return {"message": "Zeugnis wurde zur Pruefung eingereicht", "status": CertificateStatus.REVIEW}
@router.post("/{cert_id}/approve")
async def approve_certificate(cert_id: str):
"""Genehmigt ein Zeugnis."""
logger.info(f"Approving certificate {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.REVIEW:
raise HTTPException(status_code=400, detail="Nur Zeugnisse in Pruefung koennen genehmigt werden")
cert_data["status"] = CertificateStatus.APPROVED
_save_certificate(cert_data)
return {"message": "Zeugnis wurde genehmigt", "status": CertificateStatus.APPROVED}
@router.post("/{cert_id}/issue")
async def issue_certificate(cert_id: str):
"""Stellt ein Zeugnis offiziell aus."""
logger.info(f"Issuing certificate {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.APPROVED:
raise HTTPException(status_code=400, detail="Nur genehmigte Zeugnisse koennen ausgestellt werden")
cert_data["status"] = CertificateStatus.ISSUED
cert_data["issue_date"] = datetime.now().strftime("%d.%m.%Y")
_save_certificate(cert_data)
return {"message": "Zeugnis wurde ausgestellt", "status": CertificateStatus.ISSUED, "issue_date": cert_data["issue_date"]}
@router.get("/student/{student_id}", response_model=CertificateListResponse)
async def get_certificates_for_student(
student_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)
):
"""Laedt alle Zeugnisse fuer einen bestimmten Schueler."""
logger.info(f"Getting certificates for student: {student_id}")
filtered_certs = [c for c in _certificates_store.values() if c.get("student_id") == student_id]
filtered_certs.sort(key=lambda x: (x.get("school_year", ""), x.get("certificate_type", "")), reverse=True)
total = len(filtered_certs)
start = (page - 1) * page_size
paginated_certs = filtered_certs[start:start + page_size]
return CertificateListResponse(
certificates=[CertificateResponse(**c) for c in paginated_certs],
total=total, page=page, page_size=page_size
)
@router.get("/class/{class_name}/statistics", response_model=GradeStatistics)
async def get_class_statistics(
class_name: str,
school_year: str = Query(..., description="Schuljahr"),
certificate_type: CertificateType = Query(CertificateType.HALBJAHR)
):
"""Berechnet Notenstatistiken fuer eine Klasse."""
logger.info(f"Calculating statistics for class {class_name}")
class_certs = [
c for c in _certificates_store.values()
if c.get("student_class") == class_name
and c.get("school_year") == school_year
and c.get("certificate_type") == certificate_type
]
if not class_certs:
raise HTTPException(status_code=404, detail=f"Keine Zeugnisse fuer Klasse {class_name} im Schuljahr {school_year} gefunden")
all_grades: List[float] = []
subject_grades: Dict[str, List[float]] = {}
grade_counts = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}
for cert in class_certs:
avg = cert.get("average_grade")
if avg:
all_grades.append(avg)
rounded = str(round(avg))
if rounded in grade_counts:
grade_counts[rounded] += 1
for subject in cert.get("subjects", []):
name = subject.get("name")
grade_str = subject.get("grade")
try:
grade = float(grade_str)
if name not in subject_grades:
subject_grades[name] = []
subject_grades[name].append(grade)
except (ValueError, TypeError):
pass
subject_averages = {
name: round(sum(grades) / len(grades), 2)
for name, grades in subject_grades.items() if grades
}
return GradeStatistics(
class_name=class_name, school_year=school_year,
certificate_type=certificate_type, student_count=len(class_certs),
average_grade=round(sum(all_grades) / len(all_grades), 2) if all_grades else 0.0,
grade_distribution=grade_counts, subject_averages=subject_averages
)

View File

@@ -0,0 +1,184 @@
"""
Certificates Models - Pydantic models and enums for Zeugnisverwaltung.
"""
from datetime import datetime
from typing import Optional, List, Dict
from enum import Enum
from pydantic import BaseModel, Field
# =============================================================================
# Enums
# =============================================================================
class CertificateType(str, Enum):
"""Typen von Zeugnissen."""
HALBJAHR = "halbjahr"
JAHRES = "jahres"
ABSCHLUSS = "abschluss"
ABGANG = "abgang"
UEBERGANG = "uebergang"
class CertificateStatus(str, Enum):
"""Status eines Zeugnisses."""
DRAFT = "draft"
REVIEW = "review"
APPROVED = "approved"
ISSUED = "issued"
ARCHIVED = "archived"
class GradeType(str, Enum):
"""Notentyp."""
NUMERIC = "numeric"
POINTS = "points"
TEXT = "text"
class BehaviorGrade(str, Enum):
"""Verhaltens-/Arbeitsnoten."""
A = "A"
B = "B"
C = "C"
D = "D"
# =============================================================================
# Pydantic Models
# =============================================================================
class SchoolInfoModel(BaseModel):
"""Schulinformationen fuer Zeugnis."""
name: str
address: str
phone: str
email: str
website: Optional[str] = None
principal: Optional[str] = None
logo_path: Optional[str] = None
class SubjectGrade(BaseModel):
"""Note fuer ein Fach."""
name: str = Field(..., description="Fachname")
grade: str = Field(..., description="Note (1-6 oder A-D)")
points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)")
note: Optional[str] = Field(None, description="Bemerkung zum Fach")
class AttendanceInfo(BaseModel):
"""Anwesenheitsinformationen."""
days_absent: int = Field(0, description="Fehlende Tage gesamt")
days_excused: int = Field(0, description="Entschuldigte Tage")
days_unexcused: int = Field(0, description="Unentschuldigte Tage")
hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt")
class CertificateCreateRequest(BaseModel):
"""Request zum Erstellen eines neuen Zeugnisses."""
student_id: str = Field(..., description="ID des Schuelers")
student_name: str = Field(..., description="Name des Schuelers")
student_birthdate: str = Field(..., description="Geburtsdatum")
student_class: str = Field(..., description="Klasse")
school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')")
certificate_type: CertificateType = Field(..., description="Art des Zeugnisses")
subjects: List[SubjectGrade] = Field(..., description="Fachnoten")
attendance: AttendanceInfo = Field(default_factory=AttendanceInfo)
remarks: Optional[str] = Field(None, description="Bemerkungen")
class_teacher: str = Field(..., description="Klassenlehrer/in")
principal: str = Field(..., description="Schulleiter/in")
school_info: Optional[SchoolInfoModel] = Field(None)
issue_date: Optional[str] = Field(None, description="Ausstellungsdatum")
social_behavior: Optional[BehaviorGrade] = Field(None)
work_behavior: Optional[BehaviorGrade] = Field(None)
class CertificateUpdateRequest(BaseModel):
"""Request zum Aktualisieren eines Zeugnisses."""
subjects: Optional[List[SubjectGrade]] = None
attendance: Optional[AttendanceInfo] = None
remarks: Optional[str] = None
class_teacher: Optional[str] = None
principal: Optional[str] = None
social_behavior: Optional[BehaviorGrade] = None
work_behavior: Optional[BehaviorGrade] = None
status: Optional[CertificateStatus] = None
class CertificateResponse(BaseModel):
"""Response mit Zeugnisdaten."""
id: str
student_id: str
student_name: str
student_birthdate: str
student_class: str
school_year: str
certificate_type: CertificateType
subjects: List[SubjectGrade]
attendance: AttendanceInfo
remarks: Optional[str]
class_teacher: str
principal: str
school_info: Optional[SchoolInfoModel]
issue_date: Optional[str]
social_behavior: Optional[BehaviorGrade]
work_behavior: Optional[BehaviorGrade]
status: CertificateStatus
average_grade: Optional[float]
pdf_path: Optional[str]
dsms_cid: Optional[str]
created_at: datetime
updated_at: datetime
class CertificateListResponse(BaseModel):
"""Response mit Liste von Zeugnissen."""
certificates: List[CertificateResponse]
total: int
page: int
page_size: int
class GradeStatistics(BaseModel):
"""Notenstatistiken fuer eine Klasse."""
class_name: str
school_year: str
certificate_type: CertificateType
student_count: int
average_grade: float
grade_distribution: Dict[str, int]
subject_averages: Dict[str, float]
# =============================================================================
# Helper Functions
# =============================================================================
def get_type_label(cert_type: CertificateType) -> str:
"""Gibt menschenlesbare Labels fuer Zeugnistypen zurueck."""
labels = {
CertificateType.HALBJAHR: "Halbjahreszeugnis",
CertificateType.JAHRES: "Jahreszeugnis",
CertificateType.ABSCHLUSS: "Abschlusszeugnis",
CertificateType.ABGANG: "Abgangszeugnis",
CertificateType.UEBERGANG: "Uebergangszeugnis",
}
return labels.get(cert_type, cert_type.value)
def calculate_average(subjects: List[Dict]) -> Optional[float]:
"""Berechnet Notendurchschnitt."""
numeric_grades = []
for subject in subjects:
grade = subject.get("grade", "")
try:
numeric = float(grade)
if 1 <= numeric <= 6:
numeric_grades.append(numeric)
except (ValueError, TypeError):
pass
if numeric_grades:
return round(sum(numeric_grades) / len(numeric_grades), 2)
return None

View File

@@ -0,0 +1,195 @@
"""
Letters Models - Pydantic models and enums for Elternbrief-Verwaltung.
"""
from datetime import datetime
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field
# =============================================================================
# Enums
# =============================================================================
class LetterType(str, Enum):
"""Typen von Elternbriefen."""
GENERAL = "general"
HALBJAHR = "halbjahr"
FEHLZEITEN = "fehlzeiten"
ELTERNABEND = "elternabend"
LOB = "lob"
CUSTOM = "custom"
class LetterTone(str, Enum):
"""Tonalitaet 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 fuer 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 Empfaengers")
recipient_address: str = Field(..., description="Adresse des Empfaengers")
student_name: str = Field(..., description="Name des Schuelers")
student_class: str = Field(..., description="Klasse des Schuelers")
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="Tonalitaet des Briefes")
teacher_name: str = Field(..., description="Name des Lehrers")
teacher_title: Optional[str] = Field(None, description="Titel des Lehrers")
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen")
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="Gewuenschte Tonalitaet")
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]
# =============================================================================
# Helper Functions
# =============================================================================
def get_type_label(letter_type: LetterType) -> str:
"""Gibt menschenlesbare Labels fuer Brieftypen zurueck."""
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 fuer Tonalitaeten zurueck."""
labels = {
LetterTone.FORMAL: "Sehr foermlich",
LetterTone.PROFESSIONAL: "Professionell-freundlich",
LetterTone.WARM: "Warmherzig",
LetterTone.CONCERNED: "Besorgt",
LetterTone.APPRECIATIVE: "Wertschaetzend",
}
return labels.get(tone, tone.value)