This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/api/classroom/analytics.py
Benjamin Admin bfdaf63ba9 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>
2026-02-09 09:51:32 +01:00

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}