A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
678 lines
22 KiB
Python
678 lines
22 KiB
Python
"""
|
|
PDF Export Module for Abiturkorrektur System
|
|
|
|
Generates:
|
|
- Individual Gutachten PDFs for each student
|
|
- Klausur overview PDFs with grade distribution
|
|
- Niedersachsen-compliant formatting
|
|
"""
|
|
|
|
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.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import cm, mm
|
|
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
|
PageBreak, HRFlowable, Image, KeepTogether
|
|
)
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
|
|
|
|
# =============================================
|
|
# CONSTANTS
|
|
# =============================================
|
|
|
|
GRADE_POINTS_TO_NOTE = {
|
|
15: "1+", 14: "1", 13: "1-",
|
|
12: "2+", 11: "2", 10: "2-",
|
|
9: "3+", 8: "3", 7: "3-",
|
|
6: "4+", 5: "4", 4: "4-",
|
|
3: "5+", 2: "5", 1: "5-",
|
|
0: "6"
|
|
}
|
|
|
|
CRITERIA_DISPLAY_NAMES = {
|
|
"rechtschreibung": "Sprachliche Richtigkeit (Rechtschreibung)",
|
|
"grammatik": "Sprachliche Richtigkeit (Grammatik)",
|
|
"inhalt": "Inhaltliche Leistung",
|
|
"struktur": "Aufbau und Struktur",
|
|
"stil": "Ausdruck und Stil"
|
|
}
|
|
|
|
CRITERIA_WEIGHTS = {
|
|
"rechtschreibung": 15,
|
|
"grammatik": 15,
|
|
"inhalt": 40,
|
|
"struktur": 15,
|
|
"stil": 15
|
|
}
|
|
|
|
|
|
# =============================================
|
|
# STYLES
|
|
# =============================================
|
|
|
|
def get_custom_styles():
|
|
"""Create custom paragraph styles for Gutachten."""
|
|
styles = getSampleStyleSheet()
|
|
|
|
# Title style
|
|
styles.add(ParagraphStyle(
|
|
name='GutachtenTitle',
|
|
parent=styles['Heading1'],
|
|
fontSize=16,
|
|
spaceAfter=12,
|
|
alignment=TA_CENTER,
|
|
textColor=colors.HexColor('#1e3a5f')
|
|
))
|
|
|
|
# Subtitle style
|
|
styles.add(ParagraphStyle(
|
|
name='GutachtenSubtitle',
|
|
parent=styles['Heading2'],
|
|
fontSize=12,
|
|
spaceAfter=8,
|
|
spaceBefore=16,
|
|
textColor=colors.HexColor('#2c5282')
|
|
))
|
|
|
|
# Section header
|
|
styles.add(ParagraphStyle(
|
|
name='SectionHeader',
|
|
parent=styles['Heading3'],
|
|
fontSize=11,
|
|
spaceAfter=6,
|
|
spaceBefore=12,
|
|
textColor=colors.HexColor('#2d3748'),
|
|
borderColor=colors.HexColor('#e2e8f0'),
|
|
borderWidth=0,
|
|
borderPadding=0
|
|
))
|
|
|
|
# Body text
|
|
styles.add(ParagraphStyle(
|
|
name='GutachtenBody',
|
|
parent=styles['Normal'],
|
|
fontSize=10,
|
|
leading=14,
|
|
alignment=TA_JUSTIFY,
|
|
spaceAfter=6
|
|
))
|
|
|
|
# Small text for footer/meta
|
|
styles.add(ParagraphStyle(
|
|
name='MetaText',
|
|
parent=styles['Normal'],
|
|
fontSize=8,
|
|
textColor=colors.grey,
|
|
alignment=TA_LEFT
|
|
))
|
|
|
|
# List item
|
|
styles.add(ParagraphStyle(
|
|
name='ListItem',
|
|
parent=styles['Normal'],
|
|
fontSize=10,
|
|
leftIndent=20,
|
|
bulletIndent=10,
|
|
spaceAfter=4
|
|
))
|
|
|
|
return styles
|
|
|
|
|
|
# =============================================
|
|
# PDF GENERATION FUNCTIONS
|
|
# =============================================
|
|
|
|
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
|
|
gutachten = student_data.get('gutachten', {})
|
|
|
|
if gutachten:
|
|
# Einleitung
|
|
if gutachten.get('einleitung'):
|
|
story.append(Paragraph("Einleitung", styles['SectionHeader']))
|
|
story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody']))
|
|
story.append(Spacer(1, 0.3*cm))
|
|
|
|
# Hauptteil
|
|
if gutachten.get('hauptteil'):
|
|
story.append(Paragraph("Hauptteil", styles['SectionHeader']))
|
|
story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody']))
|
|
story.append(Spacer(1, 0.3*cm))
|
|
|
|
# Fazit
|
|
if gutachten.get('fazit'):
|
|
story.append(Paragraph("Fazit", styles['SectionHeader']))
|
|
story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody']))
|
|
story.append(Spacer(1, 0.3*cm))
|
|
|
|
# Staerken und Schwaechen
|
|
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']))
|
|
|
|
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
|
|
story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader']))
|
|
story.append(Spacer(1, 0.2*cm))
|
|
|
|
criteria_scores = student_data.get('criteria_scores', {})
|
|
|
|
# Build criteria table data
|
|
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
|
|
|
|
# Calculate weighted contribution
|
|
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}"
|
|
])
|
|
|
|
# Add total row
|
|
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([
|
|
# Header row
|
|
('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'),
|
|
# Body rows
|
|
('FONTSIZE', (0, 1), (-1, -1), 9),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
|
|
('TOPPADDING', (0, 0), (-1, -1), 6),
|
|
# Grid
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
|
|
# Total row
|
|
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')),
|
|
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
|
|
# Alternating row colors
|
|
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]),
|
|
]))
|
|
story.append(criteria_table)
|
|
|
|
story.append(Spacer(1, 0.5*cm))
|
|
|
|
# Final grade box
|
|
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
|
|
]))
|
|
|
|
# Examiner workflow information
|
|
if workflow_data:
|
|
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)
|
|
|
|
# Annotation summary (if any)
|
|
if annotations:
|
|
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']))
|
|
|
|
# Group annotations 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)} Anmerkungen)", styles['ListItem']))
|
|
|
|
# Footer with generation info
|
|
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 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'):
|
|
stats = fairness_data['statistics']
|
|
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))
|
|
|
|
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
|
|
story.append(Spacer(1, 0.5*cm))
|
|
|
|
# Student grades table
|
|
story.append(Paragraph("Einzelergebnisse", styles['SectionHeader']))
|
|
story.append(Spacer(1, 0.2*cm))
|
|
|
|
# Sort students by grade (descending)
|
|
sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True)
|
|
|
|
# Build table header
|
|
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')
|
|
|
|
# Format status
|
|
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
|
|
])
|
|
|
|
# Create table
|
|
student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm])
|
|
student_table.setStyle(TableStyle([
|
|
# Header
|
|
('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'),
|
|
# Body
|
|
('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
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
|
|
# Alternating rows
|
|
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]),
|
|
]))
|
|
story.append(student_table)
|
|
|
|
# Grade distribution
|
|
story.append(Spacer(1, 0.5*cm))
|
|
story.append(Paragraph("Notenverteilung", styles['SectionHeader']))
|
|
story.append(Spacer(1, 0.2*cm))
|
|
|
|
# Count grades
|
|
grade_counts = {}
|
|
for student in sorted_students:
|
|
gp = student.get('grade_points', 0)
|
|
grade_counts[gp] = grade_counts.get(gp, 0) + 1
|
|
|
|
# Build grade distribution table
|
|
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)
|
|
|
|
# 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 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))
|
|
|
|
# Sort by page then position
|
|
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')
|
|
|
|
# Build annotation text
|
|
ann_text = f"<b>[S.{page}]</b> {text}"
|
|
if suggestion:
|
|
ann_text += f" → <i>{suggestion}</i>"
|
|
|
|
# Color code by severity
|
|
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()
|