Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
359 lines
11 KiB
Python
359 lines
11 KiB
Python
"""
|
|
Classroom API - Export Routes
|
|
|
|
PDF/HTML export endpoints (Phase 5).
|
|
"""
|
|
|
|
from datetime import datetime
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from classroom_engine import LessonPhase
|
|
|
|
from ..services.persistence import (
|
|
sessions,
|
|
init_db_if_needed,
|
|
DB_ENABLED,
|
|
SessionLocal,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["Export"])
|
|
|
|
|
|
@router.get("/export/session/{session_id}", response_class=HTMLResponse)
|
|
async def export_session_html(session_id: str) -> HTMLResponse:
|
|
"""
|
|
Exportiert eine Session-Zusammenfassung als druckbares HTML.
|
|
|
|
Kann im Browser ueber Strg+P als PDF gespeichert werden.
|
|
"""
|
|
# Session-Daten aus Memory oder DB holen
|
|
session = sessions.get(session_id)
|
|
|
|
if not session and DB_ENABLED:
|
|
init_db_if_needed()
|
|
with SessionLocal() as db:
|
|
from classroom_engine.repository import AnalyticsRepository
|
|
repo = AnalyticsRepository(db)
|
|
summary = repo.get_session_summary(session_id)
|
|
|
|
if not summary:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Session {session_id} not found"
|
|
)
|
|
|
|
# HTML generieren aus Summary
|
|
return HTMLResponse(content=_generate_export_html_from_summary(summary))
|
|
|
|
if not session:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Session {session_id} not found"
|
|
)
|
|
|
|
# HTML aus In-Memory Session generieren
|
|
return HTMLResponse(content=_generate_export_html_from_session(session))
|
|
|
|
|
|
def _generate_export_html_from_summary(summary) -> str:
|
|
"""Generiert druckbares HTML aus einer SessionSummary."""
|
|
phases_html = ""
|
|
for phase in summary.phase_statistics:
|
|
diff_class = "on-time"
|
|
if phase.difference_seconds < -60:
|
|
diff_class = "under-time"
|
|
elif phase.difference_seconds > 180:
|
|
diff_class = "way-over"
|
|
elif phase.difference_seconds > 60:
|
|
diff_class = "over-time"
|
|
|
|
phases_html += f"""
|
|
<tr>
|
|
<td>{phase.display_name}</td>
|
|
<td class="center">{phase.planned_duration_seconds // 60}:{phase.planned_duration_seconds % 60:02d}</td>
|
|
<td class="center">{phase.actual_duration_seconds // 60}:{phase.actual_duration_seconds % 60:02d}</td>
|
|
<td class="center {diff_class}">{phase.difference_formatted}</td>
|
|
</tr>
|
|
"""
|
|
|
|
return _get_export_html_template(
|
|
subject=summary.subject,
|
|
class_id=summary.class_id,
|
|
topic=summary.topic,
|
|
date_formatted=summary.date_formatted,
|
|
total_duration_formatted=summary.total_duration_formatted,
|
|
phases_completed=summary.phases_completed,
|
|
total_phases=summary.total_phases,
|
|
total_overtime_formatted=summary.total_overtime_formatted,
|
|
phases_html=phases_html,
|
|
phases_with_overtime=summary.phases_with_overtime,
|
|
total_overtime_seconds=summary.total_overtime_seconds,
|
|
reflection_notes=summary.reflection_notes,
|
|
)
|
|
|
|
|
|
def _generate_export_html_from_session(session) -> str:
|
|
"""Generiert druckbares HTML aus einer In-Memory Session."""
|
|
# Phasen-Tabelle generieren
|
|
phases_html = ""
|
|
total_overtime = 0
|
|
|
|
for entry in session.phase_history:
|
|
phase = entry.get("phase", "")
|
|
if phase in ["not_started", "ended"]:
|
|
continue
|
|
|
|
planned = session.phase_durations.get(phase, 0) * 60
|
|
actual = entry.get("duration_seconds", 0) or 0
|
|
diff = actual - planned
|
|
|
|
if diff > 0:
|
|
total_overtime += diff
|
|
|
|
diff_class = "on-time"
|
|
if diff < -60:
|
|
diff_class = "under-time"
|
|
elif diff > 180:
|
|
diff_class = "way-over"
|
|
elif diff > 60:
|
|
diff_class = "over-time"
|
|
|
|
phase_names = {
|
|
"einstieg": "Einstieg",
|
|
"erarbeitung": "Erarbeitung",
|
|
"sicherung": "Sicherung",
|
|
"transfer": "Transfer",
|
|
"reflexion": "Reflexion",
|
|
}
|
|
|
|
phases_html += f"""
|
|
<tr>
|
|
<td>{phase_names.get(phase, phase)}</td>
|
|
<td class="center">{planned // 60}:{planned % 60:02d}</td>
|
|
<td class="center">{actual // 60}:{actual % 60:02d}</td>
|
|
<td class="center {diff_class}">{'+' if diff >= 0 else ''}{diff // 60}:{abs(diff) % 60:02d}</td>
|
|
</tr>
|
|
"""
|
|
|
|
# Zeiten
|
|
total_duration = 0
|
|
date_str = "--"
|
|
if session.lesson_started_at:
|
|
date_str = session.lesson_started_at.strftime("%d.%m.%Y %H:%M")
|
|
if session.lesson_ended_at:
|
|
total_duration = int((session.lesson_ended_at - session.lesson_started_at).total_seconds())
|
|
|
|
total_mins = total_duration // 60
|
|
total_secs = total_duration % 60
|
|
overtime_mins = total_overtime // 60
|
|
overtime_secs = total_overtime % 60
|
|
|
|
completed_phases = len([e for e in session.phase_history if e.get("ended_at")])
|
|
|
|
return _get_export_html_template(
|
|
subject=session.subject,
|
|
class_id=session.class_id,
|
|
topic=session.topic,
|
|
date_formatted=date_str,
|
|
total_duration_formatted=f"{total_mins:02d}:{total_secs:02d}",
|
|
phases_completed=completed_phases,
|
|
total_phases=5,
|
|
total_overtime_formatted=f"{overtime_mins:02d}:{overtime_secs:02d}",
|
|
phases_html=phases_html,
|
|
phases_with_overtime=len([e for e in session.phase_history if e.get("duration_seconds", 0) > session.phase_durations.get(e.get("phase", ""), 0) * 60]),
|
|
total_overtime_seconds=total_overtime,
|
|
reflection_notes="",
|
|
)
|
|
|
|
|
|
def _get_export_html_template(
|
|
subject: str,
|
|
class_id: str,
|
|
topic: str,
|
|
date_formatted: str,
|
|
total_duration_formatted: str,
|
|
phases_completed: int,
|
|
total_phases: int,
|
|
total_overtime_formatted: str,
|
|
phases_html: str,
|
|
phases_with_overtime: int,
|
|
total_overtime_seconds: int,
|
|
reflection_notes: str,
|
|
) -> str:
|
|
"""Returns the full HTML template for export."""
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Stundenprotokoll - {subject}</title>
|
|
<style>
|
|
@page {{
|
|
size: A4;
|
|
margin: 2cm;
|
|
}}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 11pt;
|
|
line-height: 1.5;
|
|
color: #333;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}}
|
|
.header {{
|
|
border-bottom: 2px solid #1a1a2e;
|
|
padding-bottom: 20px;
|
|
margin-bottom: 20px;
|
|
}}
|
|
.title {{
|
|
font-size: 20pt;
|
|
font-weight: bold;
|
|
color: #1a1a2e;
|
|
margin: 0;
|
|
}}
|
|
.subtitle {{
|
|
color: #666;
|
|
font-size: 12pt;
|
|
margin-top: 5px;
|
|
}}
|
|
.meta-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 15px;
|
|
margin-bottom: 25px;
|
|
}}
|
|
.meta-item {{
|
|
background: #f5f5f5;
|
|
padding: 12px 15px;
|
|
border-radius: 8px;
|
|
}}
|
|
.meta-label {{
|
|
font-size: 10pt;
|
|
color: #666;
|
|
margin-bottom: 4px;
|
|
}}
|
|
.meta-value {{
|
|
font-size: 14pt;
|
|
font-weight: 600;
|
|
color: #1a1a2e;
|
|
}}
|
|
table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-bottom: 25px;
|
|
}}
|
|
th, td {{
|
|
border: 1px solid #ddd;
|
|
padding: 10px 12px;
|
|
text-align: left;
|
|
}}
|
|
th {{
|
|
background: #1a1a2e;
|
|
color: white;
|
|
font-weight: 600;
|
|
}}
|
|
tr:nth-child(even) {{
|
|
background: #f9f9f9;
|
|
}}
|
|
.center {{
|
|
text-align: center;
|
|
}}
|
|
.on-time {{ color: #3b82f6; }}
|
|
.under-time {{ color: #10b981; }}
|
|
.over-time {{ color: #f59e0b; }}
|
|
.way-over {{ color: #ef4444; }}
|
|
.summary-box {{
|
|
background: #fff8e1;
|
|
border-left: 4px solid #f59e0b;
|
|
padding: 15px;
|
|
margin-bottom: 25px;
|
|
}}
|
|
.footer {{
|
|
margin-top: 40px;
|
|
padding-top: 15px;
|
|
border-top: 1px solid #ddd;
|
|
font-size: 9pt;
|
|
color: #999;
|
|
text-align: center;
|
|
}}
|
|
@media print {{
|
|
body {{
|
|
padding: 0;
|
|
}}
|
|
.no-print {{
|
|
display: none;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1 class="title">Stundenprotokoll</h1>
|
|
<p class="subtitle">{subject} - Klasse {class_id}{f" - {topic}" if topic else ""}</p>
|
|
</div>
|
|
|
|
<div class="meta-grid">
|
|
<div class="meta-item">
|
|
<div class="meta-label">Datum</div>
|
|
<div class="meta-value">{date_formatted}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Gesamtdauer</div>
|
|
<div class="meta-value">{total_duration_formatted}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Phasen abgeschlossen</div>
|
|
<div class="meta-value">{phases_completed}/{total_phases}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Overtime gesamt</div>
|
|
<div class="meta-value">{total_overtime_formatted}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Phasen-Analyse</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Phase</th>
|
|
<th class="center">Geplant</th>
|
|
<th class="center">Tatsaechlich</th>
|
|
<th class="center">Differenz</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{phases_html}
|
|
</tbody>
|
|
</table>
|
|
|
|
{f'''
|
|
<div class="summary-box">
|
|
<strong>Overtime-Zusammenfassung:</strong><br>
|
|
{phases_with_overtime} von {total_phases} Phasen hatten Overtime
|
|
(gesamt: {total_overtime_formatted})
|
|
</div>
|
|
''' if total_overtime_seconds > 0 else ''}
|
|
|
|
{f'''
|
|
<h2>Reflexion</h2>
|
|
<p>{reflection_notes}</p>
|
|
''' if reflection_notes else ''}
|
|
|
|
<div class="footer">
|
|
<p>Erstellt mit BreakPilot Classroom Engine | {datetime.utcnow().strftime("%d.%m.%Y %H:%M")}</p>
|
|
<p class="no-print" style="margin-top: 10px;">
|
|
<button onclick="window.print()" style="padding: 10px 20px; cursor: pointer;">
|
|
Als PDF speichern (Strg+P)
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|