from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse from app.schemas.composition import CheckpointUpdate, CompositionSpec, MolecularPanelSpec, RuntimeCheckpoint from app.schemas.visual_lesson import LessonPayload, NucleicAcidSpec, ProteinLessonSpec from app.services.visual_lesson_service import VisualLessonService from app.services.visual_lesson_store import CheckpointConflictError, LessonNotFoundError, LessonVersionError router = APIRouter(prefix="/api/visual-lessons", tags=["visual-lessons"]) _service: VisualLessonService | None = None def get_visual_lesson_service() -> VisualLessonService: global _service if _service is None: _service = VisualLessonService() return _service @router.get("/{project_id}/{lesson_id}", response_model=LessonPayload) def get_visual_lesson(project_id: str, lesson_id: str) -> LessonPayload: try: return get_visual_lesson_service().load_payload(project_id, lesson_id) except LessonNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except LessonVersionError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @router.put("/{project_id}/{lesson_id}/checkpoint", response_model=RuntimeCheckpoint) def put_visual_lesson_checkpoint(project_id: str, lesson_id: str, update: CheckpointUpdate) -> RuntimeCheckpoint: service = get_visual_lesson_service() try: return service.save_checkpoint(project_id, lesson_id, update) except LessonNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except CheckpointConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except (LessonVersionError, ValueError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @router.get("/{project_id}/{lesson_id}/assets/{asset_id}") def get_visual_lesson_asset(project_id: str, lesson_id: str, asset_id: str) -> FileResponse: service = get_visual_lesson_service() try: spec = service.store.load(project_id, lesson_id) structure = spec.structure if isinstance(spec, (ProteinLessonSpec, NucleicAcidSpec)) else None if isinstance(spec, CompositionSpec): molecular = next((panel for panel in spec.panels if isinstance(panel, MolecularPanelSpec) and panel.asset_id == asset_id), None) if molecular is None: raise LessonNotFoundError("Molecular structure asset not found") coordinate_format = molecular.coordinate_format path = service.store.asset_path(project_id, asset_id, coordinate_format) media_type = "application/octet-stream" if coordinate_format == "bcif" else "chemical/x-mmcif" return FileResponse(path, media_type=media_type, filename=f"{asset_id}.{coordinate_format}") if structure is None or structure.coordinate_asset_id != asset_id: raise LessonNotFoundError("Molecular structure asset not found") coordinate_format = structure.coordinate_format path = service.store.asset_path(project_id, asset_id, coordinate_format) media_type = "application/octet-stream" if coordinate_format == "bcif" else "chemical/x-mmcif" return FileResponse(path, media_type=media_type, filename=f"{asset_id}.{coordinate_format}") except LessonNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc