""" PDF Export - Klausur overview and annotations PDF generation. Generates: - Klausur overview with grade distribution for all students - Annotations PDF for a single student """ import io from datetime import datetime from typing import Dict, List, Optional, Any from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.units import cm from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable ) from pdf_export_styles import ( GRADE_POINTS_TO_NOTE, CRITERIA_DISPLAY_NAMES, get_custom_styles, ) def generate_klausur_overview_pdf( klausur_data: Dict[str, Any], students: List[Dict[str, Any]], fairness_data: Optional[Dict[str, Any]] = None ) -> bytes: """ Generate an overview PDF for an entire Klausur with all student grades. Args: klausur_data: Klausur metadata students: List of all student work data fairness_data: Optional fairness analysis data Returns: PDF as bytes """ buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=1.5*cm, leftMargin=1.5*cm, topMargin=2*cm, bottomMargin=2*cm ) styles = get_custom_styles() story = [] # Header story.append(Paragraph("Notenuebersicht", styles['GutachtenTitle'])) story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle'])) story.append(Spacer(1, 0.5*cm)) # Meta information meta_data = [ ["Schuljahr:", f"{klausur_data.get('year', 2025)}"], ["Kurs:", klausur_data.get('semester', 'Abitur')], ["Anzahl Arbeiten:", str(len(students))], ["Stand:", datetime.now().strftime("%d.%m.%Y")] ] meta_table = Table(meta_data, colWidths=[4*cm, 10*cm]) meta_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ])) story.append(meta_table) story.append(Spacer(1, 0.5*cm)) # Statistics (if fairness data available) if fairness_data and fairness_data.get('statistics'): _add_statistics(story, styles, fairness_data['statistics']) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.5*cm)) # Student grades table sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True) _add_student_table(story, styles, sorted_students) # Grade distribution _add_grade_distribution(story, styles, sorted_students) # Footer story.append(Spacer(1, 1*cm)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) story.append(Spacer(1, 0.2*cm)) story.append(Paragraph( f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", styles['MetaText'] )) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue() def _add_statistics(story, styles, stats): """Add statistics section.""" story.append(Paragraph("Statistik", styles['SectionHeader'])) stats_data = [ ["Durchschnitt:", f"{stats.get('average_grade', 0):.1f} Punkte"], ["Minimum:", f"{stats.get('min_grade', 0)} Punkte"], ["Maximum:", f"{stats.get('max_grade', 0)} Punkte"], ["Standardabweichung:", f"{stats.get('standard_deviation', 0):.2f}"], ] stats_table = Table(stats_data, colWidths=[4*cm, 4*cm]) stats_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f7fafc')), ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), ])) story.append(stats_table) story.append(Spacer(1, 0.5*cm)) def _add_student_table(story, styles, sorted_students): """Add student grades table.""" story.append(Paragraph("Einzelergebnisse", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) table_data = [["#", "Name", "Rohpunkte", "Notenpunkte", "Note", "Status"]] for idx, student in enumerate(sorted_students, 1): grade_points = student.get('grade_points', 0) grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "-") raw_points = student.get('raw_points', 0) status = student.get('status', 'unknown') status_display = { 'completed': 'Abgeschlossen', 'first_examiner': 'In Korrektur', 'second_examiner': 'Zweitkorrektur', 'uploaded': 'Hochgeladen', 'ocr_complete': 'OCR fertig', 'analyzing': 'Wird analysiert' }.get(status, status) table_data.append([ str(idx), student.get('student_name', 'Anonym'), f"{raw_points}/100", str(grade_points), grade_note, status_display ]) student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm]) student_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 9), ('ALIGN', (0, 0), (-1, 0), 'CENTER'), ('FONTSIZE', (0, 1), (-1, -1), 9), ('ALIGN', (0, 1), (0, -1), 'CENTER'), ('ALIGN', (2, 1), (4, -1), 'CENTER'), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('TOPPADDING', (0, 0), (-1, -1), 6), ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]), ])) story.append(student_table) def _add_grade_distribution(story, styles, sorted_students): """Add grade distribution table.""" story.append(Spacer(1, 0.5*cm)) story.append(Paragraph("Notenverteilung", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) grade_counts = {} for student in sorted_students: gp = student.get('grade_points', 0) grade_counts[gp] = grade_counts.get(gp, 0) + 1 dist_data = [["Punkte", "Note", "Anzahl"]] for points in range(15, -1, -1): if points in grade_counts: note = GRADE_POINTS_TO_NOTE.get(points, "-") count = grade_counts[points] dist_data.append([str(points), note, str(count)]) if len(dist_data) > 1: dist_table = Table(dist_data, colWidths=[2.5*cm, 2.5*cm, 2.5*cm]) dist_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), ])) story.append(dist_table) def generate_annotations_pdf( student_data: Dict[str, Any], klausur_data: Dict[str, Any], annotations: List[Dict[str, Any]] ) -> bytes: """ Generate a PDF with all annotations for a student work. Args: student_data: Student work data klausur_data: Klausur metadata annotations: List of all annotations Returns: PDF as bytes """ buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm, topMargin=2*cm, bottomMargin=2*cm ) styles = get_custom_styles() story = [] # Header story.append(Paragraph("Anmerkungen zur Klausur", styles['GutachtenTitle'])) story.append(Paragraph(f"{student_data.get('student_name', 'Anonym')}", styles['GutachtenSubtitle'])) story.append(Spacer(1, 0.5*cm)) if not annotations: story.append(Paragraph("Keine Anmerkungen vorhanden.", styles['GutachtenBody'])) else: # Group by type by_type = {} for ann in annotations: ann_type = ann.get('type', 'comment') if ann_type not in by_type: by_type[ann_type] = [] by_type[ann_type].append(ann) for ann_type, anns in by_type.items(): type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title()) story.append(Paragraph(f"{type_name} ({len(anns)})", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) sorted_anns = sorted(anns, key=lambda a: (a.get('page', 0), a.get('position', {}).get('y', 0))) for idx, ann in enumerate(sorted_anns, 1): page = ann.get('page', 1) text = ann.get('text', '') suggestion = ann.get('suggestion', '') severity = ann.get('severity', 'minor') ann_text = f"[S.{page}] {text}" if suggestion: ann_text += f" -> {suggestion}" if severity == 'critical': ann_text = f"{ann_text}" elif severity == 'major': ann_text = f"{ann_text}" story.append(Paragraph(f"{idx}. {ann_text}", styles['ListItem'])) story.append(Spacer(1, 0.3*cm)) # Footer story.append(Spacer(1, 1*cm)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) story.append(Spacer(1, 0.2*cm)) story.append(Paragraph( f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", styles['MetaText'] )) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue()