Files
breakpilot-lehrer/backend-lehrer/game_routes.py
Benjamin Admin 6811264756 [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>
2026-04-24 23:17:30 +02:00

297 lines
9.5 KiB
Python

# ==============================================
# 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!"
}