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>
316 lines
11 KiB
Python
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']
|
|
))
|