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>
344 lines
10 KiB
Python
344 lines
10 KiB
Python
"""
|
|
Classroom API - Analytics & Reflections Endpoints.
|
|
|
|
Endpoints fuer Session-Analytics und Post-Lesson Reflections (Phase 5).
|
|
"""
|
|
|
|
from uuid import uuid4
|
|
from typing import Dict, List, Optional, Any
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
|
|
from .shared import init_db_if_needed, DB_ENABLED, logger
|
|
|
|
try:
|
|
from classroom_engine.database import SessionLocal
|
|
from classroom_engine.repository import AnalyticsRepository, ReflectionRepository
|
|
from classroom_engine.analytics import LessonReflection
|
|
except ImportError:
|
|
pass
|
|
|
|
router = APIRouter(tags=["Analytics"])
|
|
|
|
|
|
# === Pydantic Models ===
|
|
|
|
class SessionSummaryResponse(BaseModel):
|
|
"""Response fuer Session-Summary."""
|
|
session_id: str
|
|
teacher_id: str
|
|
class_id: str
|
|
subject: str
|
|
topic: Optional[str]
|
|
date: Optional[str]
|
|
date_formatted: str
|
|
total_duration_seconds: int
|
|
total_duration_formatted: str
|
|
planned_duration_seconds: int
|
|
planned_duration_formatted: str
|
|
phases_completed: int
|
|
total_phases: int
|
|
completion_percentage: int
|
|
phase_statistics: List[Dict[str, Any]]
|
|
total_overtime_seconds: int
|
|
total_overtime_formatted: str
|
|
phases_with_overtime: int
|
|
total_pause_count: int
|
|
total_pause_seconds: int
|
|
reflection_notes: str = ""
|
|
reflection_rating: Optional[int] = None
|
|
key_learnings: List[str] = []
|
|
|
|
|
|
class TeacherAnalyticsResponse(BaseModel):
|
|
"""Response fuer Lehrer-Analytics."""
|
|
teacher_id: str
|
|
period_start: Optional[str]
|
|
period_end: Optional[str]
|
|
total_sessions: int
|
|
completed_sessions: int
|
|
total_teaching_minutes: int
|
|
total_teaching_hours: float
|
|
avg_phase_durations: Dict[str, int]
|
|
sessions_with_overtime: int
|
|
overtime_percentage: int
|
|
avg_overtime_seconds: int
|
|
avg_overtime_formatted: str
|
|
most_overtime_phase: Optional[str]
|
|
avg_pause_count: float
|
|
avg_pause_duration_seconds: int
|
|
subjects_taught: Dict[str, int]
|
|
classes_taught: Dict[str, int]
|
|
|
|
|
|
class ReflectionCreate(BaseModel):
|
|
"""Request-Body fuer Reflection-Erstellung."""
|
|
session_id: str
|
|
teacher_id: str
|
|
notes: str = ""
|
|
overall_rating: Optional[int] = Field(None, ge=1, le=5)
|
|
what_worked: List[str] = []
|
|
improvements: List[str] = []
|
|
notes_for_next_lesson: str = ""
|
|
|
|
|
|
class ReflectionUpdate(BaseModel):
|
|
"""Request-Body fuer Reflection-Update."""
|
|
notes: Optional[str] = None
|
|
overall_rating: Optional[int] = Field(None, ge=1, le=5)
|
|
what_worked: Optional[List[str]] = None
|
|
improvements: Optional[List[str]] = None
|
|
notes_for_next_lesson: Optional[str] = None
|
|
|
|
|
|
class ReflectionResponse(BaseModel):
|
|
"""Response fuer eine einzelne Reflection."""
|
|
reflection_id: str
|
|
session_id: str
|
|
teacher_id: str
|
|
notes: str
|
|
overall_rating: Optional[int]
|
|
what_worked: List[str]
|
|
improvements: List[str]
|
|
notes_for_next_lesson: str
|
|
created_at: Optional[str]
|
|
updated_at: Optional[str]
|
|
|
|
|
|
class ReflectionListResponse(BaseModel):
|
|
"""Response fuer Reflection-Liste."""
|
|
reflections: List[ReflectionResponse]
|
|
total: int
|
|
|
|
|
|
# === Analytics Endpoints ===
|
|
|
|
@router.get("/analytics/session/{session_id}")
|
|
async def get_session_summary(session_id: str) -> SessionSummaryResponse:
|
|
"""Gibt die Analytics-Zusammenfassung einer Session zurueck."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
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")
|
|
|
|
return SessionSummaryResponse(**summary.to_dict())
|
|
|
|
|
|
@router.get("/analytics/teacher/{teacher_id}")
|
|
async def get_teacher_analytics(
|
|
teacher_id: str,
|
|
days: int = Query(30, ge=1, le=365)
|
|
) -> TeacherAnalyticsResponse:
|
|
"""Gibt aggregierte Analytics fuer einen Lehrer zurueck."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
period_end = datetime.utcnow()
|
|
period_start = period_end - timedelta(days=days)
|
|
|
|
with SessionLocal() as db:
|
|
repo = AnalyticsRepository(db)
|
|
analytics = repo.get_teacher_analytics(teacher_id, period_start, period_end)
|
|
|
|
return TeacherAnalyticsResponse(**analytics.to_dict())
|
|
|
|
|
|
@router.get("/analytics/phase-trends/{teacher_id}/{phase}")
|
|
async def get_phase_trends(
|
|
teacher_id: str,
|
|
phase: str,
|
|
limit: int = Query(20, ge=1, le=100)
|
|
) -> Dict[str, Any]:
|
|
"""Gibt die Dauer-Trends fuer eine Phase zurueck."""
|
|
if phase not in ["einstieg", "erarbeitung", "sicherung", "transfer", "reflexion"]:
|
|
raise HTTPException(status_code=400, detail=f"Invalid phase: {phase}")
|
|
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = AnalyticsRepository(db)
|
|
trends = repo.get_phase_duration_trends(teacher_id, phase, limit)
|
|
|
|
return {
|
|
"teacher_id": teacher_id,
|
|
"phase": phase,
|
|
"data_points": trends,
|
|
"count": len(trends)
|
|
}
|
|
|
|
|
|
@router.get("/analytics/overtime/{teacher_id}")
|
|
async def get_overtime_analysis(
|
|
teacher_id: str,
|
|
limit: int = Query(30, ge=1, le=100)
|
|
) -> Dict[str, Any]:
|
|
"""Analysiert Overtime-Muster nach Phase."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = AnalyticsRepository(db)
|
|
analysis = repo.get_overtime_analysis(teacher_id, limit)
|
|
|
|
return {
|
|
"teacher_id": teacher_id,
|
|
"sessions_analyzed": limit,
|
|
"phases": analysis
|
|
}
|
|
|
|
|
|
# === Reflection Endpoints ===
|
|
|
|
@router.post("/reflections", status_code=201)
|
|
async def create_reflection(data: ReflectionCreate) -> ReflectionResponse:
|
|
"""Erstellt eine Post-Lesson Reflection."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = ReflectionRepository(db)
|
|
|
|
existing = repo.get_by_session(data.session_id)
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Reflection for session {data.session_id} already exists"
|
|
)
|
|
|
|
reflection = LessonReflection(
|
|
reflection_id=str(uuid4()),
|
|
session_id=data.session_id,
|
|
teacher_id=data.teacher_id,
|
|
notes=data.notes,
|
|
overall_rating=data.overall_rating,
|
|
what_worked=data.what_worked,
|
|
improvements=data.improvements,
|
|
notes_for_next_lesson=data.notes_for_next_lesson,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
|
|
db_reflection = repo.create(reflection)
|
|
result = repo.to_dataclass(db_reflection)
|
|
|
|
return ReflectionResponse(**result.to_dict())
|
|
|
|
|
|
@router.get("/reflections/session/{session_id}")
|
|
async def get_reflection_by_session(session_id: str) -> ReflectionResponse:
|
|
"""Holt die Reflection einer Session."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = ReflectionRepository(db)
|
|
db_reflection = repo.get_by_session(session_id)
|
|
|
|
if not db_reflection:
|
|
raise HTTPException(status_code=404, detail=f"No reflection for session {session_id}")
|
|
|
|
result = repo.to_dataclass(db_reflection)
|
|
return ReflectionResponse(**result.to_dict())
|
|
|
|
|
|
@router.get("/reflections/teacher/{teacher_id}")
|
|
async def get_reflections_by_teacher(
|
|
teacher_id: str,
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0)
|
|
) -> ReflectionListResponse:
|
|
"""Holt alle Reflections eines Lehrers."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = ReflectionRepository(db)
|
|
db_reflections = repo.get_by_teacher(teacher_id, limit, offset)
|
|
|
|
reflections = []
|
|
for db_ref in db_reflections:
|
|
result = repo.to_dataclass(db_ref)
|
|
reflections.append(ReflectionResponse(**result.to_dict()))
|
|
|
|
total = repo.count_by_teacher(teacher_id)
|
|
|
|
return ReflectionListResponse(reflections=reflections, total=total)
|
|
|
|
|
|
@router.put("/reflections/{reflection_id}")
|
|
async def update_reflection(reflection_id: str, data: ReflectionUpdate) -> ReflectionResponse:
|
|
"""Aktualisiert eine Reflection."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = ReflectionRepository(db)
|
|
db_reflection = repo.get_by_id(reflection_id)
|
|
|
|
if not db_reflection:
|
|
raise HTTPException(status_code=404, detail=f"Reflection {reflection_id} not found")
|
|
|
|
reflection = repo.to_dataclass(db_reflection)
|
|
|
|
if data.notes is not None:
|
|
reflection.notes = data.notes
|
|
if data.overall_rating is not None:
|
|
reflection.overall_rating = data.overall_rating
|
|
if data.what_worked is not None:
|
|
reflection.what_worked = data.what_worked
|
|
if data.improvements is not None:
|
|
reflection.improvements = data.improvements
|
|
if data.notes_for_next_lesson is not None:
|
|
reflection.notes_for_next_lesson = data.notes_for_next_lesson
|
|
|
|
reflection.updated_at = datetime.utcnow()
|
|
|
|
db_reflection = repo.update(reflection)
|
|
result = repo.to_dataclass(db_reflection)
|
|
|
|
return ReflectionResponse(**result.to_dict())
|
|
|
|
|
|
@router.delete("/reflections/{reflection_id}")
|
|
async def delete_reflection(reflection_id: str) -> Dict[str, str]:
|
|
"""Loescht eine Reflection."""
|
|
if not DB_ENABLED:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
init_db_if_needed()
|
|
|
|
with SessionLocal() as db:
|
|
repo = ReflectionRepository(db)
|
|
db_reflection = repo.get_by_id(reflection_id)
|
|
|
|
if not db_reflection:
|
|
raise HTTPException(status_code=404, detail=f"Reflection {reflection_id} not found")
|
|
|
|
repo.delete(reflection_id)
|
|
|
|
return {"status": "deleted", "reflection_id": reflection_id}
|