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>
205 lines
7.1 KiB
Python
205 lines
7.1 KiB
Python
# ==============================================
|
|
# 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
|
|
)
|