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>
495 lines
16 KiB
Python
495 lines
16 KiB
Python
# ==============================================
|
|
# 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,
|
|
}
|