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>
952 lines
32 KiB
Python
952 lines
32 KiB
Python
# ==============================================
|
|
# 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,
|
|
}
|