Files
breakpilot-lehrer/klausur-service/backend/korrektur/pdf_export_gutachten.py
Benjamin Admin 165c493d1e
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m22s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 23s
Restructure: Move 52 files into 7 domain packages
korrektur/ zeugnis/ admin/ compliance/ worksheet/ training/ metrics/
52 shims, relative imports, RAG untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:10:48 +02:00

316 lines
11 KiB
Python

"""
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("<i>Kein Gutachten-Text vorhanden.</i>", 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']
))