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