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>
302 lines
11 KiB
Python
302 lines
11 KiB
Python
# ==============================================
|
|
# 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)
|