Files
breakpilot-lehrer/backend-lehrer/certificates_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

341 lines
14 KiB
Python

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