[split-required] Split 500-850 LOC files (batch 2)
backend-lehrer (10 files): - game/database.py (785 → 5), correction_api.py (683 → 4) - classroom_engine/antizipation.py (676 → 5) - llm_gateway schools/edu_search already done in prior batch klausur-service (12 files): - orientation_crop_api.py (694 → 5), pdf_export.py (677 → 4) - zeugnis_crawler.py (676 → 5), grid_editor_api.py (671 → 5) - eh_templates.py (658 → 5), mail/api.py (651 → 5) - qdrant_service.py (638 → 5), training_api.py (625 → 4) website (6 pages): - middleware (696 → 8), mail (733 → 6), consent (628 → 8) - compliance/risks (622 → 5), export (502 → 5), brandbook (629 → 7) studio-v2 (3 components): - B2BMigrationWizard (848 → 3), CleanupPanel (765 → 2) - dashboard-experimental (739 → 2) admin-lehrer (4 files): - uebersetzungen (769 → 4), manager (670 → 2) - ChunkBrowserQA (675 → 6), dsfa/page (674 → 5) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
297
klausur-service/backend/pdf_export_overview.py
Normal file
297
klausur-service/backend/pdf_export_overview.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
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("<i>Keine Anmerkungen vorhanden.</i>", 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"<b>[S.{page}]</b> {text}"
|
||||
if suggestion:
|
||||
ann_text += f" -> <i>{suggestion}</i>"
|
||||
|
||||
if severity == 'critical':
|
||||
ann_text = f"<font color='red'>{ann_text}</font>"
|
||||
elif severity == 'major':
|
||||
ann_text = f"<font color='orange'>{ann_text}</font>"
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user