Restructure: Move final 16 root files into packages (backend-lehrer)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s

classroom/ (+2): state_engine_api, state_engine_models
vocabulary/ (2): api, db
worksheets/ (2): api, models
services/  (+6): audio, email, translation, claude_vision, ai_processor, story_generator
api/        (4): school, klausur_proxy, progress, user_language

Only main.py + config.py remain at root. 16 shims added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 22:50:37 +02:00
parent 6be555fb7c
commit cba877c65a
36 changed files with 3712 additions and 3564 deletions

View File

@@ -0,0 +1,6 @@
# API Module — thin proxy routers and standalone API endpoints
#
# api/school.py — Proxy to Go school-service
# api/klausur_proxy.py — Proxy to klausur-service
# api/progress.py — Student learning progress tracking
# api/user_language.py — User language preferences

View File

@@ -0,0 +1,135 @@
"""
Klausur-Service API Proxy
Routes API requests from /api/klausur/* to the klausur-service microservice
"""
import os
import jwt
import datetime
import httpx
from fastapi import APIRouter, Request, HTTPException, Response
# Klausur Service URL
KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086")
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
# Demo teacher UUID for development mode
DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
router = APIRouter(prefix="/klausur", tags=["klausur"])
def get_demo_token() -> str:
"""Generate a demo JWT token for development mode"""
payload = {
"user_id": DEMO_TEACHER_ID,
"email": "demo@breakpilot.app",
"role": "admin",
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
"iat": datetime.datetime.now(datetime.timezone.utc)
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
async def proxy_request(request: Request, path: str) -> Response:
"""Forward a request to the klausur service"""
url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}"
# Forward headers, especially Authorization
headers = {}
if "authorization" in request.headers:
headers["Authorization"] = request.headers["authorization"]
elif ENVIRONMENT == "development":
# In development mode, use demo token if no auth provided
demo_token = get_demo_token()
headers["Authorization"] = f"Bearer {demo_token}"
if "content-type" in request.headers:
headers["Content-Type"] = request.headers["content-type"]
# Get request body for POST/PUT/PATCH/DELETE
body = None
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
body = await request.body()
async with httpx.AsyncClient(timeout=60.0) as client:
try:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=request.query_params
)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type", "application/json")
)
except httpx.ConnectError:
raise HTTPException(
status_code=503,
detail="Klausur service unavailable"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="Klausur service timeout"
)
# Health check
@router.get("/health")
async def health():
"""Health check for klausur service connection"""
async with httpx.AsyncClient(timeout=5.0) as client:
try:
response = await client.get(f"{KLAUSUR_SERVICE_URL}/health")
return {"klausur_service": "healthy", "connected": response.status_code == 200}
except Exception:
return {"klausur_service": "unhealthy", "connected": False}
# Klausuren
@router.api_route("/klausuren", methods=["GET", "POST"])
async def klausuren(request: Request):
return await proxy_request(request, "/klausuren")
@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"])
async def klausur_by_id(klausur_id: str, request: Request):
return await proxy_request(request, f"/klausuren/{klausur_id}")
# Students
@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"])
async def klausur_students(klausur_id: str, request: Request):
return await proxy_request(request, f"/klausuren/{klausur_id}/students")
@router.api_route("/students/{student_id}", methods=["GET", "DELETE"])
async def student_by_id(student_id: str, request: Request):
return await proxy_request(request, f"/students/{student_id}")
# Grading
@router.api_route("/students/{student_id}/criteria", methods=["PUT"])
async def update_criteria(student_id: str, request: Request):
return await proxy_request(request, f"/students/{student_id}/criteria")
@router.api_route("/students/{student_id}/gutachten", methods=["PUT"])
async def update_gutachten(student_id: str, request: Request):
return await proxy_request(request, f"/students/{student_id}/gutachten")
@router.api_route("/students/{student_id}/finalize", methods=["POST"])
async def finalize_student(student_id: str, request: Request):
return await proxy_request(request, f"/students/{student_id}/finalize")
# Grade info
@router.get("/grade-info")
async def grade_info(request: Request):
return await proxy_request(request, "/grade-info")

View File

@@ -0,0 +1,131 @@
"""
Progress API — Tracks student learning progress per unit.
Stores coins, crowns, streak data, and exercise completion stats.
Uses JSON file storage (same pattern as learning_units.py).
"""
import os
import json
import logging
from datetime import datetime, date
from typing import Dict, Any, Optional, List
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/progress",
tags=["progress"],
)
PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress")
def _ensure_dir():
os.makedirs(PROGRESS_DIR, exist_ok=True)
def _progress_path(unit_id: str) -> Path:
return Path(PROGRESS_DIR) / f"{unit_id}.json"
def _load_progress(unit_id: str) -> Dict[str, Any]:
path = _progress_path(unit_id)
if path.exists():
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return {
"unit_id": unit_id,
"coins": 0,
"crowns": 0,
"streak_days": 0,
"last_activity": None,
"exercises": {
"flashcards": {"completed": 0, "correct": 0, "incorrect": 0},
"quiz": {"completed": 0, "correct": 0, "incorrect": 0},
"type": {"completed": 0, "correct": 0, "incorrect": 0},
"story": {"generated": 0},
},
"created_at": datetime.now().isoformat(),
}
def _save_progress(unit_id: str, data: Dict[str, Any]):
_ensure_dir()
path = _progress_path(unit_id)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
class RewardPayload(BaseModel):
exercise_type: str # flashcards, quiz, type, story
correct: bool = True
first_try: bool = True
@router.get("/{unit_id}")
def get_progress(unit_id: str):
"""Get learning progress for a unit."""
return _load_progress(unit_id)
@router.post("/{unit_id}/reward")
def add_reward(unit_id: str, payload: RewardPayload):
"""Record an exercise result and award coins."""
progress = _load_progress(unit_id)
# Update exercise stats
ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0})
ex["completed"] = ex.get("completed", 0) + 1
if payload.correct:
ex["correct"] = ex.get("correct", 0) + 1
else:
ex["incorrect"] = ex.get("incorrect", 0) + 1
progress["exercises"][payload.exercise_type] = ex
# Award coins
if payload.correct:
coins = 3 if payload.first_try else 1
else:
coins = 0
progress["coins"] = progress.get("coins", 0) + coins
# Update streak
today = date.today().isoformat()
last = progress.get("last_activity")
if last != today:
if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None:
progress["streak_days"] = progress.get("streak_days", 0) + 1
elif last != today:
progress["streak_days"] = 1
progress["last_activity"] = today
# Award crowns for milestones
total_correct = sum(
e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict)
)
progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers
_save_progress(unit_id, progress)
return {
"coins_awarded": coins,
"total_coins": progress["coins"],
"crowns": progress["crowns"],
"streak_days": progress["streak_days"],
}
@router.get("/")
def list_all_progress():
"""List progress for all units."""
_ensure_dir()
results = []
for f in Path(PROGRESS_DIR).glob("*.json"):
with open(f, "r", encoding="utf-8") as fh:
results.append(json.load(fh))
return results

View File

@@ -0,0 +1,250 @@
"""
School Service API Proxy
Routes API requests from /api/school/* to the Go school-service
"""
import os
import jwt
import datetime
import httpx
from fastapi import APIRouter, Request, HTTPException, Response
# School Service URL
SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084")
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
# Demo teacher UUID for development mode
DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20"
router = APIRouter(prefix="/school", tags=["school"])
def get_demo_token() -> str:
"""Generate a demo JWT token for development mode"""
payload = {
"user_id": DEMO_TEACHER_ID,
"email": "demo@breakpilot.app",
"role": "admin",
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24),
"iat": datetime.datetime.now(datetime.timezone.utc)
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
async def proxy_request(request: Request, path: str) -> Response:
"""Forward a request to the school service"""
url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}"
# Forward headers, especially Authorization
headers = {}
if "authorization" in request.headers:
headers["Authorization"] = request.headers["authorization"]
elif ENVIRONMENT == "development":
# In development mode, use demo token if no auth provided
demo_token = get_demo_token()
headers["Authorization"] = f"Bearer {demo_token}"
if "content-type" in request.headers:
headers["Content-Type"] = request.headers["content-type"]
# Get request body for POST/PUT/PATCH
body = None
if request.method in ("POST", "PUT", "PATCH"):
body = await request.body()
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=request.query_params
)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type", "application/json")
)
except httpx.ConnectError:
raise HTTPException(
status_code=503,
detail="School service unavailable"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="School service timeout"
)
# Health check
@router.get("/health")
async def health():
"""Health check for school service connection"""
async with httpx.AsyncClient(timeout=5.0) as client:
try:
response = await client.get(f"{SCHOOL_SERVICE_URL}/health")
return {"school_service": "healthy", "connected": response.status_code == 200}
except Exception:
return {"school_service": "unhealthy", "connected": False}
# School Years
@router.api_route("/years", methods=["GET", "POST"])
async def years(request: Request):
return await proxy_request(request, "/years")
@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"])
async def year_by_id(request: Request, year_id: str):
return await proxy_request(request, f"/years/{year_id}")
# Classes
@router.api_route("/classes", methods=["GET", "POST"])
async def classes(request: Request):
return await proxy_request(request, "/classes")
@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"])
async def class_by_id(request: Request, class_id: str):
return await proxy_request(request, f"/classes/{class_id}")
# Students
@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"])
async def students(request: Request, class_id: str):
return await proxy_request(request, f"/classes/{class_id}/students")
@router.api_route("/classes/{class_id}/students/import", methods=["POST"])
async def import_students(request: Request, class_id: str):
return await proxy_request(request, f"/classes/{class_id}/students/import")
@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"])
async def student_by_id(request: Request, class_id: str, student_id: str):
return await proxy_request(request, f"/classes/{class_id}/students/{student_id}")
# Subjects
@router.api_route("/subjects", methods=["GET", "POST"])
async def subjects(request: Request):
return await proxy_request(request, "/subjects")
@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"])
async def subject_by_id(request: Request, subject_id: str):
return await proxy_request(request, f"/subjects/{subject_id}")
# Exams
@router.api_route("/exams", methods=["GET", "POST"])
async def exams(request: Request):
return await proxy_request(request, "/exams")
@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"])
async def exam_by_id(request: Request, exam_id: str):
return await proxy_request(request, f"/exams/{exam_id}")
@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"])
async def exam_results(request: Request, exam_id: str):
return await proxy_request(request, f"/exams/{exam_id}/results")
@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"])
async def exam_result_update(request: Request, exam_id: str, student_id: str):
return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}")
@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"])
async def approve_result(request: Request, exam_id: str, student_id: str):
return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve")
@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"])
async def generate_variant(request: Request, exam_id: str):
return await proxy_request(request, f"/exams/{exam_id}/generate-variant")
# Grades
@router.api_route("/grades/{class_id}", methods=["GET"])
async def grades_by_class(request: Request, class_id: str):
return await proxy_request(request, f"/grades/{class_id}")
@router.api_route("/grades/student/{student_id}", methods=["GET"])
async def grades_by_student(request: Request, student_id: str):
return await proxy_request(request, f"/grades/student/{student_id}")
@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"])
async def update_oral_grade(request: Request, student_id: str, subject_id: str):
return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral")
@router.api_route("/grades/calculate", methods=["POST"])
async def calculate_grades(request: Request):
return await proxy_request(request, "/grades/calculate")
# Attendance
@router.api_route("/attendance/{class_id}", methods=["GET"])
async def attendance_by_class(request: Request, class_id: str):
return await proxy_request(request, f"/attendance/{class_id}")
@router.api_route("/attendance", methods=["POST"])
async def create_attendance(request: Request):
return await proxy_request(request, "/attendance")
@router.api_route("/attendance/bulk", methods=["POST"])
async def bulk_attendance(request: Request):
return await proxy_request(request, "/attendance/bulk")
# Gradebook
@router.api_route("/gradebook/{class_id}", methods=["GET"])
async def gradebook_by_class(request: Request, class_id: str):
return await proxy_request(request, f"/gradebook/{class_id}")
@router.api_route("/gradebook", methods=["POST"])
async def create_gradebook_entry(request: Request):
return await proxy_request(request, "/gradebook")
@router.api_route("/gradebook/{entry_id}", methods=["DELETE"])
async def delete_gradebook_entry(request: Request, entry_id: str):
return await proxy_request(request, f"/gradebook/{entry_id}")
# Certificates
@router.api_route("/certificates/templates", methods=["GET"])
async def certificate_templates(request: Request):
return await proxy_request(request, "/certificates/templates")
@router.api_route("/certificates/generate", methods=["POST"])
async def generate_certificate(request: Request):
return await proxy_request(request, "/certificates/generate")
@router.api_route("/certificates/{certificate_id}", methods=["GET"])
async def certificate_by_id(request: Request, certificate_id: str):
return await proxy_request(request, f"/certificates/{certificate_id}")
@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"])
async def certificate_pdf(request: Request, certificate_id: str):
return await proxy_request(request, f"/certificates/{certificate_id}/pdf")
@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"])
async def finalize_certificate(request: Request, certificate_id: str):
return await proxy_request(request, f"/certificates/{certificate_id}/finalize")

View File

@@ -0,0 +1,86 @@
"""
User Language Preferences API — Stores native language + learning level.
Each user (student, parent, teacher) can set their native language.
This drives: UI language, third-language display in flashcards,
parent portal language, and translation generation.
Supported languages: de, en, tr, ar, uk, ru, pl
"""
import logging
import os
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user", tags=["user-language"])
# Supported native languages with metadata
SUPPORTED_LANGUAGES = {
"de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False},
"en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False},
"tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False},
"ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True},
"uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False},
"ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False},
"pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False},
}
# In-memory store (will be replaced with DB later)
_preferences: Dict[str, Dict[str, Any]] = {}
class LanguagePreference(BaseModel):
native_language: str # ISO 639-1 code
role: str = "student" # student, parent, teacher
learning_level: str = "A1" # A1, A2, B1, B2, C1
@router.get("/languages")
def get_supported_languages():
"""List all supported native languages with metadata."""
return {
"languages": [
{"code": code, **meta}
for code, meta in SUPPORTED_LANGUAGES.items()
]
}
@router.get("/language-preference")
def get_language_preference(user_id: str = Query("default")):
"""Get user's language preference."""
pref = _preferences.get(user_id)
if not pref:
return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True}
return {**pref, "is_default": False}
@router.put("/language-preference")
def set_language_preference(
pref: LanguagePreference,
user_id: str = Query("default"),
):
"""Set user's native language and learning level."""
if pref.native_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. "
f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}",
)
_preferences[user_id] = {
"user_id": user_id,
"native_language": pref.native_language,
"role": pref.role,
"learning_level": pref.learning_level,
}
lang_meta = SUPPORTED_LANGUAGES[pref.native_language]
logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})")
return {**_preferences[user_id], "language_meta": lang_meta}