# ============================================== # 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)