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>
297 lines
9.5 KiB
Python
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!"
|
|
}
|
|
|
|
|