[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:
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