fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
358
backend/classroom/routes/export.py
Normal file
358
backend/classroom/routes/export.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
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>
|
||||
"""
|
||||
Reference in New Issue
Block a user