backend-lehrer (5 files): - alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3) - teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3) - mail/mail_db.py (987 → 6) klausur-service (5 files): - legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4) - ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2) - KorrekturPage.tsx (956 → 6) website (5 pages): - mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7) - ocr-labeling (946 → 7), audit-workspace (871 → 4) studio-v2 (5 files + 1 deleted): - page.tsx (946 → 5), MessagesContext.tsx (925 → 4) - korrektur (914 → 6), worksheet-cleanup (899 → 6) - useVocabWorksheet.ts (888 → 3) - Deleted dead page-original.tsx (934 LOC) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
268 lines
12 KiB
Python
268 lines
12 KiB
Python
# ==============================================
|
|
# Teacher Dashboard - Analytics & Progress Routes
|
|
# ==============================================
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
|
|
from teacher_dashboard_models import (
|
|
UnitAssignmentStatus, TeacherControlSettings,
|
|
UnitAssignment, StudentUnitProgress, ClassUnitProgress,
|
|
MisconceptionReport, ClassAnalyticsSummary, ContentResource,
|
|
get_current_teacher, get_teacher_database,
|
|
get_classes_for_teacher, get_students_in_class,
|
|
REQUIRE_AUTH,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["Teacher Dashboard"])
|
|
|
|
# Shared in-memory store reference (set from teacher_dashboard_api)
|
|
_assignments_store: Dict[str, Dict[str, Any]] = {}
|
|
|
|
|
|
def set_assignments_store(store: Dict[str, Dict[str, Any]]):
|
|
"""Share the in-memory assignments store from the main module."""
|
|
global _assignments_store
|
|
_assignments_store = store
|
|
|
|
|
|
# ==============================================
|
|
# 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."""
|
|
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")
|
|
|
|
students = await get_students_in_class(assignment["class_id"])
|
|
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
|
|
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
|
|
return ClassUnitProgress(
|
|
assignment_id=assignment_id, unit_id=assignment["unit_id"],
|
|
unit_title=f"Unit {assignment['unit_id']}", class_id=assignment["class_id"],
|
|
class_name=f"Class {assignment['class_id'][:8]}", 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."""
|
|
db = await get_teacher_database()
|
|
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")
|
|
|
|
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),
|
|
}
|
|
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}")
|
|
|
|
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, 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."""
|
|
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."""
|
|
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"
|
|
return [
|
|
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),
|
|
]
|
|
|
|
|
|
@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."""
|
|
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")
|
|
|
|
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",
|
|
}
|