""" PDF Export - Individual Gutachten PDF generation. Generates a single student's Gutachten with criteria table, workflow info, and annotation summary. """ 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, KeepTogether ) from pdf_export_styles import ( GRADE_POINTS_TO_NOTE, CRITERIA_DISPLAY_NAMES, CRITERIA_WEIGHTS, get_custom_styles, ) def generate_gutachten_pdf( student_data: Dict[str, Any], klausur_data: Dict[str, Any], annotations: List[Dict[str, Any]] = None, workflow_data: Dict[str, Any] = None ) -> bytes: """ Generate a PDF Gutachten for a single student. Args: student_data: Student work data including criteria_scores, gutachten, grade_points klausur_data: Klausur metadata (title, subject, year, etc.) annotations: List of annotations for annotation summary workflow_data: Examiner workflow data (EK, ZK, DK info) 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("Gutachten zur Abiturklausur", 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 table meta_data = [ ["Pruefling:", student_data.get('student_name', 'Anonym')], ["Schuljahr:", f"{klausur_data.get('year', 2025)}"], ["Kurs:", klausur_data.get('semester', 'Abitur')], ["Datum:", 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)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.5*cm)) # Gutachten content _add_gutachten_content(story, styles, student_data) story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.5*cm)) # Bewertungstabelle _add_criteria_table(story, styles, student_data) # Final grade box _add_grade_box(story, styles, student_data) # Examiner workflow information if workflow_data: _add_workflow_info(story, styles, workflow_data) # Annotation summary if annotations: _add_annotation_summary(story, styles, annotations) # Footer _add_footer(story, styles) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue() def _add_gutachten_content(story, styles, student_data): """Add gutachten text sections to the story.""" gutachten = student_data.get('gutachten', {}) if gutachten: if gutachten.get('einleitung'): story.append(Paragraph("Einleitung", styles['SectionHeader'])) story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody'])) story.append(Spacer(1, 0.3*cm)) if gutachten.get('hauptteil'): story.append(Paragraph("Hauptteil", styles['SectionHeader'])) story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody'])) story.append(Spacer(1, 0.3*cm)) if gutachten.get('fazit'): story.append(Paragraph("Fazit", styles['SectionHeader'])) story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody'])) story.append(Spacer(1, 0.3*cm)) if gutachten.get('staerken') or gutachten.get('schwaechen'): story.append(Spacer(1, 0.3*cm)) if gutachten.get('staerken'): story.append(Paragraph("Staerken:", styles['SectionHeader'])) for s in gutachten['staerken']: story.append(Paragraph(f"• {s}", styles['ListItem'])) if gutachten.get('schwaechen'): story.append(Paragraph("Verbesserungspotenzial:", styles['SectionHeader'])) for s in gutachten['schwaechen']: story.append(Paragraph(f"• {s}", styles['ListItem'])) else: story.append(Paragraph("Kein Gutachten-Text vorhanden.", styles['GutachtenBody'])) def _add_criteria_table(story, styles, student_data): """Add criteria scoring table to the story.""" story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) criteria_scores = student_data.get('criteria_scores', {}) table_data = [["Kriterium", "Gewichtung", "Erreicht", "Punkte"]] total_weighted = 0 total_weight = 0 for key, display_name in CRITERIA_DISPLAY_NAMES.items(): weight = CRITERIA_WEIGHTS.get(key, 0) score_data = criteria_scores.get(key, {}) score = score_data.get('score', 0) if isinstance(score_data, dict) else score_data weighted_score = (score / 100) * weight if score else 0 total_weighted += weighted_score total_weight += weight table_data.append([ display_name, f"{weight}%", f"{score}%", f"{weighted_score:.1f}" ]) table_data.append([ "Gesamt", f"{total_weight}%", "", f"{total_weighted:.1f}" ]) criteria_table = Table(table_data, colWidths=[8*cm, 2.5*cm, 2.5*cm, 2.5*cm]) criteria_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), 10), ('ALIGN', (1, 0), (-1, -1), 'CENTER'), ('FONTSIZE', (0, 1), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('TOPPADDING', (0, 0), (-1, -1), 6), ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')), ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]), ])) story.append(criteria_table) story.append(Spacer(1, 0.5*cm)) def _add_grade_box(story, styles, student_data): """Add final grade box to the story.""" grade_points = student_data.get('grade_points', 0) grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "?") raw_points = student_data.get('raw_points', 0) grade_data = [ ["Rohpunkte:", f"{raw_points} / 100"], ["Notenpunkte:", f"{grade_points} Punkte"], ["Note:", grade_note] ] grade_table = Table(grade_data, colWidths=[4*cm, 4*cm]) grade_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ebf8ff')), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTNAME', (1, -1), (1, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 11), ('FONTSIZE', (1, -1), (1, -1), 14), ('TEXTCOLOR', (1, -1), (1, -1), colors.HexColor('#2c5282')), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('LEFTPADDING', (0, 0), (-1, -1), 12), ('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#2c5282')), ('ALIGN', (1, 0), (1, -1), 'RIGHT'), ])) story.append(KeepTogether([ Paragraph("Endergebnis", styles['SectionHeader']), Spacer(1, 0.2*cm), grade_table ])) def _add_workflow_info(story, styles, workflow_data): """Add examiner workflow information to the story.""" story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("Korrekturverlauf", styles['SectionHeader'])) workflow_rows = [] if workflow_data.get('erst_korrektor'): ek = workflow_data['erst_korrektor'] workflow_rows.append([ "Erstkorrektor:", ek.get('name', 'Unbekannt'), f"{ek.get('grade_points', '-')} Punkte" ]) if workflow_data.get('zweit_korrektor'): zk = workflow_data['zweit_korrektor'] workflow_rows.append([ "Zweitkorrektor:", zk.get('name', 'Unbekannt'), f"{zk.get('grade_points', '-')} Punkte" ]) if workflow_data.get('dritt_korrektor'): dk = workflow_data['dritt_korrektor'] workflow_rows.append([ "Drittkorrektor:", dk.get('name', 'Unbekannt'), f"{dk.get('grade_points', '-')} Punkte" ]) if workflow_data.get('final_grade_source'): workflow_rows.append([ "Endnote durch:", workflow_data['final_grade_source'], "" ]) if workflow_rows: workflow_table = Table(workflow_rows, colWidths=[4*cm, 6*cm, 4*cm]) workflow_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ])) story.append(workflow_table) def _add_annotation_summary(story, styles, annotations): """Add annotation summary to the story.""" story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("Anmerkungen (Zusammenfassung)", styles['SectionHeader'])) 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)} Anmerkungen)", styles['ListItem'])) def _add_footer(story, styles): """Add generation footer to the story.""" 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'] ))