# ============================================== # Breakpilot Drive - Teacher Dashboard API # ============================================== # Lehrer-Dashboard fuer Unit-Zuweisung und Analytics: # - Units zu Klassen zuweisen # - Schueler-Fortschritt einsehen # - Klassen-Analytics # - H5P und PDF Content verwalten # - Unit-Einstellungen pro Klasse from fastapi import APIRouter, HTTPException, Query, Depends, Request from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any from datetime import datetime, timedelta from enum import Enum import uuid import os import logging import httpx logger = logging.getLogger(__name__) # Feature flags USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" REQUIRE_AUTH = os.getenv("TEACHER_REQUIRE_AUTH", "true").lower() == "true" SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") router = APIRouter(prefix="/api/teacher", tags=["Teacher Dashboard"]) # ============================================== # Pydantic Models # ============================================== class UnitAssignmentStatus(str, Enum): """Status of a unit assignment""" DRAFT = "draft" ACTIVE = "active" COMPLETED = "completed" ARCHIVED = "archived" class TeacherControlSettings(BaseModel): """Unit settings that teachers can configure""" allow_skip: bool = True allow_replay: bool = True max_time_per_stop_sec: int = 90 show_hints: bool = True require_precheck: bool = True require_postcheck: bool = True class AssignUnitRequest(BaseModel): """Request to assign a unit to a class""" unit_id: str class_id: str due_date: Optional[datetime] = None settings: Optional[TeacherControlSettings] = None notes: Optional[str] = None class UnitAssignment(BaseModel): """Unit assignment record""" assignment_id: str unit_id: str class_id: str teacher_id: str status: UnitAssignmentStatus settings: TeacherControlSettings due_date: Optional[datetime] = None notes: Optional[str] = None created_at: datetime updated_at: datetime class StudentUnitProgress(BaseModel): """Progress of a single student on a unit""" student_id: str student_name: str session_id: Optional[str] = None status: str # "not_started", "in_progress", "completed" completion_rate: float = 0.0 precheck_score: Optional[float] = None postcheck_score: Optional[float] = None learning_gain: Optional[float] = None time_spent_minutes: int = 0 last_activity: Optional[datetime] = None current_stop: Optional[str] = None stops_completed: int = 0 total_stops: int = 0 class ClassUnitProgress(BaseModel): """Overall progress of a class on a unit""" assignment_id: str unit_id: str unit_title: str class_id: str class_name: str total_students: int started_count: int completed_count: int avg_completion_rate: float avg_precheck_score: Optional[float] = None avg_postcheck_score: Optional[float] = None avg_learning_gain: Optional[float] = None avg_time_minutes: float students: List[StudentUnitProgress] class MisconceptionReport(BaseModel): """Report of detected misconceptions""" concept_id: str concept_label: str misconception: str affected_students: List[str] frequency: int unit_id: str stop_id: str class ClassAnalyticsSummary(BaseModel): """Summary analytics for a class""" class_id: str class_name: str total_units_assigned: int units_completed: int active_units: int avg_completion_rate: float avg_learning_gain: Optional[float] total_time_hours: float top_performers: List[str] struggling_students: List[str] common_misconceptions: List[MisconceptionReport] class ContentResource(BaseModel): """Generated content resource""" resource_type: str # "h5p", "pdf", "worksheet" title: str url: str generated_at: datetime unit_id: str # ============================================== # Auth Dependency # ============================================== async def get_current_teacher(request: Request) -> Dict[str, Any]: """Get current teacher from JWT token.""" if not REQUIRE_AUTH: # Dev mode: return demo teacher return { "user_id": "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20", "email": "demo@breakpilot.app", "role": "teacher", "name": "Demo Lehrer" } auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing authorization token") try: import jwt token = auth_header[7:] secret = os.getenv("JWT_SECRET", "dev-secret-key") payload = jwt.decode(token, secret, algorithms=["HS256"]) if payload.get("role") not in ["teacher", "admin"]: raise HTTPException(status_code=403, detail="Teacher or admin role required") return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token") # ============================================== # Database Integration # ============================================== _teacher_db = None async def get_teacher_database(): """Get teacher database instance with lazy initialization.""" global _teacher_db if not USE_DATABASE: return None if _teacher_db is None: try: from unit.database import get_teacher_db _teacher_db = await get_teacher_db() logger.info("Teacher database initialized") except ImportError: logger.warning("Teacher database module not available") except Exception as e: logger.warning(f"Teacher database not available: {e}") return _teacher_db # ============================================== # School Service Integration # ============================================== async def get_classes_for_teacher(teacher_id: str) -> List[Dict[str, Any]]: """Get classes assigned to a teacher from school service.""" async with httpx.AsyncClient(timeout=10.0) as client: try: response = await client.get( f"{SCHOOL_SERVICE_URL}/api/v1/school/classes", headers={"X-Teacher-ID": teacher_id} ) if response.status_code == 200: return response.json() except Exception as e: logger.error(f"Failed to get classes from school service: {e}") return [] async def get_students_in_class(class_id: str) -> List[Dict[str, Any]]: """Get students in a class from school service.""" async with httpx.AsyncClient(timeout=10.0) as client: try: response = await client.get( f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students" ) if response.status_code == 200: return response.json() except Exception as e: logger.error(f"Failed to get students from school service: {e}") return [] # ============================================== # In-Memory Storage (Fallback) # ============================================== _assignments_store: Dict[str, Dict[str, Any]] = {} # ============================================== # API Endpoints - Unit Assignment # ============================================== @router.post("/assignments", response_model=UnitAssignment) async def assign_unit_to_class( request_data: AssignUnitRequest, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> UnitAssignment: """ Assign a unit to a class. Creates an assignment that allows students in the class to play the unit. Teacher can configure settings like skip, replay, time limits. """ assignment_id = str(uuid.uuid4()) now = datetime.utcnow() settings = request_data.settings or TeacherControlSettings() assignment = { "assignment_id": assignment_id, "unit_id": request_data.unit_id, "class_id": request_data.class_id, "teacher_id": teacher["user_id"], "status": UnitAssignmentStatus.ACTIVE, "settings": settings.model_dump(), "due_date": request_data.due_date, "notes": request_data.notes, "created_at": now, "updated_at": now, } db = await get_teacher_database() if db: try: await db.create_assignment(assignment) except Exception as e: logger.error(f"Failed to store assignment: {e}") # Fallback: store in memory _assignments_store[assignment_id] = assignment logger.info(f"Unit {request_data.unit_id} assigned to class {request_data.class_id}") return UnitAssignment( assignment_id=assignment_id, unit_id=request_data.unit_id, class_id=request_data.class_id, teacher_id=teacher["user_id"], status=UnitAssignmentStatus.ACTIVE, settings=settings, due_date=request_data.due_date, notes=request_data.notes, created_at=now, updated_at=now, ) @router.get("/assignments", response_model=List[UnitAssignment]) async def list_assignments( class_id: Optional[str] = Query(None, description="Filter by class"), status: Optional[UnitAssignmentStatus] = Query(None, description="Filter by status"), teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> List[UnitAssignment]: """ List all unit assignments for the teacher. Optionally filter by class or status. """ db = await get_teacher_database() assignments = [] if db: try: assignments = await db.list_assignments( teacher_id=teacher["user_id"], class_id=class_id, status=status.value if status else None ) except Exception as e: logger.error(f"Failed to list assignments: {e}") if not assignments: # Fallback: filter in-memory store for assignment in _assignments_store.values(): if assignment["teacher_id"] != teacher["user_id"]: continue if class_id and assignment["class_id"] != class_id: continue if status and assignment["status"] != status.value: continue assignments.append(assignment) return [ UnitAssignment( assignment_id=a["assignment_id"], unit_id=a["unit_id"], class_id=a["class_id"], teacher_id=a["teacher_id"], status=a["status"], settings=TeacherControlSettings(**a["settings"]), due_date=a.get("due_date"), notes=a.get("notes"), created_at=a["created_at"], updated_at=a["updated_at"], ) for a in assignments ] @router.get("/assignments/{assignment_id}", response_model=UnitAssignment) async def get_assignment( assignment_id: str, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> UnitAssignment: """Get details of a specific assignment.""" db = await get_teacher_database() if db: try: assignment = await db.get_assignment(assignment_id) if assignment and assignment["teacher_id"] == teacher["user_id"]: return UnitAssignment( assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"], class_id=assignment["class_id"], teacher_id=assignment["teacher_id"], status=assignment["status"], settings=TeacherControlSettings(**assignment["settings"]), due_date=assignment.get("due_date"), notes=assignment.get("notes"), created_at=assignment["created_at"], updated_at=assignment["updated_at"], ) except Exception as e: logger.error(f"Failed to get assignment: {e}") # Fallback if assignment_id in _assignments_store: a = _assignments_store[assignment_id] if a["teacher_id"] == teacher["user_id"]: return UnitAssignment( assignment_id=a["assignment_id"], unit_id=a["unit_id"], class_id=a["class_id"], teacher_id=a["teacher_id"], status=a["status"], settings=TeacherControlSettings(**a["settings"]), due_date=a.get("due_date"), notes=a.get("notes"), created_at=a["created_at"], updated_at=a["updated_at"], ) raise HTTPException(status_code=404, detail="Assignment not found") @router.put("/assignments/{assignment_id}") async def update_assignment( assignment_id: str, settings: Optional[TeacherControlSettings] = None, status: Optional[UnitAssignmentStatus] = None, due_date: Optional[datetime] = None, notes: Optional[str] = None, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> UnitAssignment: """Update assignment settings or status.""" db = await get_teacher_database() assignment = None if db: try: assignment = await db.get_assignment(assignment_id) except Exception as e: logger.error(f"Failed to get assignment: {e}") if not assignment and assignment_id in _assignments_store: assignment = _assignments_store[assignment_id] if not assignment or assignment["teacher_id"] != teacher["user_id"]: raise HTTPException(status_code=404, detail="Assignment not found") # Update fields if settings: assignment["settings"] = settings.model_dump() if status: assignment["status"] = status.value if due_date: assignment["due_date"] = due_date if notes is not None: assignment["notes"] = notes assignment["updated_at"] = datetime.utcnow() if db: try: await db.update_assignment(assignment) except Exception as e: logger.error(f"Failed to update assignment: {e}") _assignments_store[assignment_id] = assignment return UnitAssignment( assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"], class_id=assignment["class_id"], teacher_id=assignment["teacher_id"], status=assignment["status"], settings=TeacherControlSettings(**assignment["settings"]), due_date=assignment.get("due_date"), notes=assignment.get("notes"), created_at=assignment["created_at"], updated_at=assignment["updated_at"], ) @router.delete("/assignments/{assignment_id}") async def delete_assignment( assignment_id: str, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> Dict[str, str]: """Delete/archive an assignment.""" db = await get_teacher_database() if db: try: assignment = await db.get_assignment(assignment_id) if assignment and assignment["teacher_id"] == teacher["user_id"]: await db.delete_assignment(assignment_id) if assignment_id in _assignments_store: del _assignments_store[assignment_id] return {"status": "deleted", "assignment_id": assignment_id} except Exception as e: logger.error(f"Failed to delete assignment: {e}") if assignment_id in _assignments_store: a = _assignments_store[assignment_id] if a["teacher_id"] == teacher["user_id"]: del _assignments_store[assignment_id] return {"status": "deleted", "assignment_id": assignment_id} raise HTTPException(status_code=404, detail="Assignment not found") # ============================================== # API Endpoints - Progress & Analytics # ============================================== @router.get("/assignments/{assignment_id}/progress", response_model=ClassUnitProgress) async def get_assignment_progress( assignment_id: str, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> ClassUnitProgress: """ Get detailed progress for an assignment. Shows each student's status, scores, and time spent. """ db = await get_teacher_database() assignment = None if db: try: assignment = await db.get_assignment(assignment_id) except Exception as e: logger.error(f"Failed to get assignment: {e}") if not assignment and assignment_id in _assignments_store: assignment = _assignments_store[assignment_id] if not assignment or assignment["teacher_id"] != teacher["user_id"]: raise HTTPException(status_code=404, detail="Assignment not found") # Get students in class students = await get_students_in_class(assignment["class_id"]) # Get progress for each student student_progress = [] total_completion = 0.0 total_precheck = 0.0 total_postcheck = 0.0 total_time = 0 precheck_count = 0 postcheck_count = 0 started = 0 completed = 0 for student in students: student_id = student.get("id", student.get("student_id")) progress = StudentUnitProgress( student_id=student_id, student_name=student.get("name", f"Student {student_id[:8]}"), status="not_started", completion_rate=0.0, stops_completed=0, total_stops=0, ) if db: try: session_data = await db.get_student_unit_session( student_id=student_id, unit_id=assignment["unit_id"] ) if session_data: progress.session_id = session_data.get("session_id") progress.status = "completed" if session_data.get("completed_at") else "in_progress" progress.completion_rate = session_data.get("completion_rate", 0.0) progress.precheck_score = session_data.get("precheck_score") progress.postcheck_score = session_data.get("postcheck_score") progress.time_spent_minutes = session_data.get("duration_seconds", 0) // 60 progress.last_activity = session_data.get("updated_at") progress.stops_completed = session_data.get("stops_completed", 0) progress.total_stops = session_data.get("total_stops", 0) if progress.precheck_score is not None and progress.postcheck_score is not None: progress.learning_gain = progress.postcheck_score - progress.precheck_score # Aggregate stats total_completion += progress.completion_rate total_time += progress.time_spent_minutes if progress.precheck_score is not None: total_precheck += progress.precheck_score precheck_count += 1 if progress.postcheck_score is not None: total_postcheck += progress.postcheck_score postcheck_count += 1 if progress.status != "not_started": started += 1 if progress.status == "completed": completed += 1 except Exception as e: logger.error(f"Failed to get student progress: {e}") student_progress.append(progress) total_students = len(students) or 1 # Avoid division by zero return ClassUnitProgress( assignment_id=assignment_id, unit_id=assignment["unit_id"], unit_title=f"Unit {assignment['unit_id']}", # Would load from unit definition class_id=assignment["class_id"], class_name=f"Class {assignment['class_id'][:8]}", # Would load from school service total_students=len(students), started_count=started, completed_count=completed, avg_completion_rate=total_completion / total_students, avg_precheck_score=total_precheck / precheck_count if precheck_count > 0 else None, avg_postcheck_score=total_postcheck / postcheck_count if postcheck_count > 0 else None, avg_learning_gain=(total_postcheck / postcheck_count - total_precheck / precheck_count) if precheck_count > 0 and postcheck_count > 0 else None, avg_time_minutes=total_time / started if started > 0 else 0, students=student_progress, ) @router.get("/classes/{class_id}/analytics", response_model=ClassAnalyticsSummary) async def get_class_analytics( class_id: str, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> ClassAnalyticsSummary: """ Get summary analytics for a class. Includes all unit assignments, overall progress, and common misconceptions. """ db = await get_teacher_database() # Get all assignments for this class assignments = [] if db: try: assignments = await db.list_assignments( teacher_id=teacher["user_id"], class_id=class_id ) except Exception as e: logger.error(f"Failed to list assignments: {e}") if not assignments: assignments = [ a for a in _assignments_store.values() if a["class_id"] == class_id and a["teacher_id"] == teacher["user_id"] ] total_units = len(assignments) completed_units = sum(1 for a in assignments if a.get("status") == "completed") active_units = sum(1 for a in assignments if a.get("status") == "active") # Aggregate student performance students = await get_students_in_class(class_id) student_scores = {} misconceptions = [] if db: try: for student in students: student_id = student.get("id", student.get("student_id")) analytics = await db.get_student_analytics(student_id) if analytics: student_scores[student_id] = { "name": student.get("name", student_id[:8]), "avg_score": analytics.get("avg_postcheck_score", 0), "total_time": analytics.get("total_time_minutes", 0), } # Get common misconceptions misconceptions_data = await db.get_class_misconceptions(class_id) for m in misconceptions_data: misconceptions.append(MisconceptionReport( concept_id=m["concept_id"], concept_label=m["concept_label"], misconception=m["misconception"], affected_students=m["affected_students"], frequency=m["frequency"], unit_id=m["unit_id"], stop_id=m["stop_id"], )) except Exception as e: logger.error(f"Failed to aggregate analytics: {e}") # Identify top and struggling students sorted_students = sorted( student_scores.items(), key=lambda x: x[1]["avg_score"], reverse=True ) top_performers = [s[1]["name"] for s in sorted_students[:3]] struggling_students = [s[1]["name"] for s in sorted_students[-3:] if s[1]["avg_score"] < 0.6] total_time = sum(s["total_time"] for s in student_scores.values()) avg_scores = [s["avg_score"] for s in student_scores.values() if s["avg_score"] > 0] avg_completion = sum(avg_scores) / len(avg_scores) if avg_scores else 0 return ClassAnalyticsSummary( class_id=class_id, class_name=f"Klasse {class_id[:8]}", total_units_assigned=total_units, units_completed=completed_units, active_units=active_units, avg_completion_rate=avg_completion, avg_learning_gain=None, # Would calculate from pre/post scores total_time_hours=total_time / 60, top_performers=top_performers, struggling_students=struggling_students, common_misconceptions=misconceptions[:5], ) @router.get("/students/{student_id}/progress") async def get_student_progress( student_id: str, teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> Dict[str, Any]: """ Get detailed progress for a specific student. Shows all units attempted and their performance. """ db = await get_teacher_database() if db: try: progress = await db.get_student_full_progress(student_id) return progress except Exception as e: logger.error(f"Failed to get student progress: {e}") return { "student_id": student_id, "units_attempted": 0, "units_completed": 0, "avg_score": 0.0, "total_time_minutes": 0, "sessions": [], } # ============================================== # API Endpoints - Content Resources # ============================================== @router.get("/assignments/{assignment_id}/resources", response_model=List[ContentResource]) async def get_assignment_resources( assignment_id: str, teacher: Dict[str, Any] = Depends(get_current_teacher), request: Request = None ) -> List[ContentResource]: """ Get generated content resources for an assignment. Returns links to H5P activities and PDF worksheets. """ db = await get_teacher_database() assignment = None if db: try: assignment = await db.get_assignment(assignment_id) except Exception as e: logger.error(f"Failed to get assignment: {e}") if not assignment and assignment_id in _assignments_store: assignment = _assignments_store[assignment_id] if not assignment or assignment["teacher_id"] != teacher["user_id"]: raise HTTPException(status_code=404, detail="Assignment not found") unit_id = assignment["unit_id"] base_url = str(request.base_url).rstrip("/") if request else "http://localhost:8000" resources = [ ContentResource( resource_type="h5p", title=f"{unit_id} - H5P Aktivitaeten", url=f"{base_url}/api/units/content/{unit_id}/h5p", generated_at=datetime.utcnow(), unit_id=unit_id, ), ContentResource( resource_type="worksheet", title=f"{unit_id} - Arbeitsblatt (HTML)", url=f"{base_url}/api/units/content/{unit_id}/worksheet", generated_at=datetime.utcnow(), unit_id=unit_id, ), ContentResource( resource_type="pdf", title=f"{unit_id} - Arbeitsblatt (PDF)", url=f"{base_url}/api/units/content/{unit_id}/worksheet.pdf", generated_at=datetime.utcnow(), unit_id=unit_id, ), ] return resources @router.post("/assignments/{assignment_id}/regenerate-content") async def regenerate_content( assignment_id: str, resource_type: str = Query("all", description="h5p, pdf, or all"), teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> Dict[str, Any]: """ Trigger regeneration of content resources. Useful after updating unit definitions. """ db = await get_teacher_database() assignment = None if db: try: assignment = await db.get_assignment(assignment_id) except Exception as e: logger.error(f"Failed to get assignment: {e}") if not assignment and assignment_id in _assignments_store: assignment = _assignments_store[assignment_id] if not assignment or assignment["teacher_id"] != teacher["user_id"]: raise HTTPException(status_code=404, detail="Assignment not found") # In production, this would trigger async job to regenerate content logger.info(f"Content regeneration triggered for {assignment['unit_id']}: {resource_type}") return { "status": "queued", "assignment_id": assignment_id, "unit_id": assignment["unit_id"], "resource_type": resource_type, "message": "Content regeneration has been queued", } # ============================================== # API Endpoints - Available Units # ============================================== @router.get("/units/available") async def list_available_units( grade: Optional[str] = Query(None, description="Filter by grade level"), template: Optional[str] = Query(None, description="Filter by template type"), locale: str = Query("de-DE", description="Locale"), teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> List[Dict[str, Any]]: """ List all available units for assignment. Teachers see all published units matching their criteria. """ db = await get_teacher_database() if db: try: units = await db.list_available_units( grade=grade, template=template, locale=locale ) return units except Exception as e: logger.error(f"Failed to list units: {e}") # Fallback: return demo units return [ { "unit_id": "bio_eye_lightpath_v1", "title": "Auge - Lichtstrahl-Flug", "template": "flight_path", "grade_band": ["5", "6", "7"], "duration_minutes": 8, "difficulty": "base", "description": "Reise durch das Auge und folge dem Lichtstrahl", "learning_objectives": [ "Verstehen des Lichtwegs durch das Auge", "Funktionen der Augenbestandteile benennen", ], }, { "unit_id": "math_pizza_equivalence_v1", "title": "Pizza-Boxenstopp - Brueche und Prozent", "template": "station_loop", "grade_band": ["5", "6"], "duration_minutes": 10, "difficulty": "base", "description": "Entdecke die Verbindung zwischen Bruechen, Dezimalzahlen und Prozent", "learning_objectives": [ "Brueche in Prozent umrechnen", "Aequivalenzen erkennen", ], }, ] # ============================================== # API Endpoints - Dashboard Overview # ============================================== @router.get("/dashboard") async def get_dashboard( teacher: Dict[str, Any] = Depends(get_current_teacher) ) -> Dict[str, Any]: """ Get teacher dashboard overview. Summary of all classes, active assignments, and alerts. """ db = await get_teacher_database() # Get teacher's classes classes = await get_classes_for_teacher(teacher["user_id"]) # Get all active assignments active_assignments = [] if db: try: active_assignments = await db.list_assignments( teacher_id=teacher["user_id"], status="active" ) except Exception as e: logger.error(f"Failed to list assignments: {e}") if not active_assignments: active_assignments = [ a for a in _assignments_store.values() if a["teacher_id"] == teacher["user_id"] and a.get("status") == "active" ] # Calculate alerts (students falling behind, due dates, etc.) alerts = [] for assignment in active_assignments: if assignment.get("due_date") and assignment["due_date"] < datetime.utcnow() + timedelta(days=2): alerts.append({ "type": "due_soon", "assignment_id": assignment["assignment_id"], "message": f"Zuweisung endet in weniger als 2 Tagen", }) return { "teacher": { "id": teacher["user_id"], "name": teacher.get("name", "Lehrer"), "email": teacher.get("email"), }, "classes": len(classes), "active_assignments": len(active_assignments), "total_students": sum(c.get("student_count", 0) for c in classes), "alerts": alerts, "recent_activity": [], # Would load recent session completions } @router.get("/health") async def health_check() -> Dict[str, Any]: """Health check for teacher dashboard API.""" db = await get_teacher_database() db_status = "connected" if db else "in-memory" return { "status": "healthy", "service": "teacher-dashboard", "database": db_status, "auth_required": REQUIRE_AUTH, }