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>
227 lines
6.7 KiB
Python
227 lines
6.7 KiB
Python
"""
|
|
Teacher Dashboard - Pydantic Models, Auth Dependency, and Service Helpers.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
from enum import Enum
|
|
|
|
from fastapi import HTTPException, Request
|
|
from pydantic import BaseModel
|
|
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")
|
|
|
|
|
|
# ==============================================
|
|
# 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:
|
|
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 []
|