[split-required] Split final batch of monoliths >1000 LOC
Python (6 files in klausur-service): - rbac.py (1,132 → 4), admin_api.py (1,012 → 4) - routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5) Python (2 files in backend-lehrer): - unit_api.py (1,226 → 6), game_api.py (1,129 → 5) Website (6 page files): - 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components in website/components/klausur-korrektur/ (17 shared files) - companion (1,057 → 10), magic-help (1,017 → 8) All re-export barrels preserve backward compatibility. Zero import errors verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
189
backend-lehrer/game_extended_routes.py
Normal file
189
backend-lehrer/game_extended_routes.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Game Extended Routes
|
||||
# ==============================================
|
||||
# Phase 5 features: achievements, progress, parent dashboard,
|
||||
# class leaderboard, and display leaderboard.
|
||||
# Extracted from game_api.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from game_routes import (
|
||||
get_optional_current_user,
|
||||
get_user_id_from_auth,
|
||||
get_game_database,
|
||||
REQUIRE_AUTH,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Phase 5: Erweiterte Features
|
||||
# ==============================================
|
||||
|
||||
@router.get("/achievements/{user_id}")
|
||||
async def get_achievements(
|
||||
user_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck.
|
||||
|
||||
Achievements werden basierend auf Spielstatistiken berechnet.
|
||||
"""
|
||||
# Verify access rights
|
||||
user_id = get_user_id_from_auth(user, user_id)
|
||||
|
||||
db = await get_game_database()
|
||||
if not db:
|
||||
return {"achievements": [], "message": "Database not available"}
|
||||
|
||||
try:
|
||||
achievements = await db.get_student_achievements(user_id)
|
||||
|
||||
unlocked = [a for a in achievements if a.unlocked]
|
||||
locked = [a for a in achievements if not a.unlocked]
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"total": len(achievements),
|
||||
"unlocked_count": len(unlocked),
|
||||
"achievements": [
|
||||
{
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"icon": a.icon,
|
||||
"category": a.category,
|
||||
"threshold": a.threshold,
|
||||
"progress": a.progress,
|
||||
"unlocked": a.unlocked,
|
||||
}
|
||||
for a in achievements
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get achievements: {e}")
|
||||
return {"achievements": [], "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/progress/{user_id}")
|
||||
async def get_progress(
|
||||
user_id: str,
|
||||
days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts).
|
||||
|
||||
- Taegliche Statistiken
|
||||
- Fuer Eltern-Dashboard und Fortschrittsanzeige
|
||||
"""
|
||||
# Verify access rights
|
||||
user_id = get_user_id_from_auth(user, user_id)
|
||||
|
||||
db = await get_game_database()
|
||||
if not db:
|
||||
return {"progress": [], "message": "Database not available"}
|
||||
|
||||
try:
|
||||
progress = await db.get_progress_over_time(user_id, days)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"days": days,
|
||||
"data_points": len(progress),
|
||||
"progress": progress,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get progress: {e}")
|
||||
return {"progress": [], "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/parent/children")
|
||||
async def get_children_dashboard(
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Eltern-Dashboard: Statistiken fuer alle Kinder.
|
||||
|
||||
Erfordert Auth mit Eltern-Rolle und children_ids Claim.
|
||||
"""
|
||||
if not REQUIRE_AUTH or user is None:
|
||||
return {
|
||||
"message": "Auth required for parent dashboard",
|
||||
"children": []
|
||||
}
|
||||
|
||||
# Get children IDs from token
|
||||
children_ids = user.get("raw_claims", {}).get("children_ids", [])
|
||||
|
||||
if not children_ids:
|
||||
return {
|
||||
"message": "No children associated with this account",
|
||||
"children": []
|
||||
}
|
||||
|
||||
db = await get_game_database()
|
||||
if not db:
|
||||
return {"children": [], "message": "Database not available"}
|
||||
|
||||
try:
|
||||
children_stats = await db.get_children_stats(children_ids)
|
||||
return {
|
||||
"parent_id": user.get("user_id"),
|
||||
"children_count": len(children_ids),
|
||||
"children": children_stats,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get children stats: {e}")
|
||||
return {"children": [], "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/leaderboard/class/{class_id}")
|
||||
async def get_class_leaderboard(
|
||||
class_id: str,
|
||||
timeframe: str = Query("week", description="day, week, month, all"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Klassenspezifische Rangliste.
|
||||
|
||||
Nur fuer Lehrer oder Schueler der Klasse sichtbar.
|
||||
"""
|
||||
db = await get_game_database()
|
||||
if not db:
|
||||
return []
|
||||
|
||||
try:
|
||||
leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit)
|
||||
return leaderboard
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get class leaderboard: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/leaderboard/display")
|
||||
async def get_display_leaderboard(
|
||||
timeframe: str = Query("day", description="day, week, month, all"),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
anonymize: bool = Query(True, description="Namen anonymisieren")
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Oeffentliche Rangliste mit Anzeigenamen.
|
||||
|
||||
Standardmaessig anonymisiert fuer Datenschutz.
|
||||
"""
|
||||
db = await get_game_database()
|
||||
if not db:
|
||||
return []
|
||||
|
||||
try:
|
||||
return await db.get_leaderboard_with_names(timeframe, limit, anonymize)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get display leaderboard: {e}")
|
||||
return []
|
||||
322
backend-lehrer/game_models.py
Normal file
322
backend-lehrer/game_models.py
Normal file
@@ -0,0 +1,322 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Game API Models & Data
|
||||
# ==============================================
|
||||
# Pydantic models, difficulty mappings, and sample questions.
|
||||
# Extracted from game_api.py for file-size compliance.
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Literal, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Pydantic Models
|
||||
# ==============================================
|
||||
|
||||
class LearningLevel(BaseModel):
|
||||
"""Lernniveau eines Benutzers aus dem Breakpilot-System"""
|
||||
user_id: str
|
||||
overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6)
|
||||
math_level: float
|
||||
german_level: float
|
||||
english_level: float
|
||||
last_updated: datetime
|
||||
|
||||
|
||||
class GameDifficulty(BaseModel):
|
||||
"""Spielschwierigkeit basierend auf Lernniveau"""
|
||||
lane_speed: float # Geschwindigkeit in m/s
|
||||
obstacle_frequency: float # Hindernisse pro Sekunde
|
||||
power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1)
|
||||
question_complexity: int # 1-5
|
||||
answer_time: int # Sekunden zum Antworten
|
||||
hints_enabled: bool
|
||||
speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version
|
||||
|
||||
|
||||
class QuizQuestion(BaseModel):
|
||||
"""Quiz-Frage fuer das Spiel"""
|
||||
id: str
|
||||
question_text: str
|
||||
audio_url: Optional[str] = None
|
||||
options: List[str] # 2-4 Antwortmoeglichkeiten
|
||||
correct_index: int # 0-3
|
||||
difficulty: int # 1-5
|
||||
subject: Literal["math", "german", "english", "general"]
|
||||
grade_level: Optional[int] = None # 2-6
|
||||
# NEU: Quiz-Modus
|
||||
quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an
|
||||
visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus
|
||||
time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick)
|
||||
|
||||
|
||||
class QuizAnswer(BaseModel):
|
||||
"""Antwort auf eine Quiz-Frage"""
|
||||
question_id: str
|
||||
selected_index: int
|
||||
answer_time_ms: int # Zeit bis zur Antwort in ms
|
||||
was_correct: bool
|
||||
|
||||
|
||||
class GameSession(BaseModel):
|
||||
"""Spielsession-Daten fuer Analytics"""
|
||||
user_id: str
|
||||
game_mode: Literal["video", "audio"]
|
||||
duration_seconds: int
|
||||
distance_traveled: float
|
||||
score: int
|
||||
questions_answered: int
|
||||
questions_correct: int
|
||||
difficulty_level: int
|
||||
quiz_answers: Optional[List[QuizAnswer]] = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Antwort nach Session-Speicherung"""
|
||||
session_id: str
|
||||
status: str
|
||||
new_level: Optional[int] = None # Falls Lernniveau angepasst wurde
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Schwierigkeits-Mapping
|
||||
# ==============================================
|
||||
|
||||
DIFFICULTY_MAPPING = {
|
||||
1: GameDifficulty(
|
||||
lane_speed=3.0,
|
||||
obstacle_frequency=0.3,
|
||||
power_up_chance=0.4,
|
||||
question_complexity=1,
|
||||
answer_time=15,
|
||||
hints_enabled=True,
|
||||
speech_speed=0.8
|
||||
),
|
||||
2: GameDifficulty(
|
||||
lane_speed=4.0,
|
||||
obstacle_frequency=0.4,
|
||||
power_up_chance=0.35,
|
||||
question_complexity=2,
|
||||
answer_time=12,
|
||||
hints_enabled=True,
|
||||
speech_speed=0.9
|
||||
),
|
||||
3: GameDifficulty(
|
||||
lane_speed=5.0,
|
||||
obstacle_frequency=0.5,
|
||||
power_up_chance=0.3,
|
||||
question_complexity=3,
|
||||
answer_time=10,
|
||||
hints_enabled=True,
|
||||
speech_speed=1.0
|
||||
),
|
||||
4: GameDifficulty(
|
||||
lane_speed=6.0,
|
||||
obstacle_frequency=0.6,
|
||||
power_up_chance=0.25,
|
||||
question_complexity=4,
|
||||
answer_time=8,
|
||||
hints_enabled=False,
|
||||
speech_speed=1.1
|
||||
),
|
||||
5: GameDifficulty(
|
||||
lane_speed=7.0,
|
||||
obstacle_frequency=0.7,
|
||||
power_up_chance=0.2,
|
||||
question_complexity=5,
|
||||
answer_time=6,
|
||||
hints_enabled=False,
|
||||
speech_speed=1.2
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Beispiel Quiz-Fragen (spaeter aus DB laden)
|
||||
# ==============================================
|
||||
|
||||
SAMPLE_QUESTIONS = [
|
||||
# ==============================================
|
||||
# QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert)
|
||||
# ==============================================
|
||||
|
||||
# Englisch Vokabeln - Objekte im Spiel (QUICK MODE)
|
||||
QuizQuestion(
|
||||
id="vq-bridge", question_text="What is this?",
|
||||
options=["Bridge", "House"], correct_index=0,
|
||||
difficulty=1, subject="english", grade_level=3,
|
||||
quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0
|
||||
),
|
||||
QuizQuestion(
|
||||
id="vq-tree", question_text="What is this?",
|
||||
options=["Tree", "Flower"], correct_index=0,
|
||||
difficulty=1, subject="english", grade_level=3,
|
||||
quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0
|
||||
),
|
||||
QuizQuestion(
|
||||
id="vq-house", question_text="What is this?",
|
||||
options=["House", "Car"], correct_index=0,
|
||||
difficulty=1, subject="english", grade_level=3,
|
||||
quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0
|
||||
),
|
||||
QuizQuestion(
|
||||
id="vq-car", question_text="What is this?",
|
||||
options=["Car", "Bus"], correct_index=0,
|
||||
difficulty=1, subject="english", grade_level=3,
|
||||
quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5
|
||||
),
|
||||
QuizQuestion(
|
||||
id="vq-mountain", question_text="What is this?",
|
||||
options=["Hill", "Mountain", "Valley"], correct_index=1,
|
||||
difficulty=2, subject="english", grade_level=4,
|
||||
quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5
|
||||
),
|
||||
QuizQuestion(
|
||||
id="vq-river", question_text="What is this?",
|
||||
options=["Lake", "River", "Sea"], correct_index=1,
|
||||
difficulty=2, subject="english", grade_level=4,
|
||||
quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5
|
||||
),
|
||||
|
||||
# Schnelle Rechenaufgaben (QUICK MODE)
|
||||
QuizQuestion(
|
||||
id="mq-1", question_text="3 + 4 = ?",
|
||||
options=["6", "7"], correct_index=1,
|
||||
difficulty=1, subject="math", grade_level=2,
|
||||
quiz_mode="quick", time_limit_seconds=4.0
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mq-2", question_text="5 x 2 = ?",
|
||||
options=["10", "12"], correct_index=0,
|
||||
difficulty=1, subject="math", grade_level=2,
|
||||
quiz_mode="quick", time_limit_seconds=4.0
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mq-3", question_text="8 - 3 = ?",
|
||||
options=["4", "5"], correct_index=1,
|
||||
difficulty=1, subject="math", grade_level=2,
|
||||
quiz_mode="quick", time_limit_seconds=3.5
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mq-4", question_text="6 x 7 = ?",
|
||||
options=["42", "48"], correct_index=0,
|
||||
difficulty=2, subject="math", grade_level=3,
|
||||
quiz_mode="quick", time_limit_seconds=5.0
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mq-5", question_text="9 x 8 = ?",
|
||||
options=["72", "64"], correct_index=0,
|
||||
difficulty=3, subject="math", grade_level=4,
|
||||
quiz_mode="quick", time_limit_seconds=5.0
|
||||
),
|
||||
|
||||
# ==============================================
|
||||
# PAUSE QUESTIONS (Spiel haelt an, mehr Zeit)
|
||||
# ==============================================
|
||||
|
||||
# Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE
|
||||
QuizQuestion(
|
||||
id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?",
|
||||
options=["6", "7", "8", "9"], correct_index=2,
|
||||
difficulty=1, subject="math", grade_level=2,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?",
|
||||
options=["4", "5", "6", "7"], correct_index=2,
|
||||
difficulty=2, subject="math", grade_level=3,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mp2-2", question_text="Was ist 45 + 27?",
|
||||
options=["72", "62", "82", "70"], correct_index=0,
|
||||
difficulty=2, subject="math", grade_level=3,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
|
||||
# Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE
|
||||
QuizQuestion(
|
||||
id="mp3-1", question_text="Was ist 7 x 8?",
|
||||
options=["54", "56", "58", "48"], correct_index=1,
|
||||
difficulty=3, subject="math", grade_level=4,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?",
|
||||
options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1,
|
||||
difficulty=3, subject="math", grade_level=4,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mp4-1", question_text="Was ist 15% von 80?",
|
||||
options=["10", "12", "8", "15"], correct_index=1,
|
||||
difficulty=4, subject="math", grade_level=5,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mp4-2", question_text="Was ist 3/4 + 1/2?",
|
||||
options=["5/4", "4/6", "1", "5/6"], correct_index=0,
|
||||
difficulty=4, subject="math", grade_level=5,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
|
||||
# Mathe Level 5 (Klasse 6) - PAUSE MODE
|
||||
QuizQuestion(
|
||||
id="mp5-1", question_text="Was ist (-5) x (-3)?",
|
||||
options=["-15", "15", "-8", "8"], correct_index=1,
|
||||
difficulty=5, subject="math", grade_level=6,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="mp5-2", question_text="Loesung von 2x + 5 = 11?",
|
||||
options=["2", "3", "4", "6"], correct_index=1,
|
||||
difficulty=5, subject="math", grade_level=6,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
|
||||
# Deutsch - PAUSE MODE (brauchen Lesezeit)
|
||||
QuizQuestion(
|
||||
id="dp1-1", question_text="Welches Wort ist ein Nomen?",
|
||||
options=["laufen", "schnell", "Hund", "und"], correct_index=2,
|
||||
difficulty=1, subject="german", grade_level=2,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?",
|
||||
options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1,
|
||||
difficulty=2, subject="german", grade_level=3,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="dp3-1", question_text="Welches Verb steht im Praeteritum?",
|
||||
options=["geht", "ging", "gegangen", "gehen"], correct_index=1,
|
||||
difficulty=3, subject="german", grade_level=4,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'",
|
||||
options=["Hund", "leuft", "schnell", "Der"], correct_index=1,
|
||||
difficulty=3, subject="german", grade_level=4,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
|
||||
# Englisch Saetze - PAUSE MODE
|
||||
QuizQuestion(
|
||||
id="ep3-1", question_text="How do you say 'Schmetterling'?",
|
||||
options=["bird", "bee", "butterfly", "beetle"], correct_index=2,
|
||||
difficulty=3, subject="english", grade_level=4,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="ep4-1", question_text="Choose the correct form: She ___ to school.",
|
||||
options=["go", "goes", "going", "gone"], correct_index=1,
|
||||
difficulty=4, subject="english", grade_level=5,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
QuizQuestion(
|
||||
id="ep4-2", question_text="What is the past tense of 'run'?",
|
||||
options=["runned", "ran", "runed", "running"], correct_index=1,
|
||||
difficulty=4, subject="english", grade_level=5,
|
||||
quiz_mode="pause"
|
||||
),
|
||||
]
|
||||
296
backend-lehrer/game_routes.py
Normal file
296
backend-lehrer/game_routes.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Game API Core Routes
|
||||
# ==============================================
|
||||
# Core game endpoints: learning level, difficulty, quiz questions.
|
||||
# Session/stats/leaderboard routes are in game_session_routes.py.
|
||||
# Extracted from game_api.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
import uuid
|
||||
import os
|
||||
import logging
|
||||
|
||||
from game_models import (
|
||||
LearningLevel,
|
||||
GameDifficulty,
|
||||
QuizQuestion,
|
||||
QuizAnswer,
|
||||
GameSession,
|
||||
SessionResponse,
|
||||
DIFFICULTY_MAPPING,
|
||||
SAMPLE_QUESTIONS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature flags
|
||||
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
|
||||
REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true"
|
||||
|
||||
router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Auth Dependency (Optional)
|
||||
# ==============================================
|
||||
|
||||
async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Optional auth dependency for Game API.
|
||||
|
||||
If GAME_REQUIRE_AUTH=true: Requires valid JWT token
|
||||
If GAME_REQUIRE_AUTH=false: Returns None (anonymous access)
|
||||
|
||||
In development mode without auth, returns demo user.
|
||||
"""
|
||||
if not REQUIRE_AUTH:
|
||||
return None
|
||||
|
||||
try:
|
||||
from auth import get_current_user
|
||||
return await get_current_user(request)
|
||||
except ImportError:
|
||||
logger.warning("Auth module not available")
|
||||
return None
|
||||
except HTTPException:
|
||||
raise # Re-raise auth errors
|
||||
except Exception as e:
|
||||
logger.error(f"Auth error: {e}")
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
|
||||
def get_user_id_from_auth(
|
||||
user: Optional[Dict[str, Any]],
|
||||
requested_user_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Get the effective user ID, respecting auth when enabled.
|
||||
|
||||
If auth is enabled and user is authenticated:
|
||||
- Returns user's own ID if requested_user_id matches
|
||||
- For parents: allows access to child IDs from token
|
||||
- For teachers: allows access to student IDs (future)
|
||||
|
||||
If auth is disabled: Returns requested_user_id as-is
|
||||
"""
|
||||
if not REQUIRE_AUTH or user is None:
|
||||
return requested_user_id
|
||||
|
||||
user_id = user.get("user_id", "")
|
||||
|
||||
# Same user - always allowed
|
||||
if requested_user_id == user_id:
|
||||
return user_id
|
||||
|
||||
# Check for parent accessing child data
|
||||
children_ids = user.get("raw_claims", {}).get("children_ids", [])
|
||||
if requested_user_id in children_ids:
|
||||
return requested_user_id
|
||||
|
||||
# Check for teacher accessing student data (future)
|
||||
realm_roles = user.get("realm_roles", [])
|
||||
if "lehrer" in realm_roles or "teacher" in realm_roles:
|
||||
# Teachers can access any student in their class (implement class check later)
|
||||
return requested_user_id
|
||||
|
||||
# Admin bypass
|
||||
if "admin" in realm_roles:
|
||||
return requested_user_id
|
||||
|
||||
# Not authorized
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this user's data"
|
||||
)
|
||||
|
||||
|
||||
# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar)
|
||||
_sessions: dict[str, GameSession] = {}
|
||||
_user_levels: dict[str, LearningLevel] = {}
|
||||
|
||||
# Database integration
|
||||
_game_db = None
|
||||
|
||||
async def get_game_database():
|
||||
"""Get game database instance with lazy initialization."""
|
||||
global _game_db
|
||||
if not USE_DATABASE:
|
||||
return None
|
||||
if _game_db is None:
|
||||
try:
|
||||
from game.database import get_game_db
|
||||
_game_db = await get_game_db()
|
||||
logger.info("Game database initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Game database not available, using in-memory: {e}")
|
||||
return _game_db
|
||||
|
||||
|
||||
# ==============================================
|
||||
# API Endpunkte
|
||||
# ==============================================
|
||||
|
||||
@router.get("/learning-level/{user_id}", response_model=LearningLevel)
|
||||
async def get_learning_level(
|
||||
user_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> LearningLevel:
|
||||
"""
|
||||
Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot.
|
||||
|
||||
- Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen
|
||||
- Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten)
|
||||
- Cached Werte fuer schnellen Zugriff
|
||||
- Speichert in PostgreSQL wenn verfuegbar
|
||||
- Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten
|
||||
"""
|
||||
# Verify access rights
|
||||
user_id = get_user_id_from_auth(user, user_id)
|
||||
|
||||
# Try database first
|
||||
db = await get_game_database()
|
||||
if db:
|
||||
state = await db.get_learning_state(user_id)
|
||||
if state:
|
||||
return LearningLevel(
|
||||
user_id=user_id,
|
||||
overall_level=state.overall_level,
|
||||
math_level=state.math_level,
|
||||
german_level=state.german_level,
|
||||
english_level=state.english_level,
|
||||
last_updated=state.updated_at or datetime.now()
|
||||
)
|
||||
|
||||
# Create new state in database
|
||||
new_state = await db.create_or_update_learning_state(
|
||||
student_id=user_id,
|
||||
overall_level=3,
|
||||
math_level=3.0,
|
||||
german_level=3.0,
|
||||
english_level=3.0
|
||||
)
|
||||
if new_state:
|
||||
return LearningLevel(
|
||||
user_id=user_id,
|
||||
overall_level=new_state.overall_level,
|
||||
math_level=new_state.math_level,
|
||||
german_level=new_state.german_level,
|
||||
english_level=new_state.english_level,
|
||||
last_updated=new_state.updated_at or datetime.now()
|
||||
)
|
||||
|
||||
# Fallback to in-memory
|
||||
if user_id in _user_levels:
|
||||
return _user_levels[user_id]
|
||||
|
||||
# Standard-Level fuer neue Benutzer
|
||||
default_level = LearningLevel(
|
||||
user_id=user_id,
|
||||
overall_level=3, # Mittleres Level als Default
|
||||
math_level=3.0,
|
||||
german_level=3.0,
|
||||
english_level=3.0,
|
||||
last_updated=datetime.now()
|
||||
)
|
||||
_user_levels[user_id] = default_level
|
||||
return default_level
|
||||
|
||||
|
||||
@router.get("/difficulty/{level}", response_model=GameDifficulty)
|
||||
async def get_game_difficulty(level: int) -> GameDifficulty:
|
||||
"""
|
||||
Gibt Spielparameter basierend auf Lernniveau zurueck.
|
||||
|
||||
Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz,
|
||||
Fragen-Schwierigkeit etc. gemappt.
|
||||
"""
|
||||
if level < 1 or level > 5:
|
||||
raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein")
|
||||
|
||||
return DIFFICULTY_MAPPING[level]
|
||||
|
||||
|
||||
@router.get("/quiz/questions", response_model=List[QuizQuestion])
|
||||
async def get_quiz_questions(
|
||||
difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"),
|
||||
count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"),
|
||||
subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"),
|
||||
mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide")
|
||||
) -> List[QuizQuestion]:
|
||||
"""
|
||||
Holt Quiz-Fragen fuer das Spiel.
|
||||
|
||||
- Filtert nach Schwierigkeitsgrad (+/- 1 Level)
|
||||
- Optional nach Fach filterbar
|
||||
- Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben)
|
||||
- Gibt zufaellige Auswahl zurueck
|
||||
"""
|
||||
# Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz)
|
||||
filtered = [
|
||||
q for q in SAMPLE_QUESTIONS
|
||||
if abs(q.difficulty - difficulty) <= 1
|
||||
and (subject is None or q.subject == subject)
|
||||
and (mode is None or q.quiz_mode == mode)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
# Fallback: Alle Fragen wenn keine passenden gefunden
|
||||
filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode]
|
||||
|
||||
# Zufaellige Auswahl
|
||||
selected = random.sample(filtered, min(count, len(filtered)))
|
||||
return selected
|
||||
|
||||
|
||||
@router.get("/quiz/visual-triggers")
|
||||
async def get_visual_triggers() -> List[dict]:
|
||||
"""
|
||||
Gibt alle verfuegbaren visuellen Trigger zurueck.
|
||||
|
||||
Unity verwendet diese Liste um zu wissen, welche Objekte
|
||||
im Spiel Quiz-Fragen ausloesen koennen.
|
||||
"""
|
||||
triggers = {}
|
||||
for q in SAMPLE_QUESTIONS:
|
||||
if q.visual_trigger and q.quiz_mode == "quick":
|
||||
if q.visual_trigger not in triggers:
|
||||
triggers[q.visual_trigger] = {
|
||||
"trigger": q.visual_trigger,
|
||||
"question_count": 0,
|
||||
"difficulties": set(),
|
||||
"subjects": set()
|
||||
}
|
||||
triggers[q.visual_trigger]["question_count"] += 1
|
||||
triggers[q.visual_trigger]["difficulties"].add(q.difficulty)
|
||||
triggers[q.visual_trigger]["subjects"].add(q.subject)
|
||||
|
||||
# Sets zu Listen konvertieren fuer JSON
|
||||
return [
|
||||
{
|
||||
"trigger": t["trigger"],
|
||||
"question_count": t["question_count"],
|
||||
"difficulties": list(t["difficulties"]),
|
||||
"subjects": list(t["subjects"])
|
||||
}
|
||||
for t in triggers.values()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/quiz/answer")
|
||||
async def submit_quiz_answer(answer: QuizAnswer) -> dict:
|
||||
"""
|
||||
Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback).
|
||||
|
||||
In der finalen Version: Speichert in Session, updated Analytics.
|
||||
"""
|
||||
return {
|
||||
"question_id": answer.question_id,
|
||||
"was_correct": answer.was_correct,
|
||||
"points": 500 if answer.was_correct else -100,
|
||||
"message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!"
|
||||
}
|
||||
|
||||
|
||||
395
backend-lehrer/game_session_routes.py
Normal file
395
backend-lehrer/game_session_routes.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Game Session & Stats Routes
|
||||
# ==============================================
|
||||
# Session saving, leaderboard, stats, suggestions,
|
||||
# quiz generation, and health check.
|
||||
# Extracted from game_routes.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from game_models import (
|
||||
LearningLevel,
|
||||
QuizQuestion,
|
||||
GameSession,
|
||||
SessionResponse,
|
||||
SAMPLE_QUESTIONS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import shared state and helpers from game_routes
|
||||
# (these are the canonical instances)
|
||||
from game_routes import (
|
||||
get_optional_current_user,
|
||||
get_user_id_from_auth,
|
||||
get_game_database,
|
||||
get_quiz_questions,
|
||||
_sessions,
|
||||
_user_levels,
|
||||
REQUIRE_AUTH,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"])
|
||||
|
||||
|
||||
@router.post("/session", response_model=SessionResponse)
|
||||
async def save_game_session(
|
||||
session: GameSession,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Speichert eine komplette Spielsession.
|
||||
|
||||
- Protokolliert Score, Distanz, Fragen-Performance
|
||||
- Aktualisiert Lernniveau bei genuegend Daten
|
||||
- Wird am Ende jedes Spiels aufgerufen
|
||||
- Speichert in PostgreSQL wenn verfuegbar
|
||||
- Bei GAME_REQUIRE_AUTH=true: User-ID aus Token
|
||||
"""
|
||||
# If auth is enabled, use user_id from token (ignore session.user_id)
|
||||
effective_user_id = session.user_id
|
||||
if REQUIRE_AUTH and user:
|
||||
effective_user_id = user.get("user_id", session.user_id)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# Lernniveau-Anpassung basierend auf Performance
|
||||
new_level = None
|
||||
old_level = 3 # Default
|
||||
|
||||
# Try to get current level first
|
||||
db = await get_game_database()
|
||||
if db:
|
||||
state = await db.get_learning_state(effective_user_id)
|
||||
if state:
|
||||
old_level = state.overall_level
|
||||
else:
|
||||
# Create initial state if not exists
|
||||
await db.create_or_update_learning_state(effective_user_id)
|
||||
old_level = 3
|
||||
elif effective_user_id in _user_levels:
|
||||
old_level = _user_levels[effective_user_id].overall_level
|
||||
|
||||
# Calculate level adjustment
|
||||
if session.questions_answered >= 5:
|
||||
accuracy = session.questions_correct / session.questions_answered
|
||||
|
||||
# Anpassung: Wenn >80% korrekt und max nicht erreicht -> Level up
|
||||
if accuracy >= 0.8 and old_level < 5:
|
||||
new_level = old_level + 1
|
||||
# Wenn <40% korrekt und min nicht erreicht -> Level down
|
||||
elif accuracy < 0.4 and old_level > 1:
|
||||
new_level = old_level - 1
|
||||
|
||||
# Save to database
|
||||
if db:
|
||||
# Save session
|
||||
db_session_id = await db.save_game_session(
|
||||
student_id=effective_user_id,
|
||||
game_mode=session.game_mode,
|
||||
duration_seconds=session.duration_seconds,
|
||||
distance_traveled=session.distance_traveled,
|
||||
score=session.score,
|
||||
questions_answered=session.questions_answered,
|
||||
questions_correct=session.questions_correct,
|
||||
difficulty_level=session.difficulty_level,
|
||||
)
|
||||
if db_session_id:
|
||||
session_id = db_session_id
|
||||
|
||||
# Save individual quiz answers if provided
|
||||
if session.quiz_answers:
|
||||
for answer in session.quiz_answers:
|
||||
await db.save_quiz_answer(
|
||||
session_id=session_id,
|
||||
question_id=answer.question_id,
|
||||
subject="general", # Could be enhanced to track actual subject
|
||||
difficulty=session.difficulty_level,
|
||||
is_correct=answer.was_correct,
|
||||
answer_time_ms=answer.answer_time_ms,
|
||||
)
|
||||
|
||||
# Update learning stats
|
||||
duration_minutes = session.duration_seconds // 60
|
||||
await db.update_learning_stats(
|
||||
student_id=effective_user_id,
|
||||
duration_minutes=duration_minutes,
|
||||
questions_answered=session.questions_answered,
|
||||
questions_correct=session.questions_correct,
|
||||
new_level=new_level,
|
||||
)
|
||||
else:
|
||||
# Fallback to in-memory
|
||||
_sessions[session_id] = session
|
||||
|
||||
if new_level:
|
||||
_user_levels[effective_user_id] = LearningLevel(
|
||||
user_id=effective_user_id,
|
||||
overall_level=new_level,
|
||||
math_level=float(new_level),
|
||||
german_level=float(new_level),
|
||||
english_level=float(new_level),
|
||||
last_updated=datetime.now()
|
||||
)
|
||||
|
||||
return SessionResponse(
|
||||
session_id=session_id,
|
||||
status="saved",
|
||||
new_level=new_level
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{user_id}")
|
||||
async def get_user_sessions(
|
||||
user_id: str,
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Holt die letzten Spielsessions eines Benutzers.
|
||||
|
||||
Fuer Statistiken und Fortschrittsanzeige.
|
||||
Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten.
|
||||
"""
|
||||
# Verify access rights
|
||||
user_id = get_user_id_from_auth(user, user_id)
|
||||
|
||||
# Try database first
|
||||
db = await get_game_database()
|
||||
if db:
|
||||
sessions = await db.get_user_sessions(user_id, limit)
|
||||
if sessions:
|
||||
return sessions
|
||||
|
||||
# Fallback to in-memory
|
||||
user_sessions = [
|
||||
{"session_id": sid, **s.model_dump()}
|
||||
for sid, s in _sessions.items()
|
||||
if s.user_id == user_id
|
||||
]
|
||||
return user_sessions[:limit]
|
||||
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def get_leaderboard(
|
||||
timeframe: str = Query("day", description="day, week, month, all"),
|
||||
limit: int = Query(10, ge=1, le=100)
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Gibt Highscore-Liste zurueck.
|
||||
|
||||
- Sortiert nach Punktzahl
|
||||
- Optional nach Zeitraum filterbar
|
||||
"""
|
||||
# Try database first
|
||||
db = await get_game_database()
|
||||
if db:
|
||||
leaderboard = await db.get_leaderboard(timeframe, limit)
|
||||
if leaderboard:
|
||||
return leaderboard
|
||||
|
||||
# Fallback to in-memory
|
||||
# Aggregiere Scores pro User
|
||||
user_scores: dict[str, int] = {}
|
||||
for session in _sessions.values():
|
||||
if session.user_id not in user_scores:
|
||||
user_scores[session.user_id] = 0
|
||||
user_scores[session.user_id] += session.score
|
||||
|
||||
# Sortieren und limitieren
|
||||
leaderboard = [
|
||||
{"rank": i + 1, "user_id": uid, "total_score": score}
|
||||
for i, (uid, score) in enumerate(
|
||||
sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit]
|
||||
)
|
||||
]
|
||||
|
||||
return leaderboard
|
||||
|
||||
|
||||
@router.get("/stats/{user_id}")
|
||||
async def get_user_stats(
|
||||
user_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Gibt detaillierte Statistiken fuer einen Benutzer zurueck.
|
||||
|
||||
- Gesamtstatistiken
|
||||
- Fach-spezifische Statistiken
|
||||
- Lernniveau-Verlauf
|
||||
- Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten
|
||||
"""
|
||||
# Verify access rights
|
||||
user_id = get_user_id_from_auth(user, user_id)
|
||||
|
||||
db = await get_game_database()
|
||||
if db:
|
||||
state = await db.get_learning_state(user_id)
|
||||
subject_stats = await db.get_subject_stats(user_id)
|
||||
|
||||
if state:
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall_level": state.overall_level,
|
||||
"math_level": state.math_level,
|
||||
"german_level": state.german_level,
|
||||
"english_level": state.english_level,
|
||||
"total_play_time_minutes": state.total_play_time_minutes,
|
||||
"total_sessions": state.total_sessions,
|
||||
"questions_answered": state.questions_answered,
|
||||
"questions_correct": state.questions_correct,
|
||||
"accuracy": state.accuracy,
|
||||
"subjects": subject_stats,
|
||||
}
|
||||
|
||||
# Fallback - return defaults
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall_level": 3,
|
||||
"math_level": 3.0,
|
||||
"german_level": 3.0,
|
||||
"english_level": 3.0,
|
||||
"total_play_time_minutes": 0,
|
||||
"total_sessions": 0,
|
||||
"questions_answered": 0,
|
||||
"questions_correct": 0,
|
||||
"accuracy": 0.0,
|
||||
"subjects": {},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/suggestions/{user_id}")
|
||||
async def get_learning_suggestions(
|
||||
user_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck.
|
||||
|
||||
Basierend auf aktueller Performance und Lernhistorie.
|
||||
Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten.
|
||||
"""
|
||||
# Verify access rights
|
||||
user_id = get_user_id_from_auth(user, user_id)
|
||||
|
||||
db = await get_game_database()
|
||||
if not db:
|
||||
return {"suggestions": [], "message": "Database not available"}
|
||||
|
||||
state = await db.get_learning_state(user_id)
|
||||
if not state:
|
||||
return {"suggestions": [], "message": "No learning state found"}
|
||||
|
||||
try:
|
||||
from game.learning_rules import (
|
||||
LearningContext,
|
||||
get_rule_engine,
|
||||
)
|
||||
|
||||
# Create context from state
|
||||
context = LearningContext.from_learning_state(state)
|
||||
|
||||
# Get suggestions from rule engine
|
||||
engine = get_rule_engine()
|
||||
suggestions = engine.evaluate(context)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall_level": state.overall_level,
|
||||
"suggestions": [
|
||||
{
|
||||
"title": s.title,
|
||||
"description": s.description,
|
||||
"action": s.action.value,
|
||||
"priority": s.priority.name.lower(),
|
||||
"metadata": s.metadata or {},
|
||||
}
|
||||
for s in suggestions[:3] # Top 3 suggestions
|
||||
]
|
||||
}
|
||||
except ImportError:
|
||||
return {"suggestions": [], "message": "Learning rules not available"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get suggestions: {e}")
|
||||
return {"suggestions": [], "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/quiz/generate")
|
||||
async def generate_quiz_questions(
|
||||
difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"),
|
||||
count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"),
|
||||
subject: Optional[str] = Query(None, description="Fach: math, german, english"),
|
||||
mode: str = Query("quick", description="Quiz-Modus: quick oder pause"),
|
||||
visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.")
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Generiert Quiz-Fragen dynamisch via LLM.
|
||||
|
||||
Fallback auf statische Fragen wenn LLM nicht verfuegbar.
|
||||
"""
|
||||
try:
|
||||
from game.quiz_generator import get_quiz_generator
|
||||
|
||||
generator = await get_quiz_generator()
|
||||
questions = await generator.get_questions(
|
||||
difficulty=difficulty,
|
||||
subject=subject or "general",
|
||||
mode=mode,
|
||||
count=count,
|
||||
visual_trigger=visual_trigger
|
||||
)
|
||||
|
||||
if questions:
|
||||
return [
|
||||
{
|
||||
"id": f"gen-{i}",
|
||||
"question_text": q.question_text,
|
||||
"options": q.options,
|
||||
"correct_index": q.correct_index,
|
||||
"difficulty": q.difficulty,
|
||||
"subject": q.subject,
|
||||
"grade_level": q.grade_level,
|
||||
"quiz_mode": q.quiz_mode,
|
||||
"visual_trigger": q.visual_trigger,
|
||||
"time_limit_seconds": q.time_limit_seconds,
|
||||
}
|
||||
for i, q in enumerate(questions)
|
||||
]
|
||||
except ImportError:
|
||||
logger.info("Quiz generator not available, using static questions")
|
||||
except Exception as e:
|
||||
logger.warning(f"Quiz generation failed: {e}")
|
||||
|
||||
# Fallback to static questions
|
||||
return await get_quiz_questions(difficulty, count, subject, mode)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict:
|
||||
"""Health-Check fuer das Spiel-Backend."""
|
||||
db = await get_game_database()
|
||||
db_status = "connected" if db and db._connected else "disconnected"
|
||||
|
||||
# Check LLM availability
|
||||
llm_status = "disabled"
|
||||
try:
|
||||
from game.quiz_generator import get_quiz_generator
|
||||
generator = await get_quiz_generator()
|
||||
llm_status = "connected" if generator._llm_available else "disconnected"
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "breakpilot-drive",
|
||||
"database": db_status,
|
||||
"llm_generator": llm_status,
|
||||
"auth_required": REQUIRE_AUTH,
|
||||
"questions_available": len(SAMPLE_QUESTIONS),
|
||||
"active_sessions": len(_sessions)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
160
backend-lehrer/unit_content_routes.py
Normal file
160
backend-lehrer/unit_content_routes.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit Content Generation Routes
|
||||
# ==============================================
|
||||
# API endpoints for H5P content, worksheets, and PDF generation.
|
||||
# Extracted from unit_api.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from unit_models import UnitDefinitionResponse
|
||||
from unit_helpers import get_optional_current_user, get_unit_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
|
||||
|
||||
|
||||
@router.get("/content/{unit_id}/h5p")
|
||||
async def generate_h5p_content(
|
||||
unit_id: str,
|
||||
locale: str = Query("de-DE", description="Target locale"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate H5P content items for a unit.
|
||||
|
||||
Returns H5P-compatible content structures for:
|
||||
- Drag and Drop (vocabulary matching)
|
||||
- Fill in the Blanks (concept texts)
|
||||
- Multiple Choice (misconception targeting)
|
||||
"""
|
||||
from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest
|
||||
|
||||
# Get unit definition
|
||||
db = await get_unit_database()
|
||||
unit_def = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
unit = await db.get_unit_definition(unit_id)
|
||||
if unit:
|
||||
unit_def = unit.get("definition", {})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get unit for H5P generation: {e}")
|
||||
|
||||
if not unit_def:
|
||||
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
|
||||
|
||||
try:
|
||||
generator = H5PGenerator(locale=locale)
|
||||
contents = generator.generate_from_unit(unit_def)
|
||||
manifest = generate_h5p_manifest(contents, unit_id)
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"locale": locale,
|
||||
"generated_count": len(contents),
|
||||
"manifest": manifest,
|
||||
"contents": [c.to_h5p_structure() for c in contents]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"H5P generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/content/{unit_id}/worksheet")
|
||||
async def generate_worksheet_html(
|
||||
unit_id: str,
|
||||
locale: str = Query("de-DE", description="Target locale"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate worksheet HTML for a unit.
|
||||
|
||||
Returns HTML that can be:
|
||||
- Displayed in browser
|
||||
- Converted to PDF using weasyprint
|
||||
- Printed directly
|
||||
"""
|
||||
from content_generators import PDFGenerator
|
||||
|
||||
# Get unit definition
|
||||
db = await get_unit_database()
|
||||
unit_def = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
unit = await db.get_unit_definition(unit_id)
|
||||
if unit:
|
||||
unit_def = unit.get("definition", {})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get unit for worksheet generation: {e}")
|
||||
|
||||
if not unit_def:
|
||||
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
|
||||
|
||||
try:
|
||||
generator = PDFGenerator(locale=locale)
|
||||
worksheet = generator.generate_from_unit(unit_def)
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"locale": locale,
|
||||
"title": worksheet.title,
|
||||
"sections": len(worksheet.sections),
|
||||
"html": worksheet.to_html()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Worksheet generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/content/{unit_id}/worksheet.pdf")
|
||||
async def download_worksheet_pdf(
|
||||
unit_id: str,
|
||||
locale: str = Query("de-DE", description="Target locale"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
):
|
||||
"""
|
||||
Generate and download worksheet as PDF.
|
||||
|
||||
Requires weasyprint to be installed on the server.
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
# Get unit definition
|
||||
db = await get_unit_database()
|
||||
unit_def = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
unit = await db.get_unit_definition(unit_id)
|
||||
if unit:
|
||||
unit_def = unit.get("definition", {})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get unit for PDF generation: {e}")
|
||||
|
||||
if not unit_def:
|
||||
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
|
||||
|
||||
try:
|
||||
from content_generators import generate_worksheet_pdf
|
||||
pdf_bytes = generate_worksheet_pdf(unit_def, locale)
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"'
|
||||
}
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="PDF generation not available. Install weasyprint: pip install weasyprint"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"PDF generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}")
|
||||
301
backend-lehrer/unit_definition_routes.py
Normal file
301
backend-lehrer/unit_definition_routes.py
Normal file
@@ -0,0 +1,301 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit Definition CRUD Routes
|
||||
# ==============================================
|
||||
# Endpoints for creating, updating, deleting, and validating
|
||||
# unit definitions. Extracted from unit_routes.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from unit_models import (
|
||||
UnitDefinitionResponse,
|
||||
CreateUnitRequest,
|
||||
UpdateUnitRequest,
|
||||
ValidationResult,
|
||||
)
|
||||
from unit_helpers import (
|
||||
get_optional_current_user,
|
||||
get_unit_database,
|
||||
validate_unit_definition,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
|
||||
|
||||
|
||||
@router.post("/definitions", response_model=UnitDefinitionResponse)
|
||||
async def create_unit_definition(
|
||||
request_data: CreateUnitRequest,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> UnitDefinitionResponse:
|
||||
"""
|
||||
Create a new unit definition.
|
||||
|
||||
- Validates unit structure
|
||||
- Saves to database or JSON file
|
||||
- Returns created unit
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Build full definition
|
||||
definition = {
|
||||
"unit_id": request_data.unit_id,
|
||||
"template": request_data.template,
|
||||
"version": request_data.version,
|
||||
"locale": request_data.locale,
|
||||
"grade_band": request_data.grade_band,
|
||||
"duration_minutes": request_data.duration_minutes,
|
||||
"difficulty": request_data.difficulty,
|
||||
"subject": request_data.subject,
|
||||
"topic": request_data.topic,
|
||||
"learning_objectives": request_data.learning_objectives,
|
||||
"stops": request_data.stops,
|
||||
"precheck": request_data.precheck or {
|
||||
"question_set_id": f"{request_data.unit_id}_precheck",
|
||||
"required": True,
|
||||
"time_limit_seconds": 120
|
||||
},
|
||||
"postcheck": request_data.postcheck or {
|
||||
"question_set_id": f"{request_data.unit_id}_postcheck",
|
||||
"required": True,
|
||||
"time_limit_seconds": 180
|
||||
},
|
||||
"teacher_controls": request_data.teacher_controls or {
|
||||
"allow_skip": True,
|
||||
"allow_replay": True,
|
||||
"max_time_per_stop_sec": 90,
|
||||
"show_hints": True,
|
||||
"require_precheck": True,
|
||||
"require_postcheck": True
|
||||
},
|
||||
"assets": request_data.assets or {},
|
||||
"metadata": request_data.metadata or {
|
||||
"author": user.get("email", "Unknown") if user else "Unknown",
|
||||
"created": datetime.utcnow().isoformat(),
|
||||
"curriculum_reference": ""
|
||||
}
|
||||
}
|
||||
|
||||
# Validate
|
||||
validation = validate_unit_definition(definition)
|
||||
if not validation.valid:
|
||||
error_msgs = [f"{e.field}: {e.message}" for e in validation.errors]
|
||||
raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}")
|
||||
|
||||
# Check if unit_id already exists
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
existing = await db.get_unit_definition(request_data.unit_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
|
||||
|
||||
# Save to database
|
||||
await db.create_unit_definition(
|
||||
unit_id=request_data.unit_id,
|
||||
template=request_data.template,
|
||||
version=request_data.version,
|
||||
locale=request_data.locale,
|
||||
grade_band=request_data.grade_band,
|
||||
duration_minutes=request_data.duration_minutes,
|
||||
difficulty=request_data.difficulty,
|
||||
definition=definition,
|
||||
status=request_data.status
|
||||
)
|
||||
logger.info(f"Unit created in database: {request_data.unit_id}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Database save failed, using JSON fallback: {e}")
|
||||
# Fallback to JSON
|
||||
units_dir = Path(__file__).parent / "data" / "units"
|
||||
units_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = units_dir / f"{request_data.unit_id}.json"
|
||||
if json_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Unit created as JSON: {json_path}")
|
||||
else:
|
||||
# JSON only mode
|
||||
units_dir = Path(__file__).parent / "data" / "units"
|
||||
units_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = units_dir / f"{request_data.unit_id}.json"
|
||||
if json_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Unit created as JSON: {json_path}")
|
||||
|
||||
return UnitDefinitionResponse(
|
||||
unit_id=request_data.unit_id,
|
||||
template=request_data.template,
|
||||
version=request_data.version,
|
||||
locale=request_data.locale,
|
||||
grade_band=request_data.grade_band,
|
||||
duration_minutes=request_data.duration_minutes,
|
||||
difficulty=request_data.difficulty,
|
||||
definition=definition
|
||||
)
|
||||
|
||||
|
||||
@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse)
|
||||
async def update_unit_definition(
|
||||
unit_id: str,
|
||||
request_data: UpdateUnitRequest,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> UnitDefinitionResponse:
|
||||
"""
|
||||
Update an existing unit definition.
|
||||
|
||||
- Merges updates with existing definition
|
||||
- Re-validates
|
||||
- Saves updated version
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Get existing unit
|
||||
db = await get_unit_database()
|
||||
existing = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
existing = await db.get_unit_definition(unit_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Database read failed: {e}")
|
||||
|
||||
if not existing:
|
||||
# Try JSON file
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
if json_path.exists():
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
file_data = json.load(f)
|
||||
existing = {
|
||||
"unit_id": file_data.get("unit_id"),
|
||||
"template": file_data.get("template"),
|
||||
"version": file_data.get("version", "1.0.0"),
|
||||
"locale": file_data.get("locale", ["de-DE"]),
|
||||
"grade_band": file_data.get("grade_band", []),
|
||||
"duration_minutes": file_data.get("duration_minutes", 8),
|
||||
"difficulty": file_data.get("difficulty", "base"),
|
||||
"definition": file_data
|
||||
}
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}")
|
||||
|
||||
# Merge updates into existing definition
|
||||
definition = existing.get("definition", {})
|
||||
update_dict = request_data.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_dict.items():
|
||||
if value is not None:
|
||||
definition[key] = value
|
||||
|
||||
# Validate updated definition
|
||||
validation = validate_unit_definition(definition)
|
||||
if not validation.valid:
|
||||
error_msgs = [f"{e.field}: {e.message}" for e in validation.errors]
|
||||
raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}")
|
||||
|
||||
# Save
|
||||
if db:
|
||||
try:
|
||||
await db.update_unit_definition(
|
||||
unit_id=unit_id,
|
||||
version=definition.get("version"),
|
||||
locale=definition.get("locale"),
|
||||
grade_band=definition.get("grade_band"),
|
||||
duration_minutes=definition.get("duration_minutes"),
|
||||
difficulty=definition.get("difficulty"),
|
||||
definition=definition,
|
||||
status=update_dict.get("status")
|
||||
)
|
||||
logger.info(f"Unit updated in database: {unit_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Database update failed, using JSON: {e}")
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Unit updated as JSON: {json_path}")
|
||||
|
||||
return UnitDefinitionResponse(
|
||||
unit_id=unit_id,
|
||||
template=definition.get("template", existing.get("template")),
|
||||
version=definition.get("version", existing.get("version", "1.0.0")),
|
||||
locale=definition.get("locale", existing.get("locale", ["de-DE"])),
|
||||
grade_band=definition.get("grade_band", existing.get("grade_band", [])),
|
||||
duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)),
|
||||
difficulty=definition.get("difficulty", existing.get("difficulty", "base")),
|
||||
definition=definition
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/definitions/{unit_id}")
|
||||
async def delete_unit_definition(
|
||||
unit_id: str,
|
||||
force: bool = Query(False, description="Force delete even if published"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a unit definition.
|
||||
|
||||
- By default, only drafts can be deleted
|
||||
- Use force=true to delete published units
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
db = await get_unit_database()
|
||||
deleted = False
|
||||
|
||||
if db:
|
||||
try:
|
||||
existing = await db.get_unit_definition(unit_id)
|
||||
if existing:
|
||||
status = existing.get("status", "draft")
|
||||
if status == "published" and not force:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true."
|
||||
)
|
||||
await db.delete_unit_definition(unit_id)
|
||||
deleted = True
|
||||
logger.info(f"Unit deleted from database: {unit_id}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Database delete failed: {e}")
|
||||
|
||||
# Also check JSON file
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
if json_path.exists():
|
||||
json_path.unlink()
|
||||
deleted = True
|
||||
logger.info(f"Unit JSON deleted: {json_path}")
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}")
|
||||
|
||||
return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"}
|
||||
|
||||
|
||||
@router.post("/definitions/validate", response_model=ValidationResult)
|
||||
async def validate_unit(
|
||||
unit_data: Dict[str, Any],
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validate a unit definition without saving.
|
||||
|
||||
Returns validation result with errors and warnings.
|
||||
"""
|
||||
return validate_unit_definition(unit_data)
|
||||
204
backend-lehrer/unit_helpers.py
Normal file
204
backend-lehrer/unit_helpers.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit API Helpers
|
||||
# ==============================================
|
||||
# Auth, database, token, and validation helpers for the Unit API.
|
||||
# Extracted from unit_api.py for file-size compliance.
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import logging
|
||||
import jwt
|
||||
|
||||
from unit_models import ValidationError, ValidationResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature flags
|
||||
USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true"
|
||||
REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true"
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Auth Dependency (reuse from game_api)
|
||||
# ==============================================
|
||||
|
||||
async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""Optional auth dependency for Unit API."""
|
||||
if not REQUIRE_AUTH:
|
||||
return None
|
||||
|
||||
try:
|
||||
from auth import get_current_user
|
||||
return await get_current_user(request)
|
||||
except ImportError:
|
||||
logger.warning("Auth module not available")
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Auth error: {e}")
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Database Integration
|
||||
# ==============================================
|
||||
|
||||
_unit_db = None
|
||||
|
||||
async def get_unit_database():
|
||||
"""Get unit database instance with lazy initialization."""
|
||||
global _unit_db
|
||||
if not USE_DATABASE:
|
||||
return None
|
||||
if _unit_db is None:
|
||||
try:
|
||||
from unit.database import get_unit_db
|
||||
_unit_db = await get_unit_db()
|
||||
logger.info("Unit database initialized")
|
||||
except ImportError:
|
||||
logger.warning("Unit database module not available")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unit database not available: {e}")
|
||||
return _unit_db
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Token Helpers
|
||||
# ==============================================
|
||||
|
||||
def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str:
|
||||
"""Create a JWT session token for telemetry authentication."""
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"student_id": student_id,
|
||||
"exp": datetime.utcnow() + timedelta(hours=expires_hours),
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify a session token and return payload."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""Extract and verify session from Authorization header."""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
token = auth_header[7:]
|
||||
return verify_session_token(token)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Validation
|
||||
# ==============================================
|
||||
|
||||
def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult:
|
||||
"""
|
||||
Validate a unit definition structure.
|
||||
|
||||
Returns validation result with errors and warnings.
|
||||
"""
|
||||
errors: List[ValidationError] = []
|
||||
warnings: List[ValidationError] = []
|
||||
|
||||
# Required fields
|
||||
if not unit_data.get("unit_id"):
|
||||
errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich"))
|
||||
|
||||
if not unit_data.get("template"):
|
||||
errors.append(ValidationError(field="template", message="template ist erforderlich"))
|
||||
elif unit_data["template"] not in ["flight_path", "station_loop"]:
|
||||
errors.append(ValidationError(
|
||||
field="template",
|
||||
message="template muss 'flight_path' oder 'station_loop' sein"
|
||||
))
|
||||
|
||||
# Validate stops
|
||||
stops = unit_data.get("stops", [])
|
||||
if not stops:
|
||||
errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich"))
|
||||
else:
|
||||
# Check minimum stops for flight_path
|
||||
if unit_data.get("template") == "flight_path" and len(stops) < 3:
|
||||
warnings.append(ValidationError(
|
||||
field="stops",
|
||||
message="FlightPath sollte mindestens 3 Stops haben",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Validate each stop
|
||||
stop_ids = set()
|
||||
for i, stop in enumerate(stops):
|
||||
if not stop.get("stop_id"):
|
||||
errors.append(ValidationError(
|
||||
field=f"stops[{i}].stop_id",
|
||||
message=f"Stop {i}: stop_id fehlt"
|
||||
))
|
||||
else:
|
||||
if stop["stop_id"] in stop_ids:
|
||||
errors.append(ValidationError(
|
||||
field=f"stops[{i}].stop_id",
|
||||
message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'"
|
||||
))
|
||||
stop_ids.add(stop["stop_id"])
|
||||
|
||||
# Check interaction type
|
||||
interaction = stop.get("interaction", {})
|
||||
if not interaction.get("type"):
|
||||
errors.append(ValidationError(
|
||||
field=f"stops[{i}].interaction.type",
|
||||
message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt"
|
||||
))
|
||||
elif interaction["type"] not in [
|
||||
"aim_and_pass", "slider_adjust", "slider_equivalence",
|
||||
"sequence_arrange", "toggle_switch", "drag_match",
|
||||
"error_find", "transfer_apply"
|
||||
]:
|
||||
warnings.append(ValidationError(
|
||||
field=f"stops[{i}].interaction.type",
|
||||
message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Check for label
|
||||
if not stop.get("label"):
|
||||
warnings.append(ValidationError(
|
||||
field=f"stops[{i}].label",
|
||||
message=f"Stop {stop.get('stop_id', i)}: Label fehlt",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Validate duration
|
||||
duration = unit_data.get("duration_minutes", 0)
|
||||
if duration < 3 or duration > 20:
|
||||
warnings.append(ValidationError(
|
||||
field="duration_minutes",
|
||||
message="Dauer sollte zwischen 3 und 20 Minuten liegen",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Validate difficulty
|
||||
if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]:
|
||||
warnings.append(ValidationError(
|
||||
field="difficulty",
|
||||
message="difficulty sollte 'base' oder 'advanced' sein",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
return ValidationResult(
|
||||
valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
149
backend-lehrer/unit_models.py
Normal file
149
backend-lehrer/unit_models.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit API Models
|
||||
# ==============================================
|
||||
# Pydantic models for the Unit API.
|
||||
# Extracted from unit_api.py for file-size compliance.
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UnitDefinitionResponse(BaseModel):
|
||||
"""Unit definition response"""
|
||||
unit_id: str
|
||||
template: str
|
||||
version: str
|
||||
locale: List[str]
|
||||
grade_band: List[str]
|
||||
duration_minutes: int
|
||||
difficulty: str
|
||||
definition: Dict[str, Any]
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
"""Request to create a unit session"""
|
||||
unit_id: str
|
||||
student_id: str
|
||||
locale: str = "de-DE"
|
||||
difficulty: str = "base"
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Response after creating a session"""
|
||||
session_id: str
|
||||
unit_definition_url: str
|
||||
session_token: str
|
||||
telemetry_endpoint: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class TelemetryEvent(BaseModel):
|
||||
"""Single telemetry event"""
|
||||
ts: Optional[str] = None
|
||||
type: str = Field(..., alias="type")
|
||||
stop_id: Optional[str] = None
|
||||
metrics: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class TelemetryPayload(BaseModel):
|
||||
"""Batch telemetry payload"""
|
||||
session_id: str
|
||||
events: List[TelemetryEvent]
|
||||
|
||||
|
||||
class TelemetryResponse(BaseModel):
|
||||
"""Response after receiving telemetry"""
|
||||
accepted: int
|
||||
|
||||
|
||||
class PostcheckAnswer(BaseModel):
|
||||
"""Single postcheck answer"""
|
||||
question_id: str
|
||||
answer: str
|
||||
|
||||
|
||||
class CompleteSessionRequest(BaseModel):
|
||||
"""Request to complete a session"""
|
||||
postcheck_answers: Optional[List[PostcheckAnswer]] = None
|
||||
|
||||
|
||||
class SessionSummaryResponse(BaseModel):
|
||||
"""Response with session summary"""
|
||||
summary: Dict[str, Any]
|
||||
next_recommendations: Dict[str, Any]
|
||||
|
||||
|
||||
class UnitListItem(BaseModel):
|
||||
"""Unit list item"""
|
||||
unit_id: str
|
||||
template: str
|
||||
difficulty: str
|
||||
duration_minutes: int
|
||||
locale: List[str]
|
||||
grade_band: List[str]
|
||||
|
||||
|
||||
class RecommendedUnit(BaseModel):
|
||||
"""Recommended unit with reason"""
|
||||
unit_id: str
|
||||
template: str
|
||||
difficulty: str
|
||||
reason: str
|
||||
|
||||
|
||||
class CreateUnitRequest(BaseModel):
|
||||
"""Request to create a new unit definition"""
|
||||
unit_id: str = Field(..., description="Unique unit identifier")
|
||||
template: str = Field(..., description="Template type: flight_path or station_loop")
|
||||
version: str = Field(default="1.0.0", description="Version string")
|
||||
locale: List[str] = Field(default=["de-DE"], description="Supported locales")
|
||||
grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels")
|
||||
duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration")
|
||||
difficulty: str = Field(default="base", description="Difficulty level: base or advanced")
|
||||
subject: Optional[str] = Field(default=None, description="Subject area")
|
||||
topic: Optional[str] = Field(default=None, description="Topic within subject")
|
||||
learning_objectives: List[str] = Field(default=[], description="Learning objectives")
|
||||
stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations")
|
||||
precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration")
|
||||
postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration")
|
||||
teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings")
|
||||
assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration")
|
||||
metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata")
|
||||
status: str = Field(default="draft", description="Publication status: draft or published")
|
||||
|
||||
|
||||
class UpdateUnitRequest(BaseModel):
|
||||
"""Request to update an existing unit definition"""
|
||||
version: Optional[str] = None
|
||||
locale: Optional[List[str]] = None
|
||||
grade_band: Optional[List[str]] = None
|
||||
duration_minutes: Optional[int] = Field(default=None, ge=3, le=20)
|
||||
difficulty: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
learning_objectives: Optional[List[str]] = None
|
||||
stops: Optional[List[Dict[str, Any]]] = None
|
||||
precheck: Optional[Dict[str, Any]] = None
|
||||
postcheck: Optional[Dict[str, Any]] = None
|
||||
teacher_controls: Optional[Dict[str, Any]] = None
|
||||
assets: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
"""Single validation error"""
|
||||
field: str
|
||||
message: str
|
||||
severity: str = "error" # error or warning
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
"""Result of unit validation"""
|
||||
valid: bool
|
||||
errors: List[ValidationError] = []
|
||||
warnings: List[ValidationError] = []
|
||||
494
backend-lehrer/unit_routes.py
Normal file
494
backend-lehrer/unit_routes.py
Normal file
@@ -0,0 +1,494 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit API Routes
|
||||
# ==============================================
|
||||
# Endpoints for listing/getting definitions, sessions, telemetry,
|
||||
# recommendations, and analytics.
|
||||
# CRUD definition routes are in unit_definition_routes.py.
|
||||
# Extracted from unit_api.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from unit_models import (
|
||||
UnitDefinitionResponse,
|
||||
CreateSessionRequest,
|
||||
SessionResponse,
|
||||
TelemetryPayload,
|
||||
TelemetryResponse,
|
||||
CompleteSessionRequest,
|
||||
SessionSummaryResponse,
|
||||
UnitListItem,
|
||||
RecommendedUnit,
|
||||
)
|
||||
from unit_helpers import (
|
||||
get_optional_current_user,
|
||||
get_unit_database,
|
||||
create_session_token,
|
||||
get_session_from_token,
|
||||
REQUIRE_AUTH,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Definition List/Get Endpoints
|
||||
# ==============================================
|
||||
|
||||
@router.get("/definitions", response_model=List[UnitListItem])
|
||||
async def list_unit_definitions(
|
||||
template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"),
|
||||
grade: Optional[str] = Query(None, description="Filter by grade level"),
|
||||
locale: str = Query("de-DE", description="Filter by locale"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> List[UnitListItem]:
|
||||
"""
|
||||
List available unit definitions.
|
||||
|
||||
Returns published units matching the filter criteria.
|
||||
"""
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
units = await db.list_units(
|
||||
template=template,
|
||||
grade=grade,
|
||||
locale=locale,
|
||||
published_only=True
|
||||
)
|
||||
return [
|
||||
UnitListItem(
|
||||
unit_id=u["unit_id"],
|
||||
template=u["template"],
|
||||
difficulty=u["difficulty"],
|
||||
duration_minutes=u["duration_minutes"],
|
||||
locale=u["locale"],
|
||||
grade_band=u["grade_band"],
|
||||
)
|
||||
for u in units
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list units: {e}")
|
||||
|
||||
# Fallback: return demo unit
|
||||
return [
|
||||
UnitListItem(
|
||||
unit_id="demo_unit_v1",
|
||||
template="flight_path",
|
||||
difficulty="base",
|
||||
duration_minutes=5,
|
||||
locale=["de-DE"],
|
||||
grade_band=["5", "6", "7"],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse)
|
||||
async def get_unit_definition(
|
||||
unit_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> UnitDefinitionResponse:
|
||||
"""
|
||||
Get a specific unit definition.
|
||||
|
||||
Returns the full unit configuration including stops, interactions, etc.
|
||||
"""
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
unit = await db.get_unit_definition(unit_id)
|
||||
if unit:
|
||||
return UnitDefinitionResponse(
|
||||
unit_id=unit["unit_id"],
|
||||
template=unit["template"],
|
||||
version=unit["version"],
|
||||
locale=unit["locale"],
|
||||
grade_band=unit["grade_band"],
|
||||
duration_minutes=unit["duration_minutes"],
|
||||
difficulty=unit["difficulty"],
|
||||
definition=unit["definition"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get unit definition: {e}")
|
||||
|
||||
# Demo unit fallback
|
||||
if unit_id == "demo_unit_v1":
|
||||
return UnitDefinitionResponse(
|
||||
unit_id="demo_unit_v1",
|
||||
template="flight_path",
|
||||
version="1.0.0",
|
||||
locale=["de-DE"],
|
||||
grade_band=["5", "6", "7"],
|
||||
duration_minutes=5,
|
||||
difficulty="base",
|
||||
definition={
|
||||
"unit_id": "demo_unit_v1",
|
||||
"template": "flight_path",
|
||||
"version": "1.0.0",
|
||||
"learning_objectives": ["Demo: Grundfunktion testen"],
|
||||
"stops": [
|
||||
{"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}},
|
||||
{"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}},
|
||||
{"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}},
|
||||
],
|
||||
"teacher_controls": {"allow_skip": True, "allow_replay": True},
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Session Endpoints
|
||||
# ==============================================
|
||||
|
||||
@router.post("/sessions", response_model=SessionResponse)
|
||||
async def create_unit_session(
|
||||
request_data: CreateSessionRequest,
|
||||
request: Request,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Create a new unit session.
|
||||
|
||||
- Validates unit exists
|
||||
- Creates session record
|
||||
- Returns session token for telemetry
|
||||
"""
|
||||
session_id = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + timedelta(hours=4)
|
||||
|
||||
# Validate unit exists
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
unit = await db.get_unit_definition(request_data.unit_id)
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}")
|
||||
|
||||
# Create session in database
|
||||
total_stops = len(unit.get("definition", {}).get("stops", []))
|
||||
await db.create_session(
|
||||
session_id=session_id,
|
||||
unit_id=request_data.unit_id,
|
||||
student_id=request_data.student_id,
|
||||
locale=request_data.locale,
|
||||
difficulty=request_data.difficulty,
|
||||
total_stops=total_stops,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create session: {e}")
|
||||
# Continue with in-memory fallback
|
||||
|
||||
# Create session token
|
||||
session_token = create_session_token(session_id, request_data.student_id)
|
||||
|
||||
# Build definition URL
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}"
|
||||
|
||||
return SessionResponse(
|
||||
session_id=session_id,
|
||||
unit_definition_url=definition_url,
|
||||
session_token=session_token,
|
||||
telemetry_endpoint="/api/units/telemetry",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/telemetry", response_model=TelemetryResponse)
|
||||
async def receive_telemetry(
|
||||
payload: TelemetryPayload,
|
||||
request: Request,
|
||||
) -> TelemetryResponse:
|
||||
"""
|
||||
Receive batched telemetry events from Unity client.
|
||||
|
||||
- Validates session token
|
||||
- Stores events in database
|
||||
- Returns count of accepted events
|
||||
"""
|
||||
# Verify session token
|
||||
session_data = await get_session_from_token(request)
|
||||
if session_data is None:
|
||||
# Allow without auth in dev mode
|
||||
if REQUIRE_AUTH:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired session token")
|
||||
logger.warning("Telemetry received without valid token (dev mode)")
|
||||
|
||||
# Verify session_id matches
|
||||
if session_data and session_data.get("session_id") != payload.session_id:
|
||||
raise HTTPException(status_code=403, detail="Session ID mismatch")
|
||||
|
||||
accepted = 0
|
||||
db = await get_unit_database()
|
||||
|
||||
for event in payload.events:
|
||||
try:
|
||||
# Set timestamp if not provided
|
||||
timestamp = event.ts or datetime.utcnow().isoformat()
|
||||
|
||||
if db:
|
||||
await db.store_telemetry_event(
|
||||
session_id=payload.session_id,
|
||||
event_type=event.type,
|
||||
stop_id=event.stop_id,
|
||||
timestamp=timestamp,
|
||||
metrics=event.metrics,
|
||||
)
|
||||
|
||||
accepted += 1
|
||||
logger.debug(f"Telemetry: {event.type} for session {payload.session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store telemetry event: {e}")
|
||||
|
||||
return TelemetryResponse(accepted=accepted)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse)
|
||||
async def complete_session(
|
||||
session_id: str,
|
||||
request_data: CompleteSessionRequest,
|
||||
request: Request,
|
||||
) -> SessionSummaryResponse:
|
||||
"""
|
||||
Complete a unit session.
|
||||
|
||||
- Processes postcheck answers if provided
|
||||
- Calculates learning gain
|
||||
- Returns summary and recommendations
|
||||
"""
|
||||
# Verify session token
|
||||
session_data = await get_session_from_token(request)
|
||||
if REQUIRE_AUTH and session_data is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired session token")
|
||||
|
||||
db = await get_unit_database()
|
||||
summary = {}
|
||||
recommendations = {}
|
||||
|
||||
if db:
|
||||
try:
|
||||
# Get session data
|
||||
session = await db.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
# Calculate postcheck score if answers provided
|
||||
postcheck_score = None
|
||||
if request_data.postcheck_answers:
|
||||
# Simple scoring: count correct answers
|
||||
# In production, would validate against question bank
|
||||
postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder
|
||||
postcheck_score = min(postcheck_score, 1.0)
|
||||
|
||||
# Complete session in database
|
||||
await db.complete_session(
|
||||
session_id=session_id,
|
||||
postcheck_score=postcheck_score,
|
||||
)
|
||||
|
||||
# Get updated session summary
|
||||
session = await db.get_session(session_id)
|
||||
|
||||
# Calculate learning gain
|
||||
pre_score = session.get("precheck_score")
|
||||
post_score = session.get("postcheck_score")
|
||||
learning_gain = None
|
||||
if pre_score is not None and post_score is not None:
|
||||
learning_gain = post_score - pre_score
|
||||
|
||||
summary = {
|
||||
"session_id": session_id,
|
||||
"unit_id": session.get("unit_id"),
|
||||
"duration_seconds": session.get("duration_seconds"),
|
||||
"completion_rate": session.get("completion_rate"),
|
||||
"precheck_score": pre_score,
|
||||
"postcheck_score": post_score,
|
||||
"pre_to_post_gain": learning_gain,
|
||||
"stops_completed": session.get("stops_completed"),
|
||||
"total_stops": session.get("total_stops"),
|
||||
}
|
||||
|
||||
# Get recommendations
|
||||
recommendations = await db.get_recommendations(
|
||||
student_id=session.get("student_id"),
|
||||
completed_unit_id=session.get("unit_id"),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to complete session: {e}")
|
||||
summary = {"session_id": session_id, "error": str(e)}
|
||||
|
||||
else:
|
||||
# Fallback summary
|
||||
summary = {
|
||||
"session_id": session_id,
|
||||
"duration_seconds": 0,
|
||||
"completion_rate": 1.0,
|
||||
"message": "Database not available",
|
||||
}
|
||||
|
||||
return SessionSummaryResponse(
|
||||
summary=summary,
|
||||
next_recommendations=recommendations or {
|
||||
"h5p_activity_ids": [],
|
||||
"worksheet_pdf_url": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get session details.
|
||||
|
||||
Returns current state of a session including progress.
|
||||
"""
|
||||
# Verify session token
|
||||
session_data = await get_session_from_token(request)
|
||||
if REQUIRE_AUTH and session_data is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired session token")
|
||||
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
session = await db.get_session(session_id)
|
||||
if session:
|
||||
return session
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get session: {e}")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# Recommendations & Analytics
|
||||
# ==============================================
|
||||
|
||||
@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit])
|
||||
async def get_recommendations(
|
||||
student_id: str,
|
||||
grade: Optional[str] = Query(None, description="Grade level filter"),
|
||||
locale: str = Query("de-DE", description="Locale filter"),
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> List[RecommendedUnit]:
|
||||
"""
|
||||
Get recommended units for a student.
|
||||
|
||||
Based on completion status and performance.
|
||||
"""
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
recommendations = await db.get_student_recommendations(
|
||||
student_id=student_id,
|
||||
grade=grade,
|
||||
locale=locale,
|
||||
limit=limit,
|
||||
)
|
||||
return [
|
||||
RecommendedUnit(
|
||||
unit_id=r["unit_id"],
|
||||
template=r["template"],
|
||||
difficulty=r["difficulty"],
|
||||
reason=r["reason"],
|
||||
)
|
||||
for r in recommendations
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recommendations: {e}")
|
||||
|
||||
# Fallback: recommend demo unit
|
||||
return [
|
||||
RecommendedUnit(
|
||||
unit_id="demo_unit_v1",
|
||||
template="flight_path",
|
||||
difficulty="base",
|
||||
reason="Neu: Noch nicht gespielt",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/analytics/student/{student_id}")
|
||||
async def get_student_analytics(
|
||||
student_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get unit analytics for a student.
|
||||
|
||||
Includes completion rates, learning gains, time spent.
|
||||
"""
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
analytics = await db.get_student_unit_analytics(student_id)
|
||||
return analytics
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get analytics: {e}")
|
||||
|
||||
return {
|
||||
"student_id": student_id,
|
||||
"units_attempted": 0,
|
||||
"units_completed": 0,
|
||||
"avg_completion_rate": 0.0,
|
||||
"avg_learning_gain": None,
|
||||
"total_minutes": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/unit/{unit_id}")
|
||||
async def get_unit_analytics(
|
||||
unit_id: str,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get analytics for a specific unit.
|
||||
|
||||
Shows aggregate performance across all students.
|
||||
"""
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
analytics = await db.get_unit_performance(unit_id)
|
||||
return analytics
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get unit analytics: {e}")
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"total_sessions": 0,
|
||||
"completed_sessions": 0,
|
||||
"completion_percent": 0.0,
|
||||
"avg_duration_minutes": 0,
|
||||
"avg_learning_gain": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""Health check for unit API."""
|
||||
db = await get_unit_database()
|
||||
db_status = "connected" if db else "disconnected"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "breakpilot-units",
|
||||
"database": db_status,
|
||||
"auth_required": REQUIRE_AUTH,
|
||||
}
|
||||
Reference in New Issue
Block a user