import logging from pathlib import Path import hashlib import json from typing import ( Annotated, List, Optional, Union, Literal, ) from django.db.models import options import yaml from pydantic import ( BaseModel, Field, PrivateAttr, TypeAdapter, ValidationError, model_validator, ) from labs.lab_manager_v2.exceptions import LabGuideNotFoundError from labs.models import LabQuiz as DBLabQuiz logger = logging.getLogger(__name__) class LabGuideMarkdown(BaseModel): type: Literal["markdown"] title: str file: str public: bool _lab_code: Optional[str] = PrivateAttr(default=None) _language_code: Optional[str] = PrivateAttr(default=None) def get_content(self, lab_guide_path: Path) -> str: with open(lab_guide_path / self.file, "r", encoding="utf-8") as f: return f.read() def to_frontend(self, lab_guide_path: Path, is_preview: bool) -> dict: data = self.model_dump(exclude=["file"]) if not is_preview or self.public: data["content"] = self.get_content(lab_guide_path) else: data["content"] = "" return data class LabGuideQuiz(BaseModel): type: Literal["quiz"] title: str file: str public: bool _content_cache: Optional["LabGuideQuizContent"] = PrivateAttr(default=None) _lab_code: Optional[str] = PrivateAttr(default=None) _language_code: Optional[str] = PrivateAttr(default=None) def get_content(self, lab_guide_path: Path) -> "LabGuideQuizContent": if self._content_cache is None: quiz_path = lab_guide_path / self.file content_bytes = quiz_path.read_bytes() # Compute fast, deterministic hash of the raw file content content_hash = hashlib.blake2s(content_bytes, digest_size=16).hexdigest() raw_content = yaml.safe_load(content_bytes.decode("utf-8")) self._content_cache = LabGuideQuizContent.model_validate(raw_content) self._content_cache._hash = content_hash self._content_cache.ensure_db_quiz_exists( self._lab_code, self._language_code ) return self._content_cache def to_frontend(self, lab_guide_path: Path, is_preview: bool) -> dict: data = self.model_dump(exclude=["file"]) if not is_preview or self.public: data["content"] = self.get_content(lab_guide_path).to_frontend() else: data["content"] = {} return data class LabGuideQuizOption(BaseModel): id: int text: str correct: bool = False class LabGuideQuizQuestion(BaseModel): type: Literal["SingleChoice", "MultipleChoice"] question: str options: List[LabGuideQuizOption] explanation: Optional[str] = None def to_frontend(self) -> dict: data = self.model_dump(exclude=["options", "explanation"]) data["options"] = [ { "id": option.id, "text": option.text, } for option in self.options ] return data @model_validator(mode="after") def validate_options(self) -> "LabGuideQuizQuestion": if len(self.options) < 2: raise ValueError("at least 2 options are required") ids = [option.id for option in self.options] if len(ids) != len(set(ids)): raise ValueError("option ids must be unique") if self.type == "SingleChoice": correct_options = [option for option in self.options if option.correct] if len(correct_options) != 1: raise ValueError("SingleChoice must have exactly 1 correct option") elif self.type == "MultipleChoice": correct_options = [option for option in self.options if option.correct] if len(correct_options) < 2: raise ValueError("MultipleChoice must have at least 2 correct options") return self class LabGuideQuizContent(BaseModel): title: str description: Optional[str] = None allow_repeat_submission: bool = False evaluate_immediately: bool = False show_correct_answer: bool = False questions: List[LabGuideQuizQuestion] = Field(default_factory=list) _hash: Optional[str] = PrivateAttr(default=None) def to_frontend(self) -> dict: data = self.model_dump(exclude=["questions"]) data["questions"] = [question.to_frontend() for question in self.questions] data["slug"] = self._hash return data def ensure_db_quiz_exists(self, lab_code: str, language_code: str) -> None: DBLabQuiz.objects.get_or_create( lab_code=lab_code, language_code=language_code, content_hash=self._hash, defaults={ "title": self.title, "description": self.description, "allow_repeat_submission": self.allow_repeat_submission, "evaluate_immediately": self.evaluate_immediately, "show_correct_answer": self.show_correct_answer, "questions": [question.model_dump() for question in self.questions], "max_score": len(self.questions), }, ) LabGuidePart = Annotated[ Union[LabGuideMarkdown, LabGuideQuiz], Field(discriminator="type"), ] class LabGuide: catalog: List[LabGuidePart] lab_guide_path: Path lab_code: str language_code: str def __init__( self, catalog: List[LabGuidePart], lab_guide_path: Path, lab_code: str, language_code: str, ): self.catalog = catalog self.lab_guide_path = lab_guide_path self.lab_code = lab_code self.language_code = language_code # propagate context to parts that need it for part in self.catalog: if isinstance(part, LabGuideQuiz): part._lab_code = self.lab_code part._language_code = self.language_code def get_parts(self, is_preview: bool) -> List[LabGuidePart]: return [ part.to_frontend(self.lab_guide_path, is_preview) for part in self.catalog ] def load_lab_guide( lab_code: str, language_code: str, lab_base_dir: Path, fallback_to_other_language: bool = True, ) -> LabGuide: # list lab_base_dir/guides dir name as languages languages = [lang.name for lang in lab_base_dir.glob("guides/*") if lang.is_dir()] languages = sorted(languages, key=lambda x: x == "en", reverse=True) if language_code in languages and languages.index(language_code) != 0: languages.remove(language_code) languages.insert(0, language_code) for lang in languages: lab_guide_path = lab_base_dir / "guides" / lang catalog_path = lab_guide_path / "catalog.yaml" try: catalog_content = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) catalog = TypeAdapter(List[LabGuidePart]).validate_python(catalog_content) return LabGuide( catalog=catalog, lab_guide_path=lab_guide_path, lab_code=lab_code, language_code=lang, ) except (FileNotFoundError, ValidationError) as e: if isinstance(e, ValidationError): logger.error( f"Invalid catalog.yaml for lab {lab_code} in language {lang}" ) if fallback_to_other_language: continue else: logger.error( f"Failed to load lab guide for lab {lab_code} in language {language_code}" ) raise LabGuideNotFoundError logger.error( f"Failed to load lab guide for lab {lab_code} in language {language_code}" ) raise LabGuideNotFoundError