[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:
301
backend-lehrer/unit_definition_routes.py
Normal file
301
backend-lehrer/unit_definition_routes.py
Normal file
@@ -0,0 +1,301 @@
|
||||
# ==============================================
|
||||
# Breakpilot Drive - Unit Definition CRUD Routes
|
||||
# ==============================================
|
||||
# Endpoints for creating, updating, deleting, and validating
|
||||
# unit definitions. Extracted from unit_routes.py for file-size compliance.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from unit_models import (
|
||||
UnitDefinitionResponse,
|
||||
CreateUnitRequest,
|
||||
UpdateUnitRequest,
|
||||
ValidationResult,
|
||||
)
|
||||
from unit_helpers import (
|
||||
get_optional_current_user,
|
||||
get_unit_database,
|
||||
validate_unit_definition,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"])
|
||||
|
||||
|
||||
@router.post("/definitions", response_model=UnitDefinitionResponse)
|
||||
async def create_unit_definition(
|
||||
request_data: CreateUnitRequest,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> UnitDefinitionResponse:
|
||||
"""
|
||||
Create a new unit definition.
|
||||
|
||||
- Validates unit structure
|
||||
- Saves to database or JSON file
|
||||
- Returns created unit
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Build full definition
|
||||
definition = {
|
||||
"unit_id": request_data.unit_id,
|
||||
"template": request_data.template,
|
||||
"version": request_data.version,
|
||||
"locale": request_data.locale,
|
||||
"grade_band": request_data.grade_band,
|
||||
"duration_minutes": request_data.duration_minutes,
|
||||
"difficulty": request_data.difficulty,
|
||||
"subject": request_data.subject,
|
||||
"topic": request_data.topic,
|
||||
"learning_objectives": request_data.learning_objectives,
|
||||
"stops": request_data.stops,
|
||||
"precheck": request_data.precheck or {
|
||||
"question_set_id": f"{request_data.unit_id}_precheck",
|
||||
"required": True,
|
||||
"time_limit_seconds": 120
|
||||
},
|
||||
"postcheck": request_data.postcheck or {
|
||||
"question_set_id": f"{request_data.unit_id}_postcheck",
|
||||
"required": True,
|
||||
"time_limit_seconds": 180
|
||||
},
|
||||
"teacher_controls": request_data.teacher_controls or {
|
||||
"allow_skip": True,
|
||||
"allow_replay": True,
|
||||
"max_time_per_stop_sec": 90,
|
||||
"show_hints": True,
|
||||
"require_precheck": True,
|
||||
"require_postcheck": True
|
||||
},
|
||||
"assets": request_data.assets or {},
|
||||
"metadata": request_data.metadata or {
|
||||
"author": user.get("email", "Unknown") if user else "Unknown",
|
||||
"created": datetime.utcnow().isoformat(),
|
||||
"curriculum_reference": ""
|
||||
}
|
||||
}
|
||||
|
||||
# Validate
|
||||
validation = validate_unit_definition(definition)
|
||||
if not validation.valid:
|
||||
error_msgs = [f"{e.field}: {e.message}" for e in validation.errors]
|
||||
raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}")
|
||||
|
||||
# Check if unit_id already exists
|
||||
db = await get_unit_database()
|
||||
if db:
|
||||
try:
|
||||
existing = await db.get_unit_definition(request_data.unit_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
|
||||
|
||||
# Save to database
|
||||
await db.create_unit_definition(
|
||||
unit_id=request_data.unit_id,
|
||||
template=request_data.template,
|
||||
version=request_data.version,
|
||||
locale=request_data.locale,
|
||||
grade_band=request_data.grade_band,
|
||||
duration_minutes=request_data.duration_minutes,
|
||||
difficulty=request_data.difficulty,
|
||||
definition=definition,
|
||||
status=request_data.status
|
||||
)
|
||||
logger.info(f"Unit created in database: {request_data.unit_id}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Database save failed, using JSON fallback: {e}")
|
||||
# Fallback to JSON
|
||||
units_dir = Path(__file__).parent / "data" / "units"
|
||||
units_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = units_dir / f"{request_data.unit_id}.json"
|
||||
if json_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Unit created as JSON: {json_path}")
|
||||
else:
|
||||
# JSON only mode
|
||||
units_dir = Path(__file__).parent / "data" / "units"
|
||||
units_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = units_dir / f"{request_data.unit_id}.json"
|
||||
if json_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}")
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Unit created as JSON: {json_path}")
|
||||
|
||||
return UnitDefinitionResponse(
|
||||
unit_id=request_data.unit_id,
|
||||
template=request_data.template,
|
||||
version=request_data.version,
|
||||
locale=request_data.locale,
|
||||
grade_band=request_data.grade_band,
|
||||
duration_minutes=request_data.duration_minutes,
|
||||
difficulty=request_data.difficulty,
|
||||
definition=definition
|
||||
)
|
||||
|
||||
|
||||
@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse)
|
||||
async def update_unit_definition(
|
||||
unit_id: str,
|
||||
request_data: UpdateUnitRequest,
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> UnitDefinitionResponse:
|
||||
"""
|
||||
Update an existing unit definition.
|
||||
|
||||
- Merges updates with existing definition
|
||||
- Re-validates
|
||||
- Saves updated version
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Get existing unit
|
||||
db = await get_unit_database()
|
||||
existing = None
|
||||
|
||||
if db:
|
||||
try:
|
||||
existing = await db.get_unit_definition(unit_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Database read failed: {e}")
|
||||
|
||||
if not existing:
|
||||
# Try JSON file
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
if json_path.exists():
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
file_data = json.load(f)
|
||||
existing = {
|
||||
"unit_id": file_data.get("unit_id"),
|
||||
"template": file_data.get("template"),
|
||||
"version": file_data.get("version", "1.0.0"),
|
||||
"locale": file_data.get("locale", ["de-DE"]),
|
||||
"grade_band": file_data.get("grade_band", []),
|
||||
"duration_minutes": file_data.get("duration_minutes", 8),
|
||||
"difficulty": file_data.get("difficulty", "base"),
|
||||
"definition": file_data
|
||||
}
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}")
|
||||
|
||||
# Merge updates into existing definition
|
||||
definition = existing.get("definition", {})
|
||||
update_dict = request_data.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_dict.items():
|
||||
if value is not None:
|
||||
definition[key] = value
|
||||
|
||||
# Validate updated definition
|
||||
validation = validate_unit_definition(definition)
|
||||
if not validation.valid:
|
||||
error_msgs = [f"{e.field}: {e.message}" for e in validation.errors]
|
||||
raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}")
|
||||
|
||||
# Save
|
||||
if db:
|
||||
try:
|
||||
await db.update_unit_definition(
|
||||
unit_id=unit_id,
|
||||
version=definition.get("version"),
|
||||
locale=definition.get("locale"),
|
||||
grade_band=definition.get("grade_band"),
|
||||
duration_minutes=definition.get("duration_minutes"),
|
||||
difficulty=definition.get("difficulty"),
|
||||
definition=definition,
|
||||
status=update_dict.get("status")
|
||||
)
|
||||
logger.info(f"Unit updated in database: {unit_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Database update failed, using JSON: {e}")
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(definition, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Unit updated as JSON: {json_path}")
|
||||
|
||||
return UnitDefinitionResponse(
|
||||
unit_id=unit_id,
|
||||
template=definition.get("template", existing.get("template")),
|
||||
version=definition.get("version", existing.get("version", "1.0.0")),
|
||||
locale=definition.get("locale", existing.get("locale", ["de-DE"])),
|
||||
grade_band=definition.get("grade_band", existing.get("grade_band", [])),
|
||||
duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)),
|
||||
difficulty=definition.get("difficulty", existing.get("difficulty", "base")),
|
||||
definition=definition
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/definitions/{unit_id}")
|
||||
async def delete_unit_definition(
|
||||
unit_id: str,
|
||||
force: bool = Query(False, description="Force delete even if published"),
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a unit definition.
|
||||
|
||||
- By default, only drafts can be deleted
|
||||
- Use force=true to delete published units
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
db = await get_unit_database()
|
||||
deleted = False
|
||||
|
||||
if db:
|
||||
try:
|
||||
existing = await db.get_unit_definition(unit_id)
|
||||
if existing:
|
||||
status = existing.get("status", "draft")
|
||||
if status == "published" and not force:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true."
|
||||
)
|
||||
await db.delete_unit_definition(unit_id)
|
||||
deleted = True
|
||||
logger.info(f"Unit deleted from database: {unit_id}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Database delete failed: {e}")
|
||||
|
||||
# Also check JSON file
|
||||
json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json"
|
||||
if json_path.exists():
|
||||
json_path.unlink()
|
||||
deleted = True
|
||||
logger.info(f"Unit JSON deleted: {json_path}")
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}")
|
||||
|
||||
return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"}
|
||||
|
||||
|
||||
@router.post("/definitions/validate", response_model=ValidationResult)
|
||||
async def validate_unit(
|
||||
unit_data: Dict[str, Any],
|
||||
user: Optional[Dict[str, Any]] = Depends(get_optional_current_user)
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validate a unit definition without saving.
|
||||
|
||||
Returns validation result with errors and warnings.
|
||||
"""
|
||||
return validate_unit_definition(unit_data)
|
||||
Reference in New Issue
Block a user